mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-09 16:50:28 +03:00
Merge branch 'dev' into explore-persistent-server-list
This commit is contained in:
commit
26e90feb36
216 changed files with 347 additions and 8774 deletions
|
|
@ -1,69 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Avatar.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
|
||||
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
|
||||
import { avatarInitials } from '../../../util/common';
|
||||
|
||||
const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => {
|
||||
let textSize = 's1';
|
||||
if (size === 'large') textSize = 'h1';
|
||||
if (size === 'small') textSize = 'b1';
|
||||
if (size === 'extra-small') textSize = 'b3';
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
|
||||
{imageSrc !== null ? (
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
onLoad={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = ImageBrokenSVG;
|
||||
}}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
||||
className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
|
||||
>
|
||||
{iconSrc !== null ? (
|
||||
<RawIcon size={size} src={iconSrc} color={iconColor} />
|
||||
) : (
|
||||
text !== null && (
|
||||
<Text variant={textSize} primary>
|
||||
{avatarInitials(text)}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Avatar.defaultProps = {
|
||||
text: null,
|
||||
bgColor: 'transparent',
|
||||
iconSrc: null,
|
||||
iconColor: null,
|
||||
imageSrc: null,
|
||||
size: 'normal',
|
||||
};
|
||||
|
||||
Avatar.propTypes = {
|
||||
text: PropTypes.string,
|
||||
bgColor: PropTypes.string,
|
||||
iconSrc: PropTypes.string,
|
||||
iconColor: PropTypes.string,
|
||||
imageSrc: PropTypes.string,
|
||||
size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']),
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
|
||||
.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;
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.avatar__border {
|
||||
@extend .cp-fx__row--c-c;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
|
||||
.text {
|
||||
color: white;
|
||||
}
|
||||
&--active {
|
||||
@extend .avatar__border;
|
||||
box-shadow: var(--bs-surface-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { avatarInitials, cssVar } from '../../../util/common';
|
||||
|
||||
// renders the avatar and returns it as an URL
|
||||
export default async function renderAvatar({
|
||||
text, bgColor, imageSrc, size, borderRadius, scale,
|
||||
}) {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size * scale;
|
||||
canvas.height = size * scale;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// rounded corners
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(size, size);
|
||||
ctx.arcTo(0, size, 0, 0, borderRadius);
|
||||
ctx.arcTo(0, 0, size, 0, borderRadius);
|
||||
ctx.arcTo(size, 0, size, size, borderRadius);
|
||||
ctx.arcTo(size, size, 0, size, borderRadius);
|
||||
|
||||
if (imageSrc) {
|
||||
// clip corners of image
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
img.onerror = reject;
|
||||
img.onload = resolve;
|
||||
});
|
||||
img.src = imageSrc;
|
||||
await promise;
|
||||
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
} else {
|
||||
// colored background
|
||||
ctx.fillStyle = cssVar(bgColor);
|
||||
ctx.fill();
|
||||
|
||||
// centered letter
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `${cssVar('--fs-s1')} ${cssVar('--font-primary')}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(avatarInitials(text), size / 2, size / 2);
|
||||
}
|
||||
|
||||
return canvas.toDataURL();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return imageSrc;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './NotificationBadge.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
|
||||
function NotificationBadge({ alert, content }) {
|
||||
const notificationClass = alert ? ' notification-badge--alert' : '';
|
||||
return (
|
||||
<div className={`notification-badge${notificationClass}`}>
|
||||
{content !== null && <Text variant="b3" weight="bold">{content}</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationBadge.defaultProps = {
|
||||
alert: false,
|
||||
content: null,
|
||||
};
|
||||
|
||||
NotificationBadge.propTypes = {
|
||||
alert: PropTypes.bool,
|
||||
content: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
};
|
||||
|
||||
export default NotificationBadge;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
.notification-badge {
|
||||
min-width: 16px;
|
||||
min-height: 8px;
|
||||
padding: 0 var(--sp-ultra-tight);
|
||||
background-color: var(--bg-badge);
|
||||
border-radius: var(--bo-radius);
|
||||
|
||||
.text {
|
||||
color: var(--tc-badge);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--alert {
|
||||
background-color: var(--bg-positive);
|
||||
}
|
||||
|
||||
&:empty {
|
||||
min-width: 8px;
|
||||
margin: 0 var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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';
|
||||
|
||||
const Button = React.forwardRef(({
|
||||
id, className, variant, iconSrc,
|
||||
type, onClick, children, disabled,
|
||||
}, ref) => {
|
||||
const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
id={id === '' ? undefined : id}
|
||||
className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`}
|
||||
onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
|
||||
onClick={onClick}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{iconSrc !== null && <RawIcon size="small" src={iconSrc} />}
|
||||
{typeof children === 'string' && <Text variant="b1">{ children }</Text>}
|
||||
{typeof children !== 'string' && children }
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.defaultProps = {
|
||||
id: '',
|
||||
className: null,
|
||||
variant: 'surface',
|
||||
iconSrc: null,
|
||||
type: 'button',
|
||||
onClick: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
id: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
iconSrc: PropTypes.string,
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
@use 'state';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/text';
|
||||
|
||||
.btn-surface,
|
||||
.btn-primary,
|
||||
.btn-positive,
|
||||
.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;
|
||||
|
||||
& .text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
|
||||
&--icon {
|
||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||
|
||||
}
|
||||
.ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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-positive {
|
||||
box-shadow: var(--bs-positive-border);
|
||||
@include color(var(--tc-positive-high), var(--ic-positive-normal));
|
||||
@include state.hover(var(--bg-positive-hover));
|
||||
@include state.focus(var(--bs-positive-outline));
|
||||
@include state.active(var(--bg-positive-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));
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Checkbox.scss';
|
||||
|
||||
function Checkbox({
|
||||
variant, isActive, onToggle,
|
||||
disabled, tabIndex,
|
||||
}) {
|
||||
const className = `checkbox checkbox-${variant}${isActive ? ' checkbox--active' : ''}`;
|
||||
if (onToggle === null) return <span className={className} />;
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button
|
||||
onClick={() => onToggle(!isActive)}
|
||||
className={className}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Checkbox.defaultProps = {
|
||||
variant: 'primary',
|
||||
isActive: false,
|
||||
onToggle: null,
|
||||
disabled: false,
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
Checkbox.propTypes = {
|
||||
variant: PropTypes.oneOf(['primary', 'positive', 'caution', 'danger']),
|
||||
isActive: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
tabIndex: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use './state';
|
||||
|
||||
.checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
background-color: var(--bg-surface-border);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
cursor: pointer;
|
||||
@include state.disabled;
|
||||
@extend .cp-fx__row--c-c;
|
||||
|
||||
&--active {
|
||||
background-color: black;
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 6px;
|
||||
border: 6px solid white;
|
||||
border-width: 0 0 3px 3px;
|
||||
transform: rotateZ(-45deg) translate(1px, -1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
.checkbox-primary.checkbox--active {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
.checkbox-positive.checkbox--active {
|
||||
background-color: var(--bg-positive);
|
||||
}
|
||||
.checkbox-caution.checkbox--active {
|
||||
background-color: var(--bg-caution);
|
||||
}
|
||||
.checkbox-danger.checkbox--active {
|
||||
background-color: var(--bg-danger);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './IconButton.scss';
|
||||
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
import Tooltip from '../tooltip/Tooltip';
|
||||
import { blurOnBubbling } from './script';
|
||||
import Text from '../text/Text';
|
||||
|
||||
const IconButton = React.forwardRef(({
|
||||
variant, size, type,
|
||||
tooltip, tooltipPlacement, src,
|
||||
onClick, tabIndex, disabled, isImage,
|
||||
className,
|
||||
}, ref) => {
|
||||
const btn = (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`ic-btn ic-btn-${variant} ${className}`}
|
||||
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
||||
onClick={onClick}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RawIcon size={size} src={src} isImage={isImage} />
|
||||
</button>
|
||||
);
|
||||
if (tooltip === null) return btn;
|
||||
return (
|
||||
<Tooltip
|
||||
placement={tooltipPlacement}
|
||||
content={<Text variant="b2">{tooltip}</Text>}
|
||||
>
|
||||
{btn}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
IconButton.defaultProps = {
|
||||
variant: 'surface',
|
||||
size: 'normal',
|
||||
type: 'button',
|
||||
tooltip: null,
|
||||
tooltipPlacement: 'top',
|
||||
onClick: null,
|
||||
tabIndex: 0,
|
||||
disabled: false,
|
||||
isImage: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
IconButton.propTypes = {
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
tooltip: PropTypes.string,
|
||||
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
|
||||
src: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
tabIndex: PropTypes.number,
|
||||
disabled: PropTypes.bool,
|
||||
isImage: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
@use 'state';
|
||||
|
||||
.ic-btn {
|
||||
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-primary {
|
||||
@include color(var(--ic-primary-normal));
|
||||
@include state.hover(var(--bg-primary-hover));
|
||||
@include focus(var(--bg-primary-hover));
|
||||
@include state.active(var(--bg-primary-active));
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
.ic-btn-positive {
|
||||
@include color(var(--ic-positive-normal));
|
||||
@include state.hover(var(--bg-positive-hover));
|
||||
@include focus(var(--bg-positive-hover));
|
||||
@include state.active(var(--bg-positive-active));
|
||||
}
|
||||
.ic-btn-caution {
|
||||
@include color(var(--ic-caution-normal));
|
||||
@include state.hover(var(--bg-caution-hover));
|
||||
@include focus(var(--bg-caution-hover));
|
||||
@include state.active(var(--bg-caution-active));
|
||||
}
|
||||
.ic-btn-danger {
|
||||
@include color(var(--ic-danger-normal));
|
||||
@include state.hover(var(--bg-danger-hover));
|
||||
@include focus(var(--bg-danger-hover));
|
||||
@include state.active(var(--bg-danger-active));
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RadioButton.scss';
|
||||
|
||||
function RadioButton({ isActive, onToggle, disabled }) {
|
||||
if (onToggle === null) return <span className={`radio-btn${isActive ? ' radio-btn--active' : ''}`} />;
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button
|
||||
onClick={() => onToggle(!isActive)}
|
||||
className={`radio-btn${isActive ? ' radio-btn--active' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
RadioButton.defaultProps = {
|
||||
isActive: false,
|
||||
onToggle: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
RadioButton.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RadioButton;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use './state';
|
||||
|
||||
.radio-btn {
|
||||
@extend .cp-fx__row--c-c;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-surface-border);
|
||||
border: 2px solid var(--bg-surface-border);
|
||||
cursor: pointer;
|
||||
@include state.disabled;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--bg-surface-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
&--active {
|
||||
border: 2px solid var(--bg-positive);
|
||||
&::before {
|
||||
background-color: var(--bg-positive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Toggle.scss';
|
||||
|
||||
function Toggle({ isActive, onToggle, disabled }) {
|
||||
const className = `toggle${isActive ? ' toggle--active' : ''}`;
|
||||
if (onToggle === null) return <span className={className} />;
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button
|
||||
onClick={() => onToggle(!isActive)}
|
||||
className={className}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Toggle.defaultProps = {
|
||||
isActive: false,
|
||||
disabled: false,
|
||||
onToggle: null,
|
||||
};
|
||||
|
||||
Toggle.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Toggle;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use './state';
|
||||
|
||||
.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);
|
||||
@include state.disabled;
|
||||
|
||||
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 {
|
||||
--ltr: translateX(calc(125%));
|
||||
--rtl: translateX(calc(-125%));
|
||||
@include dir.prop(transform, var(--ltr), var(--rtl));
|
||||
|
||||
transform: translateX(calc(125%));
|
||||
background-color: var(--bg-surface);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './InfoCard.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
import IconButton from '../button/IconButton';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function InfoCard({
|
||||
className, style,
|
||||
variant, iconSrc,
|
||||
title, content,
|
||||
rounded, requestClose,
|
||||
}) {
|
||||
const classes = [`info-card info-card--${variant}`];
|
||||
if (rounded) classes.push('info-card--rounded');
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<div className={classes.join(' ')} style={style}>
|
||||
{iconSrc && (
|
||||
<div className="info-card__icon">
|
||||
<RawIcon color={`var(--ic-${variant}-high)`} src={iconSrc} />
|
||||
</div>
|
||||
)}
|
||||
<div className="info-card__content">
|
||||
<Text>{title}</Text>
|
||||
{content}
|
||||
</div>
|
||||
{requestClose && (
|
||||
<IconButton src={CrossIC} variant={variant} onClick={requestClose} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InfoCard.defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
variant: 'surface',
|
||||
iconSrc: null,
|
||||
content: null,
|
||||
rounded: false,
|
||||
requestClose: null,
|
||||
};
|
||||
|
||||
InfoCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.shape({}),
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
iconSrc: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
content: PropTypes.node,
|
||||
rounded: PropTypes.bool,
|
||||
requestClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default InfoCard;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
@use '.././../partials/flex';
|
||||
@use '.././../partials/dir';
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 0;
|
||||
padding: var(--sp-tight);
|
||||
@include dir.prop(border-left, 4px solid transparent, none);
|
||||
@include dir.prop(border-right, none, 4px solid transparent);
|
||||
|
||||
& > .ic-btn {
|
||||
padding: 0;
|
||||
border-radius: 4;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 0 var(--sp-tight);
|
||||
@extend .cp-fx__item-one;
|
||||
|
||||
& > *:nth-child(2) {
|
||||
margin-top: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&--rounded {
|
||||
@include dir.prop(
|
||||
border-radius,
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
var(--bo-radius) 0 0 var(--bo-radius)
|
||||
);
|
||||
}
|
||||
|
||||
&--surface {
|
||||
border-color: var(--bg-surface-border);
|
||||
background-color: var(--bg-surface-hover);
|
||||
|
||||
}
|
||||
&--primary {
|
||||
border-color: var(--bg-primary);
|
||||
background-color: var(--bg-primary-hover);
|
||||
& .text {
|
||||
color: var(--tc-primary-high);
|
||||
&-b3 {
|
||||
color: var(--tc-primary-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--positive {
|
||||
border-color: var(--bg-positive-border);
|
||||
background-color: var(--bg-positive-hover);
|
||||
& .text {
|
||||
color: var(--tc-positive-high);
|
||||
&-b3 {
|
||||
color: var(--tc-positive-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--caution {
|
||||
border-color: var(--bg-caution-border);
|
||||
background-color: var(--bg-caution-hover);
|
||||
& .text {
|
||||
color: var(--tc-caution-high);
|
||||
&-b3 {
|
||||
color: var(--tc-caution-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--danger {
|
||||
border-color: var(--bg-danger-border);
|
||||
background-color: var(--bg-danger-hover);
|
||||
& .text {
|
||||
color: var(--tc-danger-high);
|
||||
&-b3 {
|
||||
color: var(--tc-danger-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Chip.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
|
||||
function Chip({
|
||||
iconSrc, iconColor, text, children,
|
||||
onClick,
|
||||
}) {
|
||||
return (
|
||||
<button className="chip" type="button" onClick={onClick}>
|
||||
{iconSrc != null && <RawIcon src={iconSrc} color={iconColor} size="extra-small" />}
|
||||
{(text != null && text !== '') && <Text variant="b3">{text}</Text>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Chip.propTypes = {
|
||||
iconSrc: PropTypes.string,
|
||||
iconColor: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
children: PropTypes.element,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Chip.defaultProps = {
|
||||
iconSrc: null,
|
||||
iconColor: null,
|
||||
text: null,
|
||||
children: null,
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
export default Chip;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.chip {
|
||||
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
background: var(--bg-surface-low);
|
||||
border-radius: var(--bo-radius);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
& > .text {
|
||||
flex: 1;
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
|
||||
& > .ic-raw {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import React, { useState, useEffect } 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, afterToggle,
|
||||
}) {
|
||||
const [isVisible, setVisibility] = useState(false);
|
||||
const showMenu = () => setVisibility(true);
|
||||
const hideMenu = () => setVisibility(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (afterToggle !== null) afterToggle(isVisible);
|
||||
}, [isVisible]);
|
||||
|
||||
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}
|
||||
duration={200}
|
||||
>
|
||||
{render(isVisible ? hideMenu : showMenu)}
|
||||
</Tippy>
|
||||
);
|
||||
}
|
||||
|
||||
ContextMenu.defaultProps = {
|
||||
maxWidth: 'unset',
|
||||
placement: 'right',
|
||||
afterToggle: null,
|
||||
};
|
||||
|
||||
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,
|
||||
afterToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
function MenuHeader({ children }) {
|
||||
return (
|
||||
<div className="context-menu__header">
|
||||
<Text variant="b3">{ children }</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MenuHeader.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
function MenuItem({
|
||||
variant, iconSrc, type,
|
||||
onClick, children, disabled,
|
||||
}) {
|
||||
return (
|
||||
<div className="context-menu__item">
|
||||
<Button
|
||||
variant={variant}
|
||||
iconSrc={iconSrc}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{ children }
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MenuItem.defaultProps = {
|
||||
variant: 'surface',
|
||||
iconSrc: null,
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
MenuItem.propTypes = {
|
||||
variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
|
||||
iconSrc: PropTypes.string,
|
||||
type: PropTypes.oneOf(['button', 'submit']),
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
function MenuBorder() {
|
||||
return <div style={{ borderBottom: '1px solid var(--bg-surface-border)' }}> </div>;
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu as default, MenuHeader, MenuItem, MenuBorder,
|
||||
};
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.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-normal);
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
|
||||
.text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu__item {
|
||||
display: flex;
|
||||
button[class^="btn"] {
|
||||
@extend .cp-fx__item-one;
|
||||
justify-content: flex-start;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
white-space: nowrap;
|
||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||
|
||||
& > .ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
|
||||
// if item doesn't have icon
|
||||
.text:first-child {
|
||||
@include dir.side(
|
||||
margin,
|
||||
calc(var(--ic-small) + var(--sp-tight)),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
.btn-surface:focus {
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
.btn-positive:focus {
|
||||
background-color: var(--bg-positive-hover);
|
||||
}
|
||||
.btn-caution:focus {
|
||||
background-color: var(--bg-caution-hover);
|
||||
}
|
||||
.btn-danger:focus {
|
||||
background-color: var(--bg-danger-hover);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import ContextMenu from './ContextMenu';
|
||||
|
||||
let key = null;
|
||||
function ReusableContextMenu() {
|
||||
const [data, setData] = useState(null);
|
||||
const openerRef = useRef(null);
|
||||
|
||||
const closeMenu = () => {
|
||||
key = null;
|
||||
if (data) openerRef.current.click();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const { cords } = data;
|
||||
openerRef.current.style.transform = `translate(${cords.x}px, ${cords.y}px)`;
|
||||
openerRef.current.style.width = `${cords.width}px`;
|
||||
openerRef.current.style.height = `${cords.height}px`;
|
||||
openerRef.current.click();
|
||||
}
|
||||
const handleContextMenuOpen = (placement, cords, render, afterClose) => {
|
||||
if (key) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
setData({
|
||||
placement, cords, render, afterClose,
|
||||
});
|
||||
};
|
||||
navigation.on(cons.events.navigation.REUSABLE_CONTEXT_MENU_OPENED, handleContextMenuOpen);
|
||||
return () => {
|
||||
navigation.removeListener(
|
||||
cons.events.navigation.REUSABLE_CONTEXT_MENU_OPENED,
|
||||
handleContextMenuOpen,
|
||||
);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const handleAfterToggle = (isVisible) => {
|
||||
if (isVisible) {
|
||||
key = Math.random();
|
||||
return;
|
||||
}
|
||||
data?.afterClose?.();
|
||||
if (setData) setData(null);
|
||||
|
||||
if (key === null) return;
|
||||
const copyKey = key;
|
||||
setTimeout(() => {
|
||||
if (key === copyKey) key = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
afterToggle={handleAfterToggle}
|
||||
placement={data?.placement || 'right'}
|
||||
content={data?.render(closeMenu) ?? ''}
|
||||
render={(toggleMenu) => (
|
||||
<input
|
||||
ref={openerRef}
|
||||
onClick={toggleMenu}
|
||||
type="button"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
visibility: 'hidden',
|
||||
appearance: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReusableContextMenu;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Divider.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
|
||||
function Divider({ text, variant, align }) {
|
||||
const dividerClass = ` divider--${variant} divider--${align}`;
|
||||
return (
|
||||
<div className={`divider${dividerClass}`}>
|
||||
{text !== null && <Text className="divider__text" variant="b3" weight="bold">{text}</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Divider.defaultProps = {
|
||||
text: null,
|
||||
variant: 'surface',
|
||||
align: 'center',
|
||||
};
|
||||
|
||||
Divider.propTypes = {
|
||||
text: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
align: PropTypes.oneOf(['left', 'center', 'right']),
|
||||
};
|
||||
|
||||
export default Divider;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
.divider-line {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--local-divider-color);
|
||||
opacity: var(--local-divider-opacity);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&--center::before,
|
||||
&--right::before {
|
||||
@extend .divider-line;
|
||||
}
|
||||
&--center::after,
|
||||
&--left::after {
|
||||
@extend .divider-line;
|
||||
}
|
||||
|
||||
&__text {
|
||||
padding: 2px var(--sp-extra-tight);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.divider--surface {
|
||||
--local-divider-color: var(--bg-divider);
|
||||
--local-divider-opacity: 1;
|
||||
|
||||
.divider__text {
|
||||
color: var(--tc-surface-low);
|
||||
border: 1px solid var(--bg-divider);
|
||||
}
|
||||
}
|
||||
.divider--primary {
|
||||
--local-divider-color: var(--bg-primary);
|
||||
--local-divider-opacity: .8;
|
||||
.divider__text {
|
||||
color: var(--tc-primary-high);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
.divider--positive {
|
||||
--local-divider-color: var(--bg-positive);
|
||||
--local-divider-opacity: .8;
|
||||
.divider__text {
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--bg-positive);
|
||||
}
|
||||
}
|
||||
.divider--danger {
|
||||
--local-divider-color: var(--bg-danger);
|
||||
--local-divider-opacity: .8;
|
||||
.divider__text {
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--bg-danger);
|
||||
}
|
||||
}
|
||||
.divider--caution {
|
||||
--local-divider-color: var(--bg-caution);
|
||||
--local-divider-opacity: .8;
|
||||
.divider__text {
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--bg-caution);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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 };
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.header {
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__title-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 var(--sp-tight);
|
||||
|
||||
&:first-child {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
|
||||
& > .text:first-child {
|
||||
@extend .cp-txt__ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
& > .text-b3{
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
margin-top: var(--sp-ultra-tight);
|
||||
@include dir.side(margin, var(--sp-tight), 0);
|
||||
@include dir.side(padding, var(--sp-tight), 0);
|
||||
@include dir.side(border, 1px solid var(--bg-surface-border), none);
|
||||
|
||||
max-height: calc(2 * var(--lh-b3));
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Input.scss';
|
||||
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
|
||||
function Input({
|
||||
id, label, name, value, placeholder,
|
||||
required, type, onChange, forwardRef,
|
||||
resizable, minHeight, onResize, state,
|
||||
onKeyDown, disabled, autoFocus,
|
||||
}) {
|
||||
return (
|
||||
<div className="input-container">
|
||||
{ label !== '' && <label className="input__label text-b2" htmlFor={id}>{label}</label> }
|
||||
{ resizable
|
||||
? (
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
style={{ minHeight: `${minHeight}px` }}
|
||||
name={name}
|
||||
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}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
dir="auto"
|
||||
ref={forwardRef}
|
||||
id={id}
|
||||
name={name}
|
||||
className={`input ${state !== 'normal' ? ` input--${state}` : ''}`}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
defaultValue={value}
|
||||
autoComplete="off"
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Input.defaultProps = {
|
||||
id: null,
|
||||
name: '',
|
||||
label: '',
|
||||
value: '',
|
||||
placeholder: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
onChange: null,
|
||||
forwardRef: null,
|
||||
resizable: false,
|
||||
minHeight: 46,
|
||||
onResize: null,
|
||||
state: 'normal',
|
||||
onKeyDown: null,
|
||||
disabled: false,
|
||||
autoFocus: false,
|
||||
};
|
||||
|
||||
Input.propTypes = {
|
||||
id: PropTypes.string,
|
||||
name: 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']),
|
||||
onKeyDown: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
autoFocus: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Input;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
@use '../../atoms/scroll/scrollbar';
|
||||
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0px;
|
||||
margin: 0;
|
||||
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);
|
||||
|
||||
:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: no-drop;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
||||
&--resizable {
|
||||
resize: vertical !important;
|
||||
overflow-y: auto !important;
|
||||
@include scrollbar.scroll;
|
||||
@include scrollbar.scroll__v;
|
||||
@include scrollbar.scroll--auto-hide;
|
||||
}
|
||||
&--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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RawModal.scss';
|
||||
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
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 ';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setIsRawModalVisible(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
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}
|
||||
>
|
||||
{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;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
.raw-modal {
|
||||
--small-modal-width: 525px;
|
||||
--medium-modal-width: 712px;
|
||||
--large-modal-width: 1024px;
|
||||
|
||||
position: relative;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModal__Overlay {
|
||||
animation: raw-modal--overlay 150ms;
|
||||
}
|
||||
|
||||
.ReactModal__Content {
|
||||
animation: raw-modal--content 150ms;
|
||||
}
|
||||
|
||||
@keyframes raw-modal--content {
|
||||
0% {
|
||||
transform: translateY(100px);
|
||||
opacity: .5;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes raw-modal--overlay {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use '_scrollbar';
|
||||
|
||||
@mixin paddingForSafari($padding) {
|
||||
@media not all and (min-resolution:.001dpcm) {
|
||||
@include dir.side(padding, 0, $padding);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include scrollbar.scroll;
|
||||
@include paddingForSafari(var(--sp-extra-tight));
|
||||
|
||||
&__h {
|
||||
@include scrollbar.scroll__h;
|
||||
}
|
||||
|
||||
&__v {
|
||||
@include scrollbar.scroll__v;
|
||||
}
|
||||
|
||||
&--auto-hide {
|
||||
@include scrollbar.scroll--auto-hide;
|
||||
}
|
||||
&--invisible {
|
||||
@include scrollbar.scroll--invisible;
|
||||
@include paddingForSafari(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
.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;
|
||||
// Below code stop scroll when x-scrollable content come in timeline
|
||||
// overscroll-behavior: none;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import React, { useState, useEffect } 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);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelect(selected);
|
||||
}, [selected]);
|
||||
|
||||
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;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
@use '../button/state';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.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;
|
||||
@include dir.side(border, 1px solid var(--bg-surface-border), none);
|
||||
|
||||
& .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;}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RawIcon.scss';
|
||||
|
||||
function RawIcon({ color, size, src, isImage }) {
|
||||
const style = {};
|
||||
if (color !== null) style.backgroundColor = color;
|
||||
if (isImage) {
|
||||
style.backgroundColor = 'transparent';
|
||||
style.backgroundImage = `url("${src}")`;
|
||||
} else {
|
||||
style.WebkitMaskImage = `url("${src}")`;
|
||||
style.maskImage = `url("${src}")`;
|
||||
}
|
||||
|
||||
return <span className={`ic-raw ic-raw-${size}`} style={style} />;
|
||||
}
|
||||
|
||||
RawIcon.defaultProps = {
|
||||
color: null,
|
||||
size: 'normal',
|
||||
isImage: false,
|
||||
};
|
||||
|
||||
RawIcon.propTypes = {
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']),
|
||||
src: PropTypes.string.isRequired,
|
||||
isImage: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RawIcon;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@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);
|
||||
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.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));
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Tabs.scss';
|
||||
|
||||
import Button from '../button/Button';
|
||||
import ScrollView from '../scroll/ScrollView';
|
||||
|
||||
function TabItem({
|
||||
selected, iconSrc,
|
||||
onClick, children, disabled,
|
||||
}) {
|
||||
const isSelected = selected ? 'tab-item--selected' : '';
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`tab-item ${isSelected}`}
|
||||
iconSrc={iconSrc}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
TabItem.defaultProps = {
|
||||
selected: false,
|
||||
iconSrc: null,
|
||||
onClick: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
TabItem.propTypes = {
|
||||
selected: PropTypes.bool,
|
||||
iconSrc: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
function Tabs({ items, defaultSelected, onSelect }) {
|
||||
const [selectedItem, setSelectedItem] = useState(items[defaultSelected]);
|
||||
|
||||
const handleTabSelection = (item, index, target) => {
|
||||
if (selectedItem === item) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
setSelectedItem(item);
|
||||
onSelect(item, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabs">
|
||||
<ScrollView horizontal vertical={false} invisible>
|
||||
<div className="tabs__content">
|
||||
{items.map((item, index) => (
|
||||
<TabItem
|
||||
key={item.text}
|
||||
selected={selectedItem.text === item.text}
|
||||
iconSrc={item.iconSrc}
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => handleTabSelection(item, index, e.currentTarget)}
|
||||
>
|
||||
{item.text}
|
||||
</TabItem>
|
||||
))}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Tabs.defaultProps = {
|
||||
defaultSelected: 0,
|
||||
};
|
||||
|
||||
Tabs.propTypes = {
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
iconSrc: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
}),
|
||||
).isRequired,
|
||||
defaultSelected: PropTypes.number,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.tabs {
|
||||
height: var(--header-height);
|
||||
box-shadow: inset 0 -1px 0 var(--bg-surface-border);
|
||||
|
||||
&__content {
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex-shrink: 0;
|
||||
|
||||
@include dir.side(padding, var(--sp-normal), 24px);
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
--bs-tab-selected: inset 0 -2px 0 var(--tc-surface-high);
|
||||
box-shadow: var(--bs-tab-selected);
|
||||
|
||||
& .ic-raw {
|
||||
background-color: var(--ic-surface-high);
|
||||
}
|
||||
& .text {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-tab-selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Text.scss';
|
||||
|
||||
function Text({
|
||||
className, style, variant, weight,
|
||||
primary, span, children,
|
||||
}) {
|
||||
const classes = [];
|
||||
if (className) classes.push(className);
|
||||
|
||||
classes.push(`text text-${variant} text-${weight}`);
|
||||
if (primary) classes.push('font-primary');
|
||||
|
||||
const textClass = classes.join(' ');
|
||||
if (span) return <span className={textClass} style={style}>{ children }</span>;
|
||||
if (variant === 'h1') return <h1 className={textClass} style={style}>{ children }</h1>;
|
||||
if (variant === 'h2') return <h2 className={textClass} style={style}>{ children }</h2>;
|
||||
if (variant === 's1') return <h4 className={textClass} style={style}>{ children }</h4>;
|
||||
return <p className={textClass} style={style}>{ children }</p>;
|
||||
}
|
||||
|
||||
Text.defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
variant: 'b1',
|
||||
weight: 'normal',
|
||||
primary: false,
|
||||
span: false,
|
||||
};
|
||||
|
||||
Text.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.shape({}),
|
||||
variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']),
|
||||
weight: PropTypes.oneOf(['light', 'normal', 'medium', 'bold']),
|
||||
primary: PropTypes.bool,
|
||||
span: PropTypes.bool,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Text;
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
@mixin font($type) {
|
||||
font-size: var(--fs-#{$type});
|
||||
letter-spacing: var(--ls-#{$type});
|
||||
line-height: var(--lh-#{$type});
|
||||
|
||||
& img.emoji,
|
||||
& img[data-mx-emoticon] {
|
||||
height: calc(var(--lh-#{$type}) - .25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--tc-surface-high);
|
||||
|
||||
& img.emoji,
|
||||
& img[data-mx-emoticon] {
|
||||
margin: 0 !important;
|
||||
margin-right: 2px !important;
|
||||
padding: 0 !important;
|
||||
position: relative;
|
||||
top: -.1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.text-light {
|
||||
font-weight: var(--fw-light);
|
||||
}
|
||||
.text-normal {
|
||||
font-weight: var(--fw-normal);
|
||||
}
|
||||
.text-medium {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.text-bold {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.text-h1 {
|
||||
@include font(h1);
|
||||
}
|
||||
.text-h2 {
|
||||
@include font(h2);
|
||||
}
|
||||
.text-s1 {
|
||||
@include font(s1);
|
||||
}
|
||||
.text-b1 {
|
||||
@include font(b1);
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
.text-b2 {
|
||||
@include font(b2);
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
.text-b3 {
|
||||
@include font(b3);
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import dateFormat from 'dateformat';
|
||||
import { isInSameDay } from '../../../util/common';
|
||||
|
||||
/**
|
||||
* Renders a formatted timestamp.
|
||||
*
|
||||
* Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
|
||||
* For older messages, it shows the date and time.
|
||||
*
|
||||
* @param {number} timestamp - The timestamp to display.
|
||||
* @param {boolean} [fullTime=false] - If true, always show the full date and time.
|
||||
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||
* @param {string} dateFormatString - Format string for the date part.
|
||||
* @returns {JSX.Element} A <time> element with the formatted date/time.
|
||||
*/
|
||||
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
const formattedFullTime = dateFormat(
|
||||
date,
|
||||
hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
|
||||
);
|
||||
let formattedDate = formattedFullTime;
|
||||
|
||||
if (!fullTime) {
|
||||
const compareDate = new Date();
|
||||
const isToday = isInSameDay(date, compareDate);
|
||||
compareDate.setDate(compareDate.getDate() - 1);
|
||||
const isYesterday = isInSameDay(date, compareDate);
|
||||
|
||||
const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
|
||||
|
||||
formattedDate = dateFormat(
|
||||
date,
|
||||
isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
|
||||
);
|
||||
if (isYesterday) {
|
||||
formattedDate = `Yesterday, ${formattedDate}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<time dateTime={date.toISOString()} title={formattedFullTime}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
Time.defaultProps = {
|
||||
fullTime: false,
|
||||
};
|
||||
|
||||
Time.propTypes = {
|
||||
timestamp: PropTypes.number.isRequired,
|
||||
fullTime: PropTypes.bool,
|
||||
hour24Clock: PropTypes.bool.isRequired,
|
||||
dateFormatString: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Time;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Tooltip.scss';
|
||||
import Tippy from '@tippyjs/react';
|
||||
|
||||
function Tooltip({
|
||||
className, placement, content, delay, children,
|
||||
}) {
|
||||
return (
|
||||
<Tippy
|
||||
content={content}
|
||||
className={`tooltip ${className}`}
|
||||
touch="hold"
|
||||
arrow={false}
|
||||
maxWidth={250}
|
||||
placement={placement}
|
||||
delay={delay}
|
||||
duration={[100, 0]}
|
||||
>
|
||||
{children}
|
||||
</Tippy>
|
||||
);
|
||||
}
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
placement: 'top',
|
||||
className: '',
|
||||
delay: [200, 0],
|
||||
};
|
||||
|
||||
Tooltip.propTypes = {
|
||||
className: PropTypes.string,
|
||||
placement: PropTypes.string,
|
||||
content: PropTypes.node.isRequired,
|
||||
delay: PropTypes.arrayOf(PropTypes.number),
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
.tooltip {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import { PasswordInput } from './password-input';
|
|||
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||
import { copyToClipboard } from '../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { clearSecretStorageKeys } from '../../client/state/secretStorageKeys';
|
||||
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { SecretStorageKeyContent } from '../../types/matrix/accountData';
|
|||
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { storePrivateKey } from '../../client/state/secretStorageKeys';
|
||||
import { storePrivateKey } from '../../client/secretStorageKeys';
|
||||
|
||||
export enum ManualVerificationMethod {
|
||||
RecoveryPassphrase = 'passphrase',
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ import {
|
|||
import * as css from './style.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { bytesToSize } from '../../../../util/common';
|
||||
import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
import { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ import {
|
|||
} from '../../../hooks/useRoomAliases';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { CutoutCard } from '../../../components/cutout-card';
|
||||
import { getIdServer } from '../../../../util/matrixUtil';
|
||||
import { replaceSpaceWithDash } from '../../../utils/common';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { getMxIdServer } from '../../../utils/matrix';
|
||||
|
||||
type RoomPublishedAddressesProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
|
|
@ -133,7 +133,7 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr
|
|||
function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getSafeUserId();
|
||||
const server = getIdServer(userId);
|
||||
const server = getMxIdServer(userId);
|
||||
const alive = useAlive();
|
||||
|
||||
const [addState, addAlias] = useAsyncCallback(addLocalAlias);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useRoomUnread } from '../../state/hooks/unread';
|
|||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { settingsAtom } from '../../state/settings';
|
|||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ import {
|
|||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { Page } from '../../components/page';
|
|||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
|
|
@ -80,10 +79,9 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
useCallback(
|
||||
(evt) => {
|
||||
if (editableActiveElement()) return;
|
||||
if (
|
||||
document.body.lastElementChild?.className !== 'ReactModalPortal' ||
|
||||
navigation.isRawModalVisible
|
||||
) {
|
||||
// means some menu or modal window is open
|
||||
const lastNode = document.body.lastElementChild;
|
||||
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
|
||||
return;
|
||||
}
|
||||
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import { _SearchPathSearchParams } from '../../pages/paths';
|
|||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { searchModalAtom } from '../../state/searchModal';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
|
|
@ -436,11 +435,8 @@ export function SearchModalRenderer() {
|
|||
}
|
||||
|
||||
// means some menu or modal window is open
|
||||
const { lastChild } = document.body;
|
||||
if (
|
||||
(lastChild && 'className' in lastChild && lastChild.className !== 'ReactModalPortal') ||
|
||||
navigation.isRawModalVisible
|
||||
) {
|
||||
const lastNode = document.body.lastElementChild;
|
||||
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import cons from '../../../../client/state/cons';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
|
|
@ -47,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
|
|||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v{cons.version}</Text>
|
||||
<Text size="T200">v4.10.0</Text>
|
||||
</Box>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { copyToClipboard } from '../../../../util/common';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
|
||||
export function MatrixId() {
|
||||
const mx = useMatrixClient();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import {
|
||||
Direction,
|
||||
EventTimeline,
|
||||
IContextResponse,
|
||||
MatrixClient,
|
||||
Method,
|
||||
Preset,
|
||||
Room,
|
||||
RoomMember,
|
||||
Visibility,
|
||||
} from 'matrix-js-sdk';
|
||||
import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
|
|
@ -12,12 +22,11 @@ import {
|
|||
rateLimitedActions,
|
||||
removeRoomIdFromMDirect,
|
||||
} from '../utils/matrix';
|
||||
import { hasDevices } from '../../util/matrixUtil';
|
||||
import * as roomActions from '../../client/action/room';
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvent } from '../utils/room';
|
||||
import { splitWithSpace } from '../utils/common';
|
||||
import { createRoomEncryptionState } from '../components/create-room';
|
||||
|
||||
export const SHRUG = '¯\\_(ツ)_/¯';
|
||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
||||
|
|
@ -195,7 +204,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
description: 'Start direct message with user. Example: /startdm userId1',
|
||||
exe: async (payload) => {
|
||||
const rawIds = splitWithSpace(payload);
|
||||
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
|
||||
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getSafeUserId());
|
||||
if (userIds.length === 0) return;
|
||||
if (userIds.length === 1) {
|
||||
const dmRoomId = getDMRoomFor(mx, userIds[0])?.roomId;
|
||||
|
|
@ -204,9 +213,14 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
return;
|
||||
}
|
||||
}
|
||||
const devices = await Promise.all(userIds.map((uid) => hasDevices(mx, uid)));
|
||||
const isEncrypt = devices.every((hasDevice) => hasDevice);
|
||||
const result = await roomActions.createDM(mx, userIds, isEncrypt);
|
||||
const result = await mx.createRoom({
|
||||
is_direct: true,
|
||||
invite: userIds,
|
||||
visibility: Visibility.Private,
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: [createRoomEncryptionState()],
|
||||
});
|
||||
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
||||
navigateRoom(result.room_id);
|
||||
},
|
||||
},
|
||||
|
|
@ -215,10 +229,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
description: 'Join room with address. Example: /join address1 address2',
|
||||
exe: async (payload) => {
|
||||
const rawIds = splitWithSpace(payload);
|
||||
const roomIds = rawIds.filter(
|
||||
const roomIdOrAliases = rawIds.filter(
|
||||
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
||||
);
|
||||
roomIds.map((id) => roomActions.join(mx, id));
|
||||
roomIdOrAliases.forEach(async (idOrAlias) => {
|
||||
await mx.joinRoom(idOrAlias);
|
||||
});
|
||||
},
|
||||
},
|
||||
[Command.Leave]: {
|
||||
|
|
@ -317,7 +333,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
exe: async (payload) => {
|
||||
const rawIds = splitWithSpace(payload);
|
||||
const userIds = rawIds.filter((id) => isUserId(id));
|
||||
if (userIds.length > 0) roomActions.ignore(mx, userIds);
|
||||
if (userIds.length > 0) {
|
||||
let ignoredUsers = mx.getIgnoredUsers().concat(userIds);
|
||||
ignoredUsers = [...new Set(ignoredUsers)];
|
||||
await mx.setIgnoredUsers(ignoredUsers);
|
||||
}
|
||||
},
|
||||
},
|
||||
[Command.UnIgnore]: {
|
||||
|
|
@ -326,7 +346,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
exe: async (payload) => {
|
||||
const rawIds = splitWithSpace(payload);
|
||||
const userIds = rawIds.filter((id) => isUserId(id));
|
||||
if (userIds.length > 0) roomActions.unignore(mx, userIds);
|
||||
if (userIds.length > 0) {
|
||||
const ignoredUsers = mx.getIgnoredUsers();
|
||||
await mx.setIgnoredUsers(ignoredUsers.filter((id) => !userIds.includes(id)));
|
||||
}
|
||||
},
|
||||
},
|
||||
[Command.MyRoomNick]: {
|
||||
|
|
@ -335,7 +358,21 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
exe: async (payload) => {
|
||||
const nick = payload.trim();
|
||||
if (nick === '') return;
|
||||
roomActions.setMyRoomNick(mx, room.roomId, nick);
|
||||
const mEvent = room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
|
||||
const content = mEvent?.getContent();
|
||||
if (!content) return;
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.RoomMember as any,
|
||||
{
|
||||
...content,
|
||||
displayname: nick,
|
||||
},
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
},
|
||||
},
|
||||
[Command.MyRoomAvatar]: {
|
||||
|
|
@ -343,7 +380,21 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
|
||||
exe: async (payload) => {
|
||||
if (payload.match(/^mxc:\/\/\S+$/)) {
|
||||
roomActions.setMyRoomAvatar(mx, room.roomId, payload);
|
||||
const mEvent = room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
|
||||
const content = mEvent?.getContent();
|
||||
if (!content) return;
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.RoomMember as any,
|
||||
{
|
||||
...content,
|
||||
avatar_url: payload,
|
||||
},
|
||||
mx.getSafeUserId()
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useStore(...args) {
|
||||
const itemRef = useRef(null);
|
||||
|
||||
const getItem = () => itemRef.current;
|
||||
|
||||
const setItem = (event) => {
|
||||
itemRef.current = event;
|
||||
return itemRef.current;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
itemRef.current = null;
|
||||
return () => {
|
||||
itemRef.current = null;
|
||||
};
|
||||
}, args);
|
||||
|
||||
return { getItem, setItem };
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ConfirmDialog.scss';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
||||
function ConfirmDialog({
|
||||
desc, actionTitle, actionType, onComplete,
|
||||
}) {
|
||||
return (
|
||||
<div className="confirm-dialog">
|
||||
<Text>{desc}</Text>
|
||||
<div className="confirm-dialog__btn">
|
||||
<Button variant={actionType} onClick={() => onComplete(true)}>{actionTitle}</Button>
|
||||
<Button onClick={() => onComplete(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ConfirmDialog.propTypes = {
|
||||
desc: PropTypes.string.isRequired,
|
||||
actionTitle: PropTypes.string.isRequired,
|
||||
actionType: PropTypes.oneOf(['primary', 'positive', 'danger', 'caution']).isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} title title of confirm dialog
|
||||
* @param {string} desc description of confirm dialog
|
||||
* @param {string} actionTitle title of main action to take
|
||||
* @param {'primary' | 'positive' | 'danger' | 'caution'} actionType type of action. default=primary
|
||||
* @return {Promise<boolean>} does it get's confirmed or not
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const confirmDialog = (title, desc, actionTitle, actionType = 'primary') => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<ConfirmDialog
|
||||
desc={desc}
|
||||
actionTitle={actionTitle}
|
||||
actionType={actionType}
|
||||
onComplete={(isConfirmed) => {
|
||||
isCompleted = true;
|
||||
resolve(isConfirmed);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
.confirm-dialog {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
&__btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Dialog.scss';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import RawModal from '../../atoms/modal/RawModal';
|
||||
|
||||
function Dialog({
|
||||
className,
|
||||
isOpen,
|
||||
title,
|
||||
onAfterOpen,
|
||||
onAfterClose,
|
||||
contentOptions,
|
||||
onRequestClose,
|
||||
closeFromOutside,
|
||||
children,
|
||||
invisibleScroll,
|
||||
}) {
|
||||
return (
|
||||
<RawModal
|
||||
className={`${className === null ? '' : `${className} `}dialog-modal`}
|
||||
isOpen={isOpen}
|
||||
onAfterOpen={onAfterOpen}
|
||||
onAfterClose={onAfterClose}
|
||||
onRequestClose={onRequestClose}
|
||||
closeFromOutside={closeFromOutside}
|
||||
size="small"
|
||||
>
|
||||
<div className="dialog">
|
||||
<div className="dialog__content">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
{typeof title === 'string' ? (
|
||||
<Text variant="h2" weight="medium" primary>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</TitleWrapper>
|
||||
{contentOptions}
|
||||
</Header>
|
||||
<div className="dialog__content__wrapper">
|
||||
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||
<div className="dialog__content-container">{children}</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RawModal>
|
||||
);
|
||||
}
|
||||
|
||||
Dialog.defaultProps = {
|
||||
className: null,
|
||||
contentOptions: null,
|
||||
onAfterOpen: null,
|
||||
onAfterClose: null,
|
||||
onRequestClose: null,
|
||||
closeFromOutside: true,
|
||||
invisibleScroll: false,
|
||||
};
|
||||
|
||||
Dialog.propTypes = {
|
||||
className: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
contentOptions: PropTypes.node,
|
||||
onAfterOpen: PropTypes.func,
|
||||
onAfterClose: PropTypes.func,
|
||||
onRequestClose: PropTypes.func,
|
||||
closeFromOutside: PropTypes.bool,
|
||||
children: PropTypes.node.isRequired,
|
||||
invisibleScroll: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
.dialog-modal {
|
||||
--modal-height: 656px;
|
||||
max-height: min(100%, var(--modal-height));
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog,
|
||||
.dialog__content,
|
||||
.dialog__content__wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
&__content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Dialog from './Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function ReusableDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (title, render, afterClose) => {
|
||||
setIsOpen(true);
|
||||
setData({ title, render, afterClose });
|
||||
};
|
||||
navigation.on(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAfterClose = () => {
|
||||
data.afterClose?.();
|
||||
setData(null);
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
title={data?.title || ''}
|
||||
onAfterClose={handleAfterClose}
|
||||
onRequestClose={handleRequestClose}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={handleRequestClose} tooltip="Close" />}
|
||||
invisibleScroll
|
||||
>
|
||||
{data?.render(handleRequestClose) || <div />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReusableDialog;
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ImageUpload.scss';
|
||||
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function ImageUpload({
|
||||
text, bgColor, imageSrc, onUpload, onRequestRemove,
|
||||
size,
|
||||
}) {
|
||||
const [uploadPromise, setUploadPromise] = useState(null);
|
||||
const uploadImageRef = useRef(null);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
async function uploadImage(e) {
|
||||
const file = e.target.files.item(0);
|
||||
if (file === null) return;
|
||||
try {
|
||||
const uPromise = mx.uploadContent(file);
|
||||
setUploadPromise(uPromise);
|
||||
|
||||
const res = await uPromise;
|
||||
if (typeof res?.content_uri === 'string') onUpload(res.content_uri);
|
||||
setUploadPromise(null);
|
||||
} catch {
|
||||
setUploadPromise(null);
|
||||
}
|
||||
uploadImageRef.current.value = null;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
mx.cancelUpload(uploadPromise);
|
||||
setUploadPromise(null);
|
||||
uploadImageRef.current.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="img-upload__wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="img-upload"
|
||||
onClick={() => {
|
||||
if (uploadPromise !== null) return;
|
||||
uploadImageRef.current.click();
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
imageSrc={imageSrc}
|
||||
text={text}
|
||||
bgColor={bgColor}
|
||||
size={size}
|
||||
/>
|
||||
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
|
||||
{uploadPromise === null && (
|
||||
size === 'large'
|
||||
? <Text variant="b3" weight="bold">Upload</Text>
|
||||
: <RawIcon src={PlusIC} color="white" />
|
||||
)}
|
||||
{uploadPromise !== null && <Spinner size="small" />}
|
||||
</div>
|
||||
</button>
|
||||
{ (typeof imageSrc === 'string' || uploadPromise !== null) && (
|
||||
<button
|
||||
className="img-upload__btn-cancel"
|
||||
type="button"
|
||||
onClick={uploadPromise === null ? onRequestRemove : cancelUpload}
|
||||
>
|
||||
<Text variant="b3">{uploadPromise ? 'Cancel' : 'Remove'}</Text>
|
||||
</button>
|
||||
)}
|
||||
<input onChange={uploadImage} style={{ display: 'none' }} ref={uploadImageRef} type="file" accept="image/*" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ImageUpload.defaultProps = {
|
||||
text: null,
|
||||
bgColor: 'transparent',
|
||||
imageSrc: null,
|
||||
size: 'large',
|
||||
};
|
||||
|
||||
ImageUpload.propTypes = {
|
||||
text: PropTypes.string,
|
||||
bgColor: PropTypes.string,
|
||||
imageSrc: PropTypes.string,
|
||||
onUpload: PropTypes.func.isRequired,
|
||||
onRequestRemove: PropTypes.func.isRequired,
|
||||
size: PropTypes.oneOf(['large', 'normal']),
|
||||
};
|
||||
|
||||
export default ImageUpload;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
.img-upload__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.img-upload {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&__process {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--bo-radius);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, .6);
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
& .text {
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
}
|
||||
&--stopped {
|
||||
display: none;
|
||||
}
|
||||
& .donut-spinner {
|
||||
border-color: rgb(255, 255, 255, .3);
|
||||
border-left-color: white;
|
||||
}
|
||||
}
|
||||
&:hover .img-upload__process--stopped {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
&__btn-cancel {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
cursor: pointer;
|
||||
& .text {
|
||||
color: var(--tc-danger-normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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} 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;
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
@use '../../partials/text';
|
||||
|
||||
.people-selector {
|
||||
width: 100%;
|
||||
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@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 {
|
||||
@extend .cp-txt__ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 var(--sp-tight);
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
&__role {
|
||||
color: var(--tc-surface-low);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
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,
|
||||
onAfterClose,
|
||||
onRequestClose,
|
||||
children,
|
||||
}) {
|
||||
const haveDrawer = drawer !== null;
|
||||
const cTitle = contentTitle !== null ? contentTitle : title;
|
||||
|
||||
return (
|
||||
<RawModal
|
||||
className={`${className === null ? '' : `${className} `}pw-modal`}
|
||||
overlayClassName="pw-modal__overlay"
|
||||
isOpen={isOpen}
|
||||
onAfterClose={onAfterClose}
|
||||
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>
|
||||
{typeof title === 'string' ? (
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</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>
|
||||
{typeof cTitle === 'string' ? (
|
||||
<Text variant="h2" weight="medium" primary>
|
||||
{cTitle}
|
||||
</Text>
|
||||
) : (
|
||||
cTitle
|
||||
)}
|
||||
</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,
|
||||
onAfterClose: null,
|
||||
onRequestClose: null,
|
||||
};
|
||||
|
||||
PopupWindow.propTypes = {
|
||||
className: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
contentTitle: PropTypes.node,
|
||||
drawer: PropTypes.node,
|
||||
drawerOptions: PropTypes.node,
|
||||
contentOptions: PropTypes.node,
|
||||
onAfterClose: PropTypes.func,
|
||||
onRequestClose: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export { PopupWindow as default, PWContentSelector };
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.pw-modal {
|
||||
--modal-height: 774px;
|
||||
max-height: var(--modal-height) !important;
|
||||
height: 100%;
|
||||
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
--modal-height: 100%;
|
||||
border-radius: 0 !important;
|
||||
&__overlay {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pw {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
display: flex;
|
||||
|
||||
&__drawer {
|
||||
width: var(--popup-window-drawer-width);
|
||||
background-color: var(--bg-surface-low);
|
||||
@include dir.side(border, none, 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-tight);
|
||||
@include dir.side(padding, var(--sp-tight), var(--sp-tight));
|
||||
& .header__title-wrapper {
|
||||
@include dir.side(margin, var(--sp-ultra-tight), var(--sp-extra-tight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pw-content-selector {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
border-radius: var(--bo-radius);
|
||||
&--selected {
|
||||
box-shadow: var(--bs-surface-border);
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
& .context-menu__item > button {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .context-menu__item > button {
|
||||
border-radius: var(--bo-radius);
|
||||
& .ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './PowerLevelSelector.scss';
|
||||
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
|
||||
import CheckIC from '../../../../public/res/ic/outlined/check.svg';
|
||||
|
||||
function PowerLevelSelector({
|
||||
value, max, onSelect,
|
||||
}) {
|
||||
const handleSubmit = (e) => {
|
||||
const powerLevel = e.target.elements['power-level']?.value;
|
||||
if (!powerLevel) return;
|
||||
onSelect(Number(powerLevel));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="power-level-selector">
|
||||
<MenuHeader>Power level selector</MenuHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||
<input
|
||||
className="input"
|
||||
defaultValue={value}
|
||||
type="number"
|
||||
name="power-level"
|
||||
placeholder="Power level"
|
||||
max={max}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<IconButton variant="primary" src={CheckIC} type="submit" />
|
||||
</form>
|
||||
{max >= 0 && <MenuHeader>Presets</MenuHeader>}
|
||||
{max >= 100 && <MenuItem variant={value === 100 ? 'positive' : 'surface'} onClick={() => onSelect(100)}>Admin - 100</MenuItem>}
|
||||
{max >= 50 && <MenuItem variant={value === 50 ? 'positive' : 'surface'} onClick={() => onSelect(50)}>Mod - 50</MenuItem>}
|
||||
{max >= 0 && <MenuItem variant={value === 0 ? 'positive' : 'surface'} onClick={() => onSelect(0)}>Member - 0</MenuItem>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PowerLevelSelector.propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default PowerLevelSelector;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.power-level-selector {
|
||||
& .context-menu__item .text {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
& form {
|
||||
margin: var(--sp-normal);
|
||||
display: flex;
|
||||
|
||||
& input {
|
||||
@extend .cp-fx__item-one;
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
width: 148px;
|
||||
padding: 9px var(--sp-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomSelector.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 RoomSelectorWrapper({
|
||||
isSelected,
|
||||
isMuted,
|
||||
isUnread,
|
||||
onClick,
|
||||
content,
|
||||
options,
|
||||
onContextMenu,
|
||||
}) {
|
||||
const classes = ['room-selector'];
|
||||
if (isMuted) classes.push('room-selector--muted');
|
||||
if (isUnread) classes.push('room-selector--unread');
|
||||
if (isSelected) classes.push('room-selector--selected');
|
||||
|
||||
return (
|
||||
<div className={classes.join(' ')}>
|
||||
<button
|
||||
className="room-selector__content"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.room-selector__content')}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
<div className="room-selector__options">{options}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomSelectorWrapper.defaultProps = {
|
||||
isMuted: false,
|
||||
options: null,
|
||||
onContextMenu: null,
|
||||
};
|
||||
RoomSelectorWrapper.propTypes = {
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
isMuted: PropTypes.bool,
|
||||
isUnread: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
content: PropTypes.node.isRequired,
|
||||
options: PropTypes.node,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
function RoomSelector({
|
||||
name,
|
||||
parentName,
|
||||
roomId,
|
||||
imageSrc,
|
||||
iconSrc,
|
||||
isSelected,
|
||||
isMuted,
|
||||
isUnread,
|
||||
notificationCount,
|
||||
isAlert,
|
||||
options,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}) {
|
||||
return (
|
||||
<RoomSelectorWrapper
|
||||
isSelected={isSelected}
|
||||
isMuted={isMuted}
|
||||
isUnread={isUnread}
|
||||
content={
|
||||
<>
|
||||
<Avatar
|
||||
text={name}
|
||||
bgColor={colorMXID(roomId)}
|
||||
imageSrc={imageSrc}
|
||||
iconColor="var(--ic-surface-low)"
|
||||
iconSrc={iconSrc}
|
||||
size="extra-small"
|
||||
/>
|
||||
<Text variant="b1" weight={isUnread ? 'medium' : 'normal'}>
|
||||
{name}
|
||||
{parentName && (
|
||||
<Text variant="b3" span>
|
||||
{' — '}
|
||||
{parentName}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{isUnread && (
|
||||
<NotificationBadge
|
||||
alert={isAlert}
|
||||
content={notificationCount !== 0 ? notificationCount : null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
options={options}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
RoomSelector.defaultProps = {
|
||||
parentName: null,
|
||||
isSelected: false,
|
||||
imageSrc: null,
|
||||
iconSrc: null,
|
||||
isMuted: false,
|
||||
options: null,
|
||||
onContextMenu: null,
|
||||
};
|
||||
RoomSelector.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
parentName: PropTypes.string,
|
||||
roomId: PropTypes.string.isRequired,
|
||||
imageSrc: PropTypes.string,
|
||||
iconSrc: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
isMuted: PropTypes.bool,
|
||||
isUnread: PropTypes.bool.isRequired,
|
||||
notificationCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
isAlert: PropTypes.bool.isRequired,
|
||||
options: PropTypes.node,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
export default RoomSelector;
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.room-selector {
|
||||
@extend .cp-fx__row--s-c;
|
||||
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--bo-radius);
|
||||
cursor: pointer;
|
||||
|
||||
&--muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--unread {
|
||||
.room-selector__content > .text {
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--bg-surface-border);
|
||||
|
||||
& .room-selector__options {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
& .room-selector__options {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:focus-within {
|
||||
background-color: var(--bg-surface-hover);
|
||||
& button {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
}
|
||||
&--selected:hover,
|
||||
&--selected:focus,
|
||||
&--selected:active {
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.room-selector__content {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__row--s-c;
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
min-height: 40px;
|
||||
cursor: inherit;
|
||||
|
||||
& > .avatar-container .avatar__border--active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-txt__ellipsis;
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
|
||||
color: var(--tc-surface-normal-low);
|
||||
}
|
||||
}
|
||||
.room-selector__options {
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
||||
display: none;
|
||||
|
||||
&:empty {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
& .ic-btn {
|
||||
padding: 6px;
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomTile.scss';
|
||||
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
|
||||
function RoomTile({ avatarSrc, name, id, inviterName, memberCount, desc, options }) {
|
||||
return (
|
||||
<div className="room-tile">
|
||||
<div className="room-tile__avatar">
|
||||
<Avatar imageSrc={avatarSrc} bgColor={colorMXID(id)} text={name} />
|
||||
</div>
|
||||
<div className="room-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="room-tile__content__desc" variant="b2">
|
||||
{desc}
|
||||
</Text>
|
||||
) : (
|
||||
desc
|
||||
)}
|
||||
</div>
|
||||
{options !== null && <div className="room-tile__options">{options}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoomTile.defaultProps = {
|
||||
avatarSrc: null,
|
||||
inviterName: null,
|
||||
options: null,
|
||||
desc: null,
|
||||
memberCount: null,
|
||||
};
|
||||
RoomTile.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 RoomTile;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
.room-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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__content">
|
||||
<div className="setting-tile__title">
|
||||
{
|
||||
typeof title === 'string'
|
||||
? <Text variant="b1">{title}</Text>
|
||||
: title
|
||||
}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
{options !== null && <div className="setting-tile__options">{options}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SettingTile.defaultProps = {
|
||||
options: null,
|
||||
content: null,
|
||||
};
|
||||
|
||||
SettingTile.propTypes = {
|
||||
title: PropTypes.node.isRequired,
|
||||
options: PropTypes.node,
|
||||
content: PropTypes.node,
|
||||
};
|
||||
|
||||
export default SettingTile;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.setting-tile {
|
||||
display: flex;
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
&__title {
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
}
|
||||
&__options {
|
||||
@include dir.side(margin, var(--sp-tight), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import './SpaceAddExisting.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
import { Debounce } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Checkbox from '../../atoms/button/Checkbox';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import RoomSelector from '../room-selector/RoomSelector';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { rateLimitedActions } from '../../utils/matrix';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
|
||||
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||
const alive = useAlive();
|
||||
const [debounce] = useState(new Debounce());
|
||||
const [process, setProcess] = useState(null);
|
||||
const [allRoomIds, setAllRoomIds] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [searchIds, setSearchIds] = useState(null);
|
||||
const mx = useMatrixClient();
|
||||
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaces = useSpaces(mx, allRoomsAtom);
|
||||
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
|
||||
useEffect(() => {
|
||||
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
||||
const allIds = roomIds.filter(
|
||||
(rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
|
||||
);
|
||||
setAllRoomIds(allIds);
|
||||
}, [spaces, rooms, directs, roomIdToParents, roomId, onlySpaces]);
|
||||
|
||||
const toggleSelection = (rId) => {
|
||||
if (process !== null) return;
|
||||
const newSelected = [...selected];
|
||||
const selectedIndex = newSelected.indexOf(rId);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
newSelected.splice(selectedIndex, 1);
|
||||
setSelected(newSelected);
|
||||
return;
|
||||
}
|
||||
newSelected.push(rId);
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setProcess(`Adding ${selected.length} items...`);
|
||||
|
||||
await rateLimitedActions(selected, async (rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
const via = getViaServers(room);
|
||||
|
||||
await mx.sendStateEvent(
|
||||
roomId,
|
||||
'm.space.child',
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via,
|
||||
},
|
||||
rId
|
||||
);
|
||||
});
|
||||
|
||||
if (!alive()) return;
|
||||
|
||||
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
||||
const allIds = roomIds.filter(
|
||||
(rId) => rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
|
||||
);
|
||||
setAllRoomIds(allIds);
|
||||
setProcess(null);
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleSearch = (ev) => {
|
||||
const term = ev.target.value.toLocaleLowerCase().replace(/\s/g, '');
|
||||
if (term === '') {
|
||||
setSearchIds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
debounce._(() => {
|
||||
const searchedIds = allRoomIds.filter((rId) => {
|
||||
let name = mx.getRoom(rId)?.name;
|
||||
if (!name) return false;
|
||||
name = name.normalize('NFKC').toLocaleLowerCase().replace(/\s/g, '');
|
||||
return name.includes(term);
|
||||
});
|
||||
setSearchIds(searchedIds);
|
||||
}, 200)();
|
||||
};
|
||||
const handleSearchClear = (ev) => {
|
||||
const btn = ev.currentTarget;
|
||||
btn.parentElement.searchInput.value = '';
|
||||
setSearchIds(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
<RawIcon size="small" src={SearchIC} />
|
||||
<Input name="searchInput" onChange={handleSearch} placeholder="Search room" autoFocus />
|
||||
<IconButton size="small" type="button" onClick={handleSearchClear} src={CrossIC} />
|
||||
</form>
|
||||
{searchIds?.length === 0 && <Text>No results found</Text>}
|
||||
{(searchIds || allRoomIds).map((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
let imageSrc =
|
||||
room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
|
||||
const parentSet = roomIdToParents.get(rId);
|
||||
const parentNames = parentSet
|
||||
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
|
||||
: undefined;
|
||||
const parents = parentNames ? parentNames.join(', ') : null;
|
||||
|
||||
const handleSelect = () => toggleSelection(rId);
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={rId}
|
||||
name={room.name}
|
||||
parentName={parents}
|
||||
roomId={rId}
|
||||
imageSrc={mDirects.has(rId) ? imageSrc : null}
|
||||
iconSrc={
|
||||
mDirects.has(rId) ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
|
||||
}
|
||||
isUnread={false}
|
||||
notificationCount={0}
|
||||
isAlert={false}
|
||||
onClick={handleSelect}
|
||||
options={
|
||||
<Checkbox
|
||||
isActive={selected.includes(rId)}
|
||||
variant="positive"
|
||||
onToggle={handleSelect}
|
||||
tabIndex={-1}
|
||||
disabled={process !== null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selected.length !== 0 && (
|
||||
<div className="space-add-existing__footer">
|
||||
{process && <Spinner size="small" />}
|
||||
<Text weight="medium">{process || `${selected.length} item selected`}</Text>
|
||||
{!process && (
|
||||
<Button onClick={handleAdd} variant="primary">
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
SpaceAddExistingContent.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
spaces: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (roomId, spaces) =>
|
||||
setData({
|
||||
roomId,
|
||||
spaces,
|
||||
});
|
||||
navigation.on(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setData(null);
|
||||
|
||||
return [data, requestClose];
|
||||
}
|
||||
|
||||
function SpaceAddExisting() {
|
||||
const [data, requestClose] = useVisibilityToggle();
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(data?.roomId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!room}
|
||||
className="space-add-existing"
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{room && room.name}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||
{' '}
|
||||
— add existing {data?.spaces ? 'spaces' : 'rooms'}
|
||||
</span>
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{room ? <SpaceAddExistingContent roomId={room.roomId} spaces={data.spaces} /> : <div />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpaceAddExisting;
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.space-add-existing {
|
||||
height: 100%;
|
||||
|
||||
.dialog__content-container {
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
@include dir.side(padding, var(--sp-extra-tight), 0);
|
||||
|
||||
& > .text {
|
||||
margin: var(--sp-loose) var(--sp-normal);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
& form {
|
||||
@extend .cp-fx__row--s-c;
|
||||
padding: var(--sp-extra-tight);
|
||||
padding-top: var(--sp-normal);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
& > .ic-raw,
|
||||
& > .ic-btn {
|
||||
position: absolute;
|
||||
}
|
||||
& > .ic-raw {
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
& > .ic-btn {
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
@include dir.prop(right, var(--sp-tight), unset);
|
||||
@include dir.prop(left, unset, var(--sp-tight));
|
||||
}
|
||||
& input {
|
||||
padding: var(--sp-tight) 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
|
||||
.room-selector {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
.room-selector__options {
|
||||
display: flex;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.space-add-existing__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
& > button {
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateRoom.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
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 { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import SegmentControl from '../../atoms/segmented-controls/SegmentedControls';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
|
||||
import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
|
||||
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
|
||||
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
|
||||
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
||||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
|
||||
const [isEncrypted, setIsEncrypted] = useState(true);
|
||||
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
||||
const [creatingError, setCreatingError] = useState(null);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
|
||||
const [isValidAddress, setIsValidAddress] = useState(null);
|
||||
const [addressValue, setAddressValue] = useState(undefined);
|
||||
const [roleIndex, setRoleIndex] = useState(0);
|
||||
|
||||
const addressRef = useRef(null);
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const userHs = getIdServer(mx.getUserId());
|
||||
|
||||
const handleSubmit = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const { target } = evt;
|
||||
|
||||
if (isCreatingRoom) return;
|
||||
setIsCreatingRoom(true);
|
||||
setCreatingError(null);
|
||||
|
||||
const name = target.name.value;
|
||||
let topic = target.topic.value;
|
||||
if (topic.trim() === '') topic = undefined;
|
||||
let roomAlias;
|
||||
if (joinRule === 'public') {
|
||||
roomAlias = addressRef?.current?.value;
|
||||
if (roomAlias.trim() === '') roomAlias = undefined;
|
||||
}
|
||||
|
||||
const powerLevel = roleIndex === 1 ? 101 : undefined;
|
||||
|
||||
try {
|
||||
const data = await roomActions.createRoom(mx, {
|
||||
name,
|
||||
topic,
|
||||
joinRule,
|
||||
alias: roomAlias,
|
||||
isEncrypted: isSpace || joinRule === 'public' ? false : isEncrypted,
|
||||
powerLevel,
|
||||
isSpace,
|
||||
parentId,
|
||||
});
|
||||
setIsCreatingRoom(false);
|
||||
setCreatingError(null);
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(undefined);
|
||||
onRequestClose();
|
||||
if (isSpace) {
|
||||
navigateSpace(data.room_id);
|
||||
} else {
|
||||
navigateRoom(data.room_id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
|
||||
setCreatingError('ERROR: Invalid characters in address');
|
||||
setIsValidAddress(false);
|
||||
} else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
|
||||
setCreatingError('ERROR: This address is already in use');
|
||||
setIsValidAddress(false);
|
||||
} else setCreatingError(e.message);
|
||||
setIsCreatingRoom(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAddress = (e) => {
|
||||
const myAddress = e.target.value;
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(e.target.value);
|
||||
setCreatingError(null);
|
||||
|
||||
setTimeout(async () => {
|
||||
if (myAddress !== addressRef.current.value) return;
|
||||
const roomAlias = addressRef.current.value;
|
||||
if (roomAlias === '') return;
|
||||
const roomAddress = `#${roomAlias}:${userHs}`;
|
||||
|
||||
if (await isRoomAliasAvailable(mx, roomAddress)) {
|
||||
setIsValidAddress(true);
|
||||
} else {
|
||||
setIsValidAddress(false);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const joinRules = ['invite', 'restricted', 'public'];
|
||||
const joinRuleShortText = ['Private', 'Restricted', 'Public'];
|
||||
const joinRuleText = [
|
||||
'Private (invite only)',
|
||||
'Restricted (space member can join)',
|
||||
'Public (anyone can join)',
|
||||
];
|
||||
const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
|
||||
const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
|
||||
const handleJoinRule = (evt) => {
|
||||
openReusableContextMenu('bottom', getEventCords(evt, '.btn-surface'), (closeMenu) => (
|
||||
<>
|
||||
<MenuHeader>Visibility (who can join)</MenuHeader>
|
||||
{joinRules.map((rule) => (
|
||||
<MenuItem
|
||||
key={rule}
|
||||
variant={rule === joinRule ? 'positive' : 'surface'}
|
||||
iconSrc={
|
||||
isSpace ? jrSpaceIC[joinRules.indexOf(rule)] : jrRoomIC[joinRules.indexOf(rule)]
|
||||
}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
setJoinRule(rule);
|
||||
}}
|
||||
disabled={!parentId && rule === 'restricted'}
|
||||
>
|
||||
{joinRuleText[joinRules.indexOf(rule)]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-room">
|
||||
<form className="create-room__form" onSubmit={handleSubmit}>
|
||||
<SettingTile
|
||||
title="Visibility"
|
||||
options={
|
||||
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
|
||||
{joinRuleShortText[joinRules.indexOf(joinRule)]}
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>
|
||||
}
|
||||
/>
|
||||
{joinRule === 'public' && (
|
||||
<div>
|
||||
<Text className="create-room__address__label" variant="b2">
|
||||
{isSpace ? 'Space address' : 'Room address'}
|
||||
</Text>
|
||||
<div className="create-room__address">
|
||||
<Text variant="b1">#</Text>
|
||||
<Input
|
||||
value={addressValue}
|
||||
onChange={validateAddress}
|
||||
state={isValidAddress === false ? 'error' : 'normal'}
|
||||
forwardRef={addressRef}
|
||||
placeholder="my_address"
|
||||
required
|
||||
/>
|
||||
<Text variant="b1">{`:${userHs}`}</Text>
|
||||
</div>
|
||||
{isValidAddress === false && (
|
||||
<Text className="create-room__address__tip" variant="b3">
|
||||
<span
|
||||
style={{ color: 'var(--bg-danger)' }}
|
||||
>{`#${addressValue}:${userHs} is already in use`}</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSpace && joinRule !== 'public' && (
|
||||
<SettingTile
|
||||
title="Enable end-to-end encryption"
|
||||
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
|
||||
content={
|
||||
<Text variant="b3">
|
||||
You can’t disable this later. Bridges & most bots won’t work yet.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Select your role"
|
||||
options={
|
||||
<SegmentControl
|
||||
selected={roleIndex}
|
||||
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
|
||||
onSelect={setRoleIndex}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||
}
|
||||
/>
|
||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||
<div className="create-room__name-wrapper">
|
||||
<Input name="name" label={`${isSpace ? 'Space' : 'Room'} name`} required />
|
||||
<Button
|
||||
disabled={isValidAddress === false || isCreatingRoom}
|
||||
iconSrc={isSpace ? SpacePlusIC : HashPlusIC}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{isCreatingRoom && (
|
||||
<div className="create-room__loading">
|
||||
<Spinner size="small" />
|
||||
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
|
||||
</div>
|
||||
)}
|
||||
{typeof creatingError === 'string' && (
|
||||
<Text className="create-room__error" variant="b3">
|
||||
{creatingError}
|
||||
</Text>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
CreateRoomContent.defaultProps = {
|
||||
parentId: null,
|
||||
};
|
||||
CreateRoomContent.propTypes = {
|
||||
isSpace: PropTypes.bool.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useWindowToggle() {
|
||||
const [create, setCreate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (isSpace, parentId) => {
|
||||
setCreate({
|
||||
isSpace,
|
||||
parentId,
|
||||
});
|
||||
};
|
||||
navigation.on(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onRequestClose = () => setCreate(null);
|
||||
|
||||
return [create, onRequestClose];
|
||||
}
|
||||
|
||||
function CreateRoom() {
|
||||
const [create, onRequestClose] = useWindowToggle();
|
||||
const { isSpace, parentId } = create ?? {};
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(parentId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={create !== null}
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{parentId ? room.name : 'Home'}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||
{` — create ${isSpace ? 'space' : 'room'}`}
|
||||
</span>
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
{create ? (
|
||||
<CreateRoomContent isSpace={isSpace} parentId={parentId} onRequestClose={onRequestClose} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateRoom;
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.create-room {
|
||||
margin: var(--sp-normal);
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
&__form > * {
|
||||
margin-top: var(--sp-normal);
|
||||
&:first-child {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
& .segment-btn {
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
&__base {
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&__address {
|
||||
display: flex;
|
||||
&__label {
|
||||
color: var(--tc-surface-low);
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
}
|
||||
&__tip {
|
||||
margin-top: var(--sp-ultra-tight);
|
||||
@include dir.side(margin, 46px, 0);
|
||||
}
|
||||
& .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 {
|
||||
@include dir.prop(border-width, 1px 0 1px 1px, 1px 1px 1px 0);
|
||||
@include dir.prop(
|
||||
border-radius,
|
||||
var(--bo-radius) 0 0 var(--bo-radius),
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
);
|
||||
}
|
||||
& .text:last-child {
|
||||
@include dir.prop(border-width, 1px 1px 1px 0, 1px 0 1px 1px);
|
||||
@include dir.prop(
|
||||
border-radius,
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
var(--bo-radius) 0 0 var(--bo-radius),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__name-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
& .input-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include dir.side(margin, 0, var(--sp-normal));
|
||||
}
|
||||
& .btn-primary {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
@extend .cp-fx__row--c-c;
|
||||
& .text {
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
}
|
||||
&__error {
|
||||
text-align: center;
|
||||
color: var(--bg-danger) !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||
|
||||
export class ImagePack {
|
||||
static parsePack(eventId, packContent) {
|
||||
if (!eventId || typeof packContent?.images !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ImagePack(eventId, packContent);
|
||||
}
|
||||
|
||||
constructor(eventId, content) {
|
||||
this.id = eventId;
|
||||
this.content = JSON.parse(JSON.stringify(content));
|
||||
|
||||
this.applyPack(content);
|
||||
this.applyImages(content);
|
||||
}
|
||||
|
||||
applyPack(content) {
|
||||
const pack = content.pack ?? {};
|
||||
|
||||
this.displayName = pack.display_name;
|
||||
this.avatarUrl = pack.avatar_url;
|
||||
this.usage = pack.usage ?? ['emoticon', 'sticker'];
|
||||
this.attribution = pack.attribution;
|
||||
}
|
||||
|
||||
applyImages(content) {
|
||||
this.images = new Map();
|
||||
this.emoticons = [];
|
||||
this.stickers = [];
|
||||
|
||||
Object.entries(content.images).forEach(([shortcode, data]) => {
|
||||
const mxc = data.url;
|
||||
const body = data.body ?? shortcode;
|
||||
const usage = data.usage ?? this.usage;
|
||||
const { info } = data;
|
||||
|
||||
if (!mxc) return;
|
||||
const image = {
|
||||
shortcode, mxc, body, usage, info,
|
||||
};
|
||||
|
||||
this.images.set(shortcode, image);
|
||||
if (usage.includes('emoticon')) {
|
||||
this.emoticons.push(image);
|
||||
}
|
||||
if (usage.includes('sticker')) {
|
||||
this.stickers.push(image);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getImages() {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
getEmojis() {
|
||||
return this.emoticons;
|
||||
}
|
||||
|
||||
getStickers() {
|
||||
return this.stickers;
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
_updatePackProperty(property, value) {
|
||||
if (this.content.pack === undefined) {
|
||||
this.content.pack = {};
|
||||
}
|
||||
this.content.pack[property] = value;
|
||||
this.applyPack(this.content);
|
||||
}
|
||||
|
||||
setAvatarUrl(avatarUrl) {
|
||||
this._updatePackProperty('avatar_url', avatarUrl);
|
||||
}
|
||||
|
||||
setDisplayName(displayName) {
|
||||
this._updatePackProperty('display_name', displayName);
|
||||
}
|
||||
|
||||
setAttribution(attribution) {
|
||||
this._updatePackProperty('attribution', attribution);
|
||||
}
|
||||
|
||||
setUsage(usage) {
|
||||
this._updatePackProperty('usage', usage);
|
||||
}
|
||||
|
||||
addImage(key, imgContent) {
|
||||
this.content.images = {
|
||||
[key]: imgContent,
|
||||
...this.content.images,
|
||||
};
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
removeImage(key) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
delete this.content.images[key];
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
updateImageKey(key, newKey) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
const copyImages = {};
|
||||
Object.keys(this.content.images).forEach((imgKey) => {
|
||||
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
|
||||
});
|
||||
this.content.images = copyImages;
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
_updateImageProperty(key, property, value) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
this.content.images[key][property] = value;
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
setImageUrl(key, url) {
|
||||
this._updateImageProperty(key, 'url', url);
|
||||
}
|
||||
|
||||
setImageBody(key, body) {
|
||||
this._updateImageProperty(key, 'body', body);
|
||||
}
|
||||
|
||||
setImageInfo(key, info) {
|
||||
this._updateImageProperty(key, 'info', info);
|
||||
}
|
||||
|
||||
setImageUsage(key, usage) {
|
||||
this._updateImageProperty(key, 'usage', usage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './InviteUser.scss';
|
||||
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { hasDevices } from '../../../util/matrixUtil';
|
||||
|
||||
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 RoomTile from '../../molecules/room-tile/RoomTile';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getDMRoomFor } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
|
||||
const [isSearching, updateIsSearching] = useState(false);
|
||||
const [searchQuery, updateSearchQuery] = useState({});
|
||||
const [users, updateUsers] = useState([]);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
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 = useMatrixClient();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
async function searchUser(username) {
|
||||
const inputUsername = username.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;
|
||||
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||
if (dmRoomId) {
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
addUserToProc(userId);
|
||||
procUserError.delete(userId);
|
||||
updateUserProcError(getMapCopy(procUserError));
|
||||
|
||||
const result = await roomActions.createDM(mx, userId, await hasDevices(mx, userId));
|
||||
roomIdToUserId.set(result.room_id, userId);
|
||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||
onDMCreated(result.room_id);
|
||||
} 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 mx.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={() => {
|
||||
navigateRoom(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 (
|
||||
<RoomTile
|
||||
key={userId}
|
||||
avatarSrc={
|
||||
typeof user.avatar_url === 'string'
|
||||
? mx.mxcUrlToHttp(
|
||||
user.avatar_url,
|
||||
42,
|
||||
42,
|
||||
'crop',
|
||||
undefined,
|
||||
undefined,
|
||||
useAuthentication
|
||||
)
|
||||
: null
|
||||
}
|
||||
name={name}
|
||||
id={userId}
|
||||
options={renderOptions(userId)}
|
||||
desc={renderError(userId)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && typeof searchTerm === 'string') searchUser(searchTerm);
|
||||
return () => {
|
||||
updateIsSearching(false);
|
||||
updateSearchQuery({});
|
||||
updateUsers([]);
|
||||
updateProcUsers(new Set());
|
||||
updateUserProcError(new Map());
|
||||
updateCreatedDM(new Map());
|
||||
updateRoomIdToUserId(new Map());
|
||||
updateInvitedUserIds(new Set());
|
||||
};
|
||||
}, [isOpen, searchTerm]);
|
||||
|
||||
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(usernameRef.current.value);
|
||||
}}
|
||||
>
|
||||
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" autoFocus />
|
||||
<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,
|
||||
searchTerm: undefined,
|
||||
};
|
||||
|
||||
InviteUser.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
roomId: PropTypes.string,
|
||||
searchTerm: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default InviteUser;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.invite-user {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
& .input-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include dir.side(margin, 0, 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);
|
||||
}
|
||||
|
||||
& .room-tile {
|
||||
margin-top: var(--sp-normal);
|
||||
&__options {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './JoinAlias.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { join } from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
||||
|
||||
function JoinAliasContent({ term, requestClose }) {
|
||||
const [process, setProcess] = useState(false);
|
||||
const [error, setError] = useState(undefined);
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const mountStore = useStore();
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const openRoom = (roomId) => {
|
||||
navigateRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
mountStore.setItem(true);
|
||||
const alias = e.target.alias.value;
|
||||
if (alias?.trim() === '') return;
|
||||
if (alias.match(ALIAS_OR_ID_REG) === null) {
|
||||
setError('Invalid address.');
|
||||
return;
|
||||
}
|
||||
setProcess('Looking for address...');
|
||||
setError(undefined);
|
||||
let via;
|
||||
if (alias.startsWith('#')) {
|
||||
try {
|
||||
const aliasData = await mx.getRoomIdForAlias(alias);
|
||||
via = aliasData?.servers.slice(0, 3) || [];
|
||||
if (mountStore.getItem()) {
|
||||
setProcess(`Joining ${alias}...`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountStore.getItem()) return;
|
||||
setProcess(false);
|
||||
setError(
|
||||
`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const roomId = await join(mx, alias, false, via);
|
||||
if (!mountStore.getItem()) return;
|
||||
openRoom(roomId);
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
setProcess(false);
|
||||
setError(`Unable to join ${alias}. Either room/space is private or doesn't exist.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="join-alias" onSubmit={handleSubmit}>
|
||||
<Input label="Address" value={term} name="alias" required autoFocus />
|
||||
{error && (
|
||||
<Text className="join-alias__error" variant="b3">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<div className="join-alias__btn">
|
||||
{process ? (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>{process}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" type="submit">
|
||||
Join
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
JoinAliasContent.defaultProps = {
|
||||
term: undefined,
|
||||
};
|
||||
JoinAliasContent.propTypes = {
|
||||
term: PropTypes.string,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useWindowToggle() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (term) => {
|
||||
setData({ term });
|
||||
};
|
||||
navigation.on(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onRequestClose = () => setData(null);
|
||||
|
||||
return [data, onRequestClose];
|
||||
}
|
||||
|
||||
function JoinAlias() {
|
||||
const [data, requestClose] = useWindowToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={data !== null}
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Join with address
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default JoinAlias;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.join-alias {
|
||||
padding: var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
& > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-extra-tight) !important;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,439 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ProfileViewer.scss';
|
||||
import { EventTimeline } from 'matrix-js-sdk';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import {
|
||||
getUsername,
|
||||
getUsernameOfRoomMember,
|
||||
getPowerLabel,
|
||||
hasDevices,
|
||||
} from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Chip from '../../atoms/chip/Chip';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import { MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
import PowerLevelSelector from '../../molecules/power-level-selector/PowerLevelSelector';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import ShieldEmptyIC from '../../../../public/res/ic/outlined/shield-empty.svg';
|
||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getDMRoomFor } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
function ModerationTools({ roomId, userId }) {
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(roomId);
|
||||
const roomMember = room.getMember(userId);
|
||||
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
const canIKick =
|
||||
roomMember?.membership === 'join' &&
|
||||
roomState?.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
const canIBan =
|
||||
['join', 'leave'].includes(roomMember?.membership) &&
|
||||
roomState?.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
|
||||
const handleKick = (e) => {
|
||||
e.preventDefault();
|
||||
const kickReason = e.target.elements['kick-reason']?.value.trim();
|
||||
mx.kick(roomId, userId, kickReason !== '' ? kickReason : undefined);
|
||||
};
|
||||
|
||||
const handleBan = (e) => {
|
||||
e.preventDefault();
|
||||
const banReason = e.target.elements['ban-reason']?.value.trim();
|
||||
mx.ban(roomId, userId, banReason !== '' ? banReason : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="moderation-tools">
|
||||
{canIKick && (
|
||||
<form onSubmit={handleKick}>
|
||||
<Input label="Kick reason" name="kick-reason" />
|
||||
<Button type="submit">Kick</Button>
|
||||
</form>
|
||||
)}
|
||||
{canIBan && (
|
||||
<form onSubmit={handleBan}>
|
||||
<Input label="Ban reason" name="ban-reason" />
|
||||
<Button type="submit">Ban</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ModerationTools.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function SessionInfo({ userId }) {
|
||||
const [devices, setDevices] = useState(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const crypto = mx.getCrypto();
|
||||
const userToDevices = await crypto.getUserDeviceInfo([userId], true);
|
||||
const myDevices = Array.from(userToDevices.get(userId).values());
|
||||
|
||||
if (isUnmounted) return;
|
||||
setDevices(myDevices);
|
||||
} catch {
|
||||
setDevices([]);
|
||||
}
|
||||
}
|
||||
loadDevices();
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
function renderSessionChips() {
|
||||
if (!isVisible) return null;
|
||||
return (
|
||||
<div className="session-info__chips">
|
||||
{devices === null && <Text variant="b2">Loading sessions...</Text>}
|
||||
{devices?.length === 0 && <Text variant="b2">No session found.</Text>}
|
||||
{devices !== null &&
|
||||
devices.map((device) => (
|
||||
<Chip
|
||||
key={device.deviceId}
|
||||
iconSrc={ShieldEmptyIC}
|
||||
text={device.displayName || device.deviceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="session-info">
|
||||
<MenuItem
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
iconSrc={isVisible ? ChevronBottomIC : ChevronRightIC}
|
||||
>
|
||||
<Text variant="b2">{`View ${
|
||||
devices?.length > 0
|
||||
? `${devices.length} ${devices.length === 1 ? 'session' : 'sessions'}`
|
||||
: 'sessions'
|
||||
}`}</Text>
|
||||
</MenuItem>
|
||||
{renderSessionChips()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SessionInfo.propTypes = {
|
||||
userId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||
const [isCreatingDM, setIsCreatingDM] = useState(false);
|
||||
const [isIgnoring, setIsIgnoring] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const [isUserIgnored, setIsUserIgnored] = useState(mx.isUserIgnored(userId));
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const room = mx.getRoom(roomId);
|
||||
const member = room.getMember(userId);
|
||||
const isInvitable = member?.membership !== 'join' && member?.membership !== 'ban';
|
||||
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [isInvited, setIsInvited] = useState(member?.membership === 'invite');
|
||||
|
||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const userPL = room.getMember(userId)?.powerLevel || 0;
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
|
||||
const canIKick =
|
||||
roomState?.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||
|
||||
const isBanned = member?.membership === 'ban';
|
||||
|
||||
const onCreated = (dmRoomId) => {
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsUserIgnored(mx.isUserIgnored(userId));
|
||||
setIsIgnoring(false);
|
||||
setIsInviting(false);
|
||||
}, [mx, userId]);
|
||||
|
||||
const openDM = async () => {
|
||||
// Check and open if user already have a DM with userId.
|
||||
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
|
||||
if (dmRoomId) {
|
||||
navigateRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new DM
|
||||
try {
|
||||
setIsCreatingDM(true);
|
||||
const result = await roomActions.createDM(mx, userId, await hasDevices(mx, userId));
|
||||
onCreated(result.room_id);
|
||||
} catch {
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIgnore = async () => {
|
||||
const isIgnored = mx.getIgnoredUsers().includes(userId);
|
||||
|
||||
try {
|
||||
setIsIgnoring(true);
|
||||
if (isIgnored) {
|
||||
await roomActions.unignore(mx, [userId]);
|
||||
} else {
|
||||
await roomActions.ignore(mx, [userId]);
|
||||
}
|
||||
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsUserIgnored(!isIgnored);
|
||||
setIsIgnoring(false);
|
||||
} catch {
|
||||
setIsIgnoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInvite = async () => {
|
||||
try {
|
||||
setIsInviting(true);
|
||||
let isInviteSent = false;
|
||||
if (isInvited) await mx.kick(roomId, userId);
|
||||
else {
|
||||
await mx.invite(roomId, userId);
|
||||
isInviteSent = true;
|
||||
}
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsInvited(isInviteSent);
|
||||
setIsInviting(false);
|
||||
} catch {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-viewer__buttons">
|
||||
<Button variant="primary" onClick={openDM} disabled={isCreatingDM}>
|
||||
{isCreatingDM ? 'Creating room...' : 'Message'}
|
||||
</Button>
|
||||
{isBanned && canIKick && (
|
||||
<Button variant="positive" onClick={() => mx.unban(roomId, userId)}>
|
||||
Unban
|
||||
</Button>
|
||||
)}
|
||||
{(isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
|
||||
<Button onClick={toggleInvite} disabled={isInviting}>
|
||||
{isInvited
|
||||
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
|
||||
: `${isInviting ? 'Inviting...' : 'Invite'}`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isUserIgnored ? 'positive' : 'danger'}
|
||||
onClick={toggleIgnore}
|
||||
disabled={isIgnoring}
|
||||
>
|
||||
{isUserIgnored
|
||||
? `${isIgnoring ? 'Unignoring...' : 'Unignore'}`
|
||||
: `${isIgnoring ? 'Ignoring...' : 'Ignore'}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ProfileFooter.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useToggleDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [roomId, setRoomId] = useState(null);
|
||||
const [userId, setUserId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfile = (uId, rId) => {
|
||||
setIsOpen(true);
|
||||
setUserId(uId);
|
||||
setRoomId(rId);
|
||||
};
|
||||
navigation.on(cons.events.navigation.PROFILE_VIEWER_OPENED, loadProfile);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.PROFILE_VIEWER_OPENED, loadProfile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeDialog = () => setIsOpen(false);
|
||||
|
||||
const afterClose = () => {
|
||||
setUserId(null);
|
||||
setRoomId(null);
|
||||
};
|
||||
|
||||
return [isOpen, roomId, userId, closeDialog, afterClose];
|
||||
}
|
||||
|
||||
function useRerenderOnProfileChange(roomId, userId) {
|
||||
const mx = useMatrixClient();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
useEffect(() => {
|
||||
const handleProfileChange = (mEvent, member) => {
|
||||
if (
|
||||
mEvent.getRoomId() === roomId &&
|
||||
(member.userId === userId || member.userId === mx.getUserId())
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
mx.on('RoomMember.powerLevel', handleProfileChange);
|
||||
mx.on('RoomMember.membership', handleProfileChange);
|
||||
return () => {
|
||||
mx.removeListener('RoomMember.powerLevel', handleProfileChange);
|
||||
mx.removeListener('RoomMember.membership', handleProfileChange);
|
||||
};
|
||||
}, [mx, roomId, userId]);
|
||||
}
|
||||
|
||||
function ProfileViewer() {
|
||||
const [isOpen, roomId, userId, closeDialog, handleAfterClose] = useToggleDialog();
|
||||
useRerenderOnProfileChange(roomId, userId);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
const renderProfile = () => {
|
||||
const roomMember = room.getMember(userId);
|
||||
const username = roomMember ? getUsernameOfRoomMember(roomMember) : getUsername(mx, userId);
|
||||
const avatarMxc = roomMember?.getMxcAvatarUrl?.() || mx.getUser(userId)?.avatarUrl;
|
||||
const avatarUrl =
|
||||
avatarMxc && avatarMxc !== 'null'
|
||||
? mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop', undefined, undefined, useAuthentication)
|
||||
: null;
|
||||
|
||||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
const canChangeRole =
|
||||
roomState?.maySendEvent('m.room.power_levels', mx.getUserId()) &&
|
||||
(powerLevel < myPowerLevel || userId === mx.getUserId());
|
||||
|
||||
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||
if (newPowerLevel === powerLevel) return;
|
||||
const SHARED_POWER_MSG =
|
||||
'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||
const DEMOTING_MYSELF_MSG =
|
||||
'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||
|
||||
const isSharedPower = newPowerLevel === myPowerLevel;
|
||||
const isDemotingMyself = userId === mx.getUserId();
|
||||
if (isSharedPower || isDemotingMyself) {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Change power level',
|
||||
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
||||
'Change',
|
||||
'caution'
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.setPowerLevel(mx, roomId, userId, newPowerLevel);
|
||||
} else {
|
||||
roomActions.setPowerLevel(mx, roomId, userId, newPowerLevel);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePowerSelector = (e) => {
|
||||
openReusableContextMenu('bottom', getEventCords(e, '.btn-surface'), (closeMenu) => (
|
||||
<PowerLevelSelector
|
||||
value={powerLevel}
|
||||
max={myPowerLevel}
|
||||
onSelect={(pl) => {
|
||||
closeMenu();
|
||||
handleChangePowerLevel(pl);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-viewer">
|
||||
<div className="profile-viewer__user">
|
||||
<Avatar imageSrc={avatarUrl} text={username} bgColor={colorMXID(userId)} size="large" />
|
||||
<div className="profile-viewer__user__info">
|
||||
<Text variant="s1" weight="medium">
|
||||
{username}
|
||||
</Text>
|
||||
<Text variant="b2">{userId}</Text>
|
||||
</div>
|
||||
<div className="profile-viewer__user__role">
|
||||
<Text variant="b3">Role</Text>
|
||||
<Button
|
||||
onClick={canChangeRole ? handlePowerSelector : null}
|
||||
iconSrc={canChangeRole ? ChevronBottomIC : null}
|
||||
>
|
||||
{`${getPowerLabel(powerLevel) || 'Member'} - ${powerLevel}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ModerationTools roomId={roomId} userId={userId} />
|
||||
<SessionInfo userId={userId} />
|
||||
{userId !== mx.getUserId() && (
|
||||
<ProfileFooter roomId={roomId} userId={userId} onRequestClose={closeDialog} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="profile-viewer__dialog"
|
||||
isOpen={isOpen}
|
||||
title={room?.name ?? ''}
|
||||
onAfterClose={handleAfterClose}
|
||||
onRequestClose={closeDialog}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={closeDialog} tooltip="Close" />}
|
||||
>
|
||||
{roomId ? renderProfile() : <div />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileViewer;
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.profile-viewer__dialog {
|
||||
& .dialog__content__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
& .dialog__content-container {
|
||||
padding-top: var(--sp-normal);
|
||||
padding-bottom: 89px;
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
}
|
||||
}
|
||||
|
||||
.profile-viewer {
|
||||
&__user {
|
||||
display: flex;
|
||||
padding-bottom: var(--sp-normal);
|
||||
|
||||
&__info {
|
||||
align-self: flex-end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
margin: 0 var(--sp-normal);
|
||||
|
||||
& .text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
&__role {
|
||||
align-self: flex-end;
|
||||
& > .text {
|
||||
margin-bottom: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .session-info {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
|
||||
& > *:nth-child(2n) {
|
||||
margin: 0 var(--sp-normal)
|
||||
}
|
||||
& > *:last-child {
|
||||
@include dir.side(margin, auto, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-viewer__admin-tool {
|
||||
.setting-tile {
|
||||
margin-top: var(--sp-loose);
|
||||
}
|
||||
}
|
||||
|
||||
.moderation-tools {
|
||||
& > form {
|
||||
margin: var(--sp-normal) 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
& .input-container {
|
||||
@extend .cp-fx__item-one;
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
& button {
|
||||
height: 46px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-info {
|
||||
box-shadow: var(--bs-surface-border);
|
||||
border-radius: var(--bo-radius);
|
||||
overflow: hidden;
|
||||
|
||||
& .context-menu__item button {
|
||||
padding: var(--sp-extra-tight);
|
||||
& .ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
}
|
||||
}
|
||||
|
||||
&__chips {
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
padding: var(--sp-tight);
|
||||
padding-top: var(--sp-ultra-tight);
|
||||
|
||||
& > .text {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
}
|
||||
& .chip {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
||||
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import JoinAlias from '../join-alias/JoinAlias';
|
||||
|
||||
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||
|
||||
function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<ProfileViewer />
|
||||
<CreateRoom />
|
||||
<JoinAlias />
|
||||
<SpaceAddExisting />
|
||||
|
||||
<ReusableDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dialogs;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import InviteUser from '../invite-user/InviteUser';
|
||||
|
||||
function Windows() {
|
||||
const [inviteUser, changeInviteUser] = useState({
|
||||
isOpen: false,
|
||||
roomId: undefined,
|
||||
term: undefined,
|
||||
});
|
||||
|
||||
function openInviteUser(roomId, searchTerm) {
|
||||
changeInviteUser({
|
||||
isOpen: true,
|
||||
roomId,
|
||||
searchTerm,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InviteUser
|
||||
isOpen={inviteUser.isOpen}
|
||||
roomId={inviteUser.roomId}
|
||||
searchTerm={inviteUser.searchTerm}
|
||||
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Windows;
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import './Search.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
|
||||
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 RawModal from '../../atoms/modal/RawModal';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { openSearch } from '../../../client/action/navigation';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { factoryRoomIdByActivity } from '../../utils/sort';
|
||||
|
||||
function useVisiblityToggle(setResult) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSearchOpen = (term) => {
|
||||
setResult({
|
||||
term,
|
||||
chunk: [],
|
||||
});
|
||||
setIsOpen(true);
|
||||
};
|
||||
navigation.on(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen === false) {
|
||||
setResult(undefined);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback((event) => {
|
||||
// Ctrl/Cmd +
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// open search modal
|
||||
if (event.key === 'k') {
|
||||
event.preventDefault();
|
||||
// means some menu or modal window is open
|
||||
if (
|
||||
document.body.lastChild.className !== 'ReactModalPortal' ||
|
||||
navigation.isRawModalVisible
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openSearch();
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const requestClose = () => setIsOpen(false);
|
||||
|
||||
return [isOpen, requestClose];
|
||||
}
|
||||
|
||||
function mapRoomIds(mx, roomIds, directs, roomIdToParents) {
|
||||
return roomIds.map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
const parentSet = roomIdToParents.get(roomId);
|
||||
const parentNames = parentSet ? [] : undefined;
|
||||
parentSet?.forEach((parentId) => parentNames.push(mx.getRoom(parentId).name));
|
||||
|
||||
const parents = parentNames ? parentNames.join(', ') : null;
|
||||
|
||||
let type = 'room';
|
||||
if (room.isSpaceRoom()) type = 'space';
|
||||
else if (directs.includes(roomId)) type = 'direct';
|
||||
|
||||
return {
|
||||
type,
|
||||
name: room.name,
|
||||
parents,
|
||||
roomId,
|
||||
room,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function Search() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [asyncSearch] = useState(new AsyncSearch());
|
||||
const [isOpen, requestClose] = useVisiblityToggle(setResult);
|
||||
const searchRef = useRef(null);
|
||||
const mx = useMatrixClient();
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaces = useSpaces(mx, allRoomsAtom);
|
||||
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const handleSearchResults = (chunk, term) => {
|
||||
setResult({
|
||||
term,
|
||||
chunk,
|
||||
});
|
||||
};
|
||||
|
||||
const generateResults = (term) => {
|
||||
const prefix = term.match(/^[#@*]/)?.[0];
|
||||
|
||||
if (term.length > 1) {
|
||||
asyncSearch.search(prefix ? term.slice(1) : term);
|
||||
return;
|
||||
}
|
||||
|
||||
let ids = null;
|
||||
|
||||
if (prefix) {
|
||||
if (prefix === '#') ids = [...rooms];
|
||||
else if (prefix === '@') ids = [...directs];
|
||||
else ids = [...spaces];
|
||||
} else {
|
||||
ids = [...rooms].concat([...directs], [...spaces]);
|
||||
}
|
||||
|
||||
ids.sort(factoryRoomIdByActivity(mx));
|
||||
const mappedIds = mapRoomIds(mx, ids, directs, roomToParents);
|
||||
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
|
||||
if (prefix) handleSearchResults(mappedIds, prefix);
|
||||
else asyncSearch.search(term);
|
||||
};
|
||||
|
||||
const loadRecentRooms = () => {
|
||||
const recentRooms = [];
|
||||
handleSearchResults(mapRoomIds(mx, recentRooms, directs, roomToParents).reverse());
|
||||
};
|
||||
|
||||
const handleAfterOpen = () => {
|
||||
searchRef.current.focus();
|
||||
loadRecentRooms();
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchResults);
|
||||
|
||||
if (typeof result.term === 'string') {
|
||||
generateResults(result.term);
|
||||
searchRef.current.value = result.term;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAfterClose = () => {
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchResults);
|
||||
};
|
||||
|
||||
const handleOnChange = () => {
|
||||
const { value } = searchRef.current;
|
||||
if (value.length === 0) {
|
||||
loadRecentRooms();
|
||||
return;
|
||||
}
|
||||
generateResults(value);
|
||||
};
|
||||
|
||||
const handleCross = (e) => {
|
||||
e.preventDefault();
|
||||
const { value } = searchRef.current;
|
||||
if (value.length === 0) requestClose();
|
||||
else {
|
||||
searchRef.current.value = '';
|
||||
searchRef.current.focus();
|
||||
loadRecentRooms();
|
||||
}
|
||||
};
|
||||
|
||||
const openItem = (roomId, type) => {
|
||||
if (type === 'space') navigateSpace(roomId);
|
||||
else navigateRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const openFirstResult = () => {
|
||||
const { chunk } = result;
|
||||
if (chunk?.length > 0) {
|
||||
const item = chunk[0];
|
||||
openItem(item.roomId, item.type);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRoomSelector = (item) => {
|
||||
let imageSrc = null;
|
||||
let iconSrc = null;
|
||||
if (item.type === 'direct') {
|
||||
imageSrc =
|
||||
item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
} else {
|
||||
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={item.roomId}
|
||||
name={item.name}
|
||||
parentName={item.parents}
|
||||
roomId={item.roomId}
|
||||
imageSrc={imageSrc}
|
||||
iconSrc={iconSrc}
|
||||
isUnread={roomToUnread.has(item.roomId)}
|
||||
notificationCount={roomToUnread.get(item.roomId)?.total ?? 0}
|
||||
isAlert={roomToUnread.get(item.roomId)?.highlight > 0}
|
||||
onClick={() => openItem(item.roomId, item.type)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<RawModal
|
||||
className="search-dialog__modal dialog-modal"
|
||||
isOpen={isOpen}
|
||||
onAfterOpen={handleAfterOpen}
|
||||
onAfterClose={handleAfterClose}
|
||||
onRequestClose={requestClose}
|
||||
size="small"
|
||||
>
|
||||
<div className="search-dialog">
|
||||
<form
|
||||
className="search-dialog__input"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
openFirstResult();
|
||||
}}
|
||||
>
|
||||
<RawIcon src={SearchIC} size="small" />
|
||||
<Input onChange={handleOnChange} forwardRef={searchRef} placeholder="Search" />
|
||||
<IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
|
||||
</form>
|
||||
<div className="search-dialog__content-wrapper">
|
||||
<ScrollView autoHide>
|
||||
<div className="search-dialog__content">
|
||||
{Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector)}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
<div className="search-dialog__footer">
|
||||
<Text variant="b3">Type # for rooms, @ for DMs and * for spaces. Hotkey: Ctrl + k</Text>
|
||||
</div>
|
||||
</div>
|
||||
</RawModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default Search;
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.search-dialog__modal {
|
||||
--modal-height: 380px;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.search-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
padding: var(--sp-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
& > .ic-raw {
|
||||
position: absolute;
|
||||
--away: calc(var(--sp-normal) + var(--sp-tight));
|
||||
@include dir.prop(left, var(--away), unset);
|
||||
@include dir.prop(right, unset, var(--away));
|
||||
}
|
||||
& > .ic-btn {
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
position: absolute;
|
||||
--away: calc(var(--sp-normal) + var(--sp-extra-tight));
|
||||
@include dir.prop(right, var(--away), unset);
|
||||
@include dir.prop(left, unset, var(--away));
|
||||
}
|
||||
& .input-container {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& input {
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
font-size: var(--fs-s1);
|
||||
letter-spacing: var(--ls-s1);
|
||||
line-height: var(--lh-s1);
|
||||
color: var(--tc-surface-high);
|
||||
}
|
||||
}
|
||||
&__content-wrapper {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-image: linear-gradient(to bottom, var(--bg-surface), var(--bg-surface-transparent));
|
||||
}
|
||||
&::after {
|
||||
top: unset;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(to bottom, var(--bg-surface-transparent), var(--bg-surface));
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: var(--sp-extra-tight);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: var(--sp-tight) var(--sp-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
|
@ -13,9 +13,19 @@ import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
|||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const useLastNodeToDetectReactPortalEntry = () => {
|
||||
useEffect(() => {
|
||||
const lastDiv = document.createElement('div');
|
||||
lastDiv.setAttribute('data-last-node', 'true');
|
||||
document.body.appendChild(lastDiv);
|
||||
}, []);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const screenSize = useScreenSize();
|
||||
|
||||
useLastNodeToDetectReactPortalEntry();
|
||||
|
||||
return (
|
||||
<ScreenSizeProvider value={screenSize}>
|
||||
<FeatureCheck>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
_SERVER_PATH,
|
||||
CREATE_PATH,
|
||||
} from './paths';
|
||||
import { isAuthenticated } from '../../client/state/auth';
|
||||
import {
|
||||
getAppPathFromHref,
|
||||
getExploreFeaturedPath,
|
||||
|
|
@ -68,6 +67,7 @@ import { HomeCreateRoom } from './client/home/CreateRoom';
|
|||
import { Create } from './client/create';
|
||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
|
|
@ -78,7 +78,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<Route
|
||||
index
|
||||
loader={() => {
|
||||
if (isAuthenticated()) return redirect(getHomePath());
|
||||
if (getFallbackSession()) return redirect(getHomePath());
|
||||
const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
|
||||
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
|
||||
return redirect(getLoginPath());
|
||||
|
|
@ -86,7 +86,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
/>
|
||||
<Route
|
||||
loader={() => {
|
||||
if (isAuthenticated()) {
|
||||
if (getFallbackSession()) {
|
||||
return redirect(getHomePath());
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
|
||||
<Route
|
||||
loader={() => {
|
||||
if (!isAuthenticated()) {
|
||||
if (!getFallbackSession()) {
|
||||
const afterLoginPath = getAppPathFromHref(
|
||||
getOriginBaseUrl(hashRouter),
|
||||
window.location.href
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function AuthFooter() {
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
v4.9.1
|
||||
v4.10.0
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { useEffect } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
|
||||
import { autoDiscovery, specVersions } from '../../../cs-api';
|
||||
import { updateLocalStore } from '../../../../client/action/auth';
|
||||
import { ErrorCode } from '../../../cs-errorcode';
|
||||
import {
|
||||
deleteAfterLoginRedirectPath,
|
||||
getAfterLoginRedirectPath,
|
||||
} from '../../afterLoginRedirectPath';
|
||||
import { getHomePath } from '../../pathUtils';
|
||||
import { setFallbackSession } from '../../../state/sessions';
|
||||
|
||||
export enum GetBaseUrlError {
|
||||
NotAllow = 'NotAllow',
|
||||
|
|
@ -114,7 +114,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
|
|||
useEffect(() => {
|
||||
if (data) {
|
||||
const { response: loginRes, baseUrl: loginBaseUrl } = data;
|
||||
updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
|
||||
deleteAfterLoginRedirectPath();
|
||||
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
|
||||
|
|
|
|||
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