Add ability to explore and join space rooms

Signed-off-by: ajbura <ajbura@gmail.com>
This commit is contained in:
ajbura 2022-02-16 20:25:36 +05:30
parent a55f1df17f
commit 1f6e5e71ef
8 changed files with 538 additions and 7 deletions

View file

@ -4,13 +4,14 @@ import PropTypes from 'prop-types';
import { twemojify } from '../../../util/twemojify'; import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { openSpaceSettings, openInviteUser } from '../../../client/action/navigation'; import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
import { leave, createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room'; import { leave, createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg'; import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg'; import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import PinIC from '../../../../public/res/ic/outlined/pin.svg'; import PinIC from '../../../../public/res/ic/outlined/pin.svg';
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg'; import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
@ -35,6 +36,10 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
openSpaceSettings(roomId); openSpaceSettings(roomId);
afterOptionSelect(); afterOptionSelect();
}; };
const handleManageRoom = () => {
openSpaceManage(roomId);
afterOptionSelect();
};
const handleLeaveClick = () => { const handleLeaveClick = () => {
if (confirm('Are you really want to leave this space?')) { if (confirm('Are you really want to leave this space?')) {
@ -59,6 +64,7 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
> >
Invite Invite
</MenuItem> </MenuItem>
<MenuItem onClick={handleManageRoom} iconSrc={HashSearchIC}>Manage rooms</MenuItem>
<MenuItem onClick={handleSettingsClick} iconSrc={SettingsIC}>Settings</MenuItem> <MenuItem onClick={handleSettingsClick} iconSrc={SettingsIC}>Settings</MenuItem>
<MenuItem <MenuItem
variant="danger" variant="danger"

View file

@ -9,6 +9,7 @@ import CreateRoom from '../create-room/CreateRoom';
import InviteUser from '../invite-user/InviteUser'; import InviteUser from '../invite-user/InviteUser';
import Settings from '../settings/Settings'; import Settings from '../settings/Settings';
import SpaceSettings from '../space-settings/SpaceSettings'; import SpaceSettings from '../space-settings/SpaceSettings';
import SpaceManage from '../space-manage/SpaceManage';
function Windows() { function Windows() {
const [isInviteList, changeInviteList] = useState(false); const [isInviteList, changeInviteList] = useState(false);
@ -85,6 +86,7 @@ function Windows() {
onRequestClose={() => changeSettings(false)} onRequestClose={() => changeSettings(false)}
/> />
<SpaceSettings /> <SpaceSettings />
<SpaceManage />
</> </>
); );
} }

View file

@ -0,0 +1,352 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './SpaceManage.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import colorMXID from '../../../util/colorMXID';
import { selectRoom, selectTab } from '../../../client/action/navigation';
import RoomsHierarchy from '../../../client/state/RoomsHierarchy';
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
import { join } from '../../../client/action/room';
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 Avatar from '../../atoms/avatar/Avatar';
import Spinner from '../../atoms/spinner/Spinner';
import ScrollView from '../../atoms/scroll/ScrollView';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { useStore } from '../../hooks/useStore';
function SpaceManageBreadcrumb({ path, onSelect }) {
return (
<div className="space-manage-breadcrumb__wrapper">
<ScrollView horizontal vertical={false} invisible>
<div className="space-manage-breadcrumb">
{
path.map((item, index) => (
<React.Fragment key={item.roomId}>
{index > 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
<Button onClick={() => onSelect(item.roomId, item.name)}>
<Text variant="b2">{twemojify(item.name)}</Text>
</Button>
</React.Fragment>
))
}
</div>
</ScrollView>
</div>
);
}
SpaceManageBreadcrumb.propTypes = {
path: PropTypes.arrayOf(PropTypes.exact({
roomId: PropTypes.string,
name: PropTypes.string,
})).isRequired,
onSelect: PropTypes.func.isRequired,
};
function SpaceManageItem({
parentId, roomInfo, onSpaceClick, requestClose,
isSelected, onSelect, roomHierarchy,
}) {
const [isExpand, setIsExpand] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const { directs } = initMatrix.roomList;
const mx = initMatrix.matrixClient;
const parentRoom = mx.getRoom(parentId);
const canManage = parentRoom?.currentState.maySendStateEvent('m.space.child', mx.getUserId()) || false;
const isSpace = roomInfo.room_type === 'm.space';
const roomId = roomInfo.room_id;
const room = mx.getRoom(roomId);
const isJoined = !!(room?.getMyMembership() === 'join' || null);
const name = room?.name || roomInfo.name || roomInfo.canonical_alias || roomId;
let imageSrc = mx.mxcUrlToHttp(roomInfo.avatar_url, 24, 24, 'crop') || null;
if (!imageSrc && room) {
imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
}
const handleOpen = () => {
if (isSpace) selectTab(roomId);
else selectRoom(roomId);
requestClose();
};
const handleJoin = () => {
const viaSet = roomHierarchy.viaMap.get(roomId);
const via = viaSet ? [...viaSet] : undefined;
join(roomId, false, via);
setIsJoining(true);
};
const roomAvatarJSX = (
<Avatar
text={name}
bgColor={colorMXID(roomId)}
imageSrc={directs.has(roomId) ? imageSrc : null}
iconColor="var(--ic-surface-low)"
iconSrc={joinRuleToIconSrc(roomInfo.join_rule, isSpace)}
size="extra-small"
/>
);
const roomNameJSX = (
<Text>
{twemojify(name)}
<Text variant="b3" span>{`${roomInfo.num_joined_members} members`}</Text>
</Text>
);
const expandBtnJsx = (
<IconButton
variant={isExpand ? 'primary' : 'surface'}
size="extra-small"
src={InfoIC}
tooltip="Topic"
tooltipPlacement="top"
onClick={() => setIsExpand(!isExpand)}
/>
);
return (
<div
className={`space-manage-item${isSpace ? '--space' : ''}`}
>
<div>
{canManage && <Checkbox isActive={isSelected} onToggle={() => onSelect(roomId)} variant="positive" />}
<button
className="space-manage-item__btn"
onClick={isSpace ? () => onSpaceClick(roomId, name) : null}
type="button"
>
{roomAvatarJSX}
{roomNameJSX}
</button>
{roomInfo.topic && expandBtnJsx}
{
isJoined
? <Button onClick={handleOpen}>Open</Button>
: <Button variant="primary" onClick={handleJoin} disabled={isJoining}>{isJoining ? 'Joining...' : 'Join'}</Button>
}
</div>
{isExpand && roomInfo.topic && <Text variant="b2">{twemojify(roomInfo.topic, undefined, true)}</Text>}
</div>
);
}
SpaceManageItem.propTypes = {
parentId: PropTypes.string.isRequired,
roomHierarchy: PropTypes.shape({}).isRequired,
roomInfo: PropTypes.shape({}).isRequired,
onSpaceClick: PropTypes.func.isRequired,
requestClose: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
function SpaceManageFooter({ roomId, selected }) {
return (
<div className="space-manage__footer">
<Text weight="medium">{`${selected.length} item selected`}</Text>
<Button variant="danger">Remove</Button>
<Button variant="primary">Mark as suggested</Button>
</div>
);
}
SpaceManageFooter.propTypes = {
roomId: PropTypes.string.isRequired,
selected: PropTypes.arrayOf(PropTypes.string).isRequired,
};
function useSpacePath(roomId) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const [spacePath, setSpacePath] = useState([{ roomId, name: room.name }]);
const addPathItem = (rId, name) => {
const newPath = [...spacePath];
const itemIndex = newPath.findIndex((item) => item.roomId === rId);
if (itemIndex < 0) {
newPath.push({ roomId: rId, name });
setSpacePath(newPath);
return;
}
newPath.splice(itemIndex + 1);
setSpacePath(newPath);
};
return [spacePath, addPathItem];
}
function useUpdateOnJoin(roomId) {
const [, forceUpdate] = useForceUpdate();
const { roomList } = initMatrix;
useEffect(() => {
const handleRoomList = () => forceUpdate();
roomList.on(cons.events.roomList.ROOM_JOINED, handleRoomList);
roomList.on(cons.events.roomList.ROOM_LEAVED, handleRoomList);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleRoomList);
roomList.removeListener(cons.events.roomList.ROOM_LEAVED, handleRoomList);
};
}, [roomId]);
}
function SpaceManageContent({ roomId, requestClose }) {
const mx = initMatrix.matrixClient;
useUpdateOnJoin(roomId);
const [roomsHierarchy] = useState(new RoomsHierarchy(mx, 30));
const [spacePath, addPathItem] = useSpacePath(roomId);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const mountStore = useStore();
const currentPath = spacePath[spacePath.length - 1];
const currentHierarchy = roomsHierarchy.getHierarchy(currentPath.roomId);
useEffect(() => {
mountStore.setItem(true);
return () => {
mountStore.setItem(false);
};
}, [roomId]);
useEffect(() => {
setSelected([]);
}, [spacePath]);
const handleSelected = (selectedRoomId) => {
const newSelected = [...selected];
const selectedIndex = newSelected.indexOf(selectedRoomId);
if (selectedIndex > -1) {
newSelected.splice(selectedIndex, 1);
setSelected(newSelected);
return;
}
newSelected.push(selectedRoomId);
setSelected(newSelected);
};
const loadRoomHierarchy = async () => {
if (!roomsHierarchy.canLoadMore(currentPath.roomId)) return;
setIsLoading(true);
try {
await roomsHierarchy.load(currentPath.roomId);
if (!mountStore.getItem()) return;
setIsLoading(false);
} catch {
if (!mountStore.getItem()) return;
setIsLoading(false);
}
};
if (!currentHierarchy) loadRoomHierarchy();
return (
<div className="space-manage__content">
{spacePath.length > 1 && (
<SpaceManageBreadcrumb path={spacePath} onSelect={addPathItem} />
)}
<Text variant="b3" weight="bold">Rooms and spaces</Text>
<div className="space-manage__content-items">
{!isLoading && currentHierarchy?.rooms?.length === 1 && (
<Text>
Either the space contains private rooms or you need to join space to view it's rooms.
</Text>
)}
{currentHierarchy && (currentHierarchy.rooms?.map((roomInfo) => (
roomInfo.room_id === currentPath.roomId
? null
: (
<SpaceManageItem
key={roomInfo.room_id}
isSelected={selected.includes(roomInfo.room_id)}
roomHierarchy={currentHierarchy}
parentId={currentPath.roomId}
roomInfo={roomInfo}
onSpaceClick={addPathItem}
requestClose={requestClose}
onSelect={handleSelected}
/>
)
)))}
{!currentHierarchy && <Text>loading...</Text>}
</div>
{currentHierarchy?.canLoadMore && !isLoading && (
<Button onClick={loadRoomHierarchy}>Load more</Button>
)}
{isLoading && (
<div className="space-manage__content-loading">
<Spinner size="small" />
<Text>Loading rooms</Text>
</div>
)}
{selected.length > 0 && <SpaceManageFooter roomId={roomId} selected={selected} />}
</div>
);
}
SpaceManageContent.propTypes = {
roomId: PropTypes.string.isRequired,
requestClose: PropTypes.func.isRequired,
};
function useWindowToggle() {
const [roomId, setRoomId] = useState(null);
useEffect(() => {
const openSpaceManage = (rId) => {
setRoomId(rId);
};
navigation.on(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
return () => {
navigation.removeListener(cons.events.navigation.SPACE_MANAGE_OPENED, openSpaceManage);
};
}, []);
const requestClose = () => setRoomId(null);
return [roomId, requestClose];
}
function SpaceManage() {
const mx = initMatrix.matrixClient;
const [roomId, requestClose] = useWindowToggle();
const room = mx.getRoom(roomId);
return (
<PopupWindow
isOpen={roomId !== null}
className="space-manage"
title={(
<Text variant="s1" weight="medium" primary>
{roomId && twemojify(room.name)}
<span style={{ color: 'var(--tc-surface-low)' }}> manage rooms</span>
</Text>
)}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{
roomId
? <SpaceManageContent roomId={roomId} requestClose={requestClose} />
: <div />
}
</PopupWindow>
);
}
export default SpaceManage;

View file

@ -0,0 +1,159 @@
@use '../../partials/text';
@use '../../partials/dir';
@use '../../partials/flex';
.space-manage {
& .pw__content-wrapper {
position: relative;
}
& .pw__content-container {
padding-top: 0;
padding-bottom: 73px;
}
}
.space-manage__content {
margin-bottom: var(--sp-extra-loose);
& > .text {
margin-top: var(--sp-extra-tight);
padding: var(--sp-extra-tight) var(--sp-normal);
text-transform: uppercase;
}
&-items {
@include dir.side(padding, var(--sp-extra-tight), 0);
& > .text:first-child {
padding: var(--sp-extra-tight);
}
}
& > button {
margin: var(--sp-normal);
}
&-loading {
padding: var(--sp-loose);
display: flex;
justify-content: center;
align-items: center;
& .text {
margin: 0 var(--sp-normal);
}
}
}
.space-manage-breadcrumb {
display: flex;
align-items: center;
height: 100%;
margin: 0 var(--sp-extra-tight);
&__wrapper {
height: var(--header-height);
position: sticky;
top: 0;
z-index: 99;
background-color: var(--bg-surface);
}
& > * {
flex-shrink: 0;
}
& > .btn-surface {
min-width: 0;
padding: var(--sp-extra-tight) 10px;
white-space: nowrap;
box-shadow: none;
& p {
@extend .cp-txt__ellipsis;
max-width: 200px;
}
&:last-child {
box-shadow: var(--bs-surface-border) !important;
background-color: var(--bg-surface);
}
}
}
.space-manage-item {
margin: var(--sp-ultra-tight) 0;
padding: 0 var(--sp-extra-tight);
border-radius: var(--bo-radius);
& > div {
min-height: 40px;
display: flex;
align-items: center;
}
&--space {
@extend .space-manage-item;
& .space-manage-item__btn {
cursor: pointer;
}
}
&:hover {
background-color: var(--bg-surface-hover);
}
& .checkbox {
@include dir.side(margin, 0, var(--sp-tight));
}
&__btn {
@extend .cp-fx__item-one;
display: flex;
& .avatar__border--active {
box-shadow: none;
}
& .text {
@extend .cp-txt__ellipsis;
min-width: 0;
margin: 0 var(--sp-extra-tight);
}
}
& .ic-btn {
padding: 7px;
@include dir.side(margin, 0, var(--sp-extra-tight));
opacity: 0.7;
}
& .btn-surface,
& .btn-primary {
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
min-width: 60px;
}
& > .text {
padding: 32px;
padding-top: 0;
padding-bottom: var(--sp-normal);
white-space: pre-wrap;
}
}
.space-manage__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

@ -100,8 +100,8 @@ function useWindowToggle(setSelectedTab) {
const [window, setWindow] = useState(null); const [window, setWindow] = useState(null);
useEffect(() => { useEffect(() => {
const openSpaceSettings = (spaceId, tab) => { const openSpaceSettings = (roomId, tab) => {
setWindow({ spaceId, tabText }); setWindow({ roomId, tabText });
const tabItem = tabItems.find((item) => item.text === tab); const tabItem = tabItems.find((item) => item.text === tab);
if (tabItem) setSelectedTab(tabItem); if (tabItem) setSelectedTab(tabItem);
}; };
@ -120,7 +120,7 @@ function SpaceSettings() {
const [selectedTab, setSelectedTab] = useState(tabItems[0]); const [selectedTab, setSelectedTab] = useState(tabItems[0]);
const [window, requestClose] = useWindowToggle(setSelectedTab); const [window, requestClose] = useWindowToggle(setSelectedTab);
const isOpen = window !== null; const isOpen = window !== null;
const roomId = window?.spaceId; const roomId = window?.roomId;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);

View file

@ -23,14 +23,21 @@ export function selectRoom(roomId, eventId) {
}); });
} }
export function openSpaceSettings(spaceId, tabText) { export function openSpaceSettings(roomId, tabText) {
appDispatcher.dispatch({ appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SPACE_SETTINGS, type: cons.actions.navigation.OPEN_SPACE_SETTINGS,
spaceId, roomId,
tabText, tabText,
}); });
} }
export function openSpaceManage(roomId) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SPACE_MANAGE,
roomId,
});
}
export function toggleRoomSettings(tabText) { export function toggleRoomSettings(tabText) {
appDispatcher.dispatch({ appDispatcher.dispatch({
type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS, type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS,

View file

@ -31,6 +31,7 @@ const cons = {
SELECT_SPACE: 'SELECT_SPACE', SELECT_SPACE: 'SELECT_SPACE',
SELECT_ROOM: 'SELECT_ROOM', SELECT_ROOM: 'SELECT_ROOM',
OPEN_SPACE_SETTINGS: 'OPEN_SPACE_SETTINGS', OPEN_SPACE_SETTINGS: 'OPEN_SPACE_SETTINGS',
OPEN_SPACE_MANAGE: 'OPEN_SPACE_MANAGE',
TOGGLE_ROOM_SETTINGS: 'TOGGLE_ROOM_SETTINGS', TOGGLE_ROOM_SETTINGS: 'TOGGLE_ROOM_SETTINGS',
OPEN_INVITE_LIST: 'OPEN_INVITE_LIST', OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS', OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
@ -69,6 +70,7 @@ const cons = {
SPACE_SELECTED: 'SPACE_SELECTED', SPACE_SELECTED: 'SPACE_SELECTED',
ROOM_SELECTED: 'ROOM_SELECTED', ROOM_SELECTED: 'ROOM_SELECTED',
SPACE_SETTINGS_OPENED: 'SPACE_SETTINGS_OPENED', SPACE_SETTINGS_OPENED: 'SPACE_SETTINGS_OPENED',
SPACE_MANAGE_OPENED: 'SPACE_MANAGE_OPENED',
ROOM_SETTINGS_TOGGLED: 'ROOM_SETTINGS_TOGGLED', ROOM_SETTINGS_TOGGLED: 'ROOM_SETTINGS_TOGGLED',
INVITE_LIST_OPENED: 'INVITE_LIST_OPENED', INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED', PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED',

View file

@ -90,7 +90,10 @@ class Navigation extends EventEmitter {
); );
}, },
[cons.actions.navigation.OPEN_SPACE_SETTINGS]: () => { [cons.actions.navigation.OPEN_SPACE_SETTINGS]: () => {
this.emit(cons.events.navigation.SPACE_SETTINGS_OPENED, action.spaceId, action.tabText); this.emit(cons.events.navigation.SPACE_SETTINGS_OPENED, action.roomId, action.tabText);
},
[cons.actions.navigation.OPEN_SPACE_MANAGE]: () => {
this.emit(cons.events.navigation.SPACE_MANAGE_OPENED, action.roomId);
}, },
[cons.actions.navigation.TOGGLE_ROOM_SETTINGS]: () => { [cons.actions.navigation.TOGGLE_ROOM_SETTINGS]: () => {
this.isRoomSettings = !this.isRoomSettings; this.isRoomSettings = !this.isRoomSettings;