From c10512fd569021089c34a8ea2b6083d0996f02b3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 1 Mar 2021 18:10:17 +0000 Subject: [PATCH] Initial SpaceRoomView work --- res/css/_components.scss | 2 + res/css/structures/_SpaceRoomView.scss | 244 +++++++++ res/css/views/spaces/_SpacePublicShare.scss | 60 +++ res/img/element-icons/link.svg | 3 + res/themes/dark/css/_dark.scss | 1 + res/themes/legacy-dark/css/_legacy-dark.scss | 1 + .../legacy-light/css/_legacy-light.scss | 1 + res/themes/light/css/_light.scss | 1 + src/RoomInvite.js | 9 +- src/components/structures/RightPanel.js | 23 +- src/components/structures/RoomView.tsx | 20 +- src/components/structures/SpaceRoomView.tsx | 503 ++++++++++++++++++ src/components/views/dialogs/InviteDialog.tsx | 66 ++- .../views/spaces/SpacePublicShare.tsx | 65 +++ .../payloads/SetRightPanelPhasePayload.ts | 2 + src/hooks/useStateArray.ts | 29 + src/i18n/strings/en_EN.json | 41 +- src/stores/RightPanelStorePhases.ts | 12 + src/utils/space.ts | 28 + 19 files changed, 1066 insertions(+), 45 deletions(-) create mode 100644 res/css/structures/_SpaceRoomView.scss create mode 100644 res/css/views/spaces/_SpacePublicShare.scss create mode 100644 res/img/element-icons/link.svg create mode 100644 src/components/structures/SpaceRoomView.tsx create mode 100644 src/components/views/spaces/SpacePublicShare.tsx create mode 100644 src/hooks/useStateArray.ts create mode 100644 src/utils/space.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 8d6597aefa..ca66aa60ec 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -28,6 +28,7 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_SpacePanel.scss"; +@import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @@ -235,6 +236,7 @@ @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/spaces/_SpaceBasicSettings.scss"; @import "./views/spaces/_SpaceCreateMenu.scss"; +@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss new file mode 100644 index 0000000000..559f405e59 --- /dev/null +++ b/res/css/structures/_SpaceRoomView.scss @@ -0,0 +1,244 @@ +/* +Copyright 2021 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. +*/ + +$SpaceRoomViewInnerWidth: 428px; + +.mx_SpaceRoomView { + .mx_MainSplit > div:first-child { + padding: 80px 60px; + flex-grow: 1; + + h1 { + margin: 0; + font-size: $font-24px; + font-weight: $font-semi-bold; + color: $primary-fg-color; + width: max-content; + } + + .mx_SpaceRoomView_description { + font-size: $font-15px; + color: $secondary-fg-color; + margin-top: 12px; + margin-bottom: 24px; + } + + .mx_SpaceRoomView_buttons { + display: block; + margin-top: 44px; + width: $SpaceRoomViewInnerWidth; + text-align: right; // button alignment right + + .mx_FormButton { + padding: 8px 22px; + margin-left: 16px; + } + } + + .mx_Field { + max-width: $SpaceRoomViewInnerWidth; + + & + .mx_Field { + margin-top: 28px; + } + } + + .mx_SpaceRoomView_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } + + .mx_SpaceRoomView_landing { + overflow-y: auto; + + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + .mx_SpaceRoomView_landing_name { + margin: 24px 0 16px; + font-size: $font-15px; + color: $secondary-fg-color; + + > span { + display: inline-block; + } + + .mx_SpaceRoomView_landing_nameRow { + margin-top: 12px; + + > h1 { + display: inline-block; + } + } + + .mx_SpaceRoomView_landing_inviter { + .mx_BaseAvatar { + margin-right: 4px; + vertical-align: middle; + } + } + + .mx_SpaceRoomView_landing_memberCount { + position: relative; + margin-left: 24px; + padding: 0 0 0 28px; + line-height: $font-24px; + vertical-align: text-bottom; + + &::before { + position: absolute; + content: ''; + width: 24px; + height: 24px; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + } + + .mx_SpaceRoomView_landing_topic { + font-size: $font-15px; + } + + .mx_SpaceRoomView_landing_joinButtons { + margin-top: 24px; + + .mx_FormButton { + padding: 8px 22px; + } + } + } + + .mx_SpaceRoomView_privateScope { + .mx_RadioButton { + width: $SpaceRoomViewInnerWidth; + border-radius: 8px; + border: 1px solid $space-button-outline-color; + padding: 16px 16px 16px 72px; + margin-top: 36px; + cursor: pointer; + box-sizing: border-box; + position: relative; + + > div:first-of-type { + // hide radio dot + display: none; + } + + .mx_RadioButton_content { + margin: 0; + + > h3 { + margin: 0 0 4px; + font-size: $font-15px; + font-weight: $font-semi-bold; + line-height: $font-18px; + } + + > div { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + &::before { + content: ""; + position: absolute; + height: 32px; + width: 32px; + top: 24px; + left: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .mx_RadioButton_checked { + border-color: $accent-color; + + .mx_RadioButton_content { + > div { + color: $primary-fg-color; + } + } + + &::before { + background-color: $accent-color; + } + } + + .mx_SpaceRoomView_privateScope_justMeButton::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + + .mx_SpaceRoomView_inviteTeammates { + .mx_SpaceRoomView_inviteTeammates_buttons { + color: $secondary-fg-color; + margin-top: 28px; + + .mx_AccessibleButton { + position: relative; + display: inline-block; + padding-left: 32px; + line-height: 24px; // to center icons + + &::before { + content: ""; + position: absolute; + height: 24px; + width: 24px; + top: 0; + left: 0; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + & + .mx_AccessibleButton { + margin-left: 32px; + } + } + + .mx_SpaceRoomView_inviteTeammates_inviteDialogButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + } +} diff --git a/res/css/views/spaces/_SpacePublicShare.scss b/res/css/views/spaces/_SpacePublicShare.scss new file mode 100644 index 0000000000..9ba0549ae3 --- /dev/null +++ b/res/css/views/spaces/_SpacePublicShare.scss @@ -0,0 +1,60 @@ +/* +Copyright 2021 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_SpacePublicShare { + .mx_AccessibleButton { + border: 1px solid $space-button-outline-color; + box-sizing: border-box; + border-radius: 8px; + padding: 12px 24px 12px 52px; + margin-top: 16px; + width: $SpaceRoomViewInnerWidth; + font-size: $font-15px; + line-height: $font-24px; + position: relative; + display: flex; + + > span { + color: #368bd6; + margin-left: auto; + } + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::before { + content: ""; + position: absolute; + width: 30px; + height: 30px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + background: $muted-fg-color; + left: 12px; + top: 9px; + } + + &.mx_SpacePublicShare_shareButton::before { + mask-image: url('$(res)/img/element-icons/link.svg'); + } + + &.mx_SpacePublicShare_inviteButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } +} diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg new file mode 100644 index 0000000000..ab3d54b838 --- /dev/null +++ b/res/img/element-icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index a878aa3cdd..0de5e69782 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -123,6 +123,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 3e3c299af9..8c5f20178b 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -120,6 +120,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: rgba(141, 151, 165, 0.2); $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index a740ba155c..3ba10a68ea 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -187,6 +187,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: #E3E8F0; $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 1c89d83c01..76bf2ddc21 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -181,6 +181,7 @@ $roomsublist-divider-color: $primary-fg-color; $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%); $groupFilterPanel-divider-color: $roomlist-header-color; +$space-button-outline-color: #E3E8F0; $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 06d3fb04e8..728ae11e79 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -22,7 +22,7 @@ import MultiInviter from './utils/MultiInviter'; import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; -import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import InviteDialog, {KIND_DM, KIND_INVITE, KIND_SPACE_INVITE} from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; @@ -75,6 +75,13 @@ export function showCommunityInviteDialog(communityId) { } } +export const showSpaceInviteDialog = (roomId) => { + Modal.createTrackedDialog("Invite Users", "Space", InviteDialog, { + kind: KIND_SPACE_INVITE, + roomId, + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); +}; + /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index d66049d3a5..3d9df2e927 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -24,7 +24,11 @@ import dis from '../../dispatcher/dispatcher'; import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; -import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import { + RightPanelPhases, + RIGHT_PANEL_PHASES_NO_ARGS, + RIGHT_PANEL_SPACE_PHASES, +} from "../../stores/RightPanelStorePhases"; import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import {Action} from "../../dispatcher/actions"; @@ -79,6 +83,8 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; + } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // from its props and some from a store, except if the contents of the store changes @@ -99,9 +105,8 @@ export default class RightPanel extends React.Component { return rps.roomPanelPhase; } return RightPanelPhases.RoomMemberInfo; - } else { - return rps.roomPanelPhase; } + return rps.roomPanelPhase; } componentDidMount() { @@ -181,6 +186,7 @@ export default class RightPanel extends React.Component { verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, widgetId: payload.widgetId, + space: payload.space, }); } } @@ -232,6 +238,13 @@ export default class RightPanel extends React.Component { panel = ; } break; + case RightPanelPhases.SpaceMemberList: + panel = ; + break; case RightPanelPhases.GroupMemberList: if (this.props.groupId) { @@ -244,10 +257,11 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.RoomMemberInfo: + case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 933514754c..1961779d0e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1400,7 +1400,7 @@ export default class RoomView extends React.Component { }); }; - private onRejectButtonClicked = ev => { + private onRejectButtonClicked = () => { this.setState({ rejecting: true, }); @@ -1460,7 +1460,7 @@ export default class RoomView extends React.Component { } }; - private onRejectThreepidInviteButtonClicked = ev => { + private onRejectThreepidInviteButtonClicked = () => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. @@ -1723,7 +1723,7 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership == 'invite') { + if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (this.state.joining || this.state.rejecting) { return ( @@ -1852,7 +1852,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek) { + if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { return (
{ previewBar } @@ -1874,6 +1874,18 @@ export default class RoomView extends React.Component { ); } + if (this.state.room?.isSpaceRoom()) { + return ; + } + const auxPanel = ( { + const members = useRoomMembers(room); + const count = members.length; + + if (children) return children(count); + return count; +}; + +const useMyRoomMembership = (room: Room) => { + const [membership, setMembership] = useState(room.getMyMembership()); + useEventEmitter(room, "Room.myMembership", () => { + setMembership(room.getMyMembership()); + }); + return membership; +}; + +const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + const joinRule = space.getJoinRule(); + const userId = cli.getUserId(); + + let joinButtons; + if (myMembership === "invite") { + joinButtons =
+ + + {_t("Decline")} + +
; + } else if (myMembership !== "join" && joinRule === "public") { + joinButtons =
+ +
; + } + + return
+ +
+ + {(name) => { + const tags = { name: () =>
+

{ name }

+ + {(count) => count > 0 ? ( + { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }} + > + { _t("%(count)s members", { count }) } + + ) : null} + +
}; + if (myMembership === "invite") { + const inviteSender = space.getMember(userId)?.events.member?.getSender(); + const inviter = inviteSender && space.getMember(inviteSender); + + if (inviteSender) { + return _t(" invited you to ", {}, { + name: tags.name, + inviter: () => inviter + ? + + { inviter.name } + + : + { inviteSender } + , + }) as JSX.Element; + } else { + return _t("You have been invited to ", {}, tags) as JSX.Element; + } + } else if (shouldShowSpaceSettings(cli, space)) { + if (space.getJoinRule() === "public") { + return _t("Your public space ", {}, tags) as JSX.Element; + } else { + return _t("Your private space ", {}, tags) as JSX.Element; + } + } + return _t("Welcome to ", {}, tags) as JSX.Element; + }} +
+
+
+ +
+ { joinButtons } +
; +}; + +const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const placeholders = [_t("General"), _t("Random"), _t("Support")]; + // TODO vary default prefills for "Just Me" spaces + const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "roomName" + i; + return setRoomName(i, ev.target.value)} + />; + }); + + const onNextClick = async () => { + setError(""); + setBusy(true); + try { + await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + return createRoom({ + createOpts: { + preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + name, + }, + spinner: false, + encryption: false, + andView: false, + inlineErrors: true, + parentSpace: space, + }); + })); + onFinished(); + } catch (e) { + console.error("Failed to create initial space rooms", e); + setError(_t("Failed to create initial space rooms")); + } + setBusy(false); + }; + + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (roomNames.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Creating rooms...") : _t("Next") + } + + return
+

{ title }

+
{ description }
+ + { error &&
{ error }
} + { fields } + +
+ +
+
; +}; + +const SpaceSetupPublicShare = ({ space, onFinished }) => { + return
+

{ _t("Share your public space") }

+
{ _t("At the moment only you can see it.") }
+ + + +
+ +
+
; +}; + +const SpaceSetupPrivateScope = ({ onFinished }) => { + const [option, setOption] = useState(null); + + return
+

{ _t("Who are you working with?") }

+
{ _t("Ensure the right people have access to the space.") }
+ + +

{ _t("Just Me") }

+
{ _t("A private space just for you") }
+ , + }, { + value: "meAndMyTeammates", + className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton", + label: +

{ _t("Me and my teammates") }

+
{ _t("A private space for you and your teammates") }
+
, + }, + ]} + /> + +
+ onFinished(option !== "justMe")} /> +
+
; +}; + +const validateEmailRules = withValidation({ + rules: [{ + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }], +}); + +const SpaceSetupPrivateInvite = ({ space, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; + const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "emailAddress" + i; + return setEmailAddress(i, ev.target.value)} + ref={fieldRefs[i]} + onValidate={validateEmailRules} + />; + }); + + const onNextClick = async () => { + setError(""); + for (let i = 0; i < fieldRefs.length; i++) { + const fieldRef = fieldRefs[i]; + const valid = await fieldRef.current.validate({ allowEmpty: true }); + + if (valid === false) { // true/null are allowed + fieldRef.current.focus(); + fieldRef.current.validate({ allowEmpty: true, focused: true }); + return; + } + } + + setBusy(true); + const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean); + try { + const result = await inviteMultipleToRoom(space.roomId, targetIds); + + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error"); + if (failedUsers.length > 0) { + console.log("Failed to invite users to space: ", result); + setError(_t("Failed to invite the following users to your space: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + })); + } else { + onFinished(); + } + } catch (err) { + console.error("Failed to invite users to space: ", err); + setError(_t("We couldn't invite those users. Please check the users you want to invite and try again.")); + } + setBusy(false); + }; + + return
+

{ _t("Invite your teammates") }

+
{ _t("Ensure the right people have access to the space.") }
+ + { error &&
{ error }
} + { fields } + +
+ showSpaceInviteDialog(space.roomId)} + > + { _t("Invite by username") } + +
+ +
+ {_t("Skip for now")} + +
+
; +}; + +export default class SpaceRoomView extends React.PureComponent { + static contextType = MatrixClientContext; + + private readonly creator: string; + private readonly dispatcherRef: string; + private readonly rightPanelStoreToken: EventSubscription; + + constructor(props, context) { + super(props, context); + + let phase = Phase.Landing; + + this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); + const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator; + + if (showSetup) { + phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat + ? Phase.PublicCreateRooms : Phase.PrivateScope; + } + + this.state = { + phase, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }; + + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); + } + + componentWillUnmount() { + defaultDispatcher.unregister(this.dispatcherRef); + this.rightPanelStoreToken.remove(); + } + + private onRightPanelStoreUpdate = () => { + this.setState({ + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }); + }; + + private onAction = (payload: ActionPayload) => { + if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return; + + if (payload.action === Action.ViewUser && payload.member) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberInfo, + refireParams: { + space: this.props.space, + member: payload.member, + }, + }); + } else if (payload.action === "view_3pid_invite" && payload.event) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.Space3pidMemberInfo, + refireParams: { + space: this.props.space, + event: payload.event, + }, + }); + } else { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: this.props.space }, + }); + } + }; + + private renderBody() { + switch (this.state.phase) { + case Phase.Landing: + return ; + + case Phase.PublicCreateRooms: + return this.setState({ phase: Phase.PublicShare })} + />; + case Phase.PublicShare: + return this.setState({ phase: Phase.Landing })} + />; + + case Phase.PrivateScope: + return { + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + }} + />; + case Phase.PrivateInvite: + return this.setState({ phase: Phase.PrivateCreateRooms })} + />; + case Phase.PrivateCreateRooms: + return this.setState({ phase: Phase.Landing })} + />; + } + } + + render() { + const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing + ? + : null; + + return
+ + + { this.renderBody() } + + +
; + } +} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 5b936e822c..9bc5b6476f 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, {createRef} from 'react'; -import {_t} from "../../../languageHandler"; +import {_t, _td} from "../../../languageHandler"; import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; @@ -48,6 +48,7 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; +export const KIND_SPACE_INVITE = "space_invite"; export const KIND_CALL_TRANSFER = "call_transfer"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first @@ -309,7 +310,7 @@ interface IInviteDialogProps { // not provided. kind: string, - // The room ID this dialog is for. Only required for KIND_INVITE. + // The room ID this dialog is for. Only required for KIND_INVITE and KIND_SPACE_INVITE. roomId: string, // The call to transfer. Only required for KIND_CALL_TRANSFER. @@ -348,8 +349,8 @@ export default class InviteDialog extends React.PureComponent) or " + - "share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - - {sub} - , - }, - ); - } else { - helpText = _t( - "Invite someone using their name, username (like ) or share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - - {sub} - , - }, - ); + let helpTextUntranslated; + if (this.props.kind === KIND_INVITE) { + if (identityServersEnabled) { + helpTextUntranslated = _td("Invite someone using their name, email address, username " + + "(like ) or share this room."); + } else { + helpTextUntranslated = _td("Invite someone using their name, username " + + "(like ) or share this room."); + } + } else { // KIND_SPACE_INVITE + if (identityServersEnabled) { + helpTextUntranslated = _td("Invite someone using their name, email address, username " + + "(like ) or share this space."); + } else { + helpTextUntranslated = _td("Invite someone using their name, username " + + "(like ) or share this space."); + } } + helpText = _t(helpTextUntranslated, {}, { + userId: () => + {userId}, + a: (sub) => + {sub}, + }); + buttonText = _t("Invite"); goButtonFn = this._inviteUsers; } else if (this.props.kind === KIND_CALL_TRANSFER) { diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx new file mode 100644 index 0000000000..064d1640a2 --- /dev/null +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -0,0 +1,65 @@ +/* +Copyright 2021 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, {useState} from "react"; +import {Room} from "matrix-js-sdk/src/models/room"; + +import {_t} from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import {copyPlaintext} from "../../../utils/strings"; +import {sleep} from "../../../utils/promise"; +import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; +import {showSpaceInviteDialog} from "../../../RoomInvite"; + +interface IProps { + space: Room; + onFinished(): void; +} + +const SpacePublicShare = ({ space, onFinished }: IProps) => { + const [copiedText, setCopiedText] = useState(_t("Click to copy")); + + return
+ { + const permalinkCreator = new RoomPermalinkCreator(space); + permalinkCreator.load(); + const success = await copyPlaintext(permalinkCreator.forRoom()); + const text = success ? _t("Copied!") : _t("Failed to copy"); + setCopiedText(text); + await sleep(10); + if (copiedText === text) { // if the text hasn't changed by another click then clear it after some time + setCopiedText(_t("Click to copy")); + } + }} + > + { _t("Share invite link") } + { copiedText } + + { + showSpaceInviteDialog(space.roomId); + onFinished(); + }} + > + { _t("Invite by email or username") } + +
; +}; + +export default SpacePublicShare; diff --git a/src/dispatcher/payloads/SetRightPanelPhasePayload.ts b/src/dispatcher/payloads/SetRightPanelPhasePayload.ts index 4126e8a669..430fad6145 100644 --- a/src/dispatcher/payloads/SetRightPanelPhasePayload.ts +++ b/src/dispatcher/payloads/SetRightPanelPhasePayload.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { ActionPayload } from "../payloads"; @@ -35,4 +36,5 @@ export interface SetRightPanelPhaseRefireParams { // XXX: The type for event should 'view_3pid_invite' action's payload event?: any; widgetId?: string; + space?: Room; } diff --git a/src/hooks/useStateArray.ts b/src/hooks/useStateArray.ts new file mode 100644 index 0000000000..e8ff6efff0 --- /dev/null +++ b/src/hooks/useStateArray.ts @@ -0,0 +1,29 @@ +/* +Copyright 2021 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 {useState} from "react"; + +// Hook to simplify managing state of arrays of a common type +export const useStateArray = (initialSize: number, initialState: T | T[]): [T[], (i: number, v: T) => void] => { + const [data, setData] = useState(() => { + return Array.isArray(initialState) ? initialState : new Array(initialSize).fill(initialState); + }); + return [data, (index: number, value: T) => setData(data => { + const copy = [...data]; + copy[index] = value; + return copy; + })] +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1b29e65b40..ae12b195a0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -998,6 +998,11 @@ "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", "Home": "Home", + "Click to copy": "Click to copy", + "Copied!": "Copied!", + "Failed to copy": "Failed to copy", + "Share invite link": "Share invite link", + "Invite by email or username": "Invite by email or username", "Remove": "Remove", "This bridge was provisioned by .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", @@ -1814,8 +1819,6 @@ "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", "Click here to see older messages.": "Click here to see older messages.", "This room is a continuation of another conversation.": "This room is a continuation of another conversation.", - "Copied!": "Copied!", - "Failed to copy": "Failed to copy", "Add an Integration": "Add an Integration", "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?", "Edited at %(date)s": "Edited at %(date)s", @@ -2164,8 +2167,11 @@ "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here", "Go": "Go", + "Invite to this space": "Invite to this space", "Invite someone using their name, email address, username (like ) or share this room.": "Invite someone using their name, email address, username (like ) or share this room.", "Invite someone using their name, username (like ) or share this room.": "Invite someone using their name, username (like ) or share this room.", + "Invite someone using their name, email address, username (like ) or share this space.": "Invite someone using their name, email address, username (like ) or share this space.", + "Invite someone using their name, username (like ) or share this space.": "Invite someone using their name, username (like ) or share this space.", "Transfer": "Transfer", "a new master key signature": "a new master key signature", "a new cross-signing key signature": "a new cross-signing key signature", @@ -2550,6 +2556,37 @@ "Failed to reject invite": "Failed to reject invite", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", + "Accept Invite": "Accept Invite", + "%(count)s members|other": "%(count)s members", + "%(count)s members|one": "%(count)s member", + " invited you to ": " invited you to ", + "You have been invited to ": "You have been invited to ", + "Your public space ": "Your public space ", + "Your private space ": "Your private space ", + "Welcome to ": "Welcome to ", + "Random": "Random", + "Support": "Support", + "Room name": "Room name", + "Failed to create initial space rooms": "Failed to create initial space rooms", + "Skip for now": "Skip for now", + "Creating rooms...": "Creating rooms...", + "Share your public space": "Share your public space", + "At the moment only you can see it.": "At the moment only you can see it.", + "Finish": "Finish", + "Who are you working with?": "Who are you working with?", + "Ensure the right people have access to the space.": "Ensure the right people have access to the space.", + "Just Me": "Just Me", + "A private space just for you": "A private space just for you", + "Me and my teammates": "Me and my teammates", + "A private space for you and your teammates": "A private space for you and your teammates", + "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s", + "Invite your teammates": "Invite your teammates", + "Invite by username": "Invite by username", + "Inviting...": "Inviting...", + "What discussions do you want to have?": "What discussions do you want to have?", + "We'll create rooms for each topic.": "We'll create rooms for each topic.", + "What projects are you working on?": "What projects are you working on?", + "We'll create rooms for each of them. You can add existing rooms after setup.": "We'll create rooms for each of them. You can add existing rooms after setup.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index 11b51dfc2d..aea78c7460 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -31,6 +31,11 @@ export enum RightPanelPhases { GroupRoomList = 'GroupRoomList', GroupRoomInfo = 'GroupRoomInfo', GroupMemberInfo = 'GroupMemberInfo', + + // Space stuff + SpaceMemberList = "SpaceMemberList", + SpaceMemberInfo = "SpaceMemberInfo", + Space3pidMemberInfo = "Space3pidMemberInfo", } // These are the phases that are safe to persist (the ones that don't require additional @@ -43,3 +48,10 @@ export const RIGHT_PANEL_PHASES_NO_ARGS = [ RightPanelPhases.GroupMemberList, RightPanelPhases.GroupRoomList, ]; + +// Subset of phases visible in the Space View +export const RIGHT_PANEL_SPACE_PHASES = [ + RightPanelPhases.SpaceMemberList, + RightPanelPhases.Space3pidMemberInfo, + RightPanelPhases.SpaceMemberInfo, +]; diff --git a/src/utils/space.ts b/src/utils/space.ts new file mode 100644 index 0000000000..85faedf5d6 --- /dev/null +++ b/src/utils/space.ts @@ -0,0 +1,28 @@ +/* +Copyright 2021 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 {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {EventType} from "matrix-js-sdk/src/@types/event"; + +export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => { + const userId = cli.getUserId(); + return space.getMyMembership() === "join" + && (space.currentState.maySendStateEvent(EventType.RoomAvatar, userId) + || space.currentState.maySendStateEvent(EventType.RoomName, userId) + || space.currentState.maySendStateEvent(EventType.RoomTopic, userId) + || space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId)); +};