initial commit
This commit is contained in:
commit
026f835a87
176 changed files with 10613 additions and 0 deletions
src
app
atoms
avatar
badge
button
context-menu
divider
header
input
modal
scroll
segmented-controls
spinner
system-icons
text
molecules
channel-intro
channel-selector
channel-tile
media
message
people-selector
popup-window
setting-tile
sidebar-avatar
organisms
channel
create-channel
emoji-board
invite-list
invite-user
navigation
public-channels
pw
settings
welcome
pages
templates
client
index.jsx
57
src/app/atoms/avatar/Avatar.jsx
Normal file
57
src/app/atoms/avatar/Avatar.jsx
Normal 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;
|
52
src/app/atoms/avatar/Avatar.scss
Normal file
52
src/app/atoms/avatar/Avatar.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
28
src/app/atoms/badge/NotificationBadge.jsx
Normal file
28
src/app/atoms/badge/NotificationBadge.jsx
Normal 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;
|
18
src/app/atoms/badge/NotificationBadge.scss
Normal file
18
src/app/atoms/badge/NotificationBadge.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
47
src/app/atoms/button/Button.jsx
Normal file
47
src/app/atoms/button/Button.jsx
Normal 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;
|
83
src/app/atoms/button/Button.scss
Normal file
83
src/app/atoms/button/Button.scss
Normal 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));
|
||||
}
|
60
src/app/atoms/button/IconButton.jsx
Normal file
60
src/app/atoms/button/IconButton.jsx
Normal 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;
|
45
src/app/atoms/button/IconButton.scss
Normal file
45
src/app/atoms/button/IconButton.scss
Normal 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);
|
||||
}
|
||||
}
|
25
src/app/atoms/button/Toggle.jsx
Normal file
25
src/app/atoms/button/Toggle.jsx
Normal 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;
|
39
src/app/atoms/button/Toggle.scss
Normal file
39
src/app/atoms/button/Toggle.scss
Normal 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%));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
25
src/app/atoms/button/_state.scss
Normal file
25
src/app/atoms/button/_state.scss
Normal 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;
|
||||
}
|
||||
}
|
23
src/app/atoms/button/script.js
Normal file
23
src/app/atoms/button/script.js
Normal 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 };
|
103
src/app/atoms/context-menu/ContextMenu.jsx
Normal file
103
src/app/atoms/context-menu/ContextMenu.jsx
Normal 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,
|
||||
};
|
71
src/app/atoms/context-menu/ContextMenu.scss
Normal file
71
src/app/atoms/context-menu/ContextMenu.scss
Normal 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);
|
||||
}
|
||||
}
|
29
src/app/atoms/divider/Divider.jsx
Normal file
29
src/app/atoms/divider/Divider.jsx
Normal 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;
|
68
src/app/atoms/divider/Divider.scss
Normal file
68
src/app/atoms/divider/Divider.scss
Normal 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);
|
||||
}
|
||||
}
|
29
src/app/atoms/header/Header.jsx
Normal file
29
src/app/atoms/header/Header.jsx
Normal 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 };
|
63
src/app/atoms/header/Header.scss
Normal file
63
src/app/atoms/header/Header.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
77
src/app/atoms/input/Input.jsx
Normal file
77
src/app/atoms/input/Input.jsx
Normal 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;
|
40
src/app/atoms/input/Input.scss
Normal file
40
src/app/atoms/input/Input.scss
Normal 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)
|
||||
}
|
||||
}
|
67
src/app/atoms/modal/RawModal.jsx
Normal file
67
src/app/atoms/modal/RawModal.jsx
Normal 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;
|
63
src/app/atoms/modal/RawModal.scss
Normal file
63
src/app/atoms/modal/RawModal.scss
Normal 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);
|
||||
}
|
||||
}
|
37
src/app/atoms/scroll/ScrollView.jsx
Normal file
37
src/app/atoms/scroll/ScrollView.jsx
Normal 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;
|
22
src/app/atoms/scroll/ScrollView.scss
Normal file
22
src/app/atoms/scroll/ScrollView.scss
Normal 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;
|
||||
}
|
||||
}
|
62
src/app/atoms/scroll/_scrollbar.scss
Normal file
62
src/app/atoms/scroll/_scrollbar.scss
Normal 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;
|
||||
}
|
||||
}
|
51
src/app/atoms/segmented-controls/SegmentedControls.jsx
Normal file
51
src/app/atoms/segmented-controls/SegmentedControls.jsx
Normal 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;
|
61
src/app/atoms/segmented-controls/SegmentedControls.scss
Normal file
61
src/app/atoms/segmented-controls/SegmentedControls.scss
Normal 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;}
|
||||
}
|
||||
}
|
||||
}
|
19
src/app/atoms/spinner/Spinner.jsx
Normal file
19
src/app/atoms/spinner/Spinner.jsx
Normal 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;
|
22
src/app/atoms/spinner/Spinner.scss
Normal file
22
src/app/atoms/spinner/Spinner.scss
Normal 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);
|
||||
}
|
||||
}
|
25
src/app/atoms/system-icons/RawIcon.jsx
Normal file
25
src/app/atoms/system-icons/RawIcon.jsx
Normal 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;
|
25
src/app/atoms/system-icons/RawIcon.scss
Normal file
25
src/app/atoms/system-icons/RawIcon.scss
Normal 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));
|
||||
}
|
28
src/app/atoms/text/Text.jsx
Normal file
28
src/app/atoms/text/Text.jsx
Normal 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;
|
41
src/app/atoms/text/Text.scss
Normal file
41
src/app/atoms/text/Text.scss
Normal 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);
|
||||
}
|
46
src/app/molecules/channel-intro/ChannelIntro.jsx
Normal file
46
src/app/molecules/channel-intro/ChannelIntro.jsx
Normal 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;
|
31
src/app/molecules/channel-intro/ChannelIntro.scss
Normal file
31
src/app/molecules/channel-intro/ChannelIntro.scss
Normal 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);
|
||||
}
|
||||
}
|
73
src/app/molecules/channel-selector/ChannelSelector.jsx
Normal file
73
src/app/molecules/channel-selector/ChannelSelector.jsx
Normal 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;
|
66
src/app/molecules/channel-selector/ChannelSelector.scss
Normal file
66
src/app/molecules/channel-selector/ChannelSelector.scss
Normal 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);
|
||||
}
|
72
src/app/molecules/channel-tile/ChannelTile.jsx
Normal file
72
src/app/molecules/channel-tile/ChannelTile.jsx
Normal 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;
|
21
src/app/molecules/channel-tile/ChannelTile.scss
Normal file
21
src/app/molecules/channel-tile/ChannelTile.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
307
src/app/molecules/media/Media.jsx
Normal file
307
src/app/molecules/media/Media.jsx
Normal 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,
|
||||
};
|
62
src/app/molecules/media/Media.scss
Normal file
62
src/app/molecules/media/Media.scss
Normal 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%
|
||||
}
|
||||
}
|
149
src/app/molecules/message/Message.jsx
Normal file
149
src/app/molecules/message/Message.jsx
Normal 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 };
|
293
src/app/molecules/message/Message.scss
Normal file
293
src/app/molecules/message/Message.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
79
src/app/molecules/message/TimelineChange.jsx
Normal file
79
src/app/molecules/message/TimelineChange.jsx
Normal 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;
|
39
src/app/molecules/message/TimelineChange.scss
Normal file
39
src/app/molecules/message/TimelineChange.scss
Normal 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);
|
||||
}
|
||||
}
|
42
src/app/molecules/people-selector/PeopleSelector.jsx
Normal file
42
src/app/molecules/people-selector/PeopleSelector.jsx
Normal 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;
|
40
src/app/molecules/people-selector/PeopleSelector.scss
Normal file
40
src/app/molecules/people-selector/PeopleSelector.scss
Normal 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);
|
||||
}
|
||||
}
|
123
src/app/molecules/popup-window/PopupWindow.jsx
Normal file
123
src/app/molecules/popup-window/PopupWindow.jsx
Normal 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 };
|
100
src/app/molecules/popup-window/PopupWindow.scss
Normal file
100
src/app/molecules/popup-window/PopupWindow.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
src/app/molecules/setting-tile/SettingTile.jsx
Normal file
32
src/app/molecules/setting-tile/SettingTile.jsx
Normal 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;
|
16
src/app/molecules/setting-tile/SettingTile.scss
Normal file
16
src/app/molecules/setting-tile/SettingTile.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
72
src/app/molecules/sidebar-avatar/SidebarAvatar.jsx
Normal file
72
src/app/molecules/sidebar-avatar/SidebarAvatar.jsx
Normal 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;
|
63
src/app/molecules/sidebar-avatar/SidebarAvatar.scss
Normal file
63
src/app/molecules/sidebar-avatar/SidebarAvatar.scss
Normal 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);
|
||||
}
|
||||
}
|
40
src/app/organisms/channel/Channel.jsx
Normal file
40
src/app/organisms/channel/Channel.jsx
Normal 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;
|
4
src/app/organisms/channel/Channel.scss
Normal file
4
src/app/organisms/channel/Channel.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.channel-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
1142
src/app/organisms/channel/ChannelView.jsx
Normal file
1142
src/app/organisms/channel/ChannelView.jsx
Normal file
File diff suppressed because it is too large
Load diff
248
src/app/organisms/channel/ChannelView.scss
Normal file
248
src/app/organisms/channel/ChannelView.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
138
src/app/organisms/channel/PeopleDrawer.jsx
Normal file
138
src/app/organisms/channel/PeopleDrawer.jsx
Normal 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;
|
75
src/app/organisms/channel/PeopleDrawer.scss
Normal file
75
src/app/organisms/channel/PeopleDrawer.scss
Normal 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%;
|
||||
}
|
||||
}
|
165
src/app/organisms/create-channel/CreateChannel.jsx
Normal file
165
src/app/organisms/create-channel/CreateChannel.jsx
Normal 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 can’t disable this later. Bridges & most bots won’t 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;
|
103
src/app/organisms/create-channel/CreateChannel.scss
Normal file
103
src/app/organisms/create-channel/CreateChannel.scss
Normal 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);
|
||||
}
|
||||
}
|
195
src/app/organisms/emoji-board/EmojiBoard.jsx
Normal file
195
src/app/organisms/emoji-board/EmojiBoard.jsx
Normal 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;
|
89
src/app/organisms/emoji-board/EmojiBoard.scss
Normal file
89
src/app/organisms/emoji-board/EmojiBoard.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
76
src/app/organisms/emoji-board/emoji.js
Normal file
76
src/app/organisms/emoji-board/emoji.js
Normal 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,
|
||||
};
|
135
src/app/organisms/invite-list/InviteList.jsx
Normal file
135
src/app/organisms/invite-list/InviteList.jsx
Normal 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;
|
39
src/app/organisms/invite-list/InviteList.scss
Normal file
39
src/app/organisms/invite-list/InviteList.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
269
src/app/organisms/invite-user/InviteUser.jsx
Normal file
269
src/app/organisms/invite-user/InviteUser.jsx
Normal 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;
|
55
src/app/organisms/invite-user/InviteUser.scss
Normal file
55
src/app/organisms/invite-user/InviteUser.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
223
src/app/organisms/navigation/Drawer.jsx
Normal file
223
src/app/organisms/navigation/Drawer.jsx
Normal 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;
|
48
src/app/organisms/navigation/Drawer.scss
Normal file
48
src/app/organisms/navigation/Drawer.scss
Normal 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;
|
||||
}
|
||||
}
|
36
src/app/organisms/navigation/Navigation.jsx
Normal file
36
src/app/organisms/navigation/Navigation.jsx
Normal 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;
|
7
src/app/organisms/navigation/Navigation.scss
Normal file
7
src/app/organisms/navigation/Navigation.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
.navigation {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface-low);
|
||||
|
||||
display: flex;
|
||||
}
|
118
src/app/organisms/navigation/SideBar.jsx
Normal file
118
src/app/organisms/navigation/SideBar.jsx
Normal 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;
|
70
src/app/organisms/navigation/SideBar.scss
Normal file
70
src/app/organisms/navigation/SideBar.scss
Normal 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);
|
||||
}
|
199
src/app/organisms/public-channels/PublicChannels.jsx
Normal file
199
src/app/organisms/public-channels/PublicChannels.jsx
Normal 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;
|
87
src/app/organisms/public-channels/PublicChannels.scss
Normal file
87
src/app/organisms/public-channels/PublicChannels.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
80
src/app/organisms/pw/Windows.jsx
Normal file
80
src/app/organisms/pw/Windows.jsx
Normal 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;
|
56
src/app/organisms/settings/Settings.jsx
Normal file
56
src/app/organisms/settings/Settings.jsx
Normal 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;
|
22
src/app/organisms/settings/Settings.scss
Normal file
22
src/app/organisms/settings/Settings.scss
Normal 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;
|
||||
}
|
20
src/app/organisms/welcome/Welcome.jsx
Normal file
20
src/app/organisms/welcome/Welcome.jsx
Normal 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;
|
20
src/app/organisms/welcome/Welcome.scss
Normal file
20
src/app/organisms/welcome/Welcome.scss
Normal 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
29
src/app/pages/App.jsx
Normal 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;
|
335
src/app/templates/auth/Auth.jsx
Normal file
335
src/app/templates/auth/Auth.jsx
Normal 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;
|
157
src/app/templates/auth/Auth.scss
Normal file
157
src/app/templates/auth/Auth.scss
Normal 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;
|
||||
}
|
47
src/app/templates/client/Client.jsx
Normal file
47
src/app/templates/client/Client.jsx
Normal 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;
|
34
src/app/templates/client/Client.scss
Normal file
34
src/app/templates/client/Client.scss
Normal 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
145
src/client/action/auth.js
Normal 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 };
|
12
src/client/action/logout.js
Normal file
12
src/client/action/logout.js
Normal 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;
|
64
src/client/action/navigation.js
Normal file
64
src/client/action/navigation.js
Normal 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
189
src/client/action/room.js
Normal 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
4
src/client/dispatcher.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Dispatcher } from 'flux';
|
||||
|
||||
const appDispatcher = new Dispatcher();
|
||||
export default appDispatcher;
|
89
src/client/initMatrix.js
Normal file
89
src/client/initMatrix.js
Normal 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;
|
288
src/client/state/RoomList.js
Normal file
288
src/client/state/RoomList.js
Normal 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;
|
161
src/client/state/RoomTimeline.js
Normal file
161
src/client/state/RoomTimeline.js
Normal 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;
|
276
src/client/state/RoomsInput.js
Normal file
276
src/client/state/RoomsInput.js
Normal 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
19
src/client/state/auth.js
Normal 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
65
src/client/state/cons.js
Normal 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;
|
59
src/client/state/navigation.js
Normal file
59
src/client/state/navigation.js
Normal 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;
|
36
src/client/state/settings.js
Normal file
36
src/client/state/settings.js
Normal 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
14
src/index.jsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue