initial commit

This commit is contained in:
unknown 2021-07-28 18:45:52 +05:30
commit 026f835a87
176 changed files with 10613 additions and 0 deletions
src
app
atoms
molecules
organisms
pages
templates
client
index.jsx

View file

@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './Avatar.scss';
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
function Avatar({
text, bgColor, iconSrc, imageSrc, size,
}) {
const [image, updateImage] = useState(imageSrc);
let textSize = 's1';
if (size === 'large') textSize = 'h1';
if (size === 'small') textSize = 'b1';
if (size === 'extra-small') textSize = 'b3';
useEffect(() => updateImage(imageSrc), [imageSrc]);
return (
<div className={`avatar-container avatar-container__${size} noselect`}>
{
image !== null
? <img src={image} onError={() => updateImage(null)} alt="avatar" />
: (
<span
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
className={`avatar__border${iconSrc !== null ? ' avatar__bordered' : ''} inline-flex--center`}
>
{
iconSrc !== null
? <RawIcon size={size} src={iconSrc} />
: text !== null && <Text variant={textSize}>{text}</Text>
}
</span>
)
}
</div>
);
}
Avatar.defaultProps = {
text: null,
bgColor: 'transparent',
iconSrc: null,
imageSrc: null,
size: 'normal',
};
Avatar.propTypes = {
text: PropTypes.string,
bgColor: PropTypes.string,
iconSrc: PropTypes.string,
imageSrc: PropTypes.string,
size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']),
};
export default Avatar;

View file

@ -0,0 +1,52 @@
.avatar-container {
display: inline-flex;
width: 42px;
height: 42px;
border-radius: var(--bo-radius);
position: relative;
&__large {
width: var(--av-large);
height: var(--av-large);
}
&__normal {
width: var(--av-normal);
height: var(--av-normal);
}
&__small {
width: var(--av-small);
height: var(--av-small);
}
&__extra-small {
width: var(--av-extra-small);
height: var(--av-extra-small);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.avatar__bordered {
box-shadow: var(--bs-surface-border);
}
.avatar__border {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
.text {
color: var(--tc-primary-high);
}
}
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import './NotificationBadge.scss';
import Text from '../text/Text';
function NotificationBadge({ alert, children }) {
const notificationClass = alert ? ' notification-badge--alert' : '';
return (
<div className={`notification-badge${notificationClass}`}>
<Text variant="b3">{children}</Text>
</div>
);
}
NotificationBadge.defaultProps = {
alert: false,
};
NotificationBadge.propTypes = {
alert: PropTypes.bool,
children: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
};
export default NotificationBadge;

View file

@ -0,0 +1,18 @@
.notification-badge {
min-width: 18px;
padding: 1px var(--sp-ultra-tight);
background-color: var(--tc-surface-low);
border-radius: 9px;
.text {
color: var(--bg-surface-low);
text-align: center;
}
&--alert {
background-color: var(--bg-positive);
.text {
color: white;
}
}
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Button.scss';
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
import { blurOnBubbling } from './script';
function Button({
id, variant, iconSrc, type, onClick, children, disabled,
}) {
const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`;
return (
<button
id={id === '' ? undefined : id}
className={`btn-${variant} ${iconClass} noselect`}
onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
onClick={onClick}
type={type === 'button' ? 'button' : 'submit'}
disabled={disabled}
>
{iconSrc !== null && <RawIcon size="small" src={iconSrc} />}
<Text variant="b1">{ children }</Text>
</button>
);
}
Button.defaultProps = {
id: '',
variant: 'surface',
iconSrc: null,
type: 'button',
onClick: null,
disabled: false,
};
Button.propTypes = {
id: PropTypes.string,
variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']),
iconSrc: PropTypes.string,
type: PropTypes.oneOf(['button', 'submit']),
onClick: PropTypes.func,
children: PropTypes.node.isRequired,
disabled: PropTypes.bool,
};
export default Button;

View file

@ -0,0 +1,83 @@
@use 'state';
.btn-surface,
.btn-primary,
.btn-caution,
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
padding: var(--sp-extra-tight) var(--sp-normal);
background-color: transparent;
border: none;
border-radius: var(--bo-radius);
cursor: pointer;
@include state.disabled;
&--icon {
padding: {
left: var(--sp-tight);
right: var(--sp-loose);
}
[dir=rtl] & {
padding: {
left: var(--sp-loose);
right: var(--sp-tight);
}
}
.ic-raw {
margin-right: var(--sp-extra-tight);
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
}
}
}
@mixin color($textColor, $iconColor) {
.text {
color: $textColor;
}
.ic-raw {
background-color: $iconColor;
}
}
.btn-surface {
box-shadow: var(--bs-surface-border);
@include color(var(--tc-surface-high), var(--ic-surface-normal));
@include state.hover(var(--bg-surface-hover));
@include state.focus(var(--bs-surface-outline));
@include state.active(var(--bg-surface-active));
}
.btn-primary {
background-color: var(--bg-primary);
@include color(var(--tc-primary-high), var(--ic-primary-normal));
@include state.hover(var(--bg-primary-hover));
@include state.focus(var(--bs-primary-outline));
@include state.active(var(--bg-primary-active));
}
.btn-caution {
box-shadow: var(--bs-caution-border);
@include color(var(--tc-caution-high), var(--ic-caution-normal));
@include state.hover(var(--bg-caution-hover));
@include state.focus(var(--bs-caution-outline));
@include state.active(var(--bg-caution-active));
}
.btn-danger {
box-shadow: var(--bs-danger-border);
@include color(var(--tc-danger-high), var(--ic-danger-normal));
@include state.hover(var(--bg-danger-hover));
@include state.focus(var(--bs-danger-outline));
@include state.active(var(--bg-danger-active));
}

View file

@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import './IconButton.scss';
import Tippy from '@tippyjs/react';
import RawIcon from '../system-icons/RawIcon';
import { blurOnBubbling } from './script';
import Text from '../text/Text';
// TODO:
// 1. [done] an icon only button have "src"
// 2. have multiple variant
// 3. [done] should have a smart accessibility "label" arial-label
// 4. [done] have size as RawIcon
const IconButton = React.forwardRef(({
variant, size, type,
tooltip, tooltipPlacement, src, onClick,
}, ref) => (
<Tippy
content={<Text variant="b2">{tooltip}</Text>}
className="ic-btn-tippy"
touch="hold"
arrow={false}
maxWidth={250}
placement={tooltipPlacement}
delay={[0, 0]}
duration={[100, 0]}
>
<button
ref={ref}
className={`ic-btn-${variant}`}
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
onClick={onClick}
type={type === 'button' ? 'button' : 'submit'}
>
<RawIcon size={size} src={src} />
</button>
</Tippy>
));
IconButton.defaultProps = {
variant: 'surface',
size: 'normal',
type: 'button',
tooltipPlacement: 'top',
onClick: null,
};
IconButton.propTypes = {
variant: PropTypes.oneOf(['surface']),
size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
type: PropTypes.oneOf(['button', 'submit']),
tooltip: PropTypes.string.isRequired,
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
src: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
export default IconButton;

View file

@ -0,0 +1,45 @@
@use 'state';
.ic-btn-surface,
.ic-btn-primary,
.ic-btn-caution,
.ic-btn-danger {
padding: var(--sp-extra-tight);
border: none;
border-radius: var(--bo-radius);
background-color: transparent;
font-size: 0;
line-height: 0;
cursor: pointer;
@include state.disabled;
}
@mixin color($color) {
.ic-raw {
background-color: $color;
}
}
@mixin focus($color) {
&:focus {
outline: none;
background-color: $color;
}
}
.ic-btn-surface {
@include color(var(--ic-surface-normal));
@include state.hover(var(--bg-surface-hover));
@include focus(var(--bg-surface-hover));
@include state.active(var(--bg-surface-active));
}
.ic-btn-tippy {
padding: var(--sp-extra-tight) var(--sp-normal);
background-color: var(--bg-tooltip);
border-radius: var(--bo-radius);
box-shadow: var(--bs-popup);
.text {
color: var(--tc-tooltip);
}
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Toggle.scss';
function Toggle({ isActive, onToggle }) {
return (
// eslint-disable-next-line jsx-a11y/control-has-associated-label
<button
onClick={() => onToggle(!isActive)}
className={`toggle${isActive ? ' toggle--active' : ''}`}
type="button"
/>
);
}
Toggle.defaultProps = {
isActive: false,
};
Toggle.propTypes = {
isActive: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
};
export default Toggle;

View file

@ -0,0 +1,39 @@
.toggle {
width: 44px;
height: 24px;
padding: 0 var(--sp-ultra-tight);
display: flex;
align-items: center;
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
cursor: pointer;
background-color: var(--bg-surface-low);
transition: background 200ms ease-in-out;
&::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-color: var(--tc-surface-low);
border-radius: calc(var(--bo-radius) / 2);
transition: transform 200ms ease-in-out,
opacity 200ms ease-in-out;
opacity: 0.6;
}
&--active {
background-color: var(--bg-positive);
&::before {
background-color: white;
transform: translateX(calc(125%));
opacity: 1;
[dir=rtl] & {
transform: translateX(calc(-125%));
}
}
}
}

View file

@ -0,0 +1,25 @@
@mixin hover($color) {
@media (hover: hover) {
&:hover {
background-color: $color;
}
}
}
@mixin focus($outline) {
&:focus {
outline: none;
box-shadow: $outline;
}
}
@mixin active($color) {
&:active {
background-color: $color !important;
}
}
@mixin disabled {
&:disabled {
opacity: 0.4;
cursor: no-drop;
}
}

View file

@ -0,0 +1,23 @@
/**
* blur [selector] element in bubbling path.
* @param {Event} e Event
* @param {string} selector element selector for Element.matches([selector])
* @return {boolean} if blured return true, else return false with warning in console
*/
function blurOnBubbling(e, selector) {
const bubblingPath = e.nativeEvent.composedPath();
for (let elIndex = 0; elIndex < bubblingPath.length; elIndex += 1) {
if (bubblingPath[elIndex] === document) {
console.warn(blurOnBubbling, 'blurOnBubbling: not found selector in bubbling path');
break;
}
if (bubblingPath[elIndex].matches(selector)) {
setTimeout(() => bubblingPath[elIndex].blur(), 50);
return true;
}
}
return false;
}
export { blurOnBubbling };

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './ContextMenu.scss';
import Tippy from '@tippyjs/react';
import 'tippy.js/animations/scale-extreme.css';
import Text from '../text/Text';
import Button from '../button/Button';
import ScrollView from '../scroll/ScrollView';
function ContextMenu({
content, placement, maxWidth, render,
}) {
const [isVisible, setVisibility] = useState(false);
const showMenu = () => setVisibility(true);
const hideMenu = () => setVisibility(false);
return (
<Tippy
animation="scale-extreme"
className="context-menu"
visible={isVisible}
onClickOutside={hideMenu}
content={<ScrollView invisible>{typeof content === 'function' ? content(hideMenu) : content}</ScrollView>}
placement={placement}
interactive
arrow={false}
maxWidth={maxWidth}
>
{render(isVisible ? hideMenu : showMenu)}
</Tippy>
);
}
ContextMenu.defaultProps = {
maxWidth: 'unset',
placement: 'right',
};
ContextMenu.propTypes = {
content: PropTypes.oneOfType([
PropTypes.node,
PropTypes.func,
]).isRequired,
placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
maxWidth: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
render: PropTypes.func.isRequired,
};
function MenuHeader({ children }) {
return (
<div className="context-menu__header">
<Text variant="b3">{ children }</Text>
</div>
);
}
MenuHeader.propTypes = {
children: PropTypes.string.isRequired,
};
function MenuItem({
variant, iconSrc, type, onClick, children,
}) {
return (
<div className="context-menu__item">
<Button
variant={variant}
iconSrc={iconSrc}
type={type}
onClick={onClick}
>
{ children }
</Button>
</div>
);
}
MenuItem.defaultProps = {
variant: 'surface',
iconSrc: 'none',
type: 'button',
};
MenuItem.propTypes = {
variant: PropTypes.oneOf(['surface', 'caution', 'danger']),
iconSrc: PropTypes.string,
type: PropTypes.oneOf(['button', 'submit']),
onClick: PropTypes.func.isRequired,
children: PropTypes.string.isRequired,
};
function MenuBorder() {
return <div style={{ borderBottom: '1px solid var(--bg-surface-border)' }}> </div>;
}
export {
ContextMenu as default, MenuHeader, MenuItem, MenuBorder,
};

View file

@ -0,0 +1,71 @@
.context-menu {
background-color: var(--bg-surface);
box-shadow: var(--bs-popup);
border-radius: var(--bo-radius);
overflow: hidden;
&:focus {
outline: none;
}
& .tippy-content > div > .scrollbar {
max-height: 90vh;
}
}
.context-menu__click-wrapper {
display: inline-flex;
&:focus {
outline: none;
}
}
.context-menu__header {
height: 34px;
padding: 0 var(--sp-tight);
margin-bottom: var(--sp-ultra-tight);
display: flex;
align-items: center;
border-bottom: 1px solid var(--bg-surface-border);
.text {
color: var(--tc-surface-low);
}
&:not(:first-child) {
margin-top: var(--sp-normal);
border-top: 1px solid var(--bg-surface-border);
}
}
.context-menu__item {
button[class^="btn"] {
width: 100%;
justify-content: start;
border-radius: 0;
box-shadow: none;
.text:first-child {
margin: {
left: calc(var(--ic-small) + var(--sp-ultra-tight));
right: var(--sp-extra-tight);
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: calc(var(--ic-small) + var(--sp-ultra-tight));
}
}
}
}
.btn-surface:focus {
background-color: var(--bg-surface-hover);
}
.btn-caution:focus {
background-color: var(--bg-caution-hover);
}
.btn-danger:focus {
background-color: var(--bg-danger-hover);
}
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Divider.scss';
import Text from '../text/Text';
function Divider({ text, variant }) {
const dividerClass = ` divider--${variant}`;
return (
<div className={`divider${dividerClass}`}>
{text !== false && <Text className="divider__text" variant="b3">{text}</Text>}
</div>
);
}
Divider.defaultProps = {
text: false,
variant: 'surface',
};
Divider.propTypes = {
text: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']),
};
export default Divider;

View file

@ -0,0 +1,68 @@
.divider {
--local-divider-color: var(--bg-surface-border);
margin: var(--sp-extra-tight) var(--sp-normal);
margin-right: var(--sp-extra-tight);
display: flex;
align-items: center;
position: relative;
&::before {
content: "";
display: inline-block;
flex: 1;
margin-left: calc(var(--av-small) + var(--sp-tight));
border-bottom: 1px solid var(--local-divider-color);
opacity: 0.18;
[dir=rtl] & {
margin: {
left: 0;
right: calc(var(--av-small) + var(--sp-tight));
}
}
}
&__text {
margin-left: var(--sp-normal);
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
&__text {
margin: {
left: 0;
right: var(--sp-normal);
}
}
}
}
.divider--surface {
--local-divider-color: var(--tc-surface-low);
.divider__text {
color: var(--tc-surface-low);
}
}
.divider--primary {
--local-divider-color: var(--bg-primary);
.divider__text {
color: var(--bg-primary);
}
}
.divider--danger {
--local-divider-color: var(--bg-danger);
.divider__text {
color: var(--bg-danger);
}
}
.divider--caution {
--local-divider-color: var(--bg-caution);
.divider__text {
color: var(--bg-caution);
}
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Header.scss';
function Header({ children }) {
return (
<div className="header">
{children}
</div>
);
}
Header.propTypes = {
children: PropTypes.node.isRequired,
};
function TitleWrapper({ children }) {
return (
<div className="header__title-wrapper">
{children}
</div>
);
}
TitleWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
export { Header as default, TitleWrapper };

View file

@ -0,0 +1,63 @@
.header {
padding: {
left: var(--sp-normal);
right: var(--sp-extra-tight);
}
width: 100%;
height: var(--header-height);
border-bottom: 1px solid var(--bg-surface-border);
display: flex;
align-items: center;
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__title-wrapper {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
margin: 0 var(--sp-tight);
&:first-child {
margin-left: 0;
[dir=rtl] & {
margin-right: 0;
}
}
& > .text:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& > .text-b3{
flex: 1;
min-width: 0;
margin-top: var(--sp-ultra-tight);
margin-left: var(--sp-tight);
padding-left: var(--sp-tight);
border-left: 1px solid var(--bg-surface-border);
max-height: calc(2 * var(--lh-b3));
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
[dir=rtl] & {
margin-left: 0;
padding-left: 0;
border-left: none;
margin-right: var(--sp-tight);
padding-right: var(--sp-tight);
border-right: 1px solid var(--bg-surface-border);
}
}
}
}

View file

@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Input.scss';
import TextareaAutosize from 'react-autosize-textarea';
function Input({
id, label, value, placeholder,
required, type, onChange, forwardRef,
resizable, minHeight, onResize, state,
}) {
return (
<div className="input-container">
{ label !== '' && <label className="input__label text-b2" htmlFor={id}>{label}</label> }
{ resizable
? (
<TextareaAutosize
style={{ minHeight: `${minHeight}px` }}
id={id}
className={`input input--resizable${state !== 'normal' ? ` input--${state}` : ''}`}
ref={forwardRef}
type={type}
placeholder={placeholder}
required={required}
defaultValue={value}
autoComplete="off"
onChange={onChange}
onResize={onResize}
/>
) : (
<input
ref={forwardRef}
id={id}
className={`input ${state !== 'normal' ? ` input--${state}` : ''}`}
type={type}
placeholder={placeholder}
required={required}
defaultValue={value}
autoComplete="off"
onChange={onChange}
/>
)}
</div>
);
}
Input.defaultProps = {
id: null,
label: '',
value: '',
placeholder: '',
type: 'text',
required: false,
onChange: null,
forwardRef: null,
resizable: false,
minHeight: 46,
onResize: null,
state: 'normal',
};
Input.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
required: PropTypes.bool,
type: PropTypes.string,
onChange: PropTypes.func,
forwardRef: PropTypes.shape({}),
resizable: PropTypes.bool,
minHeight: PropTypes.number,
onResize: PropTypes.func,
state: PropTypes.oneOf(['normal', 'success', 'error']),
};
export default Input;

View file

@ -0,0 +1,40 @@
.input {
display: block;
width: 100%;
min-width: 0px;
padding: var(--sp-tight) var(--sp-normal);
background-color: var(--bg-surface-low);
color: var(--tc-surface-normal);
box-shadow: none;
border-radius: var(--bo-radius);
border: 1px solid var(--bg-surface-border);
font-size: var(--fs-b2);
letter-spacing: var(--ls-b2);
line-height: var(--lh-b2);
&__label {
display: inline-block;
margin-bottom: var(--sp-ultra-tight);
color: var(--tc-surface-low);
}
&--resizable {
resize: vertical !important;
}
&--success {
border: 1px solid var(--bg-positive);
box-shadow: none !important;
}
&--error {
border: 1px solid var(--bg-danger);
box-shadow: none !important;
}
&:focus {
outline: none;
box-shadow: var(--bs-primary-border);
}
&::placeholder {
color: var(--tc-surface-low)
}
}

View file

@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import './RawModal.scss';
import Modal from 'react-modal';
Modal.setAppElement('#root');
function RawModal({
className, overlayClassName,
isOpen, size, onAfterOpen, onAfterClose,
onRequestClose, closeFromOutside, children,
}) {
let modalClass = (className !== null) ? `${className} ` : '';
switch (size) {
case 'large':
modalClass += 'raw-modal__large ';
break;
case 'medium':
modalClass += 'raw-modal__medium ';
break;
case 'small':
default:
modalClass += 'raw-modal__small ';
}
const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : '';
return (
<Modal
className={`${modalClass}raw-modal`}
overlayClassName={`${modalOverlayClass}raw-modal__overlay`}
isOpen={isOpen}
onAfterOpen={onAfterOpen}
onAfterClose={onAfterClose}
onRequestClose={onRequestClose}
shouldCloseOnEsc={closeFromOutside}
shouldCloseOnOverlayClick={closeFromOutside}
shouldReturnFocusAfterClose={false}
closeTimeoutMS={300}
>
{children}
</Modal>
);
}
RawModal.defaultProps = {
className: null,
overlayClassName: null,
size: 'small',
onAfterOpen: null,
onAfterClose: null,
onRequestClose: null,
closeFromOutside: true,
};
RawModal.propTypes = {
className: PropTypes.string,
overlayClassName: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
size: PropTypes.oneOf(['large', 'medium', 'small']),
onAfterOpen: PropTypes.func,
onAfterClose: PropTypes.func,
onRequestClose: PropTypes.func,
closeFromOutside: PropTypes.bool,
children: PropTypes.node.isRequired,
};
export default RawModal;

View file

@ -0,0 +1,63 @@
.ReactModal__Overlay {
opacity: 0;
transition: opacity 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99);
}
.ReactModal__Overlay--after-open{
opacity: 1;
}
.ReactModal__Overlay--before-close{
opacity: 0;
}
.ReactModal__Content {
transform: translateY(100%);
transition: transform 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99);
}
.ReactModal__Content--after-open{
transform: translateY(0);
}
.ReactModal__Content--before-close{
transform: translateY(100%);
}
.raw-modal {
--small-modal-width: 525px;
--medium-modal-width: 712px;
--large-modal-width: 1024px;
width: 100%;
max-height: 100%;
border-radius: var(--bo-radius);
box-shadow: var(--bs-popup);
outline: none;
overflow: hidden;
&__small {
max-width: var(--small-modal-width);
}
&__medium {
max-width: var(--medium-modal-width);
}
&__large {
max-width: var(--large-modal-width);
}
&__overlay {
position: fixed;
top: 0;
left: 0;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
padding: var(--sp-normal);
width: 100%;
height: 100%;
background-color: var(--bg-overlay);
}
}

View file

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ScrollView.scss';
const ScrollView = React.forwardRef(({
horizontal, vertical, autoHide, invisible, onScroll, children,
}, ref) => {
let scrollbarClasses = '';
if (horizontal) scrollbarClasses += ' scrollbar__h';
if (vertical) scrollbarClasses += ' scrollbar__v';
if (autoHide) scrollbarClasses += ' scrollbar--auto-hide';
if (invisible) scrollbarClasses += ' scrollbar--invisible';
return (
<div onScroll={onScroll} ref={ref} className={`scrollbar${scrollbarClasses}`}>
{children}
</div>
);
});
ScrollView.defaultProps = {
horizontal: false,
vertical: true,
autoHide: false,
invisible: false,
onScroll: null,
};
ScrollView.propTypes = {
horizontal: PropTypes.bool,
vertical: PropTypes.bool,
autoHide: PropTypes.bool,
invisible: PropTypes.bool,
onScroll: PropTypes.func,
children: PropTypes.node.isRequired,
};
export default ScrollView;

View file

@ -0,0 +1,22 @@
@use '_scrollbar';
.scrollbar {
width: 100%;
height: 100%;
@include scrollbar.scroll;
&__h {
@include scrollbar.scroll__h;
}
&__v {
@include scrollbar.scroll__v;
}
&--auto-hide {
@include scrollbar.scroll--auto-hide;
}
&--invisible {
@include scrollbar.scroll--invisible;
}
}

View file

@ -0,0 +1,62 @@
.firefox-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--bg-surface-hover) transparent;
&--transparent {
scrollbar-color: transparent transparent;
}
}
.webkit-scrollbar {
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
}
.webkit-scrollbar-track {
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
.webkit-scrollbar-thumb {
&::-webkit-scrollbar-thumb {
background-color: var(--bg-surface-hover);
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--bg-surface-active);
}
&--transparent {
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
}
@mixin scroll {
overflow: hidden;
@extend .firefox-scrollbar;
@extend .webkit-scrollbar;
@extend .webkit-scrollbar-track;
@extend .webkit-scrollbar-thumb;
}
@mixin scroll__h {
overflow-x: scroll;
}
@mixin scroll__v {
overflow-y: scroll;
}
@mixin scroll--auto-hide {
@extend .firefox-scrollbar--transparent;
@extend .webkit-scrollbar-thumb--transparent;
&:hover {
@extend .firefox-scrollbar;
@extend .webkit-scrollbar-thumb;
}
}
@mixin scroll--invisible {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

View file

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './SegmentedControls.scss';
import { blurOnBubbling } from '../button/script';
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
function SegmentedControls({
selected, segments, onSelect,
}) {
const [select, setSelect] = useState(selected);
function selectSegment(segmentIndex) {
setSelect(segmentIndex);
onSelect(segmentIndex);
}
return (
<div className="segmented-controls">
{
segments.map((segment, index) => (
<button
key={Math.random().toString(20).substr(2, 6)}
className={`segment-btn${select === index ? ' segment-btn--active' : ''}`}
type="button"
onClick={() => selectSegment(index)}
onMouseUp={(e) => blurOnBubbling(e, '.segment-btn')}
>
<div className="segment-btn__base">
{segment.iconSrc && <RawIcon size="small" src={segment.iconSrc} />}
{segment.text && <Text variant="b2">{segment.text}</Text>}
</div>
</button>
))
}
</div>
);
}
SegmentedControls.propTypes = {
selected: PropTypes.number.isRequired,
segments: PropTypes.arrayOf(PropTypes.shape({
iconSrc: PropTypes.string,
text: PropTypes.string,
})).isRequired,
onSelect: PropTypes.func.isRequired,
};
export default SegmentedControls;

View file

@ -0,0 +1,61 @@
@use '../button/state';
.segmented-controls {
background-color: var(--bg-surface-low);
border-radius: var(--bo-radius);
border: 1px solid var(--bg-surface-border);
display: inline-flex;
overflow: hidden;
}
.segment-btn {
padding: var(--sp-extra-tight) 0;
cursor: pointer;
@include state.hover(var(--bg-surface-hover));
@include state.active(var(--bg-surface-active));
&__base {
padding: 0 var(--sp-normal);
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border-left: none;
border-right: 1px solid var(--bg-surface-border);
}
& .text:nth-child(2) {
margin: 0 var(--sp-extra-tight);
}
}
&:first-child &__base {
border: none;
}
&--active {
background-color: var(--bg-surface);
border: 1px solid var(--bg-surface-border);
border-width: 0 1px 0 1px;
& .segment-btn__base,
& + .segment-btn .segment-btn__base {
border: none;
}
&:first-child{
border-left: none;
}
&:last-child {
border-right: none;
}
[dir=rtl] & {
border-left: 1px solid var(--bg-surface-border);
border-right: 1px solid var(--bg-surface-border);
&:first-child { border-right: none;}
&:last-child { border-left: none;}
}
}
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Spinner.scss';
function Spinner({ size }) {
return (
<div className={`donut-spinner donut-spinner--${size}`}> </div>
);
}
Spinner.defaultProps = {
size: 'normal',
};
Spinner.propTypes = {
size: PropTypes.oneOf(['normal', 'small']),
};
export default Spinner;

View file

@ -0,0 +1,22 @@
.donut-spinner {
display: inline-block;
border: 4px solid var(--bg-surface-border);
border-left-color: var(--tc-surface-normal);
border-radius: 50%;
animation: donut-spin 1.2s cubic-bezier(0.73, 0.32, 0.67, 0.86) infinite;
&--normal {
width: 40px;
height: 40px;
}
&--small {
width: 28px;
height: 28px;
}
}
@keyframes donut-spin {
to {
transform: rotate(1turn);
}
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import './RawIcon.scss';
function RawIcon({ color, size, src }) {
const style = {
WebkitMaskImage: `url(${src})`,
maskImage: `url(${src})`,
};
if (color !== null) style.backgroundColor = color;
return <span className={`ic-raw ic-raw-${size}`} style={style}> </span>;
}
RawIcon.defaultProps = {
color: null,
size: 'normal',
};
RawIcon.propTypes = {
color: PropTypes.string,
size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']),
src: PropTypes.string.isRequired,
};
export default RawIcon;

View file

@ -0,0 +1,25 @@
@mixin icSize($size) {
width: $size;
height: $size;
}
.ic-raw {
display: inline-block;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: cover;
mask-size: cover;
background-color: var(--ic-surface-normal);
}
.ic-raw-large {
@include icSize(var(--ic-large));
}
.ic-raw-normal {
@include icSize(var(--ic-normal));
}
.ic-raw-small {
@include icSize(var(--ic-small));
}
.ic-raw-extra-small {
@include icSize(var(--ic-extra-small));
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Text.scss';
function Text({
id, className, variant, children,
}) {
const cName = className !== '' ? `${className} ` : '';
if (variant === 'h1') return <h1 id={id === '' ? undefined : id} className={`${cName}text text-h1`}>{ children }</h1>;
if (variant === 'h2') return <h2 id={id === '' ? undefined : id} className={`${cName}text text-h2`}>{ children }</h2>;
if (variant === 's1') return <h4 id={id === '' ? undefined : id} className={`${cName}text text-s1`}>{ children }</h4>;
return <p id={id === '' ? undefined : id} className={`${cName}text text-${variant}`}>{ children }</p>;
}
Text.defaultProps = {
id: '',
className: '',
variant: 'b1',
};
Text.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']),
children: PropTypes.node.isRequired,
};
export default Text;

View file

@ -0,0 +1,41 @@
@mixin font($type, $weight) {
font-size: var(--fs-#{$type});
font-weight: $weight;
letter-spacing: var(--ls-#{$type});
line-height: var(--lh-#{$type});
}
%text {
margin: 0;
padding: 0;
color: var(--tc-surface-high);
}
.text-h1 {
@extend %text;
@include font(h1, 500);
}
.text-h2 {
@extend %text;
@include font(h2, 500);
}
.text-s1 {
@extend %text;
@include font(s1, 400);
}
.text-b1 {
@extend %text;
@include font(b1, 400);
color: var(--tc-surface-normal);
}
.text-b2 {
@extend %text;
@include font(b2, 400);
color: var(--tc-surface-normal);
}
.text-b3 {
@extend %text;
@include font(b3, 400);
color: var(--tc-surface-low);
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ChannelIntro.scss';
import Linkify from 'linkifyjs/react';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function ChannelIntro({
avatarSrc, name, heading, desc, time,
}) {
return (
<div className="channel-intro">
<Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(name)} size="large" />
<div className="channel-intro__content">
<Text className="channel-intro__name" variant="h1">{heading}</Text>
<Text className="channel-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
{ time !== null && <Text className="channel-intro__time" variant="b3">{time}</Text>}
</div>
</div>
);
}
ChannelIntro.defaultProps = {
avatarSrc: false,
time: null,
};
ChannelIntro.propTypes = {
avatarSrc: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
name: PropTypes.string.isRequired,
heading: PropTypes.string.isRequired,
desc: PropTypes.string.isRequired,
time: PropTypes.string,
};
export default ChannelIntro;

View file

@ -0,0 +1,31 @@
.channel-intro {
margin-top: calc(2 * var(--sp-extra-loose));
margin-bottom: var(--sp-extra-loose);
padding-left: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
padding-right: var(--sp-extra-tight);
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
}
}
.channel-intro__content {
margin-top: var(--sp-extra-loose);
max-width: 640px;
}
&__name {
color: var(--tc-surface-high);
}
&__desc {
color: var(--tc-surface-normal);
margin: var(--sp-tight) 0 var(--sp-extra-tight);
& a {
word-break: break-all;
}
}
&__time {
color: var(--tc-surface-low);
}
}

View file

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ChannelSelector.scss';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
import NotificationBadge from '../../atoms/badge/NotificationBadge';
import { blurOnBubbling } from '../../atoms/button/script';
function ChannelSelector({
selected, unread, notificationCount, alert,
iconSrc, imageSrc, roomId, onClick, children,
}) {
return (
<button
className={`channel-selector__button-wrapper${selected ? ' channel-selector--selected' : ''}`}
type="button"
onClick={onClick}
onMouseUp={(e) => blurOnBubbling(e, '.channel-selector__button-wrapper')}
>
<div className="channel-selector">
<div className="channel-selector__icon flex--center">
<Avatar
text={children.slice(0, 1)}
bgColor={colorMXID(roomId)}
imageSrc={imageSrc}
iconSrc={iconSrc}
size="extra-small"
/>
</div>
<div className="channel-selector__text-container">
<Text variant="b1">{children}</Text>
</div>
<div className="channel-selector__badge-container">
{
notificationCount !== 0
? unread && (
<NotificationBadge alert={alert}>
{notificationCount}
</NotificationBadge>
)
: unread && <div className="channel-selector--unread" />
}
</div>
</div>
</button>
);
}
ChannelSelector.defaultProps = {
selected: false,
unread: false,
notificationCount: 0,
alert: false,
iconSrc: null,
imageSrc: null,
};
ChannelSelector.propTypes = {
selected: PropTypes.bool,
unread: PropTypes.bool,
notificationCount: PropTypes.number,
alert: PropTypes.bool,
iconSrc: PropTypes.string,
imageSrc: PropTypes.string,
roomId: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
children: PropTypes.string.isRequired,
};
export default ChannelSelector;

View file

@ -0,0 +1,66 @@
.channel-selector__button-wrapper {
display: block;
width: calc(100% - var(--sp-extra-tight));
margin-left: auto;
padding: var(--sp-extra-tight) var(--sp-extra-tight);
border: 1px solid transparent;
border-radius: var(--bo-radius);
cursor: pointer;
[dir=rtl] & {
margin: {
left: 0;
right: auto;
}
}
@media (hover: hover) {
&:hover {
background-color: var(--bg-surface-hover);
}
}
&:focus {
outline: none;
background-color: var(--bg-surface-hover);
}
&:active {
background-color: var(--bg-surface-active);
}
}
.channel-selector {
display: flex;
align-items: center;
&__icon {
width: 24px;
height: 24px;
.avatar__border {
box-shadow: none;
}
}
&__text-container {
flex: 1;
min-width: 0;
margin: 0 var(--sp-extra-tight);
& .text {
color: var(--tc-surface-normal);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.channel-selector--unread {
margin: 0 var(--sp-ultra-tight);
height: 8px;
width: 8px;
background-color: var(--tc-surface-low);
border-radius: 50%;
opacity: .4;
}
.channel-selector--selected {
background-color: var(--bg-surface);
border-color: var(--bg-surface-border);
}

View file

@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import './ChannelTile.scss';
import Linkify from 'linkifyjs/react';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function ChannelTile({
avatarSrc, name, id,
inviterName, memberCount, desc, options,
}) {
return (
<div className="channel-tile">
<div className="channel-tile__avatar">
<Avatar
imageSrc={avatarSrc}
bgColor={colorMXID(id)}
text={name.slice(0, 1)}
/>
</div>
<div className="channel-tile__content">
<Text variant="s1">{name}</Text>
<Text variant="b3">
{
inviterName !== null
? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : `${memberCount} members`}`
: id + (memberCount === null ? '' : `${memberCount} members`)
}
</Text>
{
desc !== null && (typeof desc === 'string')
? <Text className="channel-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
: desc
}
</div>
{ options !== null && (
<div className="channel-tile__options">
{options}
</div>
)}
</div>
);
}
ChannelTile.defaultProps = {
avatarSrc: null,
inviterName: null,
options: null,
desc: null,
memberCount: null,
};
ChannelTile.propTypes = {
avatarSrc: PropTypes.string,
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
inviterName: PropTypes.string,
memberCount: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
desc: PropTypes.node,
options: PropTypes.node,
};
export default ChannelTile;

View file

@ -0,0 +1,21 @@
.channel-tile {
display: flex;
&__content {
flex: 1;
min-width: 0;
margin: 0 var(--sp-normal);
&__desc {
white-space: pre-wrap;
& a {
white-space: wrap;
}
}
& .text:not(:first-child) {
margin-top: var(--sp-ultra-tight);
}
}
}

View file

@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './Media.scss';
import encrypt from 'browser-encrypt-attachment';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
// https://github.com/matrix-org/matrix-react-sdk/blob/a9e28db33058d1893d964ec96cd247ecc3d92fc3/src/utils/blobs.ts#L73
const ALLOWED_BLOB_MIMETYPES = [
'image/jpeg',
'image/gif',
'image/png',
'video/mp4',
'video/webm',
'video/ogg',
'audio/mp4',
'audio/webm',
'audio/aac',
'audio/mpeg',
'audio/ogg',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wav',
'audio/flac',
'audio/x-flac',
];
function getBlobSafeMimeType(mimetype) {
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
return 'application/octet-stream';
}
return mimetype;
}
async function getDecryptedBlob(response, type, decryptData) {
const arrayBuffer = await response.arrayBuffer();
const dataArray = await encrypt.decryptAttachment(arrayBuffer, decryptData);
const blob = new Blob([dataArray], { type: getBlobSafeMimeType(type) });
return blob;
}
async function getUrl(link, type, decryptData) {
try {
const response = await fetch(link, { method: 'GET' });
if (decryptData !== null) {
return URL.createObjectURL(await getDecryptedBlob(response, type, decryptData));
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (e) {
return link;
}
}
function getNativeHeight(width, height) {
const MEDIA_MAX_WIDTH = 296;
const scale = MEDIA_MAX_WIDTH / width;
return scale * height;
}
function FileHeader({
name, link, external,
file, type,
}) {
const [url, setUrl] = useState(null);
async function getFile() {
const myUrl = await getUrl(link, type, file);
setUrl(myUrl);
}
async function handleDownload(e) {
if (file !== null && url === null) {
e.preventDefault();
await getFile();
e.target.click();
}
}
return (
<div className="file-header">
<Text className="file-name" variant="b3">{name}</Text>
{ link !== null && (
<>
{
external && (
<IconButton
size="extra-small"
tooltip="Open in new tab"
src={ExternalSVG}
onClick={() => window.open(url || link)}
/>
)
}
<a href={url || link} download={name} target="_blank" rel="noreferrer">
<IconButton
size="extra-small"
tooltip="Download"
src={DownloadSVG}
onClick={handleDownload}
/>
</a>
</>
)}
</div>
);
}
FileHeader.defaultProps = {
external: false,
file: null,
link: null,
};
FileHeader.propTypes = {
name: PropTypes.string.isRequired,
link: PropTypes.string,
external: PropTypes.bool,
file: PropTypes.shape({}),
type: PropTypes.string.isRequired,
};
function File({
name, link, file, type,
}) {
return (
<div className="file-container">
<FileHeader name={name} link={link} file={file} type={type} />
</div>
);
}
File.defaultProps = {
file: null,
};
File.propTypes = {
name: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
file: PropTypes.shape({}),
};
function Image({
name, width, height, link, file, type,
}) {
const [url, setUrl] = useState(null);
useEffect(() => {
let unmounted = false;
async function fetchUrl() {
const myUrl = await getUrl(link, type, file);
if (unmounted) return;
setUrl(myUrl);
}
fetchUrl();
return () => {
unmounted = true;
};
}, []);
return (
<div className="file-container">
<FileHeader name={name} link={url || link} type={type} external />
<div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
{ url !== null && <img src={url || link} alt={name} />}
</div>
</div>
);
}
Image.defaultProps = {
file: null,
width: null,
height: null,
};
Image.propTypes = {
name: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
link: PropTypes.string.isRequired,
file: PropTypes.shape({}),
type: PropTypes.string.isRequired,
};
function Audio({
name, link, type, file,
}) {
const [isLoading, setIsLoading] = useState(false);
const [url, setUrl] = useState(null);
async function loadAudio() {
const myUrl = await getUrl(link, type, file);
setUrl(myUrl);
setIsLoading(false);
}
function handlePlayAudio() {
setIsLoading(true);
loadAudio();
}
return (
<div className="file-container">
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
<div className="audio-container">
{ url === null && isLoading && <Spinner size="small" /> }
{ url === null && !isLoading && <IconButton onClick={handlePlayAudio} tooltip="Play audio" src={PlaySVG} />}
{ url !== null && (
/* eslint-disable-next-line jsx-a11y/media-has-caption */
<audio autoPlay controls>
<source src={url} type={getBlobSafeMimeType(type)} />
</audio>
)}
</div>
</div>
);
}
Audio.defaultProps = {
file: null,
};
Audio.propTypes = {
name: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
file: PropTypes.shape({}),
};
function Video({
name, link, thumbnail,
width, height, file, type, thumbnailFile, thumbnailType,
}) {
const [isLoading, setIsLoading] = useState(false);
const [url, setUrl] = useState(null);
const [thumbUrl, setThumbUrl] = useState(null);
useEffect(() => {
let unmounted = false;
async function fetchUrl() {
const myThumbUrl = await getUrl(thumbnail, thumbnailType, thumbnailFile);
if (unmounted) return;
setThumbUrl(myThumbUrl);
}
if (thumbnail !== null) fetchUrl();
return () => {
unmounted = true;
};
}, []);
async function loadVideo() {
const myUrl = await getUrl(link, type, file);
setUrl(myUrl);
setIsLoading(false);
}
function handlePlayVideo() {
setIsLoading(true);
loadVideo();
}
return (
<div className="file-container">
<FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
<div
style={{
height: width !== null ? getNativeHeight(width, height) : 'unset',
backgroundImage: thumbUrl === null ? 'none' : `url(${thumbUrl}`,
}}
className="video-container"
>
{ url === null && isLoading && <Spinner size="small" /> }
{ url === null && !isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
{ url !== null && (
/* eslint-disable-next-line jsx-a11y/media-has-caption */
<video autoPlay controls poster={thumbUrl}>
<source src={url} type={getBlobSafeMimeType(type)} />
</video>
)}
</div>
</div>
);
}
Video.defaultProps = {
width: null,
height: null,
file: null,
thumbnail: null,
thumbnailType: null,
thumbnailFile: null,
};
Video.propTypes = {
name: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
thumbnail: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
file: PropTypes.shape({}),
type: PropTypes.string.isRequired,
thumbnailFile: PropTypes.shape({}),
thumbnailType: PropTypes.string,
};
export {
File, Image, Audio, Video,
};

View file

@ -0,0 +1,62 @@
.file-header {
display: flex;
align-items: center;
padding: var(--sp-ultra-tight) var(--sp-tight);
min-height: 42px;
& .file-name {
flex: 1;
color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.file-container {
--media-max-width: 296px;
background-color: var(--bg-surface-hover);
border-radius: calc(var(--bo-radius) / 2);
overflow: hidden;
max-width: var(--media-max-width);
white-space: initial;
}
.image-container,
.video-container,
.audio-container {
font-size: 0;
line-height: 0;
display: flex;
justify-content: center;
align-items: center;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.image-container {
& img {
max-width: unset !important;
width: 100% !important;
border-radius: 0 !important;
margin: 0 !important;
}
}
.video-container {
& .ic-btn-surface {
background-color: var(--bg-surface-low);
}
video {
width: 100%
}
}
.audio-container {
audio {
width: 100%
}
}

View file

@ -0,0 +1,149 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Message.scss';
import Linkify from 'linkifyjs/react';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import Avatar from '../../atoms/avatar/Avatar';
import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
const components = {
code({
// eslint-disable-next-line react/prop-types
inline, className, children,
}) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={coy}
language={match[1]}
PreTag="div"
showLineNumbers
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className}>{String(children)}</code>
);
},
};
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function genMarkdown(content) {
return <ReactMarkdown remarkPlugins={[gfm]} components={components} linkTarget="_blank">{content}</ReactMarkdown>;
}
function PlaceholderMessage() {
return (
<div className="ph-msg">
<div className="ph-msg__avatar-container">
<div className="ph-msg__avatar" />
</div>
<div className="ph-msg__main-container">
<div className="ph-msg__header" />
<div className="ph-msg__content">
<div />
<div />
<div />
<div />
</div>
</div>
</div>
);
}
function Message({
color, avatarSrc, name, content,
time, markdown, contentOnly, reply,
edited, reactions,
}) {
const msgClass = contentOnly ? 'message--content-only' : 'message--full';
return (
<div className={`message ${msgClass}`}>
<div className="message__avatar-container">
{!contentOnly && <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={color} size="small" />}
</div>
<div className="message__main-container">
{ !contentOnly && (
<div className="message__header">
<div style={{ color }} className="message__profile">
<Text variant="b1">{name}</Text>
</div>
<div className="message__time">
<Text variant="b3">{time}</Text>
</div>
</div>
)}
<div className="message__content">
{ reply !== null && (
<div className="message__reply-content">
<Text variant="b2">
<RawIcon color={reply.color} size="extra-small" src={ReplyArrowIC} />
<span style={{ color: reply.color }}>{reply.to}</span>
<>{` ${reply.content}`}</>
</Text>
</div>
)}
<div className="text text-b1">
{ markdown ? genMarkdown(content) : linkifyContent(content) }
</div>
{ edited && <Text className="message__edited" variant="b3">(edited)</Text>}
{ reactions && (
<div className="message__reactions text text-b3 noselect">
{
reactions.map((reaction) => (
<button key={reaction.id} onClick={() => alert('Sending reactions is yet to be implemented.')} type="button" className={`msg__reaction${reaction.active ? ' msg__reaction--active' : ''}`}>
{`${reaction.key} ${reaction.count}`}
</button>
))
}
</div>
)}
</div>
</div>
</div>
);
}
Message.defaultProps = {
color: 'var(--tc-surface-high)',
avatarSrc: null,
markdown: false,
contentOnly: false,
reply: null,
edited: false,
reactions: null,
};
Message.propTypes = {
color: PropTypes.string,
avatarSrc: PropTypes.string,
name: PropTypes.string.isRequired,
content: PropTypes.node.isRequired,
time: PropTypes.string.isRequired,
markdown: PropTypes.bool,
contentOnly: PropTypes.bool,
reply: PropTypes.shape({
color: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
}),
edited: PropTypes.bool,
reactions: PropTypes.arrayOf(PropTypes.exact({
id: PropTypes.string,
key: PropTypes.string,
count: PropTypes.number,
active: PropTypes.bool,
})),
};
export { Message as default, PlaceholderMessage };

View file

@ -0,0 +1,293 @@
@use '../../atoms/scroll/scrollbar';
.message,
.ph-msg {
padding: var(--sp-ultra-tight) var(--sp-normal);
padding-right: var(--sp-extra-tight);
display: flex;
&:hover {
background-color: var(--bg-surface-hover);
}
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__avatar-container {
padding-top: 6px;
}
&__avatar-container,
&__profile {
margin-right: var(--sp-tight);
[dir=rtl] & {
margin: {
left: var(--sp-tight);
right: 0;
}
}
}
&__main-container {
flex: 1;
min-width: 0;
}
}
.message {
&--full + &--full,
&--content-only + &--full,
& + .timeline-change,
.timeline-change + & {
margin-top: var(--sp-normal);
}
&__avatar-container {
width: var(--av-small);
}
&__reply-content {
.text {
color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ic-raw {
width: 16px;
height: 14px;
}
}
&__edited {
color: var(--tc-surface-low);
}
&__reactions {
margin-top: var(--sp-ultra-tight);
}
}
.ph-msg {
&__avatar {
width: var(--av-small);
height: var(--av-small);
background-color: var(--bg-surface-hover);
border-radius: var(--bo-radius);
}
&__header,
&__content > div {
margin: var(--sp-ultra-tight) 0;
margin-right: var(--sp-extra-tight);
height: var(--fs-b1);
width: 100%;
max-width: 100px;
background-color: var(--bg-surface-hover);
border-radius: calc(var(--bo-radius) / 2);
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
}
&__content {
display: flex;
flex-wrap: wrap;
}
&__content > div:nth-child(1n) {
max-width: 10%;
}
&__content > div:nth-child(2n) {
max-width: 50%;
}
}
.message__header {
display: flex;
align-items: baseline;
& .message__profile {
flex: 1;
min-width: 0;
color: var(--tc-surface-high);
& > .text {
color: inherit;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
& .message__time {
& > .text {
color: var(--tc-surface-low);
}
}
}
.message__content {
max-width: 640px;
word-break: break-word;
& > .text > * {
white-space: pre-wrap;
}
& a {
word-break: break-all;
}
}
.msg__reaction {
--reaction-height: 24px;
--reaction-padding: 6px;
--reaction-radius: calc(var(--bo-radius) / 2);
display: inline-flex;
align-items: center;
color: var(--tc-surface-normal);
border: 1px solid var(--bg-surface-border);
padding: 0 var(--reaction-padding);
border-radius: var(--reaction-radius);
cursor: pointer;
height: var(--reaction-height);
margin-right: var(--sp-extra-tight);
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
@media (hover: hover) {
&:hover {
background-color: var(--bg-surface-hover);
}
}
&:active {
background-color: var(--bg-surface-active)
}
&--active {
background-color: var(--bg-caution-active);
@media (hover: hover) {
&:hover {
background-color: var(--bg-caution-hover);
}
}
&:active {
background-color: var(--bg-caution-active)
}
}
}
// markdown formating
.message {
& h1,
& h2 {
color: var(--tc-surface-high);
margin: var(--sp-extra-loose) 0 var(--sp-normal);
line-height: var(--lh-h1);
}
& h3,
& h4 {
color: var(--tc-surface-high);
margin: var(--sp-loose) 0 var(--sp-tight);
line-height: var(--lh-h2);
}
& h5,
& h6 {
color: var(--tc-surface-high);
margin: var(--sp-normal) 0 var(--sp-extra-tight);
line-height: var(--lh-s1);
}
& hr {
border-color: var(--bg-surface-border);
}
.text img {
margin: var(--sp-ultra-tight) 0;
max-width: 296px;
border-radius: calc(var(--bo-radius) / 2);
}
& p,
& pre,
& blockquote {
margin: 0;
padding: 0;
}
& pre,
& blockquote {
margin: var(--sp-ultra-tight) 0;
padding: var(--sp-extra-tight);
background-color: var(--bg-surface-hover) !important;
border-radius: calc(var(--bo-radius) / 2);
}
& pre {
div {
background: none !important;
margin: 0 !important;
}
span {
background: none !important;
}
.linenumber {
min-width: 2.25em !important;
}
}
& code {
padding: 0 !important;
color: var(--tc-code) !important;
white-space: pre-wrap;
@include scrollbar.scroll;
@include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide;
}
& pre code {
color: var(--tc-surface-normal) !important;
}
& blockquote {
padding-left: var(--sp-extra-tight);
border-left: 4px solid var(--bg-surface-active);
white-space: initial !important;
& > * {
white-space: pre-wrap;
}
[dir=rtl] & {
padding: {
left: 0;
right: var(--sp-extra-tight);
}
border: {
left: none;
right: 4px solid var(--bg-surface-active);
}
}
}
& ul,
& ol {
margin: var(--sp-ultra-tight) 0;
padding-left: 24px;
white-space: initial !important;
& > * {
white-space: pre-wrap;
}
[dir=rtl] & {
padding: {
left: 0;
right: 24px;
}
}
}
}

View file

@ -0,0 +1,79 @@
import React from 'react';
import PropTypes from 'prop-types';
import './TimelineChange.scss';
// import Linkify from 'linkifyjs/react';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
function TimelineChange({ variant, content, time }) {
let iconSrc;
switch (variant) {
case 'join':
iconSrc = JoinArraowIC;
break;
case 'leave':
iconSrc = LeaveArraowIC;
break;
case 'invite':
iconSrc = InviteArraowIC;
break;
case 'invite-cancel':
iconSrc = InviteCancelArraowIC;
break;
case 'avatar':
iconSrc = UserIC;
break;
case 'follow':
iconSrc = TickMarkIC;
break;
default:
iconSrc = JoinArraowIC;
break;
}
return (
<div className="timeline-change">
<div className="timeline-change__avatar-container">
<RawIcon src={iconSrc} size="extra-small" />
</div>
<div className="timeline-change__content">
<Text variant="b2">
{content}
{/* <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify> */}
</Text>
</div>
<div className="timeline-change__time">
<Text variant="b3">{time}</Text>
</div>
</div>
);
}
TimelineChange.defaultProps = {
variant: 'other',
};
TimelineChange.propTypes = {
variant: PropTypes.oneOf([
'join', 'leave', 'invite',
'invite-cancel', 'avatar', 'other',
'follow',
]),
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
]).isRequired,
time: PropTypes.string.isRequired,
};
export default TimelineChange;

View file

@ -0,0 +1,39 @@
.timeline-change {
padding: var(--sp-ultra-tight) var(--sp-normal);
padding-right: var(--sp-extra-tight);
display: flex;
align-items: center;
&:hover {
background-color: var(--bg-surface-hover);
}
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__avatar-container {
width: var(--av-small);
display: inline-flex;
justify-content: center;
align-items: center;
opacity: 0.38;
.ic-raw {
background-color: var(--tc-surface-low);
}
}
& .text {
color: var(--tc-surface-low);
}
&__content {
flex: 1;
min-width: 0;
margin: 0 var(--sp-tight);
}
}

View file

@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import './PeopleSelector.scss';
import { blurOnBubbling } from '../../atoms/button/script';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
function PeopleSelector({
avatarSrc, name, color, peopleRole, onClick,
}) {
return (
<div className="people-selector__container">
<button
className="people-selector"
onMouseUp={(e) => blurOnBubbling(e, '.people-selector')}
onClick={onClick}
type="button"
>
<Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={color} size="extra-small" />
<Text className="people-selector__name" variant="b1">{name}</Text>
{peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
</button>
</div>
);
}
PeopleSelector.defaultProps = {
avatarSrc: null,
peopleRole: null,
};
PeopleSelector.propTypes = {
avatarSrc: PropTypes.string,
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
peopleRole: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
export default PeopleSelector;

View file

@ -0,0 +1,40 @@
.people-selector {
width: 100%;
padding: var(--sp-extra-tight);
padding-left: var(--sp-normal);
display: flex;
align-items: center;
cursor: pointer;
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
@media (hover: hover) {
&:hover {
background-color: var(--bg-surface-hover);
}
}
&:focus {
outline: none;
background-color: var(--bg-surface-hover);
}
&:active {
background-color: var(--bg-surface-active);
}
&__name {
flex: 1;
min-width: 0;
margin: 0 var(--sp-tight);
color: var(--tc-surface-normal);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__role {
color: var(--tc-surface-low);
}
}

View file

@ -0,0 +1,123 @@
import React from 'react';
import PropTypes from 'prop-types';
import './PopupWindow.scss';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import { MenuItem } from '../../atoms/context-menu/ContextMenu';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import ScrollView from '../../atoms/scroll/ScrollView';
import RawModal from '../../atoms/modal/RawModal';
import ChevronLeftIC from '../../../../public/res/ic/outlined/chevron-left.svg';
function PWContentSelector({
selected, variant, iconSrc,
type, onClick, children,
}) {
const pwcsClass = selected ? ' pw-content-selector--selected' : '';
return (
<div className={`pw-content-selector${pwcsClass}`}>
<MenuItem
variant={variant}
iconSrc={iconSrc}
type={type}
onClick={onClick}
>
{children}
</MenuItem>
</div>
);
}
PWContentSelector.defaultProps = {
selected: false,
variant: 'surface',
iconSrc: 'none',
type: 'button',
};
PWContentSelector.propTypes = {
selected: PropTypes.bool,
variant: PropTypes.oneOf(['surface', 'caution', 'danger']),
iconSrc: PropTypes.string,
type: PropTypes.oneOf(['button', 'submit']),
onClick: PropTypes.func.isRequired,
children: PropTypes.string.isRequired,
};
function PopupWindow({
className, isOpen, title, contentTitle,
drawer, drawerOptions, contentOptions,
onRequestClose, children,
}) {
const haveDrawer = drawer !== null;
return (
<RawModal
className={`${className === null ? '' : `${className} `}pw-model`}
isOpen={isOpen}
onRequestClose={onRequestClose}
size={haveDrawer ? 'large' : 'medium'}
>
<div className="pw">
{haveDrawer && (
<div className="pw__drawer">
<Header>
<IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
<TitleWrapper>
<Text variant="s1">{title}</Text>
</TitleWrapper>
{drawerOptions}
</Header>
<div className="pw__drawer__content__wrapper">
<ScrollView invisible>
<div className="pw__drawer__content">
{drawer}
</div>
</ScrollView>
</div>
</div>
)}
<div className="pw__content">
<Header>
<TitleWrapper>
<Text variant="h2">{contentTitle !== null ? contentTitle : title}</Text>
</TitleWrapper>
{contentOptions}
</Header>
<div className="pw__content__wrapper">
<ScrollView autoHide>
<div className="pw__content-container">
{children}
</div>
</ScrollView>
</div>
</div>
</div>
</RawModal>
);
}
PopupWindow.defaultProps = {
className: null,
drawer: null,
contentTitle: null,
drawerOptions: null,
contentOptions: null,
onRequestClose: null,
};
PopupWindow.propTypes = {
className: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
contentTitle: PropTypes.string,
drawer: PropTypes.node,
drawerOptions: PropTypes.node,
contentOptions: PropTypes.node,
onRequestClose: PropTypes.func,
children: PropTypes.node.isRequired,
};
export { PopupWindow as default, PWContentSelector };

View file

@ -0,0 +1,100 @@
.pw-model {
--modal-height: 656px;
max-height: var(--modal-height) !important;
height: 100%;
}
.pw {
--popup-window-drawer-width: 312px;
width: 100%;
height: 100%;
background-color: var(--bg-surface);
display: flex;
&__drawer {
width: var(--popup-window-drawer-width);
background-color: var(--bg-surface-low);
border-right: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border: {
right: none;
left: 1px solid var(--bg-surface-border);
}
}
}
&__content {
flex: 1;
min-width: 0;
}
&__drawer,
&__content {
display: flex;
flex-direction: column;
}
}
.pw__drawer__content,
.pw__content-container {
padding-top: var(--sp-extra-tight);
padding-bottom: var(--sp-extra-loose);
}
.pw__drawer__content__wrapper,
.pw__content__wrapper {
flex: 1;
min-height: 0;
}
.pw__drawer {
& .header {
padding-left: var(--sp-extra-tight);
& .ic-btn-surface:first-child {
margin-right: var(--sp-ultra-tight);
}
[dir=rtl] & {
padding-right: var(--sp-extra-tight);
& .ic-btn-surface:first-child {
margin-right: 0;
margin-left: var(--sp-ultra-tight);
}
}
}
}
.pw-content-selector {
&--selected {
border: 1px solid var(--bg-surface-border);
border-width: 1px 0;
background-color: var(--bg-surface);
& .context-menu__item > button {
&:hover {
background-color: transparent;
}
}
}
& .context-menu__item > button {
& .text {
color: var(--tc-surface-normal);
}
padding-left: var(--sp-normal);
& .ic-raw {
margin-right: var(--sp-tight);
}
[dir=rtl] & {
padding-right: var(--sp-normal);
& .ic-raw {
margin-right: 0;
margin-left: var(--sp-tight);
}
}
}
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import './SettingTile.scss';
import Text from '../../atoms/text/Text';
function SettingTile({ title, options, content }) {
return (
<div className="setting-tile">
<div className="setting-tile__title__wrapper">
<div className="setting-tile__title">
<Text variant="b1">{title}</Text>
</div>
{options !== null && <div className="setting-tile__options">{options}</div>}
</div>
{content !== null && <div className="setting-tile__content">{content}</div>}
</div>
);
}
SettingTile.defaultProps = {
options: null,
content: null,
};
SettingTile.propTypes = {
title: PropTypes.string.isRequired,
options: PropTypes.node,
content: PropTypes.node,
};
export default SettingTile;

View file

@ -0,0 +1,16 @@
.setting-tile {
&__title__wrapper {
display: flex;
align-items: center;
}
&__title {
flex: 1;
min-width: 0;
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import './SidebarAvatar.scss';
import Tippy from '@tippyjs/react';
import Avatar from '../../atoms/avatar/Avatar';
import Text from '../../atoms/text/Text';
import NotificationBadge from '../../atoms/badge/NotificationBadge';
import { blurOnBubbling } from '../../atoms/button/script';
const SidebarAvatar = React.forwardRef(({
tooltip, text, bgColor, imageSrc,
iconSrc, active, onClick, notifyCount,
}, ref) => {
let activeClass = '';
if (active) activeClass = ' sidebar-avatar--active';
return (
<Tippy
content={<Text variant="b1">{tooltip}</Text>}
className="sidebar-avatar-tippy"
touch="hold"
arrow={false}
placement="right"
maxWidth={200}
delay={[0, 0]}
duration={[100, 0]}
offset={[0, 0]}
>
<button
ref={ref}
className={`sidebar-avatar${activeClass}`}
type="button"
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
onClick={onClick}
>
<Avatar
text={text}
bgColor={bgColor}
imageSrc={imageSrc}
iconSrc={iconSrc}
size="normal"
/>
{ notifyCount !== null && <NotificationBadge alert>{notifyCount}</NotificationBadge> }
</button>
</Tippy>
);
});
SidebarAvatar.defaultProps = {
text: null,
bgColor: 'transparent',
iconSrc: null,
imageSrc: null,
active: false,
onClick: null,
notifyCount: null,
};
SidebarAvatar.propTypes = {
tooltip: PropTypes.string.isRequired,
text: PropTypes.string,
bgColor: PropTypes.string,
imageSrc: PropTypes.string,
iconSrc: PropTypes.string,
active: PropTypes.bool,
onClick: PropTypes.func,
notifyCount: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
};
export default SidebarAvatar;

View file

@ -0,0 +1,63 @@
.sidebar-avatar-tippy {
padding: var(--sp-extra-tight) var(--sp-normal);
background-color: var(--bg-tooltip);
border-radius: var(--bo-radius);
box-shadow: var(--bs-popup);
.text {
color: var(--tc-tooltip);
}
}
.sidebar-avatar {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
cursor: pointer;
& .notification-badge {
position: absolute;
right: var(--sp-extra-tight);
top: calc(-1 * var(--sp-ultra-tight));
box-shadow: 0 0 0 2px var(--bg-surface-low);
}
&:focus {
outline: none;
}
&:active .avatar-container {
box-shadow: var(--bs-surface-outline);
}
&:hover::before,
&:focus::before,
&--active::before {
content: "";
display: block;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 12px;
background-color: var(--ic-surface-normal);
border-radius: 0 4px 4px 0;
transition: height 200ms linear;
[dir=rtl] & {
right: 0;
border-radius: 4px 0 0 4px;
}
}
&--active:hover::before,
&--active:focus::before,
&--active::before {
height: 28px;
}
&--active .avatar-container {
background-color: var(--bg-surface);
}
}

View file

@ -0,0 +1,40 @@
import React, { useState, useEffect } from 'react';
import './Channel.scss';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import Welcome from '../welcome/Welcome';
import ChannelView from './ChannelView';
import PeopleDrawer from './PeopleDrawer';
function Channel() {
const [selectedRoomId, changeSelectedRoomId] = useState(null);
const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible);
useEffect(() => {
const handleRoomSelected = (roomId) => {
changeSelectedRoomId(roomId);
};
const handleDrawerToggling = (visiblity) => {
toggleDrawerVisiblity(visiblity);
};
navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
};
}, []);
if (selectedRoomId === null) return <Welcome />;
return (
<div className="channel-container">
<ChannelView roomId={selectedRoomId} />
{ isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
</div>
);
}
export default Channel;

View file

@ -0,0 +1,4 @@
.channel-container {
display: flex;
height: 100%;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
.channel-view-flexBox {
display: flex;
flex-direction: column;
}
.channel-view-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.channel-view {
@extend .channel-view-flexItem;
@extend .channel-view-flexBox;
&__content-wrapper {
@extend .channel-view-flexItem;
@extend .channel-view-flexBox;
}
&__scrollable {
@extend .channel-view-flexItem;
position: relative;
}
&__content {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
& .timeline__wrapper {
--typing-noti-height: 28px;
min-height: 0;
min-width: 0;
padding-bottom: var(--typing-noti-height);
}
}
&__typing {
display: flex;
padding: var(--sp-ultra-tight) var(--sp-normal);
background: var(--bg-surface);
transition: transform 200ms ease-in-out;
& b {
color: var(--tc-surface-high);
}
&--open {
transform: translateY(-99%);
}
& .text {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0 var(--sp-tight);
}
}
.bouncingLoader {
transform: translateY(2px);
margin: 0 calc(var(--sp-ultra-tight) / 2);
}
.bouncingLoader > div,
.bouncingLoader:before,
.bouncingLoader:after {
display: inline-block;
width: 8px;
height: 8px;
background: var(--tc-surface-high);
border-radius: 50%;
animation: bouncing-loader 0.6s infinite alternate;
}
.bouncingLoader:before,
.bouncingLoader:after {
content: "";
}
.bouncingLoader > div {
margin: 0 4px;
}
.bouncingLoader > div {
animation-delay: 0.2s;
}
.bouncingLoader:after {
animation-delay: 0.4s;
}
@keyframes bouncing-loader {
to {
opacity: 0.1;
transform: translate3d(0, -4px, 0);
}
}
&__STB {
position: absolute;
right: var(--sp-normal);
bottom: 0;
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
background-color: var(--bg-surface-low);
transition: transform 200ms ease-in-out;
transform: translateY(100%) scale(0);
[dir=rtl] & {
right: unset;
left: var(--sp-normal);
}
&--open {
transform: translateY(-28px) scale(1);
}
}
&__sticky {
min-height: 85px;
position: relative;
background: var(--bg-surface);
border-top: 1px solid var(--bg-surface-border);
}
}
.channel-input {
padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px);
display: flex;
min-height: 48px;
&__space {
min-width: 0;
align-self: center;
margin: auto;
padding: 0 var(--sp-tight);
}
&__input-container {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
margin: 0 calc(var(--sp-tight) - 2px);
background-color: var(--bg-surface-low);
box-shadow: var(--bs-surface-border);
border-radius: var(--bo-radius);
& > .ic-raw {
transform: scale(0.8);
margin-left: var(--sp-extra-tight);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-extra-tight);
}
}
& .scrollbar {
max-height: 50vh;
}
}
&__textarea-wrapper {
min-height: 40px;
display: flex;
align-items: center;
& textarea {
resize: none;
width: 100%;
min-width: 0;
min-height: 100%;
padding: var(--sp-ultra-tight) calc(var(--sp-tight) - 2px);
&::placeholder {
color: var(--tc-surface-low);
}
&:focus {
outline: none;
}
}
}
}
.channel-cmd-bar {
--cmd-bar-height: 28px;
min-height: var(--cmd-bar-height);
& .timeline-change {
justify-content: flex-end;
padding: var(--sp-ultra-tight) var(--sp-normal);
&__content {
margin: 0;
flex: unset;
& > .text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
& b {
color: var(--tc-surface-normal);
}
}
}
}
}
.channel-attachment {
--side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
display: flex;
align-items: center;
margin-left: var(--side-spacing);
margin-top: var(--sp-extra-tight);
line-height: 0;
[dir=rtl] & {
margin-left: 0;
margin-right: var(--side-spacing);
}
&__preview > img {
max-height: 40px;
border-radius: var(--bo-radius);
}
&__icon {
padding: var(--sp-extra-tight);
background-color: var(--bg-surface-low);
box-shadow: var(--bs-surface-border);
border-radius: var(--bo-radius);
}
&__info {
flex: 1;
min-width: 0;
margin: 0 var(--sp-tight);
}
&__option button {
transition: transform 200ms ease-in-out;
transform: translateY(-48px);
& .ic-raw {
transition: transform 200ms ease-in-out;
transform: rotate(45deg);
background-color: var(--bg-caution);
}
}
}

View file

@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './PeopleDrawer.scss';
import initMatrix from '../../../client/initMatrix';
import { getUsername } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID';
import { openInviteUser } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import ScrollView from '../../atoms/scroll/ScrollView';
import Input from '../../atoms/input/Input';
import PeopleSelector from '../../molecules/people-selector/PeopleSelector';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
function getPowerLabel(powerLevel) {
switch (powerLevel) {
case 100:
return 'Admin';
case 50:
return 'Mod';
default:
return null;
}
}
function compare(m1, m2) {
let aName = m1.name;
let bName = m2.name;
// remove "#" from the room name
// To ignore it in sorting
aName = aName.replaceAll('#', '');
bName = bName.replaceAll('#', '');
if (aName.toLowerCase() < bName.toLowerCase()) {
return -1;
}
if (aName.toLowerCase() > bName.toLowerCase()) {
return 1;
}
return 0;
}
function sortByPowerLevel(m1, m2) {
let pl1 = String(m1.powerLevel);
let pl2 = String(m2.powerLevel);
if (pl1 === '100') pl1 = '90.9';
if (pl2 === '100') pl2 = '90.9';
if (pl1.toLowerCase() > pl2.toLowerCase()) {
return -1;
}
if (pl1.toLowerCase() < pl2.toLowerCase()) {
return 1;
}
return 0;
}
function PeopleDrawer({ roomId }) {
const PER_PAGE_MEMBER = 50;
const room = initMatrix.matrixClient.getRoom(roomId);
const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
const [memberList, updateMemberList] = useState([]);
let isRoomChanged = false;
function loadMorePeople() {
updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER));
}
useEffect(() => {
updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER));
room.loadMembersIfNeeded().then(() => {
if (isRoomChanged) return;
const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel);
updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER));
});
return () => {
isRoomChanged = true;
};
}, [roomId]);
return (
<div className="people-drawer">
<Header>
<TitleWrapper>
<Text variant="s1">
People
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
</Text>
</TitleWrapper>
<IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
</Header>
<div className="people-drawer__content-wrapper">
<div className="people-drawer__scrollable">
<ScrollView autoHide>
<div className="people-drawer__content">
{
memberList.map((member) => (
<PeopleSelector
key={member.userId}
onClick={() => alert('Viewing profile is yet to be implemented')}
avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
name={getUsername(member.userId)}
color={colorMXID(member.userId)}
peopleRole={getPowerLabel(member.powerLevel)}
/>
))
}
<div className="people-drawer__load-more">
{
memberList.length !== totalMemberList.length && (
<Button onClick={loadMorePeople}>View more</Button>
)
}
</div>
</div>
</ScrollView>
</div>
<div className="people-drawer__sticky">
<form onSubmit={(e) => e.preventDefault()} className="people-search">
<Input type="text" placeholder="Search" required />
</form>
</div>
</div>
</div>
);
}
PeopleDrawer.propTypes = {
roomId: PropTypes.string.isRequired,
};
export default PeopleDrawer;

View file

@ -0,0 +1,75 @@
.people-drawer-flexBox {
display: flex;
flex-direction: column;
}
.people-drawer-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.people-drawer {
@extend .people-drawer-flexBox;
width: var(--people-drawer-width);
background-color: var(--bg-surface-low);
border-left: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border: {
left: none;
right: 1px solid var(--bg-surface-hover);
}
}
&__member-count {
color: var(--tc-surface-low);
}
&__content-wrapper {
@extend .people-drawer-flexItem;
@extend .people-drawer-flexBox;
}
&__scrollable {
@extend .people-drawer-flexItem;
}
&__sticky {
display: none;
& .people-search {
min-height: 48px;
margin: 0 var(--sp-normal);
position: relative;
bottom: var(--sp-normal);
& .input {
height: 48px;
}
}
}
}
.people-drawer__content {
padding-top: var(--sp-extra-tight);
padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal));
}
.people-drawer__load-more {
padding: var(--sp-normal);
padding: {
bottom: 0;
right: var(--sp-extra-tight);
}
[dir=rtl] & {
padding-right: var(--sp-normal);
padding-left: var(--sp-extra-tight);
}
& .btn-surface {
width: 100%;
}
}

View file

@ -0,0 +1,165 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import './CreateChannel.scss';
import initMatrix from '../../../client/initMatrix';
import { isRoomAliasAvailable } from '../../../util/matrixUtil';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Toggle from '../../atoms/button/Toggle';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
function CreateChannel({ isOpen, onRequestClose }) {
const [isPublic, togglePublic] = useState(false);
const [isEncrypted, toggleEncrypted] = useState(true);
const [isValidAddress, updateIsValidAddress] = useState(null);
const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
const [creatingError, updateCreatingError] = useState(null);
const [titleValue, updateTitleValue] = useState(undefined);
const [topicValue, updateTopicValue] = useState(undefined);
const [addressValue, updateAddressValue] = useState(undefined);
const addressRef = useRef(null);
const topicRef = useRef(null);
const nameRef = useRef(null);
const userId = initMatrix.matrixClient.getUserId();
const hsString = userId.slice(userId.indexOf(':'));
function resetForm() {
togglePublic(false);
toggleEncrypted(true);
updateIsValidAddress(null);
updateIsCreatingRoom(false);
updateCreatingError(null);
updateTitleValue(undefined);
updateTopicValue(undefined);
updateAddressValue(undefined);
}
async function createRoom() {
if (isCreatingRoom) return;
updateIsCreatingRoom(true);
updateCreatingError(null);
const name = nameRef.current.value;
let topic = topicRef.current.value;
if (topic.trim() === '') topic = undefined;
let roomAlias;
if (isPublic) {
roomAlias = addressRef?.current?.value;
if (roomAlias.trim() === '') roomAlias = undefined;
}
try {
await roomActions.create({
name, topic, isPublic, roomAlias, isEncrypted,
});
resetForm();
onRequestClose();
} catch (e) {
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
updateCreatingError('ERROR: Invalid characters in channel address');
updateIsValidAddress(false);
} else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
updateCreatingError('ERROR: Channel address is already in use');
updateIsValidAddress(false);
} else updateCreatingError(e.message);
}
updateIsCreatingRoom(false);
}
function validateAddress(e) {
const myAddress = e.target.value;
updateIsValidAddress(null);
updateAddressValue(e.target.value);
updateCreatingError(null);
setTimeout(async () => {
if (myAddress !== addressRef.current.value) return;
const roomAlias = addressRef.current.value;
if (roomAlias === '') return;
const roomAddress = `#${roomAlias}${hsString}`;
if (await isRoomAliasAvailable(roomAddress)) {
updateIsValidAddress(true);
} else {
updateIsValidAddress(false);
}
}, 1000);
}
function handleTitleChange(e) {
if (e.target.value.trim() === '') updateTitleValue(undefined);
updateTitleValue(e.target.value);
}
function handleTopicChange(e) {
if (e.target.value.trim() === '') updateTopicValue(undefined);
updateTopicValue(e.target.value);
}
return (
<PopupWindow
isOpen={isOpen}
title="Create channel"
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
onRequestClose={onRequestClose}
>
<div className="create-channel">
<form className="create-channel__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
<SettingTile
title="Make channel public"
options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
content={<Text variant="b3">Public channel can be joined by anyone.</Text>}
/>
{isPublic && (
<div>
<Text className="create-channel__address__label" variant="b2">Channel address</Text>
<div className="create-channel__address">
<Text variant="b1">#</Text>
<Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
<Text variant="b1">{hsString}</Text>
</div>
{isValidAddress === false && <Text className="create-channel__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
</div>
)}
{!isPublic && (
<SettingTile
title="Enable end-to-end encryption"
options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
content={<Text variant="b3">You cant disable this later. Bridges & most bots wont work yet.</Text>}
/>
)}
<Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
<div className="create-channel__name-wrapper">
<Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Channel name" required />
<Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
</div>
{isCreatingRoom && (
<div className="create-channel__loading">
<Spinner size="small" />
<Text>Creating channel...</Text>
</div>
)}
{typeof creatingError === 'string' && <Text className="create-channel__error" variant="b3">{creatingError}</Text>}
</form>
</div>
</PopupWindow>
);
}
CreateChannel.propTypes = {
isOpen: PropTypes.bool.isRequired,
onRequestClose: PropTypes.func.isRequired,
};
export default CreateChannel;

View file

@ -0,0 +1,103 @@
.create-channel {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
&__form > * {
margin-top: var(--sp-normal);
&:first-child {
margin-top: var(--sp-extra-tight);
}
}
&__address {
display: flex;
&__label {
color: var(--tc-surface-low);
margin-bottom: var(--sp-ultra-tight);
}
&__tip {
margin-left: 46px;
margin-top: var(--sp-ultra-tight);
[dir=rtl] & {
margin-left: 0;
margin-right: 46px;
}
}
& .text {
display: flex;
align-items: center;
padding: 0 var(--sp-normal);
border: 1px solid var(--bg-surface-border);
border-radius: var(--bo-radius);
color: var(--tc-surface-low);
}
& *:nth-child(2) {
flex: 1;
min-width: 0;
& .input {
border-radius: 0;
}
}
& .text:first-child {
border-right-width: 0;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
& .text:last-child {
border-left-width: 0;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
[dir=rtl] & {
& .text:first-child {
border-left-width: 0;
border-right-width: 1px;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
& .text:last-child {
border-right-width: 0;
border-left-width: 1px;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
}
}
&__name-wrapper {
display: flex;
align-items: flex-end;
& .input-container {
flex: 1;
min-width: 0;
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
}
& .btn-primary {
padding-top: 11px;
padding-bottom: 11px;
}
}
&__loading {
display: flex;
justify-content: center;
align-items: center;
& .text {
margin-left: var(--sp-normal);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-normal);
}
}
}
&__error {
text-align: center;
color: var(--bg-danger) !important;
}
[dir=rtl] & {
margin-right: var(--sp-normal);
margin-left: var(--sp-extra-tight);
}
}

View file

@ -0,0 +1,195 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './EmojiBoard.scss';
import EventEmitter from 'events';
import parse from 'html-react-parser';
import twemoji from 'twemoji';
import { emojiGroups, searchEmoji } from './emoji';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import ScrollView from '../../atoms/scroll/ScrollView';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
import BallIC from '../../../../public/res/ic/outlined/ball.svg';
import PhotoIC from '../../../../public/res/ic/outlined/photo.svg';
import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
const viewEvent = new EventEmitter();
function EmojiGroup({ name, emojis }) {
function getEmojiBoard() {
const ROW_EMOJIS_COUNT = 7;
const emojiRows = [];
const totalEmojis = emojis.length;
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
const emojiRow = [];
for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) {
const emojiIndex = r + c;
if (emojiIndex >= totalEmojis) break;
const emoji = emojis[emojiIndex];
emojiRow.push(
<span key={emojiIndex}>
{
parse(twemoji.parse(
emoji.unicode,
{
attributes: () => ({
unicode: emoji.unicode,
shortcodes: emoji.shortcodes?.toString(),
}),
},
))
}
</span>,
);
}
emojiRows.push(<div key={r} className="emoji-row">{emojiRow}</div>);
}
return emojiRows;
}
return (
<div className="emoji-group">
<Text className="emoji-group__header" variant="b2">{name}</Text>
<div className="emoji-set">{getEmojiBoard()}</div>
</div>
);
}
EmojiGroup.propTypes = {
name: PropTypes.string.isRequired,
emojis: PropTypes.arrayOf(PropTypes.shape({
length: PropTypes.number,
unicode: PropTypes.string,
shortcodes: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
})).isRequired,
};
function SearchedEmoji() {
const [searchedEmojis, setSearchedEmojis] = useState([]);
function handleSearchEmoji(term) {
if (term.trim() === '') {
setSearchedEmojis([]);
return;
}
setSearchedEmojis(searchEmoji(term));
}
useEffect(() => {
viewEvent.on('search-emoji', handleSearchEmoji);
return () => {
viewEvent.removeListener('search-emoji', handleSearchEmoji);
};
}, []);
return searchedEmojis.length !== 0 && <EmojiGroup key="-1" name="Search results" emojis={searchedEmojis} />;
}
function EmojiBoard({ onSelect }) {
const searchRef = useRef(null);
const scrollEmojisRef = useRef(null);
function isTargetNotEmoji(target) {
return target.classList.contains('emoji') === false;
}
function getEmojiDataFromTarget(target) {
const unicode = target.getAttribute('unicode');
let shortcodes = target.getAttribute('shortcodes');
if (typeof shortcodes === 'undefined') shortcodes = undefined;
else shortcodes = shortcodes.split(',');
return { unicode, shortcodes };
}
function selectEmoji(e) {
if (isTargetNotEmoji(e.target)) return;
const emoji = e.target;
onSelect(getEmojiDataFromTarget(emoji));
}
function hoverEmoji(e) {
if (isTargetNotEmoji(e.target)) return;
const emoji = e.target;
const { shortcodes } = getEmojiDataFromTarget(emoji);
if (typeof shortcodes === 'undefined') {
searchRef.current.placeholder = 'Search';
return;
}
if (searchRef.current.placeholder === shortcodes[0]) return;
searchRef.current.setAttribute('placeholder', `:${shortcodes[0]}:`);
}
function handleSearchChange(e) {
const term = e.target.value;
setTimeout(() => {
if (e.target.value !== term) return;
viewEvent.emit('search-emoji', term);
scrollEmojisRef.current.scrollTop = 0;
}, 500);
}
function openGroup(groupOrder) {
let tabIndex = groupOrder;
const $emojiContent = scrollEmojisRef.current.firstElementChild;
const groupCount = $emojiContent.childElementCount;
if (groupCount > emojiGroups.length) tabIndex += groupCount - emojiGroups.length;
$emojiContent.children[tabIndex].scrollIntoView();
}
return (
<div id="emoji-board" className="emoji-board">
<div className="emoji-board__content">
<div className="emoji-board__emojis">
<ScrollView ref={scrollEmojisRef} autoHide>
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
<SearchedEmoji />
{
emojiGroups.map((group) => (
<EmojiGroup key={group.name} name={group.name} emojis={group.emojis} />
))
}
</div>
</ScrollView>
</div>
<div className="emoji-board__search">
<RawIcon size="small" src={SearchIC} />
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
</div>
</div>
<div className="emoji-board__nav">
<IconButton onClick={() => openGroup(0)} src={EmojiIC} tooltip="Smileys" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(1)} src={DogIC} tooltip="Animals" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(2)} src={CupIC} tooltip="Food" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(3)} src={BallIC} tooltip="Activity" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(4)} src={PhotoIC} tooltip="Travel" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(5)} src={BulbIC} tooltip="Objects" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(6)} src={PeaceIC} tooltip="Symbols" tooltipPlacement="right" />
<IconButton onClick={() => openGroup(7)} src={FlagIC} tooltip="Flags" tooltipPlacement="right" />
</div>
</div>
);
}
EmojiBoard.propTypes = {
onSelect: PropTypes.func.isRequired,
};
export default EmojiBoard;

View file

@ -0,0 +1,89 @@
.emoji-board-flexBoxV {
display: flex;
flex-direction: column;
}
.emoji-board-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.emoji-board {
display: flex;
&__content {
@extend .emoji-board-flexItem;
@extend .emoji-board-flexBoxV;
height: 360px;
}
&__nav {
@extend .emoji-board-flexBoxV;
padding: 4px 6px;
background-color: var(--bg-surface-low);
border-left: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border-left: none;
border-right: 1px solid var(--bg-surface-border);
}
& > .ic-btn-surface {
margin: calc(var(--sp-ultra-tight) / 2) 0;
}
}
}
.emoji-board__emojis {
@extend .emoji-board-flexItem;
}
.emoji-board__search {
display: flex;
align-items: center;
padding: calc(var(--sp-ultra-tight) / 2) var(--sp-normal);
& .input-container {
@extend .emoji-board-flexItem;
& .input {
min-width: 100%;
width: 0;
background-color: transparent;
border: none !important;
box-shadow: none !important;
}
}
}
.emoji-group {
--emoji-padding: 6px;
position: relative;
margin-bottom: var(--sp-normal);
&__header {
position: sticky;
top: 0;
z-index: 99;
background-color: var(--bg-surface);
padding: var(--sp-tight) var(--sp-normal);
text-transform: uppercase;
font-weight: 600;
}
& .emoji-set {
margin: 0 calc(var(--sp-normal) - var(--emoji-padding));
margin-right: calc(var(--sp-extra-tight) - var(--emoji-padding));
[dir=rtl] & {
margin-right: calc(var(--sp-normal) - var(--emoji-padding));
margin-left: calc(var(--sp-extra-tight) - var(--emoji-padding));
}
}
& .emoji {
width: 38px;
padding: var(--emoji-padding);
cursor: pointer;
&:hover {
background-color: var(--bg-surface-hover);
border-radius: var(--bo-radius);
}
}
}

View file

@ -0,0 +1,76 @@
import emojisData from 'emojibase-data/en/compact.json';
import shortcodes from 'emojibase-data/en/shortcodes/joypixels.json';
import Fuse from 'fuse.js';
const emojiGroups = [{
name: 'Smileys & people',
order: 0,
emojis: [],
}, {
name: 'Animals & nature',
order: 1,
emojis: [],
}, {
name: 'Food & drinks',
order: 2,
emojis: [],
}, {
name: 'Activity',
order: 3,
emojis: [],
}, {
name: 'Travel & places',
order: 4,
emojis: [],
}, {
name: 'Objects',
order: 5,
emojis: [],
}, {
name: 'Symbols',
order: 6,
emojis: [],
}, {
name: 'Flags',
order: 7,
emojis: [],
}];
Object.freeze(emojiGroups);
function addEmoji(emoji, order) {
emojiGroups[order].emojis.push(emoji);
}
function addToGroup(emoji) {
if (emoji.group === 0 || emoji.group === 1) addEmoji(emoji, 0);
else if (emoji.group === 3) addEmoji(emoji, 1);
else if (emoji.group === 4) addEmoji(emoji, 2);
else if (emoji.group === 6) addEmoji(emoji, 3);
else if (emoji.group === 5) addEmoji(emoji, 4);
else if (emoji.group === 7) addEmoji(emoji, 5);
else if (emoji.group === 8) addEmoji(emoji, 6);
else if (emoji.group === 9) addEmoji(emoji, 7);
}
const emojis = [];
emojisData.forEach((emoji) => {
const em = { ...emoji, shortcodes: shortcodes[emoji.hexcode] };
addToGroup(em);
emojis.push(em);
});
function searchEmoji(term) {
const options = {
includeScore: true,
keys: ['shortcodes', 'annotation', 'tags'],
threshold: '0.3',
};
const fuse = new Fuse(emojis, options);
let result = fuse.search(term);
if (result.length > 20) result = result.slice(0, 20);
return result.map((finding) => finding.item);
}
export {
emojis, emojiGroups, searchEmoji,
};

View file

@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './InviteList.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import ChannelTile from '../../molecules/channel-tile/ChannelTile';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
function InviteList({ isOpen, onRequestClose }) {
const [procInvite, changeProcInvite] = useState(new Set());
function acceptInvite(roomId, isDM) {
procInvite.add(roomId);
changeProcInvite(new Set(Array.from(procInvite)));
roomActions.join(roomId, isDM);
}
function rejectInvite(roomId, isDM) {
procInvite.add(roomId);
changeProcInvite(new Set(Array.from(procInvite)));
roomActions.leave(roomId, isDM);
}
function updateInviteList(roomId) {
if (procInvite.has(roomId)) {
procInvite.delete(roomId);
changeProcInvite(new Set(Array.from(procInvite)));
} else changeProcInvite(new Set(Array.from(procInvite)));
const rl = initMatrix.roomList;
const totalInvites = rl.inviteDirects.size + rl.inviteRooms.size;
if (totalInvites === 0) onRequestClose();
}
useEffect(() => {
initMatrix.roomList.on(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
return () => {
initMatrix.roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, updateInviteList);
};
}, [procInvite]);
function renderChannelTile(roomId) {
const myRoom = initMatrix.matrixClient.getRoom(roomId);
const roomName = myRoom.name;
let roomAlias = myRoom.getCanonicalAlias();
if (roomAlias === null) roomAlias = myRoom.roomId;
return (
<ChannelTile
key={myRoom.roomId}
name={roomName}
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
id={roomAlias}
inviterName={myRoom.getJoinedMembers()[0].userId}
options={
procInvite.has(myRoom.roomId)
? (<Spinner size="small" />)
: (
<div className="invite-btn__container">
<Button onClick={() => rejectInvite(myRoom.roomId)}>Reject</Button>
<Button onClick={() => acceptInvite(myRoom.roomId)} variant="primary">Accept</Button>
</div>
)
}
/>
);
}
return (
<PopupWindow
isOpen={isOpen}
title="Invites"
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
onRequestClose={onRequestClose}
>
<div className="invites-content">
{ initMatrix.roomList.inviteDirects.size !== 0 && (
<div className="invites-content__subheading">
<Text variant="b3">Direct Messages</Text>
</div>
)}
{
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
const myRoom = initMatrix.matrixClient.getRoom(roomId);
const roomName = myRoom.name;
return (
<ChannelTile
key={myRoom.roomId}
name={roomName}
id={myRoom.getDMInviter()}
options={
procInvite.has(myRoom.roomId)
? (<Spinner size="small" />)
: (
<div className="invite-btn__container">
<Button onClick={() => rejectInvite(myRoom.roomId, true)}>Reject</Button>
<Button onClick={() => acceptInvite(myRoom.roomId, true)} variant="primary">Accept</Button>
</div>
)
}
/>
);
})
}
{ initMatrix.roomList.inviteSpaces.size !== 0 && (
<div className="invites-content__subheading">
<Text variant="b3">Spaces</Text>
</div>
)}
{ Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) }
{ initMatrix.roomList.inviteRooms.size !== 0 && (
<div className="invites-content__subheading">
<Text variant="b3">Channels</Text>
</div>
)}
{ Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) }
</div>
</PopupWindow>
);
}
InviteList.propTypes = {
isOpen: PropTypes.bool.isRequired,
onRequestClose: PropTypes.func.isRequired,
};
export default InviteList;

View file

@ -0,0 +1,39 @@
.invites-content {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
&__subheading {
margin-top: var(--sp-extra-loose);
& .text {
text-transform: uppercase;
font-weight: 600;
}
&:first-child {
margin-top: var(--sp-tight);
}
}
& .channel-tile {
margin-top: var(--sp-normal);
&__options {
align-self: flex-end;
}
}
& .invite-btn__container .btn-surface {
margin-right: var(--sp-normal);
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-normal);
}
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,269 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './InviteUser.scss';
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 Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import Input from '../../atoms/input/Input';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import ChannelTile from '../../molecules/channel-tile/ChannelTile';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
function InviteUser({ isOpen, roomId, onRequestClose }) {
const [isSearching, updateIsSearching] = useState(false);
const [searchQuery, updateSearchQuery] = useState({});
const [users, updateUsers] = useState([]);
const [procUsers, updateProcUsers] = useState(new Set()); // proc stands for processing.
const [procUserError, updateUserProcError] = useState(new Map());
const [createdDM, updateCreatedDM] = useState(new Map());
const [roomIdToUserId, updateRoomIdToUserId] = useState(new Map());
const [invitedUserIds, updateInvitedUserIds] = useState(new Set());
const usernameRef = useRef(null);
const mx = initMatrix.matrixClient;
function getMapCopy(myMap) {
const newMap = new Map();
myMap.forEach((data, key) => {
newMap.set(key, data);
});
return newMap;
}
function addUserToProc(userId) {
procUsers.add(userId);
updateProcUsers(new Set(Array.from(procUsers)));
}
function deleteUserFromProc(userId) {
procUsers.delete(userId);
updateProcUsers(new Set(Array.from(procUsers)));
}
function onDMCreated(newRoomId) {
const myDMPartnerId = roomIdToUserId.get(newRoomId);
if (typeof myDMPartnerId === 'undefined') return;
createdDM.set(myDMPartnerId, newRoomId);
roomIdToUserId.delete(newRoomId);
deleteUserFromProc(myDMPartnerId);
updateCreatedDM(getMapCopy(createdDM));
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
}
useEffect(() => () => {
updateIsSearching(false);
updateSearchQuery({});
updateUsers([]);
updateProcUsers(new Set());
updateUserProcError(new Map());
updateCreatedDM(new Map());
updateRoomIdToUserId(new Map());
updateInvitedUserIds(new Set());
}, [isOpen]);
useEffect(() => {
initMatrix.roomList.on(cons.events.roomList.ROOM_CREATED, onDMCreated);
return () => {
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_CREATED, onDMCreated);
};
}, [isOpen, procUsers, createdDM, roomIdToUserId]);
async function searchUser() {
const inputUsername = usernameRef.current.value.trim();
if (isSearching || inputUsername === '' || inputUsername === searchQuery.username) return;
const isInputUserId = inputUsername[0] === '@' && inputUsername.indexOf(':') > 1;
updateIsSearching(true);
updateSearchQuery({ username: inputUsername });
if (isInputUserId) {
try {
const result = await mx.getProfileInfo(inputUsername);
updateUsers([{
user_id: inputUsername,
display_name: result.displayname,
avatar_url: result.avatar_url,
}]);
} catch (e) {
updateSearchQuery({ error: `${inputUsername} not found!` });
}
} else {
try {
const result = await mx.searchUserDirectory({
term: inputUsername,
limit: 20,
});
if (result.results.length === 0) {
updateSearchQuery({ error: `No matches found for "${inputUsername}"!` });
updateIsSearching(false);
return;
}
updateUsers(result.results);
} catch (e) {
updateSearchQuery({ error: 'Something went wrong!' });
}
}
updateIsSearching(false);
}
async function createDM(userId) {
if (mx.getUserId() === userId) return;
try {
addUserToProc(userId);
procUserError.delete(userId);
updateUserProcError(getMapCopy(procUserError));
const result = await roomActions.create({
isPublic: false,
isEncrypted: true,
isDirect: true,
invite: [userId],
});
roomIdToUserId.set(result.room_id, userId);
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
} catch (e) {
deleteUserFromProc(userId);
if (typeof e.message === 'string') procUserError.set(userId, e.message);
else procUserError.set(userId, 'Something went wrong!');
updateUserProcError(getMapCopy(procUserError));
}
}
async function inviteToRoom(userId) {
if (typeof roomId === 'undefined') return;
try {
addUserToProc(userId);
procUserError.delete(userId);
updateUserProcError(getMapCopy(procUserError));
await roomActions.invite(roomId, userId);
invitedUserIds.add(userId);
updateInvitedUserIds(new Set(Array.from(invitedUserIds)));
deleteUserFromProc(userId);
} catch (e) {
deleteUserFromProc(userId);
if (typeof e.message === 'string') procUserError.set(userId, e.message);
else procUserError.set(userId, 'Something went wrong!');
updateUserProcError(getMapCopy(procUserError));
}
}
function renderUserList() {
const renderOptions = (userId) => {
const messageJSX = (message, isPositive) => <Text variant="b2"><span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>{message}</span></Text>;
if (mx.getUserId() === userId) return null;
if (procUsers.has(userId)) {
return <Spinner size="small" />;
}
if (createdDM.has(userId)) {
// eslint-disable-next-line max-len
return <Button onClick={() => { selectRoom(createdDM.get(userId)); onRequestClose(); }}>Open</Button>;
}
if (invitedUserIds.has(userId)) {
return messageJSX('Invited', true);
}
if (typeof roomId === 'string') {
const member = mx.getRoom(roomId).getMember(userId);
if (member !== null) {
const userMembership = member.membership;
switch (userMembership) {
case 'join':
return messageJSX('Already joined', true);
case 'invite':
return messageJSX('Already Invited', true);
case 'ban':
return messageJSX('Banned', false);
default:
}
}
}
return (typeof roomId === 'string')
? <Button onClick={() => inviteToRoom(userId)} variant="primary">Invite</Button>
: <Button onClick={() => createDM(userId)} variant="primary">Message</Button>;
};
const renderError = (userId) => {
if (!procUserError.has(userId)) return null;
return <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span></Text>;
};
return users.map((user) => {
const userId = user.user_id;
const name = typeof user.display_name === 'string' ? user.display_name : userId;
return (
<ChannelTile
key={userId}
avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
name={name}
id={userId}
options={renderOptions(userId)}
desc={renderError(userId)}
/>
);
});
}
return (
<PopupWindow
isOpen={isOpen}
title={(typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message')}
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
onRequestClose={onRequestClose}
>
<div className="invite-user">
<form className="invite-user__form" onSubmit={(e) => { e.preventDefault(); searchUser(); }}>
<Input forwardRef={usernameRef} label="Username or userId" />
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">Search</Button>
</form>
<div className="invite-user__search-status">
{
typeof searchQuery.username !== 'undefined' && isSearching && (
<div className="flex--center">
<Spinner size="small" />
<Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
</div>
)
}
{
typeof searchQuery.username !== 'undefined' && !isSearching && (
<Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
)
}
{
searchQuery.error && <Text className="invite-user__search-error" variant="b2">{searchQuery.error}</Text>
}
</div>
{ users.length !== 0 && (
<div className="invite-user__content">
{renderUserList()}
</div>
)}
</div>
</PopupWindow>
);
}
InviteUser.defaultProps = {
roomId: undefined,
};
InviteUser.propTypes = {
isOpen: PropTypes.bool.isRequired,
roomId: PropTypes.string,
onRequestClose: PropTypes.func.isRequired,
};
export default InviteUser;

View file

@ -0,0 +1,55 @@
.invite-user {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight);
&__form {
display: flex;
align-items: flex-end;
& .input-container {
flex: 1;
min-width: 0;
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
}
& .btn-primary {
padding: {
top: 11px;
bottom: 11px;
}
}
}
&__search-status {
margin-top: var(--sp-extra-loose);
margin-bottom: var(--sp-tight);
& .donut-spinner {
margin: 0 var(--sp-tight);
}
}
&__search-error {
color: var(--bg-danger);
}
&__content {
border-top: 1px solid var(--bg-surface-border);
}
& .channel-tile {
margin-top: var(--sp-normal);
&__options {
align-self: flex-end;
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './Drawer.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { doesRoomHaveUnread } from '../../../util/matrixUtil';
import {
selectRoom, openPublicChannels, openCreateChannel, openInviteUser,
} from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import ScrollView from '../../atoms/scroll/ScrollView';
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
import ChannelSelector from '../../molecules/channel-selector/ChannelSelector';
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
// import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
function AtoZ(aId, bId) {
let aName = initMatrix.matrixClient.getRoom(aId).name;
let bName = initMatrix.matrixClient.getRoom(bId).name;
// remove "#" from the room name
// To ignore it in sorting
aName = aName.replaceAll('#', '');
bName = bName.replaceAll('#', '');
if (aName.toLowerCase() < bName.toLowerCase()) {
return -1;
}
if (aName.toLowerCase() > bName.toLowerCase()) {
return 1;
}
return 0;
}
function DrawerHeader({ tabId }) {
return (
<Header>
<TitleWrapper>
<Text variant="s1">{(tabId === 'channels' ? 'Home' : 'Direct messages')}</Text>
</TitleWrapper>
{(tabId === 'dm')
? <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="normal" />
: (
<ContextMenu
content={(hideMenu) => (
<>
<MenuHeader>Add channel</MenuHeader>
<MenuItem
iconSrc={HashPlusIC}
onClick={() => { hideMenu(); openCreateChannel(); }}
>
Create new channel
</MenuItem>
<MenuItem
iconSrc={HashSearchIC}
onClick={() => { hideMenu(); openPublicChannels(); }}
>
Add Public channel
</MenuItem>
</>
)}
render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add channel" src={PlusIC} size="normal" />)}
/>
)}
{/* <IconButton onClick={() => ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */}
</Header>
);
}
DrawerHeader.propTypes = {
tabId: PropTypes.string.isRequired,
};
function DrawerBradcrumb() {
return (
<div className="breadcrumb__wrapper">
<ScrollView horizontal vertical={false}>
<div>
{/* TODO: bradcrumb space paths when spaces become a thing */}
</div>
</ScrollView>
</div>
);
}
function renderSelector(room, roomId, isSelected, isDM) {
const mx = initMatrix.matrixClient;
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop');
if (typeof imageSrc === 'undefined') imageSrc = null;
return (
<ChannelSelector
key={roomId}
iconSrc={
isDM
? null
: (() => {
if (room.isSpaceRoom()) {
return (room.getJoinRule() === 'invite' ? SpaceLockIC : SpaceIC);
}
return (room.getJoinRule() === 'invite' ? HashLockIC : HashIC);
})()
}
imageSrc={isDM ? imageSrc : null}
roomId={roomId}
unread={doesRoomHaveUnread(room)}
onClick={() => selectRoom(roomId)}
notificationCount={room.getUnreadNotificationCount('total')}
alert={room.getUnreadNotificationCount('highlight') !== 0}
selected={isSelected}
>
{room.name}
</ChannelSelector>
);
}
function Directs({ selectedRoomId }) {
const mx = initMatrix.matrixClient;
const directIds = [...initMatrix.roomList.directs].sort(AtoZ);
return directIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, true));
}
Directs.defaultProps = { selectedRoomId: null };
Directs.propTypes = { selectedRoomId: PropTypes.string };
function Home({ selectedRoomId }) {
const mx = initMatrix.matrixClient;
const spaceIds = [...initMatrix.roomList.spaces].sort(AtoZ);
const roomIds = [...initMatrix.roomList.rooms].sort(AtoZ);
return (
<>
{ spaceIds.length !== 0 && <Text className="cat-header" variant="b3">Spaces</Text> }
{ spaceIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, false)) }
{ roomIds.length !== 0 && <Text className="cat-header" variant="b3">Channels</Text> }
{ roomIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, false)) }
</>
);
}
Home.defaultProps = { selectedRoomId: null };
Home.propTypes = { selectedRoomId: PropTypes.string };
function Channels({ tabId }) {
const [selectedRoomId, changeSelectedRoomId] = useState(null);
const [, updateState] = useState();
const selectHandler = (roomId) => changeSelectedRoomId(roomId);
const handleDataChanges = () => updateState({});
const onRoomListChange = () => {
const { spaces, rooms, directs } = initMatrix.roomList;
if (!(
spaces.has(selectedRoomId)
|| rooms.has(selectedRoomId)
|| directs.has(selectedRoomId))
) {
selectRoom(null);
}
};
useEffect(() => {
navigation.on(cons.events.navigation.ROOM_SELECTED, selectHandler);
initMatrix.roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleDataChanges);
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectHandler);
initMatrix.roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleDataChanges);
};
}, []);
useEffect(() => {
initMatrix.roomList.on(cons.events.roomList.ROOMLIST_UPDATED, onRoomListChange);
return () => {
initMatrix.roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, onRoomListChange);
};
}, [selectedRoomId]);
return (
<div className="channels-container">
{
tabId === 'channels'
? <Home selectedRoomId={selectedRoomId} />
: <Directs selectedRoomId={selectedRoomId} />
}
</div>
);
}
Channels.propTypes = {
tabId: PropTypes.string.isRequired,
};
function Drawer({ tabId }) {
return (
<div className="drawer">
<DrawerHeader tabId={tabId} />
<div className="drawer__content-wrapper">
<DrawerBradcrumb />
<div className="channels__wrapper">
<ScrollView autoHide>
<Channels tabId={tabId} />
</ScrollView>
</div>
</div>
</div>
);
}
Drawer.propTypes = {
tabId: PropTypes.string.isRequired,
};
export default Drawer;

View file

@ -0,0 +1,48 @@
.drawer-flexBox {
display: flex;
flex-direction: column;
}
.drawer-flexItem {
flex: 1;
min-height: 0;
}
.drawer {
@extend .drawer-flexItem;
@extend .drawer-flexBox;
min-width: 0;
border-right: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border-right: none;
border-left: 1px solid var(--bg-surface-border);
}
&__content-wrapper {
@extend .drawer-flexItem;
@extend .drawer-flexBox;
}
}
.breadcrumb__wrapper {
display: none;
height: var(--header-height);
}
.channels__wrapper {
@extend .drawer-flexItem;
}
.channels-container {
padding-bottom: var(--sp-extra-loose);
& > .channel-selector__button-wrapper:first-child {
margin-top: var(--sp-extra-tight);
}
& .cat-header {
margin: var(--sp-normal);
margin-bottom: var(--sp-extra-tight);
text-transform: uppercase;
font-weight: 600;
}
}

View file

@ -0,0 +1,36 @@
import React, { useState, useEffect } from 'react';
import './Navigation.scss';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { handleTabChange } from '../../../client/action/navigation';
import SideBar from './SideBar';
import Drawer from './Drawer';
function Navigation() {
const [activeTab, changeActiveTab] = useState(navigation.getActiveTab());
function changeTab(tabId) {
handleTabChange(tabId);
}
useEffect(() => {
const handleTab = () => {
changeActiveTab(navigation.getActiveTab());
};
navigation.on(cons.events.navigation.TAB_CHANGED, handleTab);
return () => {
navigation.removeListener(cons.events.navigation.TAB_CHANGED, handleTab);
};
}, []);
return (
<div className="navigation">
<SideBar tabId={activeTab} changeTab={changeTab} />
<Drawer tabId={activeTab} />
</div>
);
}
export default Navigation;

View file

@ -0,0 +1,7 @@
.navigation {
width: 100%;
height: 100%;
background-color: var(--bg-surface-low);
display: flex;
}

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './SideBar.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import colorMXID from '../../../util/colorMXID';
import logout from '../../../client/action/logout';
import { openInviteList, openPublicChannels, openSettings } from '../../../client/action/navigation';
import ScrollView from '../../atoms/scroll/ScrollView';
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
import ContextMenu, { MenuItem, MenuHeader, MenuBorder } from '../../atoms/context-menu/ContextMenu';
import HomeIC from '../../../../public/res/ic/outlined/home.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import PowerIC from '../../../../public/res/ic/outlined/power.svg';
function ProfileAvatarMenu() {
const mx = initMatrix.matrixClient;
return (
<ContextMenu
content={(hideMenu) => (
<>
<MenuHeader>{mx.getUserId()}</MenuHeader>
{/* <MenuItem iconSrc={UserIC} onClick={() => ''}>Profile</MenuItem> */}
{/* <MenuItem iconSrc={BellIC} onClick={() => ''}>Notification settings</MenuItem> */}
<MenuItem
iconSrc={SettingsIC}
onClick={() => { hideMenu(); openSettings(); }}
>
Settings
</MenuItem>
<MenuBorder />
<MenuItem iconSrc={PowerIC} variant="danger" onClick={logout}>Logout</MenuItem>
</>
)}
render={(toggleMenu) => (
<SidebarAvatar
onClick={toggleMenu}
tooltip={mx.getUser(mx.getUserId()).displayName}
imageSrc={mx.getUser(mx.getUserId()).avatarUrl !== null ? mx.mxcUrlToHttp(mx.getUser(mx.getUserId()).avatarUrl, 42, 42, 'crop') : null}
bgColor={colorMXID(mx.getUserId())}
text={mx.getUser(mx.getUserId()).displayName.slice(0, 1)}
/>
)}
/>
);
}
function SideBar({ tabId, changeTab }) {
const totalInviteCount = () => initMatrix.roomList.inviteRooms.size
+ initMatrix.roomList.inviteSpaces.size
+ initMatrix.roomList.inviteDirects.size;
const [totalInvites, updateTotalInvites] = useState(totalInviteCount());
function onInviteListChange() {
updateTotalInvites(totalInviteCount());
}
useEffect(() => {
initMatrix.roomList.on(
cons.events.roomList.INVITELIST_UPDATED,
onInviteListChange,
);
return () => {
initMatrix.roomList.removeListener(
cons.events.roomList.INVITELIST_UPDATED,
onInviteListChange,
);
};
}, []);
return (
<div className="sidebar">
<div className="sidebar__scrollable">
<ScrollView invisible>
<div className="scrollable-content">
<div className="featured-container">
<SidebarAvatar active={tabId === 'channels'} onClick={() => changeTab('channels')} tooltip="Home" iconSrc={HomeIC} />
<SidebarAvatar active={tabId === 'dm'} onClick={() => changeTab('dm')} tooltip="People" iconSrc={UserIC} />
<SidebarAvatar onClick={() => openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} />
</div>
<div className="sidebar-divider" />
<div className="space-container" />
</div>
</ScrollView>
</div>
<div className="sidebar__sticky">
<div className="sidebar-divider" />
<div className="sticky-container">
{ totalInvites !== 0 && (
<SidebarAvatar
notifyCount={totalInvites}
onClick={() => openInviteList()}
tooltip="Invites"
iconSrc={InviteIC}
/>
)}
<ProfileAvatarMenu />
</div>
</div>
</div>
);
}
SideBar.propTypes = {
tabId: PropTypes.string.isRequired,
changeTab: PropTypes.func.isRequired,
};
export default SideBar;

View file

@ -0,0 +1,70 @@
.sidebar__flexBox {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
.sidebar {
@extend .sidebar__flexBox;
width: var(--navigation-sidebar-width);
height: 100%;
border-right: 1px solid var(--bg-surface-border);
[dir=rtl] & {
border-right: none;
border-left: 1px solid var(--bg-surface-border);
}
&__scrollable,
&__sticky {
width: 100%;
}
&__scrollable {
flex: 1;
min-height: 0px;
}
&__sticky {
align-items: center;
}
}
.scrollable-content {
&::after {
content: "";
display: block;
width: 100%;
height: 8px;
background: transparent;
// background-image: linear-gradient(to top, var(--bg-surface-low), transparent);
// It produce bug in safari
// To fix it, we have to set the color as a fully transparent version of that exact color. like:
// background-image: linear-gradient(to top, rgb(255, 255, 255), rgba(255, 255, 255, 0));
// TODO: fix this bug while implementing spaces
position: sticky;
bottom: 0;
left: 0;
}
}
.featured-container,
.space-container,
.sticky-container {
@extend .sidebar__flexBox;
padding: var(--sp-ultra-tight) 0;
& > .sidebar-avatar,
& > .avatar-container {
margin: calc(var(--sp-tight) / 2) 0;
}
}
.sidebar-divider {
margin: auto;
width: 24px;
height: 1px;
background-color: var(--bg-surface-border);
}

View file

@ -0,0 +1,199 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './PublicChannels.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { selectRoom } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import Input from '../../atoms/input/Input';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import ChannelTile from '../../molecules/channel-tile/ChannelTile';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
const SEARCH_LIMIT = 20;
function PublicChannels({ isOpen, onRequestClose }) {
const [isSearching, updateIsSearching] = useState(false);
const [isViewMore, updateIsViewMore] = useState(false);
const [publicChannels, updatePublicChannels] = useState([]);
const [nextBatch, updateNextBatch] = useState(undefined);
const [searchQuery, updateSearchQuery] = useState({});
const [joiningChannels, updateJoiningChannels] = useState(new Set());
const channelNameRef = useRef(null);
const hsRef = useRef(null);
const userId = initMatrix.matrixClient.getUserId();
async function searchChannels(viewMore) {
let inputHs = hsRef?.current?.value;
let inputChannelName = channelNameRef?.current?.value;
if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1);
if (typeof inputChannelName !== 'string') inputChannelName = '';
if (isSearching) return;
if (viewMore !== true
&& inputChannelName === searchQuery.name
&& inputHs === searchQuery.homeserver
) return;
updateSearchQuery({
name: inputChannelName,
homeserver: inputHs,
});
if (isViewMore !== viewMore) updateIsViewMore(viewMore);
updateIsSearching(true);
try {
const result = await initMatrix.matrixClient.publicRooms({
server: inputHs,
limit: SEARCH_LIMIT,
since: viewMore ? nextBatch : undefined,
include_all_networks: true,
filter: {
generic_search_term: inputChannelName,
},
});
const totalChannels = viewMore ? publicChannels.concat(result.chunk) : result.chunk;
updatePublicChannels(totalChannels);
updateNextBatch(result.next_batch);
updateIsSearching(false);
updateIsViewMore(false);
} catch (e) {
updatePublicChannels([]);
updateSearchQuery({ error: 'Something went wrong!' });
updateIsSearching(false);
updateNextBatch(undefined);
updateIsViewMore(false);
}
}
useEffect(() => {
if (isOpen) searchChannels();
}, [isOpen]);
function handleOnRoomAdded(roomId) {
if (joiningChannels.has(roomId)) {
joiningChannels.delete(roomId);
updateJoiningChannels(new Set(Array.from(joiningChannels)));
}
}
useEffect(() => {
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
return () => {
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded);
};
}, [joiningChannels]);
function handleViewChannel(roomId) {
selectRoom(roomId);
onRequestClose();
}
function joinChannel(roomId) {
joiningChannels.add(roomId);
updateJoiningChannels(new Set(Array.from(joiningChannels)));
roomActions.join(roomId, false);
}
function renderChannelList(channels) {
return channels.map((channel) => {
const alias = typeof channel.canonical_alias === 'string' ? channel.canonical_alias : channel.room_id;
const name = typeof channel.name === 'string' ? channel.name : alias;
const isJoined = initMatrix.roomList.rooms.has(channel.room_id);
return (
<ChannelTile
key={channel.room_id}
avatarSrc={typeof channel.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(channel.avatar_url, 42, 42, 'crop') : null}
name={name}
id={alias}
memberCount={channel.num_joined_members}
desc={typeof channel.topic === 'string' ? channel.topic : null}
options={(
<>
{isJoined && <Button onClick={() => handleViewChannel(channel.room_id)}>Open</Button>}
{!isJoined && (joiningChannels.has(channel.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinChannel(channel.room_id)} variant="primary">Join</Button>)}
</>
)}
/>
);
});
}
return (
<PopupWindow
isOpen={isOpen}
title="Public channels"
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
onRequestClose={onRequestClose}
>
<div className="public-channels">
<form className="public-channels__form" onSubmit={(e) => { e.preventDefault(); searchChannels(); }}>
<div className="public-channels__input-wrapper">
<Input forwardRef={channelNameRef} label="Channel name" />
<Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
</div>
<Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
</form>
<div className="public-channels__search-status">
{
typeof searchQuery.name !== 'undefined' && isSearching && (
searchQuery.name === ''
? (
<div className="flex--center">
<Spinner size="small" />
<Text variant="b2">{`Loading public channels from ${searchQuery.homeserver}...`}</Text>
</div>
)
: (
<div className="flex--center">
<Spinner size="small" />
<Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
</div>
)
)
}
{
typeof searchQuery.name !== 'undefined' && !isSearching && (
searchQuery.name === ''
? <Text variant="b2">{`Public channels on ${searchQuery.homeserver}.`}</Text>
: <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
)
}
{
searchQuery.error && <Text className="public-channels__search-error" variant="b2">{searchQuery.error}</Text>
}
</div>
{ publicChannels.length !== 0 && (
<div className="public-channels__content">
{ renderChannelList(publicChannels) }
</div>
)}
{ publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && (
<div className="public-channels__view-more">
{ isViewMore !== true && (
<Button onClick={() => searchChannels(true)}>View more</Button>
)}
{ isViewMore && <Spinner /> }
</div>
)}
</div>
</PopupWindow>
);
}
PublicChannels.propTypes = {
isOpen: PropTypes.bool.isRequired,
onRequestClose: PropTypes.func.isRequired,
};
export default PublicChannels;

View file

@ -0,0 +1,87 @@
.public-channels {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight);
&__form {
display: flex;
align-items: flex-end;
& .btn-primary {
padding: {
top: 11px;
bottom: 11px;
}
}
}
&__input-wrapper {
flex: 1;
min-width: 0;
display: flex;
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
& > div:first-child {
flex: 1;
min-width: 0;
& .input {
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
[dir=rtl] & {
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
}
}
& > div:last-child .input {
width: 120px;
border-left-width: 0;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
[dir=rtl] & {
border-left-width: 1px;
border-right-width: 0;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
}
}
&__search-status {
margin-top: var(--sp-extra-loose);
margin-bottom: var(--sp-tight);
& .donut-spinner {
margin: 0 var(--sp-tight);
}
}
&__search-error {
color: var(--bg-danger);
}
&__content {
border-top: 1px solid var(--bg-surface-border);
}
&__view-more {
margin-top: var(--sp-loose);
margin-left: calc(var(--av-normal) + var(--sp-normal));
[dir=rtl] & {
margin-left: 0;
margin-right: calc(var(--av-normal) + var(--sp-normal));
}
}
& .channel-tile {
margin-top: var(--sp-normal);
&__options {
align-self: flex-end;
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
}

View file

@ -0,0 +1,80 @@
import React, { useState, useEffect } from 'react';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import InviteList from '../invite-list/InviteList';
import PublicChannels from '../public-channels/PublicChannels';
import CreateChannel from '../create-channel/CreateChannel';
import InviteUser from '../invite-user/InviteUser';
import Settings from '../settings/Settings';
function Windows() {
const [isInviteList, changeInviteList] = useState(false);
const [isPubilcChannels, changePubilcChannels] = useState(false);
const [isCreateChannel, changeCreateChannel] = useState(false);
const [inviteUser, changeInviteUser] = useState({ isOpen: false, roomId: undefined });
const [settings, changeSettings] = useState(false);
function openInviteList() {
changeInviteList(true);
}
function openPublicChannels() {
changePubilcChannels(true);
}
function openCreateChannel() {
changeCreateChannel(true);
}
function openInviteUser(roomId) {
changeInviteUser({
isOpen: true,
roomId,
});
}
function openSettings() {
changeSettings(true);
}
useEffect(() => {
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
navigation.on(cons.events.navigation.PUBLIC_CHANNELS_OPENED, openPublicChannels);
navigation.on(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel);
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
return () => {
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
navigation.removeListener(cons.events.navigation.PUBLIC_CHANNELS_OPENED, openPublicChannels);
navigation.removeListener(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel);
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
};
}, []);
return (
<>
<InviteList
isOpen={isInviteList}
onRequestClose={() => changeInviteList(false)}
/>
<PublicChannels
isOpen={isPubilcChannels}
onRequestClose={() => changePubilcChannels(false)}
/>
<CreateChannel
isOpen={isCreateChannel}
onRequestClose={() => changeCreateChannel(false)}
/>
<InviteUser
isOpen={inviteUser.isOpen}
roomId={inviteUser.roomId}
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
/>
<Settings
isOpen={settings}
onRequestClose={() => changeSettings(false)}
/>
</>
);
}
export default Windows;

View file

@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Settings.scss';
import settings from '../../../client/state/settings';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
function Settings({ isOpen, onRequestClose }) {
return (
<PopupWindow
className="settings-window"
isOpen={isOpen}
onRequestClose={onRequestClose}
title="Settings"
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
>
<div className="settings-content">
<SettingTile
title="Theme"
content={(
<SegmentedControls
selected={settings.getThemeIndex()}
segments={[
{ text: 'Light' },
{ text: 'Silver' },
{ text: 'Dark' },
{ text: 'Butter' },
]}
onSelect={(index) => settings.setTheme(index)}
/>
)}
/>
<div style={{ flex: '1' }} />
<Text className="settings__about" variant="b1">
<a href="https://cinny.in/#about" target="_blank" rel="noreferrer">About</a>
</Text>
<Text className="settings__about">Version: 1.0.0</Text>
</div>
</PopupWindow>
);
}
Settings.propTypes = {
isOpen: PropTypes.bool.isRequired,
onRequestClose: PropTypes.func.isRequired,
};
export default Settings;

View file

@ -0,0 +1,22 @@
.settings-window {
& .pw__content-container {
height: 100%;
}
}
.settings-content {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
[dir=rtl] & {
margin-left: var(--sp-extra-tight);
margin-right: var(--sp-normal);
}
display: flex;
flex-direction: column;
height: 100%;
}
.settings__about {
text-align: center;
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import './Welcome.scss';
import Text from '../../atoms/text/Text';
import CinnySvg from '../../../../public/res/svg/cinny.svg';
function Welcome() {
return (
<div className="app-welcome flex--center">
<div className="flex-v--center">
<img className="app-welcome__logo noselect" src={CinnySvg} alt="Cinny logo" />
<Text className="app-welcome__heading" variant="h1">Welcome to Cinny</Text>
<Text className="app-welcome__subheading" variant="s1">Yet another matrix client</Text>
</div>
</div>
);
}
export default Welcome;

View file

@ -0,0 +1,20 @@
.app-welcome {
width: 100%;
height: 100%;
& > div {
max-width: 600px;
align-items: center;
}
&__logo {
width: 64px;
height: 64px;
}
&__heading {
margin: var(--sp-extra-loose) 0 var(--sp-tight);
color: var(--tc-surface-high);
}
&__subheading {
color: var(--tc-surface-normal);
}
}

29
src/app/pages/App.jsx Normal file
View file

@ -0,0 +1,29 @@
import React from 'react';
import {
BrowserRouter, Switch, Route, Redirect,
} from 'react-router-dom';
import { isAuthanticated } from '../../client/state/auth';
import Auth from '../templates/auth/Auth';
import Client from '../templates/client/Client';
function App() {
return (
<BrowserRouter>
<Switch>
<Route exact path="/">
{ isAuthanticated() ? <Client /> : <Redirect to="/login" />}
</Route>
<Route path="/login">
{ isAuthanticated() ? <Redirect to="/" /> : <Auth type="login" />}
</Route>
<Route path="/register">
{ isAuthanticated() ? <Redirect to="/" /> : <Auth type="register" />}
</Route>
</Switch>
</BrowserRouter>
);
}
export default App;

View file

@ -0,0 +1,335 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import './Auth.scss';
import ReCAPTCHA from 'react-google-recaptcha';
import { Link } from 'react-router-dom';
import * as auth from '../../../client/action/auth';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import CinnySvg from '../../../../public/res/svg/cinny.svg';
const USERNAME_REGEX = /^[a-z0-9_-]+$/;
const BAD_USERNAME_ERROR = 'Username must contain only lowercase letters, numbers, dashes and underscores.';
const PASSWORD_REGEX = /.+/;
const PASSWORD_STRENGHT_REGEX = /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[^\w\d\s:])([^\s]){8,16}$/;
const BAD_PASSWORD_ERROR = 'Password must contain 1 number, 1 uppercase letters, 1 lowercase letters, 1 non-alpha numeric number, 8-16 characters with no space.';
const CONFIRM_PASSWORD_ERROR = 'Password don\'t match.';
const EMAIL_REGEX = /([a-z0-9]+[_a-z0-9.-][a-z0-9]+)@([a-z0-9-]+(?:.[a-z0-9-]+).[a-z]{2,4})/;
const BAD_EMAIL_ERROR = 'Invalid email address';
function isValidInput(value, regex) {
return regex.test(value);
}
function renderErrorMessage(error) {
const $error = document.getElementById('auth_error');
$error.textContent = error;
$error.style.display = 'block';
}
function showBadInputError($input, error) {
renderErrorMessage(error);
$input.focus();
const myInput = $input;
myInput.style.border = '1px solid var(--bg-danger)';
myInput.style.boxShadow = 'none';
document.getElementById('auth_submit-btn').disabled = true;
}
function validateOnChange(e, regex, error) {
if (!isValidInput(e.target.value, regex) && e.target.value) {
showBadInputError(e.target, error);
return;
}
document.getElementById('auth_error').style.display = 'none';
e.target.style.removeProperty('border');
e.target.style.removeProperty('box-shadow');
document.getElementById('auth_submit-btn').disabled = false;
}
function Auth({ type }) {
const [process, changeProcess] = useState(null);
const usernameRef = useRef(null);
const homeserverRef = useRef(null);
const passwordRef = useRef(null);
const confirmPasswordRef = useRef(null);
const emailRef = useRef(null);
function register(recaptchaValue, terms, verified) {
auth.register(
usernameRef.current.value,
homeserverRef.current.value,
passwordRef.current.value,
emailRef.current.value,
recaptchaValue,
terms,
verified,
).then((res) => {
document.getElementById('auth_submit-btn').disabled = false;
if (res.type === 'recaptcha') {
changeProcess({ type: res.type, sitekey: res.public_key });
return;
}
if (res.type === 'terms') {
changeProcess({ type: res.type, en: res.en });
}
if (res.type === 'email') {
changeProcess({ type: res.type });
}
if (res.type === 'done') {
window.location.replace('/');
}
}).catch((error) => {
changeProcess(null);
renderErrorMessage(error);
document.getElementById('auth_submit-btn').disabled = false;
});
if (terms) {
changeProcess({ type: 'loading', message: 'Sending email verification link...' });
} else changeProcess({ type: 'loading', message: 'Registration in progress...' });
}
function handleLogin(e) {
e.preventDefault();
document.getElementById('auth_submit-btn').disabled = true;
document.getElementById('auth_error').style.display = 'none';
if (!isValidInput(usernameRef.current.value, USERNAME_REGEX)) {
showBadInputError(usernameRef.current, BAD_USERNAME_ERROR);
return;
}
auth.login(usernameRef.current.value, homeserverRef.current.value, passwordRef.current.value)
.then(() => {
document.getElementById('auth_submit-btn').disabled = false;
window.location.replace('/');
})
.catch((error) => {
changeProcess(null);
renderErrorMessage(error);
document.getElementById('auth_submit-btn').disabled = false;
});
changeProcess({ type: 'loading', message: 'Login in progress...' });
}
function handleRegister(e) {
e.preventDefault();
document.getElementById('auth_submit-btn').disabled = true;
document.getElementById('auth_error').style.display = 'none';
if (!isValidInput(usernameRef.current.value, USERNAME_REGEX)) {
showBadInputError(usernameRef.current, BAD_USERNAME_ERROR);
return;
}
if (!isValidInput(passwordRef.current.value, PASSWORD_STRENGHT_REGEX)) {
showBadInputError(passwordRef.current, BAD_PASSWORD_ERROR);
return;
}
if (passwordRef.current.value !== confirmPasswordRef.current.value) {
showBadInputError(confirmPasswordRef.current, CONFIRM_PASSWORD_ERROR);
return;
}
if (!isValidInput(emailRef.current.value, EMAIL_REGEX)) {
showBadInputError(emailRef.current, BAD_EMAIL_ERROR);
return;
}
register();
}
const handleAuth = (type === 'login') ? handleLogin : handleRegister;
return (
<>
{process?.type === 'loading' && <LoadingScreen message={process.message} />}
{process?.type === 'recaptcha' && <Recaptcha message="Please check the box below to proceed." sitekey={process.sitekey} onChange={(v) => { if (typeof v === 'string') register(v); }} />}
{process?.type === 'terms' && <Terms url={process.en.url} onSubmit={register} />}
{process?.type === 'email' && (
<ProcessWrapper>
<div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
<Text variant="h2">Verify email</Text>
<div style={{ margin: 'var(--sp-normal) 0' }}>
<Text variant="b1">
Please check your email
{' '}
<b>{`(${emailRef.current.value})`}</b>
{' '}
and validate before continuing further.
</Text>
</div>
<Button variant="primary" onClick={() => register(undefined, undefined, true)}>Continue</Button>
</div>
</ProcessWrapper>
)}
<StaticWrapper>
<div className="auth-form__wrapper flex-v--center">
<form onSubmit={handleAuth} className="auth-form">
<Text variant="h2">{ type === 'login' ? 'Login' : 'Register' }</Text>
<div className="username__wrapper">
<Input
forwardRef={usernameRef}
onChange={(e) => validateOnChange(e, USERNAME_REGEX, BAD_USERNAME_ERROR)}
id="auth_username"
label="Username"
required
/>
<Input
forwardRef={homeserverRef}
id="auth_homeserver"
placeholder="Homeserver"
value="matrix.org"
required
/>
</div>
<Input
forwardRef={passwordRef}
onChange={(e) => validateOnChange(e, ((type === 'login') ? PASSWORD_REGEX : PASSWORD_STRENGHT_REGEX), BAD_PASSWORD_ERROR)}
id="auth_password"
type="password"
label="Password"
required
/>
{type === 'register' && (
<>
<Input
forwardRef={confirmPasswordRef}
onChange={(e) => validateOnChange(e, new RegExp(`^(${passwordRef.current.value})$`), CONFIRM_PASSWORD_ERROR)}
id="auth_confirmPassword"
type="password"
label="Confirm password"
required
/>
<Input
forwardRef={emailRef}
onChange={(e) => validateOnChange(e, EMAIL_REGEX, BAD_EMAIL_ERROR)}
id="auth_email"
type="email"
label="Email"
required
/>
</>
)}
<div className="submit-btn__wrapper flex--end">
<Text id="auth_error" className="error-message" variant="b3">Error</Text>
<Button
id="auth_submit-btn"
variant="primary"
type="submit"
>
{type === 'login' ? 'Login' : 'Register' }
</Button>
</div>
</form>
</div>
<div className="flex--center">
<Text variant="b2">
{`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
<Link to={type === 'login' ? '/register' : '/login'}>
{ type === 'login' ? ' Register' : ' Login' }
</Link>
</Text>
</div>
</StaticWrapper>
</>
);
}
Auth.propTypes = {
type: PropTypes.string.isRequired,
};
function StaticWrapper({ children }) {
return (
<div className="auth__wrapper flex--center">
<div className="auth-card">
<div className="auth-card__interactive flex-v">
<div className="app-ident flex">
<img className="app-ident__logo noselect" src={CinnySvg} alt="Cinny logo" />
<div className="app-ident__text flex-v--center">
<Text variant="h2">Cinny</Text>
<Text variant="b2">Yet another matrix client.</Text>
</div>
</div>
{ children }
</div>
</div>
</div>
);
}
StaticWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
function LoadingScreen({ message }) {
return (
<ProcessWrapper>
<Spinner />
<div style={{ marginTop: 'var(--sp-normal)' }}>
<Text variant="b1">{message}</Text>
</div>
</ProcessWrapper>
);
}
LoadingScreen.propTypes = {
message: PropTypes.string.isRequired,
};
function Recaptcha({ message, sitekey, onChange }) {
return (
<ProcessWrapper>
<div style={{ marginBottom: 'var(--sp-normal)' }}>
<Text variant="s1">{message}</Text>
</div>
<ReCAPTCHA sitekey={sitekey} onChange={onChange} />
</ProcessWrapper>
);
}
Recaptcha.propTypes = {
message: PropTypes.string.isRequired,
sitekey: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
function Terms({ url, onSubmit }) {
return (
<ProcessWrapper>
<form onSubmit={() => onSubmit(undefined, true)}>
<div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
<Text variant="h2">Agree with terms</Text>
<div style={{ marginBottom: 'var(--sp-normal)' }} />
<Text variant="b1">In order to complete registration, you need to agree with terms and conditions.</Text>
<div style={{ display: 'flex', alignItems: 'center', margin: 'var(--sp-normal) 0' }}>
<input id="termsCheckbox" type="checkbox" required />
<Text variant="b1">
{'I accept '}
<a style={{ cursor: 'pointer' }} href={url} rel="noreferrer" target="_blank">Terms and Conditions</a>
</Text>
</div>
<Button id="termsBtn" type="submit" variant="primary">Submit</Button>
</div>
</form>
</ProcessWrapper>
);
}
Terms.propTypes = {
url: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
};
function ProcessWrapper({ children }) {
return (
<div className="process-wrapper">
{children}
</div>
);
}
ProcessWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
export default Auth;

View file

@ -0,0 +1,157 @@
.auth__wrapper {
min-height: 100vh;
padding: var(--sp-loose);
background-color: var(--bg-surface-low);
background-image: url("https://images.unsplash.com/photo-1562619371-b67725b6fde2?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80");
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.auth-card {
width: 462px;
min-height: 644px;
background-color: var(--bg-surface-low);
border-radius: var(--bo-radius);
box-shadow: var(--bs-popup);
overflow: hidden;
display: flex;
flex-flow: row nowrap;
&__interactive{
flex: 1;
min-width: 0;
}
&__interactive {
padding: calc(var(--sp-normal) + var(--sp-extra-loose));
padding-bottom: var(--sp-extra-loose);
background-color: var(--bg-surface);
}
}
}
.app-ident {
margin-bottom: var(--sp-extra-loose);
&__logo {
width: 60px;
height: 60px;
}
&__text {
margin-left: calc(var(--sp-loose) + var(--sp-ultra-tight));
.text-s1 {
margin-top: var(--sp-tight);
color: var(--tc-surface-normal);
}
[dir=rtl] & {
margin-left: 0;
margin-right: calc(var(--sp-loose) + var(--sp-ultra-tight));
}
}
}
.auth-form {
& > .text {
margin-bottom: var(--sp-loose);
margin-top: var(--sp-loose);
}
& > .input-container {
margin-top: var(--sp-tight);
}
.submit-btn__wrapper {
margin-top: var(--sp-extra-loose);
margin-bottom: var(--sp-loose);
align-items: flex-start;
& > .error-message {
display: none;
flex: 1;
color: var(--tc-danger-normal);
margin-right: var(--sp-normal);
word-break: break;
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-normal);
}
}
}
}
&__wrapper {
height: 100%;
}
}
.username__wrapper {
display: flex;
align-items: flex-end;
& > :first-child {
flex: 1;
.input {
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
[dir=rtl] & {
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
}
}
& > :last-child {
width: 110px;
.input {
border-left-width: 0;
background-color: var(--bg-surface);
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
[dir=rtl] & {
border-left-width: 1px;
border-right-width: 0;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
}
}
}
@media (max-width: 462px) {
.auth__wrapper {
padding: 0;
background-image: none;
background-color: var(--bg-surface);
.auth-card {
border-radius: 0;
box-shadow: none;
&__interactive {
padding: var(--sp-extra-loose);
}
}
}
}
.process-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100%;
width: 100%;
background-color: var(--bg-surface-low);
opacity: .96;
position: fixed;
top: 0;
left: 0;
z-index: 999;
}

View file

@ -0,0 +1,47 @@
import React, { useState, useEffect } from 'react';
import './Client.scss';
import Text from '../../atoms/text/Text';
import Spinner from '../../atoms/spinner/Spinner';
import Navigation from '../../organisms/navigation/Navigation';
import Channel from '../../organisms/channel/Channel';
import Windows from '../../organisms/pw/Windows';
import initMatrix from '../../../client/initMatrix';
function Client() {
const [isLoading, changeLoading] = useState(true);
useEffect(() => {
initMatrix.once('init_loading_finished', () => {
changeLoading(false);
});
initMatrix.init();
}, []);
if (isLoading) {
return (
<div className="loading-display">
<Spinner />
<Text className="loading__message" variant="b2">Heating up</Text>
<div className="loading__appname">
<Text variant="h2">Cinny</Text>
</div>
</div>
);
}
return (
<div className="client-container">
<div className="navigation__wrapper">
<Navigation />
</div>
<div className="channel__wrapper">
<Channel />
</div>
<Windows />
</div>
);
}
export default Client;

View file

@ -0,0 +1,34 @@
.client-container {
display: flex;
height: 100%;
}
.navigation__wrapper {
width: var(--navigation-width);
}
.channel__wrapper {
flex: 1;
min-width: 0;
background-color: var(--bg-surface);
}
.loading-display {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.loading__message {
margin-top: var(--sp-normal);
}
.loading__appname {
position: absolute;
bottom: var(--sp-normal);
}

145
src/client/action/auth.js Normal file
View file

@ -0,0 +1,145 @@
import * as sdk from 'matrix-js-sdk';
import cons from '../state/cons';
import { getBaseUrl } from '../../util/matrixUtil';
async function login(username, homeserver, password) {
const baseUrl = await getBaseUrl(homeserver);
if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
const client = sdk.createClient({ baseUrl });
const response = await client.login('m.login.password', {
user: `@${username}:${homeserver}`,
password,
initial_device_display_name: cons.DEVICE_DISPLAY_NAME,
});
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token);
localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id);
localStorage.setItem(cons.secretKey.USER_ID, response.user_id);
localStorage.setItem(cons.secretKey.BASE_URL, response.well_known['m.homeserver'].base_url);
}
async function getAdditionalInfo(baseUrl, content) {
try {
const res = await fetch(`${baseUrl}/_matrix/client/r0/register`, {
method: 'POST',
body: JSON.stringify(content),
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
credentials: 'same-origin',
});
const data = await res.json();
return data;
} catch (e) {
throw new Error(e);
}
}
async function verifyEmail(baseUrl, content) {
try {
const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken `, {
method: 'POST',
body: JSON.stringify(content),
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
credentials: 'same-origin',
});
const data = await res.json();
return data;
} catch (e) {
throw new Error(e);
}
}
let session = null;
let clientSecret = null;
let sid = null;
async function register(username, homeserver, password, email, recaptchaValue, terms, verified) {
const baseUrl = await getBaseUrl(homeserver);
if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found');
const client = sdk.createClient({ baseUrl });
const isAvailable = await client.isUsernameAvailable(username);
if (!isAvailable) throw new Error('Username not available');
if (typeof recaptchaValue === 'string') {
await getAdditionalInfo(baseUrl, {
auth: {
type: 'm.login.recaptcha',
session,
response: recaptchaValue,
},
});
} else if (terms === true) {
await getAdditionalInfo(baseUrl, {
auth: {
type: 'm.login.terms',
session,
},
});
} else if (verified !== true) {
session = null;
clientSecret = client.generateClientSecret();
console.log(clientSecret);
const verifyData = await verifyEmail(baseUrl, {
email,
client_secret: clientSecret,
send_attempt: 1,
});
if (typeof verifyData.error === 'string') {
throw new Error(verifyData.error);
}
sid = verifyData.sid;
}
const additionalInfo = await getAdditionalInfo(baseUrl, {
auth: { session: (session !== null) ? session : undefined },
});
session = additionalInfo.session;
if (typeof additionalInfo.completed === 'undefined' || additionalInfo.completed.length === 0) {
return ({
type: 'recaptcha',
public_key: additionalInfo.params['m.login.recaptcha'].public_key,
});
}
if (additionalInfo.completed.find((process) => process === 'm.login.recaptcha') === 'm.login.recaptcha'
&& !additionalInfo.completed.find((process) => process === 'm.login.terms')) {
return ({
type: 'terms',
en: additionalInfo.params['m.login.terms'].policies.privacy_policy.en,
});
}
if (verified || additionalInfo.completed.find((process) => process === 'm.login.terms') === 'm.login.terms') {
const tpc = {
client_secret: clientSecret,
sid,
};
const verifyData = await getAdditionalInfo(baseUrl, {
auth: {
session,
type: 'm.login.email.identity',
threepidCreds: tpc,
threepid_creds: tpc,
},
username,
password,
});
if (verifyData.errcode === 'M_UNAUTHORIZED') {
return { type: 'email' };
}
localStorage.setItem(cons.secretKey.ACCESS_TOKEN, verifyData.access_token);
localStorage.setItem(cons.secretKey.DEVICE_ID, verifyData.device_id);
localStorage.setItem(cons.secretKey.USER_ID, verifyData.user_id);
localStorage.setItem(cons.secretKey.BASE_URL, baseUrl);
return { type: 'done' };
}
return {};
}
export { login, register };

View file

@ -0,0 +1,12 @@
import initMatrix from '../initMatrix';
function logout() {
const mx = initMatrix.matrixClient;
mx.logout().then(() => {
mx.clearStores();
window.localStorage.clear();
window.location.reload();
});
}
export default logout;

View file

@ -0,0 +1,64 @@
import appDispatcher from '../dispatcher';
import cons from '../state/cons';
function handleTabChange(tabId) {
appDispatcher.dispatch({
type: cons.actions.navigation.CHANGE_TAB,
tabId,
});
}
function selectRoom(roomId) {
appDispatcher.dispatch({
type: cons.actions.navigation.SELECT_ROOM,
roomId,
});
}
function togglePeopleDrawer() {
appDispatcher.dispatch({
type: cons.actions.navigation.TOGGLE_PEOPLE_DRAWER,
});
}
function openInviteList() {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_INVITE_LIST,
});
}
function openPublicChannels() {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_PUBLIC_CHANNELS,
});
}
function openCreateChannel() {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_CREATE_CHANNEL,
});
}
function openInviteUser(roomId) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_INVITE_USER,
roomId,
});
}
function openSettings() {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SETTINGS,
});
}
export {
handleTabChange,
selectRoom,
togglePeopleDrawer,
openInviteList,
openPublicChannels,
openCreateChannel,
openInviteUser,
openSettings,
};

189
src/client/action/room.js Normal file
View file

@ -0,0 +1,189 @@
import initMatrix from '../initMatrix';
import appDispatcher from '../dispatcher';
import cons from '../state/cons';
/**
* https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L73
* @param {string} roomId Id of room to add
* @param {string} userId User id to which dm
* @returns {Promise} A promise
*/
function addRoomToMDirect(roomId, userId) {
const mx = initMatrix.matrixClient;
const mDirectsEvent = mx.getAccountData('m.direct');
let userIdToRoomIds = {};
if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent();
// remove it from the lists of any others users
// (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((thisUserId) => {
const roomIds = userIdToRoomIds[thisUserId];
if (thisUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
}
}
});
// now add it, if it's not already there
if (userId) {
const roomIds = userIdToRoomIds[userId] || [];
if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId);
}
userIdToRoomIds[userId] = roomIds;
}
return mx.setAccountData('m.direct', userIdToRoomIds);
}
/**
* Given a room, estimate which of its members is likely to
* be the target if the room were a DM room and return that user.
* https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L117
*
* @param {Object} room Target room
* @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room, myUserId) {
let oldestMemberTs;
let oldestMember;
// Pick the joined user who's been here longest (and isn't us),
room.getJoinedMembers().forEach((member) => {
if (member.userId === myUserId) return;
if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) {
oldestMember = member;
oldestMemberTs = member.events.member.getTs();
}
});
if (oldestMember) return oldestMember.userId;
// if there are no joined members other than us, use the oldest member
room.currentState.getMembers().forEach((member) => {
if (member.userId === myUserId) return;
if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) {
oldestMember = member;
oldestMemberTs = member.events.member.getTs();
}
});
if (typeof oldestMember === 'undefined') return myUserId;
return oldestMember.userId;
}
/**
*
* @param {string} roomId
* @param {boolean} isDM
*/
function join(roomId, isDM) {
const mx = initMatrix.matrixClient;
mx.joinRoom(roomId)
.then(async () => {
if (isDM) {
const targetUserId = guessDMRoomTargetId(mx.getRoom(roomId), mx.getUserId());
await addRoomToMDirect(roomId, targetUserId);
}
appDispatcher.dispatch({
type: cons.actions.room.JOIN,
roomId,
isDM,
});
}).catch();
}
/**
*
* @param {string} roomId
* @param {boolean} isDM
*/
function leave(roomId, isDM) {
const mx = initMatrix.matrixClient;
mx.leave(roomId)
.then(() => {
appDispatcher.dispatch({
type: cons.actions.room.LEAVE,
roomId,
isDM,
});
}).catch();
}
/**
* Create a room.
* @param {Object} opts
* @param {string} [opts.name]
* @param {string} [opts.topic]
* @param {boolean} [opts.isPublic=false] Sets room visibility to public
* @param {string} [opts.roomAlias] Sets the room address
* @param {boolean} [opts.isEncrypted=false] Makes room encrypted
* @param {boolean} [opts.isDirect=false] Makes room as direct message
* @param {string[]} [opts.invite=[]] An array of userId's to invite
*/
async function create(opts) {
const mx = initMatrix.matrixClient;
const options = {
name: opts.name,
topic: opts.topic,
visibility: opts.isPublic === true ? 'public' : 'private',
room_alias_name: opts.roomAlias,
is_direct: opts.isDirect === true,
invite: opts.invite || [],
initial_state: [],
};
if (opts.isPublic !== true && opts.isEncrypted === true) {
options.initial_state.push({
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
});
}
try {
const result = await mx.createRoom(options);
if (opts.isDirect === true && typeof opts.invite[0] !== 'undefined') {
await addRoomToMDirect(result.room_id, opts.invite[0]);
}
appDispatcher.dispatch({
type: cons.actions.room.CREATE,
roomId: result.room_id,
isDM: opts.isDirect === true,
});
return result;
} catch (e) {
const errcodes = ['M_UNKNOWN', 'M_BAD_JSON', 'M_ROOM_IN_USE', 'M_INVALID_ROOM_STATE', 'M_UNSUPPORTED_ROOM_VERSION'];
if (errcodes.find((errcode) => errcode === e.errcode)) {
appDispatcher.dispatch({
type: cons.actions.room.error.CREATE,
error: e,
});
throw new Error(e);
}
throw new Error('Something went wrong!');
}
}
async function invite(roomId, userId) {
const mx = initMatrix.matrixClient;
try {
const result = await mx.invite(roomId, userId);
return result;
} catch (e) {
throw new Error(e);
}
}
export {
join, leave, create, invite,
};

4
src/client/dispatcher.js Normal file
View file

@ -0,0 +1,4 @@
import { Dispatcher } from 'flux';
const appDispatcher = new Dispatcher();
export default appDispatcher;

89
src/client/initMatrix.js Normal file
View file

@ -0,0 +1,89 @@
import EventEmitter from 'events';
import * as sdk from 'matrix-js-sdk';
import { secret } from './state/auth';
import RoomList from './state/RoomList';
import RoomsInput from './state/RoomsInput';
global.Olm = require('olm');
class InitMatrix extends EventEmitter {
async init() {
await this.startClient();
this.setupSync();
this.listenEvents();
}
async startClient() {
const indexedDBStore = new sdk.IndexedDBStore({
indexedDB: global.indexedDB,
localStorage: global.localStorage,
dbName: 'web-sync-store',
});
await indexedDBStore.startup();
this.matrixClient = sdk.createClient({
baseUrl: secret.baseUrl,
accessToken: secret.accessToken,
userId: secret.userId,
store: indexedDBStore,
sessionStore: new sdk.WebStorageSessionStore(global.localStorage),
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
deviceId: secret.deviceId,
});
await this.matrixClient.initCrypto();
await this.matrixClient.startClient({
lazyLoadMembers: true,
});
this.matrixClient.setGlobalErrorOnUnknownDevices(false);
}
setupSync() {
const sync = {
NULL: () => {
console.log('NULL state');
},
SYNCING: () => {
console.log('SYNCING state');
},
PREPARED: (prevState) => {
console.log('PREPARED state');
console.log('previous state: ', prevState);
// TODO: remove global.initMatrix at end
global.initMatrix = this;
if (prevState === null) {
this.roomList = new RoomList(this.matrixClient);
this.roomsInput = new RoomsInput(this.matrixClient);
this.emit('init_loading_finished');
}
},
RECONNECTING: () => {
console.log('RECONNECTING state');
},
CATCHUP: () => {
console.log('CATCHUP state');
},
ERROR: () => {
console.log('ERROR state');
},
STOPPED: () => {
console.log('STOPPED state');
},
};
this.matrixClient.on('sync', (state, prevState) => sync[state](prevState));
}
listenEvents() {
this.matrixClient.on('Session.logged_out', () => {
this.matrixClient.clearStores();
window.localStorage.clear();
window.location.reload();
});
}
}
const initMatrix = new InitMatrix();
export default initMatrix;

View file

@ -0,0 +1,288 @@
import EventEmitter from 'events';
import appDispatcher from '../dispatcher';
import cons from './cons';
class RoomList extends EventEmitter {
constructor(matrixClient) {
super();
this.matrixClient = matrixClient;
this.mDirects = this.getMDirects();
this.inviteDirects = new Set();
this.inviteSpaces = new Set();
this.inviteRooms = new Set();
this.directs = new Set();
this.spaces = new Set();
this.rooms = new Set();
this.processingRooms = new Map();
this._populateRooms();
this._listenEvents();
appDispatcher.register(this.roomActions.bind(this));
}
roomActions(action) {
const addRoom = (roomId, isDM) => {
const myRoom = this.matrixClient.getRoom(roomId);
if (myRoom === null) return false;
if (isDM) this.directs.add(roomId);
else if (myRoom.isSpaceRoom()) this.spaces.add(roomId);
else this.rooms.add(roomId);
return true;
};
const actions = {
[cons.actions.room.JOIN]: () => {
if (addRoom(action.roomId, action.isDM)) {
setTimeout(() => {
this.emit(cons.events.roomList.ROOM_JOINED, action.roomId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}, 100);
} else {
this.processingRooms.set(action.roomId, {
roomId: action.roomId,
isDM: action.isDM,
task: 'JOIN',
});
}
},
[cons.actions.room.CREATE]: () => {
if (addRoom(action.roomId, action.isDM)) {
setTimeout(() => {
this.emit(cons.events.roomList.ROOM_CREATED, action.roomId);
this.emit(cons.events.roomList.ROOM_JOINED, action.roomId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}, 100);
} else {
this.processingRooms.set(action.roomId, {
roomId: action.roomId,
isDM: action.isDM,
task: 'CREATE',
});
}
},
};
actions[action.type]?.();
}
getMDirects() {
const mDirectsId = new Set();
const mDirect = this.matrixClient
.getAccountData('m.direct')
?.getContent();
if (typeof mDirect === 'undefined') return mDirectsId;
Object.keys(mDirect).forEach((direct) => {
mDirect[direct].forEach((directId) => mDirectsId.add(directId));
});
return mDirectsId;
}
_populateRooms() {
this.directs.clear();
this.spaces.clear();
this.rooms.clear();
this.inviteDirects.clear();
this.inviteSpaces.clear();
this.inviteRooms.clear();
this.matrixClient.getRooms().forEach((room) => {
const { roomId } = room;
const tombstone = room.currentState.events.get('m.room.tombstone');
if (typeof tombstone !== 'undefined') {
const repRoomId = tombstone.get('').getContent().replacement_room;
const repRoomMembership = this.matrixClient.getRoom(repRoomId)?.getMyMembership();
if (repRoomMembership === 'join') return;
}
if (room.getMyMembership() === 'invite') {
if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId);
else this.inviteRooms.add(roomId);
return;
}
if (room.getMyMembership() !== 'join') return;
if (this.mDirects.has(roomId)) this.directs.add(roomId);
else if (room.isSpaceRoom()) this.spaces.add(roomId);
else this.rooms.add(roomId);
});
}
_isDMInvite(room) {
const me = room.getMember(this.matrixClient.getUserId());
const myEventContent = me.events.member.getContent();
return myEventContent.membership === 'invite' && myEventContent.is_direct;
}
_listenEvents() {
// Update roomList when m.direct changes
this.matrixClient.on('accountData', (event) => {
if (event.getType() !== 'm.direct') return;
const latestMDirects = this.getMDirects();
latestMDirects.forEach((directId) => {
const myRoom = this.matrixClient.getRoom(directId);
if (this.mDirects.has(directId)) return;
// Update mDirects
this.mDirects.add(directId);
if (myRoom === null) return;
if (this._isDMInvite(myRoom)) return;
if (myRoom.getMyMembership === 'join' && !this.directs.has(directId)) {
this.directs.add(directId);
}
// Newly added room.
// at this time my membership can be invite | join
if (myRoom.getMyMembership() === 'join' && this.rooms.has(directId)) {
// found a DM which accidentally gets added to this.rooms
this.rooms.delete(directId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}
});
});
this.matrixClient.on('Room.name', () => {
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
});
this.matrixClient.on('Room.receipt', (event) => {
if (event.getType() === 'm.receipt') {
const evContent = event.getContent();
const userId = Object.keys(evContent[Object.keys(evContent)[0]]['m.read'])[0];
if (userId !== this.matrixClient.getUserId()) return;
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}
});
this.matrixClient.on('RoomState.events', (event) => {
if (event.getType() !== 'm.room.join_rules') return;
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
});
this.matrixClient.on('Room.myMembership', (room, membership, prevMembership) => {
// room => prevMembership = null | invite | join | leave | kick | ban | unban
// room => membership = invite | join | leave | kick | ban | unban
const { roomId } = room;
if (membership === 'unban') return;
// When user_reject/sender_undo room invite
if (prevMembership === 'invite') {
if (this.inviteDirects.has(roomId)) this.inviteDirects.delete(roomId);
else if (this.inviteSpaces.has(roomId)) this.inviteSpaces.delete(roomId);
else this.inviteRooms.delete(roomId);
this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
}
// When user get invited
if (membership === 'invite') {
if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId);
else this.inviteRooms.add(roomId);
this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
return;
}
// When user join room (first time) or start DM.
if ((prevMembership === null || prevMembership === 'invite') && membership === 'join') {
// when user create room/DM OR accept room/dm invite from this client.
// we will update this.rooms/this.directs with user action
if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return;
if (this.processingRooms.has(roomId)) {
const procRoomInfo = this.processingRooms.get(roomId);
if (procRoomInfo.isDM) this.directs.add(roomId);
else if (room.isSpaceRoom()) this.spaces.add(roomId);
else this.rooms.add(roomId);
if (procRoomInfo.task === 'CREATE') this.emit(cons.events.roomList.ROOM_CREATED, roomId);
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
this.processingRooms.delete(roomId);
return;
}
if (room.isSpaceRoom()) {
this.spaces.add(roomId);
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
return;
}
// below code intented to work when user create room/DM
// OR accept room/dm invite from other client.
// and we have to update our client. (it's ok to have 10sec delay)
// create a buffer of 10sec and HOPE client.accoundData get updated
// then accoundData event listener will update this.mDirects.
// and we will be able to know if it's a DM.
// ----------
// less likely situation:
// if we don't get accountData with 10sec then:
// we will temporary add it to this.rooms.
// and in future when accountData get updated
// accountData listener will automatically goona REMOVE it from this.rooms
// and will ADD it to this.directs
// and emit the cons.events.roomList.ROOMLIST_UPDATED to update the UI.
setTimeout(() => {
if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return;
if (this.mDirects.has(roomId)) this.directs.add(roomId);
else this.rooms.add(roomId);
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
}, 10000);
return;
}
// when room is a DM add/remove it from DM's and return.
if (this.directs.has(roomId)) {
if (membership === 'leave' || membership === 'kick' || membership === 'ban') {
this.directs.delete(roomId);
this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
}
}
if (this.mDirects.has(roomId)) {
if (membership === 'join') {
this.directs.add(roomId);
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
}
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
return;
}
// when room is not a DM add/remove it from rooms.
if (membership === 'leave' || membership === 'kick' || membership === 'ban') {
if (room.isSpaceRoom()) this.spaces.delete(roomId);
else this.rooms.delete(roomId);
this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
}
if (membership === 'join') {
if (room.isSpaceRoom()) this.spaces.add(roomId);
else this.rooms.add(roomId);
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
}
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
});
this.matrixClient.on('Room.timeline', () => {
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
});
}
}
export default RoomList;

View file

@ -0,0 +1,161 @@
import EventEmitter from 'events';
import initMatrix from '../initMatrix';
import cons from './cons';
class RoomTimeline extends EventEmitter {
constructor(roomId) {
super();
this.matrixClient = initMatrix.matrixClient;
this.roomId = roomId;
this.room = this.matrixClient.getRoom(roomId);
this.timeline = this.room.timeline;
this.editedTimeline = this.getEditedTimeline();
this.reactionTimeline = this.getReactionTimeline();
this.isOngoingPagination = false;
this.ongoingDecryptionCount = 0;
this.typingMembers = new Set();
this._listenRoomTimeline = (event, room) => {
if (room.roomId !== this.roomId) return;
if (event.isEncrypted()) {
this.ongoingDecryptionCount += 1;
return;
}
this.timeline = this.room.timeline;
if (this.isEdited(event)) {
this.addToMap(this.editedTimeline, event);
}
if (this.isReaction(event)) {
this.addToMap(this.reactionTimeline, event);
}
if (this.ongoingDecryptionCount !== 0) return;
this.emit(cons.events.roomTimeline.EVENT);
};
this._listenDecryptEvent = (event) => {
if (event.getRoomId() !== this.roomId) return;
if (this.ongoingDecryptionCount > 0) this.ongoingDecryptionCount -= 1;
this.timeline = this.room.timeline;
if (this.ongoingDecryptionCount !== 0) return;
this.emit(cons.events.roomTimeline.EVENT);
};
this._listenTypingEvent = (event, member) => {
if (member.roomId !== this.roomId) return;
const isTyping = member.typing;
if (isTyping) this.typingMembers.add(member.userId);
else this.typingMembers.delete(member.userId);
this.emit(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, new Set([...this.typingMembers]));
};
this._listenReciptEvent = (event, room) => {
if (room.roomId !== this.roomId) return;
const receiptContent = event.getContent();
if (this.timeline.length === 0) return;
const tmlLastEvent = this.timeline[this.timeline.length - 1];
const lastEventId = tmlLastEvent.getId();
const lastEventRecipt = receiptContent[lastEventId];
if (typeof lastEventRecipt === 'undefined') return;
if (lastEventRecipt['m.read']) {
this.emit(cons.events.roomTimeline.READ_RECEIPT);
}
};
this.matrixClient.on('Room.timeline', this._listenRoomTimeline);
this.matrixClient.on('Event.decrypted', this._listenDecryptEvent);
this.matrixClient.on('RoomMember.typing', this._listenTypingEvent);
this.matrixClient.on('Room.receipt', this._listenReciptEvent);
// TODO: remove below line when release
window.selectedRoom = this;
if (this.isEncryptedRoom()) this.room.decryptAllEvents();
}
isEncryptedRoom() {
return this.matrixClient.isRoomEncrypted(this.roomId);
}
// eslint-disable-next-line class-methods-use-this
isEdited(mEvent) {
return mEvent.getRelation()?.rel_type === 'm.replace';
}
// eslint-disable-next-line class-methods-use-this
getRelateToId(mEvent) {
const relation = mEvent.getRelation();
return relation && relation.event_id;
}
addToMap(myMap, mEvent) {
const relateToId = this.getRelateToId(mEvent);
if (relateToId === null) return null;
if (typeof myMap.get(relateToId) === 'undefined') myMap.set(relateToId, []);
myMap.get(relateToId).push(mEvent);
return mEvent;
}
getEditedTimeline() {
const mReplace = new Map();
this.timeline.forEach((mEvent) => {
if (this.isEdited(mEvent)) {
this.addToMap(mReplace, mEvent);
}
});
return mReplace;
}
// eslint-disable-next-line class-methods-use-this
isReaction(mEvent) {
return mEvent.getType() === 'm.reaction';
}
getReactionTimeline() {
const mReaction = new Map();
this.timeline.forEach((mEvent) => {
if (this.isReaction(mEvent)) {
this.addToMap(mReaction, mEvent);
}
});
return mReaction;
}
paginateBack() {
if (this.isOngoingPagination) return;
this.isOngoingPagination = true;
const MSG_LIMIT = 30;
this.matrixClient.scrollback(this.room, MSG_LIMIT).then(async (room) => {
if (room.oldState.paginationToken === null) {
// We have reached start of the timeline
this.isOngoingPagination = false;
if (this.isEncryptedRoom()) await this.room.decryptAllEvents();
this.emit(cons.events.roomTimeline.PAGINATED, false);
return;
}
this.editedTimeline = this.getEditedTimeline();
this.reactionTimeline = this.getReactionTimeline();
this.isOngoingPagination = false;
if (this.isEncryptedRoom()) await this.room.decryptAllEvents();
this.emit(cons.events.roomTimeline.PAGINATED, true);
});
}
removeInternalListeners() {
this.matrixClient.removeListener('Room.timeline', this._listenRoomTimeline);
this.matrixClient.removeListener('Event.decrypted', this._listenDecryptEvent);
this.matrixClient.removeListener('RoomMember.typing', this._listenTypingEvent);
this.matrixClient.removeListener('Room.receipt', this._listenReciptEvent);
}
}
export default RoomTimeline;

View file

@ -0,0 +1,276 @@
import EventEmitter from 'events';
import encrypt from 'browser-encrypt-attachment';
import cons from './cons';
function getImageDimension(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = async () => {
resolve({
w: img.width,
h: img.height,
});
};
img.src = URL.createObjectURL(file);
});
}
function loadVideo(videoFile) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.playsInline = true;
video.muted = true;
const reader = new FileReader();
reader.onload = (ev) => {
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = async () => {
resolve(video);
video.pause();
};
video.onerror = (e) => {
reject(e);
};
video.src = ev.target.result;
video.load();
video.play();
};
reader.onerror = (e) => {
reject(e);
};
reader.readAsDataURL(videoFile);
});
}
function getVideoThumbnail(video, width, height, mimeType) {
return new Promise((resolve) => {
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
let targetWidth = width;
let targetHeight = height;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, targetWidth, targetHeight);
canvas.toBlob((thumbnail) => {
resolve({
thumbnail,
info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
});
}, mimeType);
});
}
class RoomsInput extends EventEmitter {
constructor(mx) {
super();
this.matrixClient = mx;
this.roomIdToInput = new Map();
}
cleanEmptyEntry(roomId) {
const input = this.getInput(roomId);
const isEmpty = typeof input.attachment === 'undefined'
&& (typeof input.message === 'undefined' || input.message === '');
if (isEmpty) {
this.roomIdToInput.delete(roomId);
}
}
getInput(roomId) {
return this.roomIdToInput.get(roomId) || {};
}
setMessage(roomId, message) {
const input = this.getInput(roomId);
input.message = message;
this.roomIdToInput.set(roomId, input);
if (message === '') this.cleanEmptyEntry(roomId);
}
getMessage(roomId) {
const input = this.getInput(roomId);
if (typeof input.message === 'undefined') return '';
return input.message;
}
setAttachment(roomId, file) {
const input = this.getInput(roomId);
input.attachment = {
file,
};
this.roomIdToInput.set(roomId, input);
}
getAttachment(roomId) {
const input = this.getInput(roomId);
if (typeof input.attachment === 'undefined') return null;
return input.attachment.file;
}
cancelAttachment(roomId) {
const input = this.getInput(roomId);
if (typeof input.attachment === 'undefined') return;
const { uploadingPromise } = input.attachment;
if (uploadingPromise) {
this.matrixClient.cancelUpload(uploadingPromise);
delete input.attachment.uploadingPromise;
}
if (input.message) {
delete input.attachment;
delete input.isSending;
this.roomIdToInput.set(roomId, input);
} else {
this.roomIdToInput.delete(roomId);
}
this.emit(cons.events.roomsInput.ATTACHMENT_CANCELED, roomId);
}
isSending(roomId) {
return this.roomIdToInput.get(roomId)?.isSending || false;
}
async sendInput(roomId) {
const input = this.getInput(roomId);
input.isSending = true;
this.roomIdToInput.set(roomId, input);
if (input.attachment) {
await this.sendFile(roomId, input.attachment.file);
}
if (this.getMessage(roomId).trim() !== '') {
const content = {
body: input.message,
msgtype: 'm.text',
};
this.matrixClient.sendMessage(roomId, content);
}
if (this.isSending(roomId)) this.roomIdToInput.delete(roomId);
this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId);
}
async sendFile(roomId, file) {
const fileType = file.type.slice(0, file.type.indexOf('/'));
const info = {
mimetype: file.type,
size: file.size,
};
const content = { info };
let uploadData = null;
if (fileType === 'image') {
const imgDimension = await getImageDimension(file);
info.w = imgDimension.w;
info.h = imgDimension.h;
content.msgtype = 'm.image';
content.body = file.name || 'Image';
} else if (fileType === 'video') {
content.msgtype = 'm.video';
content.body = file.name || 'Video';
try {
const video = await loadVideo(file);
info.w = video.videoWidth;
info.h = video.videoHeight;
const thumbnailData = await getVideoThumbnail(video, video.videoWidth, video.videoHeight, 'image/jpeg');
const thumbnailUploadData = await this.uploadFile(roomId, thumbnailData.thumbnail);
info.thumbnail_info = thumbnailData.info;
if (this.matrixClient.isRoomEncrypted(roomId)) {
info.thumbnail_file = thumbnailUploadData.file;
} else {
info.thumbnail_url = thumbnailUploadData.url;
}
} catch (e) {
this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId);
return;
}
} else if (fileType === 'audio') {
content.msgtype = 'm.audio';
content.body = file.name || 'Audio';
} else {
content.msgtype = 'm.file';
content.body = file.name || 'File';
}
try {
uploadData = await this.uploadFile(roomId, file, (data) => {
// data have two properties: data.loaded, data.total
this.emit(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, roomId, data);
});
this.emit(cons.events.roomsInput.FILE_UPLOADED, roomId);
} catch (e) {
this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId);
return;
}
if (this.matrixClient.isRoomEncrypted(roomId)) {
content.file = uploadData.file;
await this.matrixClient.sendMessage(roomId, content);
} else {
content.url = uploadData.url;
await this.matrixClient.sendMessage(roomId, content);
}
}
async uploadFile(roomId, file, progressHandler) {
const isEncryptedRoom = this.matrixClient.isRoomEncrypted(roomId);
let encryptInfo = null;
let encryptBlob = null;
if (isEncryptedRoom) {
const dataBuffer = await file.arrayBuffer();
if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled');
const encryptedResult = await encrypt.encryptAttachment(dataBuffer);
if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled');
encryptInfo = encryptedResult.info;
encryptBlob = new Blob([encryptedResult.data]);
}
const uploadingPromise = this.matrixClient.uploadContent(isEncryptedRoom ? encryptBlob : file, {
// don't send filename if room is encrypted.
includeFilename: !isEncryptedRoom,
progressHandler,
});
const input = this.getInput(roomId);
input.attachment.uploadingPromise = uploadingPromise;
this.roomIdToInput.set(roomId, input);
const url = await uploadingPromise;
delete input.attachment.uploadingPromise;
this.roomIdToInput.set(roomId, input);
if (isEncryptedRoom) {
encryptInfo.url = url;
if (file.type) encryptInfo.mimetype = file.type;
return { file: encryptInfo };
}
return { url };
}
}
export default RoomsInput;

19
src/client/state/auth.js Normal file
View file

@ -0,0 +1,19 @@
import cons from './cons';
function getSecret(key) {
return localStorage.getItem(key);
}
const isAuthanticated = () => getSecret(cons.secretKey.ACCESS_TOKEN) !== null;
const secret = {
accessToken: getSecret(cons.secretKey.ACCESS_TOKEN),
deviceId: getSecret(cons.secretKey.DEVICE_ID),
userId: getSecret(cons.secretKey.USER_ID),
baseUrl: getSecret(cons.secretKey.BASE_URL),
};
export {
isAuthanticated,
secret,
};

65
src/client/state/cons.js Normal file
View file

@ -0,0 +1,65 @@
const cons = {
secretKey: {
ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id',
USER_ID: 'cinny_user_id',
BASE_URL: 'cinny_hs_base_url',
},
DEVICE_DISPLAY_NAME: 'Cinny Web',
actions: {
navigation: {
CHANGE_TAB: 'CHANGE_TAB',
SELECT_ROOM: 'SELECT_ROOM',
TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER',
OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
OPEN_PUBLIC_CHANNELS: 'OPEN_PUBLIC_CHANNELS',
OPEN_CREATE_CHANNEL: 'OPEN_CREATE_CHANNEL',
OPEN_INVITE_USER: 'OPEN_INVITE_USER',
OPEN_SETTINGS: 'OPEN_SETTINGS',
},
room: {
JOIN: 'JOIN',
LEAVE: 'LEAVE',
CREATE: 'CREATE',
error: {
CREATE: 'CREATE',
},
},
},
events: {
navigation: {
TAB_CHANGED: 'TAB_CHANGED',
ROOM_SELECTED: 'ROOM_SELECTED',
PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED',
INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
PUBLIC_CHANNELS_OPENED: 'PUBLIC_CHANNELS_OPENED',
CREATE_CHANNEL_OPENED: 'CREATE_CHANNEL_OPENED',
INVITE_USER_OPENED: 'INVITE_USER_OPENED',
SETTINGS_OPENED: 'SETTINGS_OPENED',
},
roomList: {
ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
INVITELIST_UPDATED: 'INVITELIST_UPDATED',
ROOM_JOINED: 'ROOM_JOINED',
ROOM_LEAVED: 'ROOM_LEAVED',
ROOM_CREATED: 'ROOM_CREATED',
},
roomTimeline: {
EVENT: 'EVENT',
PAGINATED: 'PAGINATED',
TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED',
READ_RECEIPT: 'READ_RECEIPT',
},
roomsInput: {
MESSAGE_SENT: 'MESSAGE_SENT',
FILE_UPLOADED: 'FILE_UPLOADED',
UPLOAD_PROGRESS_CHANGES: 'UPLOAD_PROGRESS_CHANGES',
FILE_UPLOAD_CANCELED: 'FILE_UPLOAD_CANCELED',
ATTACHMENT_CANCELED: 'ATTACHMENT_CANCELED',
},
},
};
Object.freeze(cons);
export default cons;

View file

@ -0,0 +1,59 @@
import EventEmitter from 'events';
import appDispatcher from '../dispatcher';
import cons from './cons';
class Navigation extends EventEmitter {
constructor() {
super();
this.activeTab = 'channels';
this.selectedRoom = null;
this.isPeopleDrawerVisible = true;
}
getActiveTab() {
return this.activeTab;
}
getActiveRoom() {
return this.selectedRoom;
}
navigate(action) {
const actions = {
[cons.actions.navigation.CHANGE_TAB]: () => {
this.activeTab = action.tabId;
this.emit(cons.events.navigation.TAB_CHANGED, this.activeTab);
},
[cons.actions.navigation.SELECT_ROOM]: () => {
this.selectedRoom = action.roomId;
this.emit(cons.events.navigation.ROOM_SELECTED, this.selectedRoom);
},
[cons.actions.navigation.TOGGLE_PEOPLE_DRAWER]: () => {
this.isPeopleDrawerVisible = !this.isPeopleDrawerVisible;
this.emit(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, this.isPeopleDrawerVisible);
},
[cons.actions.navigation.OPEN_INVITE_LIST]: () => {
this.emit(cons.events.navigation.INVITE_LIST_OPENED);
},
[cons.actions.navigation.OPEN_PUBLIC_CHANNELS]: () => {
this.emit(cons.events.navigation.PUBLIC_CHANNELS_OPENED);
},
[cons.actions.navigation.OPEN_CREATE_CHANNEL]: () => {
this.emit(cons.events.navigation.CREATE_CHANNEL_OPENED);
},
[cons.actions.navigation.OPEN_INVITE_USER]: () => {
this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId);
},
[cons.actions.navigation.OPEN_SETTINGS]: () => {
this.emit(cons.events.navigation.SETTINGS_OPENED);
},
};
actions[action.type]?.();
}
}
const navigation = new Navigation();
appDispatcher.register(navigation.navigate.bind(navigation));
export default navigation;

View file

@ -0,0 +1,36 @@
class Settings {
constructor() {
this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme'];
this.themeIndex = this.getThemeIndex();
}
getThemeIndex() {
if (typeof this.themeIndex === 'number') return this.themeIndex;
let settings = localStorage.getItem('settings');
if (settings === null) return 0;
settings = JSON.parse(settings);
if (typeof settings.themeIndex === 'undefined') return 0;
// eslint-disable-next-line radix
return parseInt(settings.themeIndex);
}
getThemeName() {
return this.themes[this.themeIndex];
}
setTheme(themeIndex) {
const appBody = document.getElementById('appBody');
this.themes.forEach((themeName) => {
if (themeName === '') return;
appBody.classList.remove(themeName);
});
if (this.themes[themeIndex] !== '') appBody.classList.add(this.themes[themeIndex]);
localStorage.setItem('settings', JSON.stringify({ themeIndex }));
this.themeIndex = themeIndex;
}
}
const settings = new Settings();
export default settings;

14
src/index.jsx Normal file
View file

@ -0,0 +1,14 @@
import React from 'react';
import ReactDom from 'react-dom';
import './index.scss';
import settings from './client/state/settings';
import App from './app/pages/App';
settings.setTheme(settings.getThemeIndex());
ReactDom.render(
<App />,
document.getElementById('root'),
);

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