diff --git a/res/css/_components.scss b/res/css/_components.scss index 56403ea190e..d1250184adc 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -129,6 +129,7 @@ @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; +@import "./views/elements/_OverlaySpinner.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index f089fa3dc29..b72ad951ae1 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -22,6 +22,7 @@ limitations under the License. .mx_EditableItem { display: flex; margin-bottom: 5px; + justify-content: flex-end; } .mx_EditableItem_delete { @@ -43,17 +44,21 @@ limitations under the License. .mx_EditableItem_promptText { margin-right: 10px; - order: 2; + order: 1; } .mx_EditableItem_confirmBtn { - margin-right: 5px; + order: 2; + padding: inherit; // Override link height (!?) + + &:not(:last-child) { + margin-right: 5px; + } } .mx_EditableItem_item { flex: auto 1 0; order: 1; - width: calc(100% - 14px); // leave space for the remove button overflow-x: hidden; text-overflow: ellipsis; } diff --git a/res/css/views/elements/_OverlaySpinner.scss b/res/css/views/elements/_OverlaySpinner.scss new file mode 100644 index 00000000000..78defd919d2 --- /dev/null +++ b/res/css/views/elements/_OverlaySpinner.scss @@ -0,0 +1,31 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_OverlaySpinner { + background: $primary-bg-color; + z-index: 2; +} + +.mx_OverlaySpinner_visible { + opacity: 0.9; + transition: opacity 0.5s ease-in; +} + +.mx_OverlaySpinner_hidden { + opacity: 0; + transition: opacity 0.2s ease-in; + pointer-events: none; +} diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.tsx similarity index 72% rename from src/components/views/elements/EditableItemList.js rename to src/components/views/elements/EditableItemList.tsx index d8ec5af2780..13c01e5d7e4 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.tsx @@ -18,39 +18,48 @@ import React from 'react'; import PropTypes from 'prop-types'; import {_t} from '../../../languageHandler'; import Field from "./Field"; -import AccessibleButton from "./AccessibleButton"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import AccessibleButton, {ButtonEvent} from "./AccessibleButton"; -export class EditableItem extends React.Component { +interface IEditableItemProps { + index: number, + value: string, + onRemove: (index: number) => void, +} + +interface IEditableItemState { + verifyRemove: boolean, +} + +export class EditableItem extends React.Component { static propTypes = { index: PropTypes.number, value: PropTypes.string, onRemove: PropTypes.func, }; - constructor() { - super(); + constructor(props) { + super(props); this.state = { verifyRemove: false, }; } - _onRemove = (e) => { + _onRemove = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); this.setState({verifyRemove: true}); }; - _onDontRemove = (e) => { + _onDontRemove = (e: ButtonEvent) => { e.stopPropagation(); e.preventDefault(); this.setState({verifyRemove: false}); }; - _onActuallyRemove = (e) => { + _onActuallyRemove = (e: ButtonEvent) => { e.stopPropagation(); e.preventDefault(); @@ -65,18 +74,14 @@ export class EditableItem extends React.Component { {_t("Are you sure?")} - + {_t("Yes")} - + {_t("No")} @@ -92,8 +97,26 @@ export class EditableItem extends React.Component { } } -@replaceableComponent("views.elements.EditableItemList") -export default class EditableItemList extends React.Component { +interface IProps { + id: string, + items: string[], + itemsLabel?: string, + noItemsLabel?: string, + placeholder?: string, + newItem?: string, + suggestionsListId?: string, + + onItemAdded?: (item: string) => void, + onItemRemoved?: (index: number) => void, + onNewItemChanged?: (item: string) => void, + + canEdit?: boolean, + canRemove?: boolean, + + error?: string, // for the benefit of PublishedAliases +} + +export default class EditableItemList extends React.Component { static propTypes = { id: PropTypes.string.isRequired, items: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -110,18 +133,18 @@ export default class EditableItemList extends React.Component { canRemove: PropTypes.bool, }; - _onItemAdded = (e) => { + _onItemAdded = (e: any) => { e.stopPropagation(); e.preventDefault(); if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem); }; - _onItemRemoved = (index) => { + _onItemRemoved = (index: number) => { if (this.props.onItemRemoved) this.props.onItemRemoved(index); }; - _onNewItemChanged = (e) => { + _onNewItemChanged = (e: any) => { if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value); }; @@ -136,7 +159,10 @@ export default class EditableItemList extends React.Component { - + {_t("Add")} diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 59d9a115961..3489572aa6e 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -31,7 +31,10 @@ function getId() { interface IProps { // The field's ID, which binds the input and label together. Immutable. - id?: string; + id?: string, + // The element to create. Defaults to "input". + // To define options for a select, use + element?: "input" | "select" | "textarea", // The field's type (when used as an ). Defaults to "text". type?: string; // id of a element for suggestions diff --git a/src/components/views/elements/OverlaySpinner.tsx b/src/components/views/elements/OverlaySpinner.tsx new file mode 100644 index 00000000000..49594d1ad81 --- /dev/null +++ b/src/components/views/elements/OverlaySpinner.tsx @@ -0,0 +1,92 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useEffect, useRef, useState, FunctionComponent} from "react"; +import Spinner from "./Spinner"; + +/* + * A component which measures its children, and places a spinner over them + * while 'active' is true. + * We use 'holdOff' and 'holdOver' to shift the animations in time: + * We don't animate instantly to avoid a quick 'ghost' spinner for fast round trips. + * We have a short holdOver to allow the fade out to occur. + */ + +interface IProps { + active: boolean, + className?: string, +} + +export const OverlaySpinner: FunctionComponent = ({active, className, children}) => { + const measured = useRef(null); + const [state, setState] = useState({w: 0, h: 0}); + + const firstMount = useRef(true); + const [holdOver, setHoldOver] = useState(false); + const [holdOff, setHoldOff] = useState(false); + + /* Follow the size of the element we're meant to cover */ + useEffect(() => { + const interval = requestAnimationFrame(() => { + if (!measured.current) return; + setState({ + w: measured.current.clientWidth, + h: measured.current.clientHeight, + }); + }); + return () => cancelAnimationFrame(interval); + }); + + /* If it's not the first mount and the state changes to inactive, + * set 'holdOver' to true so the exit animation can play */ + useEffect(() => { + if (firstMount.current) { + firstMount.current = false; + return; + } + if (!active) { + const handle = setTimeout(setHoldOver, 200, false); + setHoldOver(true); + return () => { + setHoldOver(false); + clearTimeout(handle); + }; + } else { + const handle = setTimeout(setHoldOff, 200, false); + setHoldOff(true); + return () => { + setHoldOff(false); + clearTimeout(handle); + }; + } + }, [active]); + + const visibility = !holdOff && active ? "visible" : "hidden"; + + return (
+
+
+ {children} +
+
); +} + +export default OverlaySpinner; diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index 80e0099ab3e..121f572cb21 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -21,11 +21,10 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import * as sdk from "../../../index"; import { _t } from '../../../languageHandler'; -import Field from "../elements/Field"; import ErrorDialog from "../dialogs/ErrorDialog"; import AccessibleButton from "../elements/AccessibleButton"; import Modal from "../../../Modal"; -import RoomPublishSetting from "./RoomPublishSetting"; +import PublishedAliases from "./PublishedAliases"; import {replaceableComponent} from "../../../utils/replaceableComponent"; class EditableAliasesList extends EditableItemList { @@ -81,7 +80,6 @@ export default class AliasSettings extends React.Component { roomId: PropTypes.string.isRequired, canSetCanonicalAlias: PropTypes.bool.isRequired, canSetAliases: PropTypes.bool.isRequired, - canonicalAliasEvent: PropTypes.object, // MatrixEvent }; static defaultProps = { @@ -94,23 +92,11 @@ export default class AliasSettings extends React.Component { super(props); const state = { - altAliases: [], // [ #alias:domain.tld, ... ] localAliases: [], // [ #alias:my-hs.tld, ... ] - canonicalAlias: null, // #canonical:domain.tld - updatingCanonicalAlias: false, localAliasesLoading: false, detailsOpen: false, }; - if (props.canonicalAliasEvent) { - const content = props.canonicalAliasEvent.getContent(); - const altAliases = content.alt_aliases; - if (Array.isArray(altAliases)) { - state.altAliases = altAliases.slice(); - } - state.canonicalAlias = content.alias; - } - this.state = state; } @@ -139,69 +125,6 @@ export default class AliasSettings extends React.Component { } } - changeCanonicalAlias(alias) { - if (!this.props.canSetCanonicalAlias) return; - - const oldAlias = this.state.canonicalAlias; - this.setState({ - canonicalAlias: alias, - updatingCanonicalAlias: true, - }); - - const eventContent = { - alt_aliases: this.state.altAliases, - }; - - if (alias) eventContent["alias"] = alias; - - MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias", - eventContent, "").catch((err) => { - console.error(err); - Modal.createTrackedDialog('Error updating main address', '', ErrorDialog, { - title: _t("Error updating main address"), - description: _t( - "There was an error updating the room's main address. It may not be allowed by the server " + - "or a temporary failure occurred.", - ), - }); - this.setState({canonicalAlias: oldAlias}); - }).finally(() => { - this.setState({updatingCanonicalAlias: false}); - }); - } - - changeAltAliases(altAliases) { - if (!this.props.canSetCanonicalAlias) return; - - this.setState({ - updatingCanonicalAlias: true, - altAliases, - }); - - const eventContent = {}; - - if (this.state.canonicalAlias) { - eventContent.alias = this.state.canonicalAlias; - } - if (altAliases) { - eventContent["alt_aliases"] = altAliases; - } - - MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias", - eventContent, "").catch((err) => { - console.error(err); - Modal.createTrackedDialog('Error updating alternative addresses', '', ErrorDialog, { - title: _t("Error updating main address"), - description: _t( - "There was an error updating the room's alternative addresses. " + - "It may not be allowed by the server or a temporary failure occurred.", - ), - }); - }).finally(() => { - this.setState({updatingCanonicalAlias: false}); - }); - } - onNewAliasChanged = (value) => { this.setState({newAlias: value}); }; @@ -217,9 +140,6 @@ export default class AliasSettings extends React.Component { localAliases: this.state.localAliases.concat(alias), newAlias: null, }); - if (!this.state.canonicalAlias) { - this.changeCanonicalAlias(alias); - } }).catch((err) => { console.error(err); Modal.createTrackedDialog('Error creating address', '', ErrorDialog, { @@ -239,10 +159,6 @@ export default class AliasSettings extends React.Component { MatrixClientPeg.get().deleteAlias(alias).then(() => { const localAliases = this.state.localAliases.filter(a => a !== alias); this.setState({localAliases}); - - if (this.state.canonicalAlias === alias) { - this.changeCanonicalAlias(null); - } }).catch((err) => { console.error(err); let description; @@ -272,71 +188,10 @@ export default class AliasSettings extends React.Component { this.setState({detailsOpen: event.target.open}); }; - onCanonicalAliasChange = (event) => { - this.changeCanonicalAlias(event.target.value); - }; - - onNewAltAliasChanged = (value) => { - this.setState({newAltAlias: value}); - } - - onAltAliasAdded = (alias) => { - const altAliases = this.state.altAliases.slice(); - if (!altAliases.some(a => a.trim() === alias.trim())) { - altAliases.push(alias.trim()); - this.changeAltAliases(altAliases); - this.setState({newAltAlias: ""}); - } - } - - onAltAliasDeleted = (index) => { - const altAliases = this.state.altAliases.slice(); - altAliases.splice(index, 1); - this.changeAltAliases(altAliases); - } - - _getAliases() { - return this.state.altAliases.concat(this._getLocalNonAltAliases()); - } - - _getLocalNonAltAliases() { - const {altAliases} = this.state; - return this.state.localAliases.filter(alias => !altAliases.includes(alias)); - } - render() { const localDomain = MatrixClientPeg.get().getDomain(); - let found = false; - const canonicalValue = this.state.canonicalAlias || ""; - const canonicalAliasSection = ( - - - { - this._getAliases().map((alias, i) => { - if (alias === this.state.canonicalAlias) found = true; - return ( - - ); - }) - } - { - found || !this.state.canonicalAlias ? '' : - - } - - ); + const room = MatrixClientPeg.get().getRoom(this.props.roomId); let localAliasesList; if (this.state.localAliasesLoading) { @@ -361,31 +216,7 @@ export default class AliasSettings extends React.Component { return (
- {_t("Published Addresses")} -

{_t("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.")}

- {canonicalAliasSection} - - - {this._getLocalNonAltAliases().map(alias => { - return - + {_t("Local Addresses")}

{_t("Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", {localDomain})}

diff --git a/src/components/views/room_settings/PublishedAliases.tsx b/src/components/views/room_settings/PublishedAliases.tsx new file mode 100644 index 00000000000..8a506c4fa38 --- /dev/null +++ b/src/components/views/room_settings/PublishedAliases.tsx @@ -0,0 +1,403 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useContext, useEffect, useReducer, useRef} from 'react'; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { _t } from '../../../languageHandler'; +import EditableItemList from "../elements/EditableItemList"; +import OverlaySpinner from "../elements/OverlaySpinner"; +import Modal from "../../../Modal"; +import ErrorDialog from "../dialogs/ErrorDialog"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import RoomPublishSetting from "./RoomPublishSetting"; + +const UPDATE_ALT_ALIAS = 'update_alt_alias'; +const SYNCED_ALIASES = 'synced_aliases'; +const BEGIN_COMMIT = 'begin_commit'; +const END_COMMIT = 'end_commit'; +const ABORT_COMMIT = 'abort_commit'; + +interface State { + alt: { + synced: string[], + working: string[], + }, + canonical: { + synced: string, + working: string, + }, + submitting: string | undefined, + regarding: string | undefined, + error: Error & { during?: string, errcode?: string } | undefined, + newAltAlias: string, +} + +type Alts = string[]; + +interface AUpdateAlias { + type: 'update_alt_alias', + value: string, +} + +interface ASyncedAliases { + type: 'synced_aliases', + alt: Alts, + canonical: string | undefined, +} + +interface ABeginCommit { + type: 'begin_commit', + submitting: 'add' | 'remove' | 'canonical', + alt?: Alts, + canonical?: string, + regarding?: string, +} + +interface AEndCommit { + type: 'end_commit' +} + +interface AAbortCommit { + type: 'abort_commit', + error: Error +} + +type Action = AUpdateAlias | ASyncedAliases | ABeginCommit | AEndCommit | AAbortCommit; + +/* + * 'synced' represents the latest state event from the server. + * 'working' represents what we think the server state should be, based on what we've asked it to do + */ +export function reducer(state: State, action: Action): State { + switch (action.type) { + case UPDATE_ALT_ALIAS: + return {...state, newAltAlias: action.value, error: undefined}; + case SYNCED_ALIASES: + return { + ...state, + alt: { + synced: action.alt, + working: action.alt, + }, + canonical: { + synced: action.canonical, + working: action.canonical, + }, + }; + case BEGIN_COMMIT: + if (!action.submitting) throw new Error("Missing 'submitting' value in BEGIN_COMMIT"); + return { + ...state, + alt: { + ...state.alt, + working: action.alt ? action.alt : state.alt.working, + }, + canonical: { + ...state.canonical, + working: (action.canonical !== undefined) ? action.canonical : state.canonical.working, + }, + submitting: action.submitting, + regarding: action.regarding, + error: undefined, + }; + case END_COMMIT: + return { + ...state, + submitting: null, + newAltAlias: state.submitting === 'add' ? "" : state.newAltAlias, + regarding: undefined, + }; + case ABORT_COMMIT: + return { + ...state, + submitting: null, + alt: { + synced: state.alt.synced, + working: state.alt.synced, + }, + canonical: { + synced: state.canonical.synced, + working: state.canonical.synced, + }, + error: {...action.error, during: state.submitting}, + }; + default: + return state; + } +} + +/* This entire class exists so we can: + * 1. Add a tooltip to the field + * 2. Do validation + * TODO: Fixing up this, and AliasSettings, to not need a subclass is a very + * likely candidate for further cleanup + */ + +class EditableAltAliasList extends EditableItemList { + private _publishedAliasField = React.createRef(); + + _onAliasAdded = () => { + this.props.onItemAdded(this.props.newItem); + + this._publishedAliasField.current.focus(); + }; + + _renderNewItemField() { + return ( +
+ #} + value={this.props.newItem || ""} + onChange={this._onNewItemChanged} + list={this.props.suggestionsListId} + tooltipContent={this.props.error} + forceTooltipVisible={true} + /> + + { _t("Add") } + + + ); + } +} + +function useIsMountedRef() { + const isMounted = useRef(null); + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + } + }); + return isMounted; +} + +export default function PublishedAliases({ room, localAliases }) { + const client: any = useContext(MatrixClientContext); + const canSetCanonicalAlias = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client); + const initialAltAliases = room.getAltAliases(); + const initialCanonicalAlias = room.getCanonicalAlias(); + const isMounted = useIsMountedRef(); + + /* + * Reasons why an alias change failed. + * Note that if one of the errors listed here happens while adding an alias, we report it in a tooltip. + * Otherwise we report in a dialog box. + */ + const errorReasons = { + "M_BAD_ALIAS": _t("Room address does not point to the room"), + "M_INVALID_PARAM": _t("The server rejected the room address as invalid"), + }; + + const [state, dispatch] = useReducer(reducer, { + alt: { + synced: initialAltAliases, + working: initialAltAliases, + }, + canonical: { + synced: initialCanonicalAlias, + working: initialCanonicalAlias, + }, + newAltAlias: "", + submitting: null, + } as State); + + /* This action dispatches a given state to the server. Called after we've dispatched to update local state + * to reflect the change that was made */ + async function submitChanges(updatedState: State) { + if (updatedState === undefined) throw new Error("Unable to completeCommit: updatedState is undefined"); + + const alt = updatedState.alt.working; + const canonical = updatedState.canonical.working; + const { submitting } = updatedState; + + const content = { alt_aliases: alt }; + if (canonical) { + content['alias'] = canonical; + } + + try { + await client.sendStateEvent(room.roomId, "m.room.canonical_alias", content, ""); + if (isMounted.current) dispatch({type: END_COMMIT}); + } catch (err) { + console.error(`Error updating aliases in ${room.roomId}`, err); + + /* Don't throw up a dialog if the user closed the parent */ + if (!isMounted.current) return; + + dispatch({type: ABORT_COMMIT, error: err}); + + if (submitting === 'canonical') { + Modal.createTrackedDialog('Error updating main address', '', ErrorDialog, { + title: _t("Error updating main address"), + description: _t( + "There was an error updating the room's main address. It may not be allowed by the server " + + "or a temporary failure occurred.", + ), + }); + } else if (submitting !== 'add' || !(errorReasons[err.errcode])) { + /* Errors while adding aliases are reported inline */ + /* Also show an error dialog if we don't have a specific, short message to show for a failed add */ + Modal.createTrackedDialog('Error updating alternative addresses', '', ErrorDialog, { + title: _t("Error updating alternative addresses"), + description: _t( + "There was an error updating the room's alternative addresses. " + + "It may not be allowed by the server or a temporary failure occurred.", + ), + }); + } + } + } + + async function onSetCanonical(canonical: string) { + const action: ABeginCommit = { + canonical, + type: BEGIN_COMMIT, + submitting: 'canonical', + }; + dispatch(action); + /* State doesn't update until after this, but that doesn't mean we can't infer it */ + return submitChanges(reducer(state, action)); + } + + async function onRemoveAlias(index: number) { + const alt = state.alt.working.slice(); + alt.splice(index, 1); + const action: ABeginCommit = { + alt, + type: BEGIN_COMMIT, + submitting: 'remove', + regarding: state.alt.working[index], + }; + dispatch(action); + /* State doesn't update until after this, but that doesn't mean we can't infer it */ + return submitChanges(reducer(state, action)); + } + + function onAddAlias(alias: string) { + if (state.submitting) return; + if (alias.trim().length === 0) return; + const fullAlias = alias.trim().startsWith("#") ? alias.trim() : "#" + alias.trim(); + if (state.alt.working.some((existing) => existing === fullAlias)) { + /* If the user tries to submit something in the list, pretend we did a dispatch */ + dispatch({ + type: UPDATE_ALT_ALIAS, + value: '', + }); + } else { + const alt = state.alt.working.slice(); + alt.push(fullAlias); + const action: ABeginCommit = { + alt, + type: BEGIN_COMMIT, + submitting: 'add', + regarding: fullAlias, + }; + dispatch(action); + /* State doesn't update until after this, but that doesn't mean we can't infer it */ + return submitChanges(reducer(state, action)); + } + } + + /* Any updates from the server need to be immediately reflected in the UI */ + useEffect(() => { + function handleEvent(event: any) { + if (event.getRoomId() !== room.roomId) return; + if (event.getType() !== "m.room.canonical_alias") return; + + if (isMounted.current) { + dispatch({ + type: 'synced_aliases', + alt: room.getAltAliases(), + canonical: room.getCanonicalAlias(), + }); + } + } + + client.on("RoomState.events", handleEvent); + return () => client.off("RoomState.events", handleEvent); + }, [client, room]); // eslint-disable-line react-hooks/exhaustive-deps + + const error = state.error && errorReasons[state.error.errcode]; + + /* Alternate aliases must be either an existing local alias, or an existing remote alias. Suggest all existing + * local aliases that are not already in the alt alias list. */ + const altSuggestions = localAliases && localAliases.filter((alias: string) => + !state.alt.working.includes(alias), + ).map((alias: string) => alias.replace(/^#/, '')); // strip the # for display + + /* Canonical aliases can be either existing local aliases or existing alt aliases */ + const canonicalSuggestions = Array.from(new Set([...state.alt.working, ...(localAliases || [])])); + + /* A canonical alias may represent an alias that's not in the current alt aliases list. + * When this happens, we need to add it manually so the user can see it in the dropdown */ + const explicitlyInclude = (state.canonical.working && !canonicalSuggestions.includes(state.canonical.working)); + + return ( + + {_t("Published Addresses")} +

{_t("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.")}

+ ) => onSetCanonical(ev.target.value)} + value={state.canonical.working || ''} + > + + {canonicalSuggestions.map((alias, i) => + )} + {explicitlyInclude && } + + + + {altSuggestions && altSuggestions.map(alias => + + dispatch({value, type: UPDATE_ALT_ALIAS})} + placeholder={_t('New published address (e.g. #address:server)')} + suggestionsListId="mx_AliasSettings_altRecommendations" + /> +
); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 23010571c28..602f8a7213c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1662,26 +1662,30 @@ "Record a voice message": "Record a voice message", "Stop the recording": "Stop the recording", "Delete recording": "Delete recording", - "Error updating main address": "Error updating main address", - "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", - "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", "Error creating address": "Error creating address", "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.": "There was an error creating that address. It may not be allowed by the server or a temporary failure occurred.", "You don't have permission to delete the address.": "You don't have permission to delete the address.", "There was an error removing that address. It may no longer exist or a temporary error occurred.": "There was an error removing that address. It may no longer exist or a temporary error occurred.", "Error removing address": "Error removing address", - "Main address": "Main address", - "not specified": "not specified", "This room has no local addresses": "This room has no local addresses", "Local address": "Local address", + "Local Addresses": "Local Addresses", + "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", + "Show more": "Show more", + "Room address does not point to the room": "Room address does not point to the room", + "The server rejected the room address as invalid": "The server rejected the room address as invalid", + "Error updating main address": "Error updating main address", + "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", + "Error updating alternative addresses": "Error updating alternative addresses", + "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", "Published Addresses": "Published Addresses", "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.": "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.", + "Main address": "Main address", + "not specified": "not specified", "Other published addresses:": "Other published addresses:", "No other published addresses yet, add one below": "No other published addresses yet, add one below", - "New published address (e.g. #alias:server)": "New published address (e.g. #alias:server)", - "Local Addresses": "Local Addresses", - "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", - "Show more": "Show more", + "No other published addresses yet.": "No other published addresses yet.", + "New published address (e.g. #address:server)": "New published address (e.g. #address:server)", "Error updating flair": "Error updating flair", "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.", "Invalid community ID": "Invalid community ID",