diff --git a/res/css/_components.scss b/res/css/_components.scss index 24d2ffa2b0..45ed6b3300 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -68,6 +68,7 @@ @import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; +@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 78795c85a2..6fa2f2578e 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -16,9 +16,33 @@ limitations under the License. .mx_UserMenu { - // to make the ... button sort of aligned with the explore button below + // to make the menu button sort of aligned with the explore button below padding-right: 6px; + &.mx_UserMenu_prototype { + // The margin & padding combination between here and the ::after is to + // align the border line with the tag panel. + margin-bottom: 6px; + + padding-right: 0; // make the right edge line up with the explore button + + .mx_UserMenu_headerButtons { + // considering we've eliminated right padding on the menu itself, we need to + // push the chevron in slightly (roughly lining up with the center of the + // plus buttons) + margin-right: 2px; + } + + // we cheat opacity on the theme colour with an after selector here + &::after { + content: ''; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + opacity: 0.2; + display: block; + padding-top: 8px; + } + } + .mx_UserMenu_headerButtons { width: 16px; height: 16px; @@ -36,7 +60,7 @@ limitations under the License. mask-size: contain; mask-repeat: no-repeat; background: $primary-fg-color; - mask-image: url('$(res)/img/element-icons/context-menu.svg'); + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } } @@ -56,6 +80,28 @@ limitations under the License. } } + .mx_UserMenu_doubleName { + flex: 1; + min-width: 0; // make flexbox aware that it can crush this to a tiny width + + .mx_UserMenu_userName, + .mx_UserMenu_subUserName { + display: block; + } + + .mx_UserMenu_subUserName { + color: $muted-fg-color; + font-size: $font-13px; + line-height: $font-18px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + .mx_UserMenu_userName { font-weight: 600; font-size: $font-15px; @@ -89,6 +135,44 @@ limitations under the License. .mx_UserMenu_contextMenu { width: 247px; + // These override the styles already present on the user menu rather than try to + // define a new menu. They are specifically for the stacked menu when a community + // is being represented as a prototype. + &.mx_UserMenu_contextMenu_prototype { + padding-bottom: 16px; + + .mx_UserMenu_contextMenu_header { + padding-bottom: 0; + padding-top: 16px; + + &:nth-child(n + 2) { + padding-top: 8px; + } + } + + hr { + width: 85%; + opacity: 0.2; + border: none; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + } + + &.mx_IconizedContextMenu { + > .mx_IconizedContextMenu_optionList { + margin-top: 4px; + + &::before { + border: none; + } + + > .mx_AccessibleButton { + padding-top: 2px; + padding-bottom: 2px; + } + } + } + } + &.mx_IconizedContextMenu .mx_IconizedContextMenu_optionList_red { .mx_AccessibleButton { padding-top: 16px; @@ -193,4 +277,12 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url('$(res)/img/element-icons/leave.svg'); } + + .mx_UserMenu_iconMembers::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_UserMenu_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } } diff --git a/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss new file mode 100644 index 0000000000..75a56bf6b3 --- /dev/null +++ b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss @@ -0,0 +1,77 @@ +/* +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. +*/ + +// XXX: many of these styles are shared with the create dialog +.mx_EditCommunityPrototypeDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 12px; + + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + display: block; + height: 32px; + font-size: $font-16px; + line-height: 32px; + } + + .mx_EditCommunityPrototypeDialog_rowAvatar { + display: flex; + flex-direction: row; + align-items: center; + } + + .mx_EditCommunityPrototypeDialog_avatarContainer { + margin-top: 20px; + margin-bottom: 20px; + + .mx_EditCommunityPrototypeDialog_avatar, + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + width: 96px; + height: 96px; + border-radius: 96px; + } + + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + background-color: #368bd6; // hardcoded for both themes + + &::before { + display: inline-block; + background-color: #fff; // hardcoded because the background is + mask-repeat: no-repeat; + mask-size: 96px; + width: 96px; + height: 96px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/add-photo.svg'); + } + } + } + + .mx_EditCommunityPrototypeDialog_tip { + margin-left: 20px; + + & > b, & > span { + display: block; + color: $muted-fg-color; + } + } + } +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js index cae4501901..7eb7f5dbb2 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -24,7 +24,7 @@ import * as sdk from './'; import { _t } from './languageHandler'; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; -import GroupStore from "./stores/GroupStore"; +import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; /** * Invites multiple addresses to a room @@ -66,18 +66,10 @@ export function showCommunityRoomInviteDialog(roomId, communityName) { } export function showCommunityInviteDialog(communityId) { - const rooms = GroupStore.getGroupRooms(communityId) - .map(r => MatrixClientPeg.get().getRoom(r.roomId)) - .filter(r => !!r); - let chat = rooms.find(r => { - const idState = r.currentState.getStateEvents("im.vector.general_chat", ""); - if (!idState || idState.getContent()['groupId'] !== communityId) return false; - return true; - }); - if (!chat) chat = rooms[0]; + const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); if (chat) { - const summary = GroupStore.getSummary(communityId); - showCommunityRoomInviteDialog(chat.roomId, summary?.profile?.name || communityId); + const name = CommunityPrototypeStore.instance.getCommunityName(communityId); + showCommunityRoomInviteDialog(chat.roomId, name); } else { throw new Error("Failed to locate appropriate room to start an invite in"); } diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 64233e51ad..0bb169abf8 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -26,8 +26,9 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitem export const MenuItem: React.FC = ({children, label, ...props}) => { + const ariaLabel = props["aria-label"] || label; return ( - + { children } ); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 69b9c3f26e..b83369d296 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -42,6 +42,14 @@ import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; +import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; +import * as fbEmitter from "fbemitter"; +import TagOrderStore from "../../stores/TagOrderStore"; +import { showCommunityInviteDialog } from "../../RoomInvite"; +import dis from "../../dispatcher/dispatcher"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; interface IProps { isMinimized: boolean; @@ -58,6 +66,7 @@ export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; private buttonRef: React.RefObject = createRef(); + private tagStoreRef: fbEmitter.EventSubscription; constructor(props: IProps) { super(props); @@ -77,14 +86,20 @@ export default class UserMenu extends React.Component { public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate); } public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + this.tagStoreRef.remove(); } + private onTagStoreUpdate = () => { + this.forceUpdate(); // we don't have anything useful in state to update + }; + private isUserOnDarkTheme(): boolean { const theme = SettingsStore.getValue("theme"); if (theme.startsWith("custom-")) { @@ -189,9 +204,54 @@ export default class UserMenu extends React.Component { defaultDispatcher.dispatch({action: 'view_home_page'}); }; + private onCommunitySettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, { + communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(), + }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // We'd ideally just pop open a right panel with the member list, but the current + // way the right panel is structured makes this exceedingly difficult. Instead, we'll + // switch to the general room and open the member list there as it should be in sync + // anyways. + const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); + if (chat) { + dis.dispatch({ + action: 'view_room', + room_id: chat.roomId, + }, true); + dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); + } else { + // "This should never happen" clauses go here for the prototype. + Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, { + title: _t('Failed to find the general chat for this community'), + description: _t("Failed to find the general chat for this community"), + }); + } + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private renderContextMenu = (): React.ReactNode => { if (!this.state.contextMenuPosition) return null; + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + let hostingLink; const signupLink = getHostingLink("user-context-menu"); if (signupLink) { @@ -225,22 +285,137 @@ export default class UserMenu extends React.Component { ); } - return -
+ let primaryHeader = ( +
+ + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
+ ); + let primaryOptionList = ( + + + {homeButton} + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + /> + this.onSettingsOpen(e, USER_SECURITY_TAB)} + /> + this.onSettingsOpen(e, null)} + /> + {/* */} + + + + + + + ); + let secondarySection = null; + + if (prototypeCommunityName) { + primaryHeader = (
- {OwnProfileStore.instance.displayName} - - - {MatrixClientPeg.get().getUserId()} + {prototypeCommunityName}
+ ); + primaryOptionList = ( + + + + + + ); + secondarySection = ( + +
+
+
+ + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
+
+ + this.onSettingsOpen(e, null)} + /> + + + + + +
+ ) + } + + const classes = classNames({ + "mx_UserMenu_contextMenu": true, + "mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName, + }); + + return +
+ {primaryHeader} {
{hostingLink} - - {homeButton} - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} - /> - this.onSettingsOpen(e, USER_SECURITY_TAB)} - /> - this.onSettingsOpen(e, null)} - /> - {/* */} - - - - - + {primaryOptionList} + {secondarySection}
; }; @@ -298,12 +440,34 @@ export default class UserMenu extends React.Component { const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + + let isPrototype = false; + let menuName = _t("User menu"); let name = {displayName}; let buttons = ( {/* masked image in CSS */} ); + if (prototypeCommunityName) { + name = ( +
+ {prototypeCommunityName} + {displayName} +
+ ); + menuName = _t("Community and user menu"); + isPrototype = true; + } else if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + name = ( +
+ {_t("Home")} + {displayName} +
+ ); + isPrototype = true; + } if (this.props.isMinimized) { name = null; buttons = null; @@ -312,6 +476,7 @@ export default class UserMenu extends React.Component { const classes = classNames({ 'mx_UserMenu': true, 'mx_UserMenu_minimized': this.props.isMinimized, + 'mx_UserMenu_prototype': isPrototype, }); return ( @@ -320,7 +485,7 @@ export default class UserMenu extends React.Component { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("User menu")} + label={menuName} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} > diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 4890626527..5d370af341 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -25,8 +25,7 @@ import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {Key} from "../../../Keyboard"; import {privateShouldBeEncrypted} from "../../../createRoom"; -import TagOrderStore from "../../../stores/TagOrderStore"; -import GroupStore from "../../../stores/GroupStore"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; export default createReactClass({ displayName: 'CreateRoomDialog', @@ -72,8 +71,8 @@ export default createReactClass({ opts.encryption = this.state.isEncrypted; } - if (TagOrderStore.getSelectedPrototypeTag()) { - opts.associatedWithCommunity = TagOrderStore.getSelectedPrototypeTag(); + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } return opts; @@ -198,7 +197,7 @@ export default createReactClass({ "Private rooms can be found and joined by invitation only. Public rooms can be " + "found and joined by anyone.", )}

; - if (TagOrderStore.getSelectedPrototypeTag()) { + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { publicPrivateLabel =

{_t( "Private rooms can be found and joined by invitation only. Public rooms can be " + "found and joined by anyone in this community.", @@ -239,9 +238,8 @@ export default createReactClass({ } let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); - if (TagOrderStore.getSelectedPrototypeTag()) { - const summary = GroupStore.getSummary(TagOrderStore.getSelectedPrototypeTag()); - const name = summary?.profile?.name || TagOrderStore.getSelectedPrototypeTag(); + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); title = _t("Create a room in %(communityName)s", {communityName: name}); } return ( diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx new file mode 100644 index 0000000000..3071854b3e --- /dev/null +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -0,0 +1,167 @@ +/* +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, { ChangeEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import FlairStore from "../../../stores/FlairStore"; + +interface IProps extends IDialogProps { + communityId: string; +} + +interface IState { + name: string; + error: string; + busy: boolean; + currentAvatarUrl: string; + avatarFile: File; + avatarPreview: string; +} + +// XXX: This is a lot of duplication from the create dialog, just in a different shape +export default class EditCommunityPrototypeDialog extends React.PureComponent { + private avatarUploadRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + + const profile = CommunityPrototypeStore.instance.getCommunityProfile(props.communityId); + + this.state = { + name: profile?.name || "", + error: null, + busy: false, + avatarFile: null, + avatarPreview: null, + currentAvatarUrl: profile?.avatarUrl, + }; + } + + private onNameChange = (ev: ChangeEvent) => { + this.setState({name: ev.target.value}); + }; + + private onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.state.busy) return; + + // We'll create the community now to see if it's taken, leaving it active in + // the background for the user to look at while they invite people. + this.setState({busy: true}); + try { + let avatarUrl = this.state.currentAvatarUrl || ""; // must be a string for synapse to accept it + if (this.state.avatarFile) { + avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile); + } + + await MatrixClientPeg.get().setGroupProfile(this.props.communityId, { + name: this.state.name, + avatar_url: avatarUrl, + }); + + // ask the flair store to update the profile too + await FlairStore.refreshGroupProfile(MatrixClientPeg.get(), this.props.communityId); + + // we did it, so close the dialog + this.props.onFinished(true); + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t("There was an error updating your community. The server is unable to process your request."), + }); + } + }; + + private onAvatarChanged = (e: ChangeEvent) => { + if (!e.target.files || !e.target.files.length) { + this.setState({avatarFile: null}); + } else { + this.setState({busy: true}); + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (ev: ProgressEvent) => { + this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + }; + reader.readAsDataURL(file); + } + }; + + private onChangeAvatar = () => { + if (this.avatarUploadRef.current) this.avatarUploadRef.current.click(); + }; + + public render() { + let preview = ; + if (!this.state.avatarPreview) { + if (this.state.currentAvatarUrl) { + const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + preview = ; + } else { + preview =

+ } + } + + return ( + +
+
+
+ +
+
+ + {preview} +
+ {_t("Add image (optional)")} + + {_t("An image will help people identify your community.")} + +
+
+ + {_t("Save")} + +
+
+
+ ); + } +} diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 934ed12ac1..80d8f1fc2c 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -37,8 +37,7 @@ import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; import {DefaultTagID} from "../../../stores/room-list/models"; import RoomListStore from "../../../stores/room-list/RoomListStore"; -import TagOrderStore from "../../../stores/TagOrderStore"; -import GroupStore from "../../../stores/GroupStore"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -913,7 +912,7 @@ export default class InviteDialog extends React.PureComponent { _onCommunityInviteClick = (e) => { this.props.onFinished(); - showCommunityInviteDialog(TagOrderStore.getSelectedPrototypeTag()); + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); }; _renderSection(kind: "recents"|"suggestions") { @@ -924,9 +923,8 @@ export default class InviteDialog extends React.PureComponent { let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); let sectionSubname = null; - if (kind === 'suggestions' && TagOrderStore.getSelectedPrototypeTag()) { - const summary = GroupStore.getSummary(TagOrderStore.getSelectedPrototypeTag()); - const communityName = summary?.profile?.name || TagOrderStore.getSelectedPrototypeTag(); + if (kind === 'suggestions' && CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); sectionSubname = _t("May include members not in %(communityName)s", {communityName}); } @@ -1098,9 +1096,8 @@ export default class InviteDialog extends React.PureComponent { return {userId}; }}, ); - if (TagOrderStore.getSelectedPrototypeTag()) { - const communityId = TagOrderStore.getSelectedPrototypeTag(); - const communityName = GroupStore.getSummary(communityId)?.profile?.name || communityId; + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); helpText = _t( "Start a conversation with someone using their name, username (like ) or email address. " + "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click " + diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index e2d7e3f8e0..15b629921c 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -27,6 +27,7 @@ import rate_limited_func from "../../../ratelimitedfunc"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import * as sdk from "../../../index"; import CallHandler from "../../../CallHandler"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -464,10 +465,16 @@ export default createReactClass({ } } + let inviteButtonText = _t("Invite to this room"); + const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); + if (chat && chat.roomId === this.props.roomId) { + inviteButtonText = _t("Invite to this community"); + } + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); inviteButton = - { _t('Invite to this room') } + { inviteButtonText } ; } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index ec8c8d6840..92bbdfeacb 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -45,7 +45,7 @@ import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays"; import { objectShallowClone, objectWithOnly } from "../../../utils/objects"; import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import AccessibleButton from "../elements/AccessibleButton"; -import TagOrderStore from "../../../stores/TagOrderStore"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; interface IProps { onKeyDown: (ev: React.KeyboardEvent) => void; @@ -130,7 +130,7 @@ const TAG_AESTHETICS: { }} /> { return CommunityPrototypeStore.internalInstance; } + public getSelectedCommunityId(): string { + if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + return TagOrderStore.getSelectedTags()[0]; + } + return null; // no selection as far as this function is concerned + } + + public getSelectedCommunityName(): string { + return CommunityPrototypeStore.instance.getCommunityName(this.getSelectedCommunityId()); + } + + public getSelectedCommunityGeneralChat(): Room { + const communityId = this.getSelectedCommunityId(); + if (communityId) { + return this.getGeneralChat(communityId); + } + } + + public getCommunityName(communityId: string): string { + const profile = FlairStore.getGroupProfileCachedFast(this.matrixClient, communityId); + return profile?.name || communityId; + } + + public getCommunityProfile(communityId: string): { name?: string, avatarUrl?: string } { + return FlairStore.getGroupProfileCachedFast(this.matrixClient, communityId); + } + + public getGeneralChat(communityId: string): Room { + const rooms = GroupStore.getGroupRooms(communityId) + .map(r => MatrixClientPeg.get().getRoom(r.roomId)) + .filter(r => !!r); + let chat = rooms.find(r => { + const idState = r.currentState.getStateEvents("im.vector.general_chat", ""); + if (!idState || idState.getContent()['groupId'] !== communityId) return false; + return true; + }); + if (!chat) chat = rooms[0]; + return chat; // can be null + } + protected async onAction(payload: ActionPayload): Promise { if (!this.matrixClient || !SettingsStore.getValue("feature_communities_v2_prototypes")) { return; @@ -71,6 +116,15 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient { if (payload.event_type.startsWith("im.vector.group_info.")) { this.emit(UPDATE_EVENT, payload.event_type.substring("im.vector.group_info.".length)); } + } else if (payload.action === "select_tag") { + // Automatically select the general chat when switching communities + const chat = this.getGeneralChat(payload.tag); + if (chat) { + dis.dispatch({ + action: 'view_room', + room_id: chat.roomId, + }); + } } } diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 94b81c1ba5..53d07d0452 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -148,6 +148,23 @@ class FlairStore extends EventEmitter { }); } + /** + * Gets the profile for the given group if known, otherwise returns null. + * This triggers `getGroupProfileCached` if needed, though the result of the + * call will not be returned by this function. + * @param {MatrixClient} matrixClient The matrix client to use to fetch the profile, if needed. + * @param {string} groupId The group ID to get the profile for. + * @returns {*} The profile if known, otherwise null. + */ + getGroupProfileCachedFast(matrixClient, groupId) { + if (!matrixClient || !groupId) return null; + if (this._groupProfiles[groupId]) { + return this._groupProfiles[groupId]; + } + this.getGroupProfileCached(matrixClient, groupId); + return null; + } + async getGroupProfileCached(matrixClient, groupId) { if (this._groupProfiles[groupId]) { return this._groupProfiles[groupId]; diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js index 2eb35e6dc2..2b72a963b0 100644 --- a/src/stores/TagOrderStore.js +++ b/src/stores/TagOrderStore.js @@ -166,25 +166,6 @@ class TagOrderStore extends Store { selectedTags: newTags, }); - if (!allowMultiple && newTags.length === 1) { - // We're in prototype behaviour: select the general chat for the community - const rooms = GroupStore.getGroupRooms(newTags[0]) - .map(r => MatrixClientPeg.get().getRoom(r.roomId)) - .filter(r => !!r); - let chat = rooms.find(r => { - const idState = r.currentState.getStateEvents("im.vector.general_chat", ""); - if (!idState || idState.getContent()['groupId'] !== newTags[0]) return false; - return true; - }); - if (!chat) chat = rooms[0]; - if (chat) { - dis.dispatch({ - action: 'view_room', - room_id: chat.roomId, - }); - } - } - Analytics.trackEvent('FilterStore', 'select_tag'); } break; @@ -285,13 +266,6 @@ class TagOrderStore extends Store { getSelectedTags() { return this._state.selectedTags; } - - getSelectedPrototypeTag() { - if (SettingsStore.getValue("feature_communities_v2_prototypes")) { - return this.getSelectedTags()[0]; - } - return null; // no selection as far as this function is concerned - } } if (global.singletonTagOrderStore === undefined) {