initial commit

This commit is contained in:
unknown 2021-07-28 18:45:52 +05:30
commit 026f835a87
176 changed files with 10613 additions and 0 deletions

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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