diff --git a/src/app/molecules/space-options/SpaceOptions.jsx b/src/app/molecules/space-options/SpaceOptions.jsx index a1f0c6b..cb69e18 100644 --- a/src/app/molecules/space-options/SpaceOptions.jsx +++ b/src/app/molecules/space-options/SpaceOptions.jsx @@ -4,13 +4,14 @@ import PropTypes from 'prop-types'; import { twemojify } from '../../../util/twemojify'; 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 { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import AddUserIC from '../../../../public/res/ic/outlined/add-user.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 PinIC from '../../../../public/res/ic/outlined/pin.svg'; import PinFilledIC from '../../../../public/res/ic/filled/pin.svg'; @@ -35,6 +36,10 @@ function SpaceOptions({ roomId, afterOptionSelect }) { openSpaceSettings(roomId); afterOptionSelect(); }; + const handleManageRoom = () => { + openSpaceManage(roomId); + afterOptionSelect(); + }; const handleLeaveClick = () => { if (confirm('Are you really want to leave this space?')) { @@ -59,6 +64,7 @@ function SpaceOptions({ roomId, afterOptionSelect }) { > Invite + Manage rooms Settings changeSettings(false)} /> + ); } diff --git a/src/app/organisms/space-manage/SpaceManage.jsx b/src/app/organisms/space-manage/SpaceManage.jsx new file mode 100644 index 0000000..fd275b3 --- /dev/null +++ b/src/app/organisms/space-manage/SpaceManage.jsx @@ -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 ( +
+ +
+ { + path.map((item, index) => ( + + {index > 0 && } + + + )) + } +
+
+
+ ); +} +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 = ( + + ); + const roomNameJSX = ( + + {twemojify(name)} + {` • ${roomInfo.num_joined_members} members`} + + ); + + const expandBtnJsx = ( + setIsExpand(!isExpand)} + /> + ); + + return ( +
+
+ {canManage && onSelect(roomId)} variant="positive" />} + + {roomInfo.topic && expandBtnJsx} + { + isJoined + ? + : + } +
+ {isExpand && roomInfo.topic && {twemojify(roomInfo.topic, undefined, true)}} +
+ ); +} +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 ( +
+ {`${selected.length} item selected`} + + +
+ ); +} +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 ( +
+ {spacePath.length > 1 && ( + + )} + Rooms and spaces +
+ {!isLoading && currentHierarchy?.rooms?.length === 1 && ( + + Either the space contains private rooms or you need to join space to view it's rooms. + + )} + {currentHierarchy && (currentHierarchy.rooms?.map((roomInfo) => ( + roomInfo.room_id === currentPath.roomId + ? null + : ( + + ) + )))} + {!currentHierarchy && loading...} +
+ {currentHierarchy?.canLoadMore && !isLoading && ( + + )} + {isLoading && ( +
+ + Loading rooms +
+ )} + {selected.length > 0 && } +
+ ); +} +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 ( + + {roomId && twemojify(room.name)} + — manage rooms + + )} + contentOptions={} + onRequestClose={requestClose} + > + { + roomId + ? + :
+ } + + ); +} + +export default SpaceManage; diff --git a/src/app/organisms/space-manage/SpaceManage.scss b/src/app/organisms/space-manage/SpaceManage.scss new file mode 100644 index 0000000..6592d48 --- /dev/null +++ b/src/app/organisms/space-manage/SpaceManage.scss @@ -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); + } +} \ No newline at end of file diff --git a/src/app/organisms/space-settings/SpaceSettings.jsx b/src/app/organisms/space-settings/SpaceSettings.jsx index c3f7e29..697bbc1 100644 --- a/src/app/organisms/space-settings/SpaceSettings.jsx +++ b/src/app/organisms/space-settings/SpaceSettings.jsx @@ -100,8 +100,8 @@ function useWindowToggle(setSelectedTab) { const [window, setWindow] = useState(null); useEffect(() => { - const openSpaceSettings = (spaceId, tab) => { - setWindow({ spaceId, tabText }); + const openSpaceSettings = (roomId, tab) => { + setWindow({ roomId, tabText }); const tabItem = tabItems.find((item) => item.text === tab); if (tabItem) setSelectedTab(tabItem); }; @@ -120,7 +120,7 @@ function SpaceSettings() { const [selectedTab, setSelectedTab] = useState(tabItems[0]); const [window, requestClose] = useWindowToggle(setSelectedTab); const isOpen = window !== null; - const roomId = window?.spaceId; + const roomId = window?.roomId; const mx = initMatrix.matrixClient; const room = mx.getRoom(roomId); diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index 23420b8..6f82a42 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -23,14 +23,21 @@ export function selectRoom(roomId, eventId) { }); } -export function openSpaceSettings(spaceId, tabText) { +export function openSpaceSettings(roomId, tabText) { appDispatcher.dispatch({ type: cons.actions.navigation.OPEN_SPACE_SETTINGS, - spaceId, + roomId, tabText, }); } +export function openSpaceManage(roomId) { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_SPACE_MANAGE, + roomId, + }); +} + export function toggleRoomSettings(tabText) { appDispatcher.dispatch({ type: cons.actions.navigation.TOGGLE_ROOM_SETTINGS, diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 97dcf9e..2ac4cbd 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -31,6 +31,7 @@ const cons = { SELECT_SPACE: 'SELECT_SPACE', SELECT_ROOM: 'SELECT_ROOM', OPEN_SPACE_SETTINGS: 'OPEN_SPACE_SETTINGS', + OPEN_SPACE_MANAGE: 'OPEN_SPACE_MANAGE', TOGGLE_ROOM_SETTINGS: 'TOGGLE_ROOM_SETTINGS', OPEN_INVITE_LIST: 'OPEN_INVITE_LIST', OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS', @@ -69,6 +70,7 @@ const cons = { SPACE_SELECTED: 'SPACE_SELECTED', ROOM_SELECTED: 'ROOM_SELECTED', SPACE_SETTINGS_OPENED: 'SPACE_SETTINGS_OPENED', + SPACE_MANAGE_OPENED: 'SPACE_MANAGE_OPENED', ROOM_SETTINGS_TOGGLED: 'ROOM_SETTINGS_TOGGLED', INVITE_LIST_OPENED: 'INVITE_LIST_OPENED', PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED', diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index ff1d065..a7044f5 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -90,7 +90,10 @@ class Navigation extends EventEmitter { ); }, [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]: () => { this.isRoomSettings = !this.isRoomSettings;