diff --git a/src/app/atoms/context-menu/ContextMenu.jsx b/src/app/atoms/context-menu/ContextMenu.jsx index 99dd9e6..7d1acd4 100644 --- a/src/app/atoms/context-menu/ContextMenu.jsx +++ b/src/app/atoms/context-menu/ContextMenu.jsx @@ -91,16 +91,17 @@ function MenuItem({ MenuItem.defaultProps = { variant: 'surface', - iconSrc: 'none', + iconSrc: null, type: 'button', disabled: false, + onClick: null, }; MenuItem.propTypes = { variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']), iconSrc: PropTypes.string, type: PropTypes.oneOf(['button', 'submit']), - onClick: PropTypes.func.isRequired, + onClick: PropTypes.func, children: PropTypes.node.isRequired, disabled: PropTypes.bool, }; diff --git a/src/app/molecules/room-aliases/RoomAliases.jsx b/src/app/molecules/room-aliases/RoomAliases.jsx new file mode 100644 index 0000000..f8896eb --- /dev/null +++ b/src/app/molecules/room-aliases/RoomAliases.jsx @@ -0,0 +1,352 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './RoomAliases.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import { Debounce } from '../../../util/common'; +import { isRoomAliasAvailable } from '../../../util/matrixUtil'; + +import Text from '../../atoms/text/Text'; +import Button from '../../atoms/button/Button'; +import Input from '../../atoms/input/Input'; +import Checkbox from '../../atoms/button/Checkbox'; +import Toggle from '../../atoms/button/Toggle'; +import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu'; +import SettingTile from '../../molecules/setting-tile/SettingTile'; + +import { useStore } from '../../hooks/useStore'; + +function useValidate(hsString) { + const [debounce] = useState(new Debounce()); + const [validate, setValidate] = useState({ alias: null, status: cons.status.PRE_FLIGHT }); + + const setValidateToDefault = () => { + setValidate({ + alias: null, + status: cons.status.PRE_FLIGHT, + }); + }; + + const checkValueOK = (value) => { + if (value.trim() === '') { + setValidateToDefault(); + return false; + } + if (!value.match(/^[a-zA-Z0-9_-]+$/)) { + setValidate({ + alias: null, + status: cons.status.ERROR, + msg: 'Invalid character: only letter, numbers and _- are allowed.', + }); + return false; + } + return true; + }; + + const handleAliasChange = (e) => { + const input = e.target; + if (validate.status !== cons.status.PRE_FLIGHT) { + setValidateToDefault(); + } + if (checkValueOK(input.value) === false) return; + + debounce._(async () => { + const { value } = input; + const alias = `#${value}:${hsString}`; + if (checkValueOK(value) === false) return; + + setValidate({ + alias, + status: cons.status.IN_FLIGHT, + msg: `validating ${alias}...`, + }); + + const isValid = await isRoomAliasAvailable(alias); + setValidate(() => { + if (e.target.value !== value) { + return { alias: null, status: cons.status.PRE_FLIGHT }; + } + return { + alias, + status: isValid ? cons.status.SUCCESS : cons.status.ERROR, + msg: isValid ? `${alias} is available.` : `${alias} is already in use.`, + }; + }); + }, 600)(); + }; + + return [validate, setValidateToDefault, handleAliasChange]; +} + +function getAliases(roomId) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + + const main = room.getCanonicalAlias(); + const published = room.getAltAliases(); + if (main && !published.includes(main)) published.splice(0, 0, main); + + return { + main, + published: [...new Set(published)], + local: [], + }; +} + +function RoomAliases({ roomId }) { + const mx = initMatrix.matrixClient; + const room = mx.getRoom(roomId); + const userId = mx.getUserId(); + const hsString = userId.slice(userId.indexOf(':') + 1); + + const isMountedStore = useStore(); + const [isPublic, setIsPublic] = useState(false); + const [isLocalVisible, setIsLocalVisible] = useState(false); + const [aliases, setAliases] = useState(getAliases(roomId)); + const [selectedAlias, setSelectedAlias] = useState(null); + const [deleteAlias, setDeleteAlias] = useState(null); + const [validate, setValidateToDefault, handleAliasChange] = useValidate(hsString); + + const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId); + + useEffect(() => isMountedStore.setItem(true), []); + + useEffect(() => { + let isUnmounted = false; + + const loadLocalAliases = async () => { + let local = []; + try { + const result = await mx.unstableGetLocalAliases(roomId); + local = result.aliases.filter((alias) => !aliases.published.includes(alias)); + } catch { + local = []; + } + aliases.local = [...new Set(local.reverse())]; + + if (isUnmounted) return; + setAliases({ ...aliases }); + }; + const loadVisibility = async () => { + const result = await mx.getRoomDirectoryVisibility(roomId); + if (isUnmounted) return; + setIsPublic(result.visibility === 'public'); + }; + loadLocalAliases(); + loadVisibility(); + return () => { + isUnmounted = true; + }; + }, [roomId]); + + const toggleDirectoryVisibility = () => { + mx.setRoomDirectoryVisibility(roomId, isPublic ? 'private' : 'public'); + setIsPublic(!isPublic); + }; + + const handleAliasSubmit = async (e) => { + e.preventDefault(); + if (validate.status === cons.status.ERROR) return; + if (!validate.alias) return; + + const { alias } = validate; + const aliasInput = e.target.elements['alias-input']; + aliasInput.value = ''; + setValidateToDefault(); + + try { + aliases.local.push(alias); + setAliases({ ...aliases }); + await mx.createAlias(alias, roomId); + } catch { + if (isMountedStore.getItem()) { + const lIndex = alias.local.indexOf(alias); + if (lIndex === -1) return; + aliases.local.splice(lIndex, 1); + setAliases({ ...aliases }); + } + } + }; + + const handleAliasSelect = (alias) => { + setSelectedAlias(alias === selectedAlias ? null : alias); + }; + + const handlePublishAlias = (alias) => { + const { main, published } = aliases; + let { local } = aliases; + + if (!published.includes(aliases)) { + published.push(alias); + local = local.filter((al) => al !== alias); + mx.sendStateEvent(roomId, 'm.room.canonical_alias', { + alias: main, + alt_aliases: published.filter((al) => al !== main), + }); + setAliases({ main, published, local }); + setSelectedAlias(null); + } + }; + + const handleUnPublishAlias = (alias) => { + let { main, published } = aliases; + const { local } = aliases; + + if (published.includes(alias) || main === alias) { + if (main === alias) main = null; + published = published.filter((al) => al !== alias); + local.push(alias); + mx.sendStateEvent(roomId, 'm.room.canonical_alias', { + alias: main, + alt_aliases: published.filter((al) => al !== main), + }); + setAliases({ main, published, local }); + setSelectedAlias(null); + } + }; + + const handleSetMainAlias = (alias) => { + let { main, local } = aliases; + const { published } = aliases; + + if (main !== alias) { + main = alias; + if (!published.includes(alias)) published.splice(0, 0, alias); + local = local.filter((al) => al !== alias); + mx.sendStateEvent(roomId, 'm.room.canonical_alias', { + alias: main, + alt_aliases: published.filter((al) => al !== main), + }); + setAliases({ main, published, local }); + setSelectedAlias(null); + } + }; + + const handleDeleteAlias = async (alias) => { + try { + setDeleteAlias({ alias, status: cons.status.IN_FLIGHT, msg: 'deleting...' }); + await mx.deleteAlias(alias); + let { main, published, local } = aliases; + if (published.includes(alias)) { + handleUnPublishAlias(alias); + if (main === alias) main = null; + published = published.filter((al) => al !== alias); + } + + local = local.filter((al) => al !== alias); + setAliases({ main, published, local }); + setDeleteAlias(null); + setSelectedAlias(null); + } catch (err) { + setDeleteAlias({ alias, status: cons.status.ERROR, msg: err.message }); + } + }; + + const renderAliasBtns = (alias) => { + const isPublished = aliases.published.includes(alias); + const isMain = aliases.main === alias; + if (deleteAlias?.alias === alias) { + const isError = deleteAlias.status === cons.status.ERROR; + return ( +
+ + {deleteAlias.msg} + +
+ ); + } + + return ( +
+ {canPublishAlias && !isMain && } + {!isPublished && canPublishAlias && } + {isPublished && canPublishAlias && } + +
+ ); + }; + + const renderAlias = (alias) => { + const isActive = selectedAlias === alias; + const disabled = !canPublishAlias && aliases.published.includes(alias); + const isMain = aliases.main === alias; + + return ( + +
+ handleAliasSelect(alias)} /> + + {alias} + {isMain && Main} + +
+ {isActive && renderAliasBtns(alias)} +
+ ); + }; + + let inputState = 'normal'; + if (validate.status === cons.status.ERROR) inputState = 'error'; + if (validate.status === cons.status.SUCCESS) inputState = 'success'; + return ( +
+ {`Publish this room to the ${hsString}'s public room directory?`}} + options={( + + )} + /> + +
+ Published addresses + {(aliases.published.length === 0) && No published addresses} + {(aliases.published.length > 0 && !aliases.main) && No Main address (select one from below)} + {aliases.published.map(renderAlias)} + Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first. +
+ { isLocalVisible && ( +
+ Local addresses + {(aliases.local.length === 0) && No local addresses} + {aliases.local.map(renderAlias)} + Set local addresses for this room so users can find this room through your homeserver. + + Add local address +
+
+ +
+ +
+
+ {validate.status === cons.status.SUCCESS && {validate.msg}} + {validate.status === cons.status.ERROR && {validate.msg}} +
+
+ )} +
+ +
+
+ ); +} + +RoomAliases.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +export default RoomAliases; diff --git a/src/app/molecules/room-aliases/RoomAliases.scss b/src/app/molecules/room-aliases/RoomAliases.scss new file mode 100644 index 0000000..b0df614 --- /dev/null +++ b/src/app/molecules/room-aliases/RoomAliases.scss @@ -0,0 +1,84 @@ +@use '../../partials/flex'; +@use '../../partials/dir'; +@use '../../partials/text'; + +.room-aliases { + &__message, + & .setting-tile { + margin: var(--sp-tight) var(--sp-normal); + } + & .setting-tile { + margin-bottom: var(--sp-loose); + } + + &__alias-item { + padding: var(--sp-extra-tight) var(--sp-normal); + @extend .cp-fx__row--s-c; + &.checkbox { + @include dir.side(margin, 0 , var(--sp-tight)); + } + & .text { + @extend .cp-fx__item-one; + @extend .cp-txt__ellipsis; + color: var(--tc-surface-high); + span { + margin: 0 var(--sp-extra-tight); + padding: 0 var(--sp-ultra-tight); + color: var(--bg-surface); + background-color: var(--tc-surface-low); + border-radius: 4px; + } + } + } + &__item-btns { + @include dir.side(margin, 48px, 0); + & button { + padding: var(--sp-ultra-tight) var(--sp-tight); + margin-bottom: var(--sp-tight); + @include dir.side(margin, 0, var(--sp-tight)); + } + } + + &__content { + margin-bottom: var(--sp-normal); + + & .checkbox { + @include dir.side(margin, 0, var(--sp-tight)); + min-width: 20px; + } + & > button { + margin: 0 var(--sp-normal); + } + } + + &__form { + padding: var(--sp-normal); + padding-top: 0; + display: flex; + &-label { + padding: var(--sp-normal) var(--sp-normal) var(--sp-ultra-tight); + } + } + + &__input-wrapper { + display: flex; + @extend .cp-fx__item-one; + @include dir.side(margin, 0, var(--sp-tight)); + + & .input-container { + @extend .cp-fx__item-one; + } + } + + &__input-status { + padding: 0 var(--sp-normal); + } + &__valid { + color: var(--tc-positive-high); + padding-bottom: var(--sp-normal); + } + &__invalid { + color: var(--tc-danger-high); + padding-bottom: var(--sp-normal); + } +} \ No newline at end of file diff --git a/src/app/organisms/room/RoomSettings.jsx b/src/app/organisms/room/RoomSettings.jsx index 0504f47..8c7323b 100644 --- a/src/app/organisms/room/RoomSettings.jsx +++ b/src/app/organisms/room/RoomSettings.jsx @@ -16,6 +16,7 @@ import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import RoomProfile from '../../molecules/room-profile/RoomProfile'; import RoomNotification from '../../molecules/room-notification/RoomNotification'; import RoomVisibility from '../../molecules/room-visibility/RoomVisibility'; +import RoomAliases from '../../molecules/room-aliases/RoomAliases'; import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg'; @@ -71,9 +72,13 @@ function GeneralSettings({ roomId }) { roomActions.leave(roomId)} iconSrc={LeaveArrowIC}>Leave
- Visibility (who can join) + Room visibility (who can join)
+
+ Room addresses + +
); } diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js index 07e703b..e86194e 100644 --- a/src/util/matrixUtil.js +++ b/src/util/matrixUtil.js @@ -34,16 +34,11 @@ function getUsernameOfRoomMember(roomMember) { async function isRoomAliasAvailable(alias) { try { - const myUserId = initMatrix.matrixClient.getUserId(); - const myServer = myUserId.slice(myUserId.indexOf(':') + 1); const result = await initMatrix.matrixClient.resolveRoomAlias(alias); - const aliasIsRegisteredOnMyServer = typeof result.servers.find((server) => server === myServer) === 'string'; - - if (aliasIsRegisteredOnMyServer) return false; - return true; + if (result.room_id) return false; + return false; } catch (e) { if (e.errcode === 'M_NOT_FOUND') return true; - if (e.errcode === 'M_INVALID_PARAM') throw new Error(e); return false; } }