Merge branch 'dev' into explore-persistent-server-list

This commit is contained in:
Ginger 2025-09-02 08:59:40 -04:00 committed by GitHub
commit 26e90feb36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
216 changed files with 347 additions and 8774 deletions

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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));
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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));
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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 };

View file

@ -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;

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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,
};

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 };

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;}
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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));
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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';

View file

@ -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',

View file

@ -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,

View file

@ -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);

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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)) {

View file

@ -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';

View file

@ -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);

View file

@ -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>

View file

@ -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();

View file

@ -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()
);
}
},
},

View file

@ -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 };
}

View file

@ -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);
},
);
});

View file

@ -1,11 +0,0 @@
.confirm-dialog {
padding: var(--sp-normal);
& > .text {
padding-bottom: var(--sp-normal);
}
&__btn {
display: flex;
gap: var(--sp-normal);
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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)
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 };

View file

@ -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));
}
}
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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 cant disable this later. Bridges & most bots wont 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;

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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));
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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