Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into t3chguy/dpsah/6785.2

This commit is contained in:
Michael Telatynski 2020-09-03 16:07:37 +01:00
commit 368571bcff
31 changed files with 747 additions and 119 deletions

View File

@ -68,6 +68,7 @@
@import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss";
@import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss";
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";

View File

@ -16,9 +16,33 @@ limitations under the License.
.mx_UserMenu { .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; 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 { .mx_UserMenu_headerButtons {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -36,7 +60,7 @@ limitations under the License.
mask-size: contain; mask-size: contain;
mask-repeat: no-repeat; mask-repeat: no-repeat;
background: $primary-fg-color; 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 { .mx_UserMenu_userName {
font-weight: 600; font-weight: 600;
font-size: $font-15px; font-size: $font-15px;
@ -89,6 +135,44 @@ limitations under the License.
.mx_UserMenu_contextMenu { .mx_UserMenu_contextMenu {
width: 247px; 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_IconizedContextMenu .mx_IconizedContextMenu_optionList_red {
.mx_AccessibleButton { .mx_AccessibleButton {
padding-top: 16px; padding-top: 16px;
@ -193,4 +277,12 @@ limitations under the License.
.mx_UserMenu_iconSignOut::before { .mx_UserMenu_iconSignOut::before {
mask-image: url('$(res)/img/element-icons/leave.svg'); 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');
}
} }

View File

@ -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;
}
}
}
}

View File

@ -89,6 +89,13 @@ limitations under the License.
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.mx_InviteDialog_subname {
margin-bottom: 10px;
margin-top: -10px; // HACK: Positioning with margins is bad
font-size: $font-12px;
color: $muted-fg-color;
}
} }
.mx_InviteDialog_roomTile { .mx_InviteDialog_roomTile {
@ -226,3 +233,7 @@ limitations under the License.
.mx_InviteDialog_addressBar { .mx_InviteDialog_addressBar {
margin-right: 45px; margin-right: 45px;
} }
.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link {
padding: 0;
}

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import commonmark from 'commonmark'; import commonmark from 'commonmark';
import escape from 'lodash/escape'; import {escape} from "lodash";
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];

View File

@ -24,6 +24,7 @@ import * as sdk from './';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
/** /**
* Invites multiple addresses to a room * Invites multiple addresses to a room
@ -64,6 +65,16 @@ export function showCommunityRoomInviteDialog(roomId, communityName) {
); );
} }
export function showCommunityInviteDialog(communityId) {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
showCommunityRoomInviteDialog(chat.roomId, name);
} else {
throw new Error("Failed to locate appropriate room to start an invite in");
}
}
/** /**
* Checks if the given MatrixEvent is a valid 3rd party user invite. * Checks if the given MatrixEvent is a valid 3rd party user invite.
* @param {MatrixEvent} event The event to check * @param {MatrixEvent} event The event to check

View File

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import _clamp from 'lodash/clamp'; import {clamp} from "lodash";
export default class SendHistoryManager { export default class SendHistoryManager {
history: Array<HistoryItem> = []; history: Array<HistoryItem> = [];
@ -54,7 +54,7 @@ export default class SendHistoryManager {
} }
getItem(offset: number): ?HistoryItem { getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex]; return this.history[this.currentIndex];
} }
} }

View File

@ -26,8 +26,9 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
// Semantic component for representing a role=menuitem // Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => { export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
const ariaLabel = props["aria-label"] || label;
return ( return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}> <AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
{ children } { children }
</AccessibleButton> </AccessibleButton>
); );

View File

@ -23,7 +23,7 @@ import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher'; import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import _sortBy from 'lodash/sortBy'; import {sortBy} from "lodash";
import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore"; import FlairStore from "../stores/FlairStore";
@ -81,7 +81,7 @@ export default class CommunityProvider extends AutocompleteProvider {
const matchedString = command[0]; const matchedString = command[0];
completions = this.matcher.match(matchedString); completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [ completions = sortBy(completions, [
(c) => score(matchedString, c.groupId), (c) => score(matchedString, c.groupId),
(c) => c.groupId.length, (c) => c.groupId.length,
]).map(({avatarUrl, groupId, name}) => ({ ]).map(({avatarUrl, groupId, name}) => ({

View File

@ -23,8 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher'; import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import {ICompletion, ISelectionRange} from './Autocompleter'; import {ICompletion, ISelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq'; import {uniq, sortBy} from 'lodash';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils'; import { shortcodeToUnicode } from '../HtmlUtils';
import { EMOJI, IEmoji } from '../emoji'; import { EMOJI, IEmoji } from '../emoji';
@ -115,7 +114,7 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
// Finally, sort by original ordering // Finally, sort by original ordering
sorters.push((c) => c._orderBy); sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters); completions = sortBy(uniq(completions), sorters);
completions = completions.map(({shortname}) => { completions = completions.map(({shortname}) => {
const unicode = shortcodeToUnicode(shortname); const unicode = shortcodeToUnicode(shortname);

View File

@ -16,8 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import _at from 'lodash/at'; import {at, uniq} from 'lodash';
import _uniq from 'lodash/uniq';
import {removeHiddenChars} from "matrix-js-sdk/src/utils"; import {removeHiddenChars} from "matrix-js-sdk/src/utils";
interface IOptions<T extends {}> { interface IOptions<T extends {}> {
@ -73,7 +72,7 @@ export default class QueryMatcher<T extends Object> {
// type for their values. We assume that those values who's keys have // type for their values. We assume that those values who's keys have
// been specified will be string. Also, we cannot infer all the // been specified will be string. Also, we cannot infer all the
// types of the keys of the objects at compile. // types of the keys of the objects at compile.
const keyValues = _at<string>(<any>object, this._options.keys); const keyValues = at<string>(<any>object, this._options.keys);
if (this._options.funcs) { if (this._options.funcs) {
for (const f of this._options.funcs) { for (const f of this._options.funcs) {
@ -137,7 +136,7 @@ export default class QueryMatcher<T extends Object> {
}); });
// Now map the keys to the result objects. Also remove any duplicates. // Now map the keys to the result objects. Also remove any duplicates.
return _uniq(matches.map((match) => match.object)); return uniq(matches.map((match) => match.object));
} }
private processQuery(query: string): string { private processQuery(query: string): string {

View File

@ -27,7 +27,7 @@ import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
import { uniqBy, sortBy } from 'lodash'; import {uniqBy, sortBy} from "lodash";
const ROOM_REGEX = /\B#\S*/g; const ROOM_REGEX = /\B#\S*/g;

View File

@ -23,7 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import QueryMatcher from './QueryMatcher'; import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy'; import {sortBy} from 'lodash';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import MatrixEvent from "matrix-js-sdk/src/models/event"; import MatrixEvent from "matrix-js-sdk/src/models/event";
@ -156,7 +156,7 @@ export default class UserProvider extends AutocompleteProvider {
const currentUserId = MatrixClientPeg.get().credentials.userId; const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId); this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }

View File

@ -20,7 +20,7 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Key } from '../../Keyboard'; import { Key } from '../../Keyboard';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { throttle } from 'lodash'; import {throttle} from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton'; import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -42,6 +42,14 @@ import IconizedContextMenu, {
IconizedContextMenuOption, IconizedContextMenuOption,
IconizedContextMenuOptionList, IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu"; } 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 { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -58,6 +66,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private themeWatcherRef: string; private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef(); private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -77,14 +86,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); 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 { private isUserOnDarkTheme(): boolean {
const theme = SettingsStore.getValue("theme"); const theme = SettingsStore.getValue("theme");
if (theme.startsWith("custom-")) { if (theme.startsWith("custom-")) {
@ -189,9 +204,54 @@ export default class UserMenu extends React.Component<IProps, IState> {
defaultDispatcher.dispatch({action: 'view_home_page'}); 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 => { private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null; if (!this.state.contextMenuPosition) return null;
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let hostingLink; let hostingLink;
const signupLink = getHostingLink("user-context-menu"); const signupLink = getHostingLink("user-context-menu");
if (signupLink) { if (signupLink) {
@ -225,14 +285,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
); );
} }
return <IconizedContextMenu let primaryHeader = (
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
className="mx_UserMenu_contextMenu"
>
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name"> <div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName"> <span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName} {OwnProfileStore.instance.displayName}
@ -241,19 +294,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
{MatrixClientPeg.get().getUserId()} {MatrixClientPeg.get().getUserId()}
</span> </span>
</div> </div>
<AccessibleTooltipButton );
className="mx_UserMenu_contextMenu_themeButton" let primaryOptionList = (
onClick={this.onSwitchThemeClick} <React.Fragment>
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg")}
alt={_t("Switch theme")}
width={16}
/>
</AccessibleTooltipButton>
</div>
{hostingLink}
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
{homeButton} {homeButton}
<IconizedContextMenuOption <IconizedContextMenuOption
@ -289,6 +332,105 @@ export default class UserMenu extends React.Component<IProps, IState> {
onClick={this.onSignOutClick} onClick={this.onSignOutClick}
/> />
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
</React.Fragment>
);
let secondarySection = null;
if (prototypeCommunityName) {
primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{prototypeCommunityName}
</span>
</div>
);
primaryOptionList = (
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("Community settings")}
onClick={this.onCommunitySettingsClick}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMembers"
label={_t("Members")}
onClick={this.onCommunityMembersClick}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconInvite"
label={_t("Invite")}
onClick={this.onCommunityInviteClick}
/>
</IconizedContextMenuOptionList>
);
secondarySection = (
<React.Fragment>
<hr />
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
</div>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("User settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</React.Fragment>
)
}
const classes = classNames({
"mx_UserMenu_contextMenu": true,
"mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName,
});
return <IconizedContextMenu
// numerical adjustments to overlap the context menu by just over the width of the
// menu icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 10}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height + 8}
onFinished={this.onCloseMenu}
className={classes}
>
<div className="mx_UserMenu_contextMenu_header">
{primaryHeader}
<AccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg")}
alt={_t("Switch theme")}
width={16}
/>
</AccessibleTooltipButton>
</div>
{hostingLink}
{primaryOptionList}
{secondarySection}
</IconizedContextMenu>; </IconizedContextMenu>;
}; };
@ -298,12 +440,34 @@ export default class UserMenu extends React.Component<IProps, IState> {
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let isPrototype = false;
let menuName = _t("User menu");
let name = <span className="mx_UserMenu_userName">{displayName}</span>; let name = <span className="mx_UserMenu_userName">{displayName}</span>;
let buttons = ( let buttons = (
<span className="mx_UserMenu_headerButtons"> <span className="mx_UserMenu_headerButtons">
{/* masked image in CSS */} {/* masked image in CSS */}
</span> </span>
); );
if (prototypeCommunityName) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
<span className="mx_UserMenu_subUserName">{displayName}</span>
</div>
);
menuName = _t("Community and user menu");
isPrototype = true;
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{_t("Home")}</span>
<span className="mx_UserMenu_subUserName">{displayName}</span>
</div>
);
isPrototype = true;
}
if (this.props.isMinimized) { if (this.props.isMinimized) {
name = null; name = null;
buttons = null; buttons = null;
@ -312,6 +476,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
'mx_UserMenu': true, 'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized, 'mx_UserMenu_minimized': this.props.isMinimized,
'mx_UserMenu_prototype': isPrototype,
}); });
return ( return (
@ -320,7 +485,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
className={classes} className={classes}
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.buttonRef} inputRef={this.buttonRef}
label={_t("User menu")} label={menuName}
isExpanded={!!this.state.contextMenuPosition} isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
> >

View File

@ -25,8 +25,7 @@ import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {privateShouldBeEncrypted} from "../../../createRoom"; import {privateShouldBeEncrypted} from "../../../createRoom";
import TagOrderStore from "../../../stores/TagOrderStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import GroupStore from "../../../stores/GroupStore";
export default createReactClass({ export default createReactClass({
displayName: 'CreateRoomDialog', displayName: 'CreateRoomDialog',
@ -72,8 +71,8 @@ export default createReactClass({
opts.encryption = this.state.isEncrypted; opts.encryption = this.state.isEncrypted;
} }
if (TagOrderStore.getSelectedPrototypeTag()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
opts.associatedWithCommunity = TagOrderStore.getSelectedPrototypeTag(); opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
} }
return opts; return opts;
@ -198,7 +197,7 @@ export default createReactClass({
"Private rooms can be found and joined by invitation only. Public rooms can be " + "Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone.", "found and joined by anyone.",
)}</p>; )}</p>;
if (TagOrderStore.getSelectedPrototypeTag()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
publicPrivateLabel = <p>{_t( publicPrivateLabel = <p>{_t(
"Private rooms can be found and joined by invitation only. Public rooms can be " + "Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone in this community.", "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'); let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
if (TagOrderStore.getSelectedPrototypeTag()) { if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const summary = GroupStore.getSummary(TagOrderStore.getSelectedPrototypeTag()); const name = CommunityPrototypeStore.instance.getSelectedCommunityName();
const name = summary?.profile?.name || TagOrderStore.getSelectedPrototypeTag();
title = _t("Create a room in %(communityName)s", {communityName: name}); title = _t("Create a room in %(communityName)s", {communityName: name});
} }
return ( return (

View File

@ -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<IProps, IState> {
private avatarUploadRef: React.RefObject<HTMLInputElement> = 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<FileReader>) => {
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 = <img src={this.state.avatarPreview} className="mx_EditCommunityPrototypeDialog_avatar" />;
if (!this.state.avatarPreview) {
if (this.state.currentAvatarUrl) {
const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl);
preview = <img src={url} className="mx_EditCommunityPrototypeDialog_avatar" />;
} else {
preview = <div className="mx_EditCommunityPrototypeDialog_placeholderAvatar" />
}
}
return (
<BaseDialog
className="mx_EditCommunityPrototypeDialog"
onFinished={this.props.onFinished}
title={_t("Update community")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="mx_EditCommunityPrototypeDialog_rowName">
<Field
value={this.state.name}
onChange={this.onNameChange}
placeholder={_t("Enter name")}
label={_t("Enter name")}
/>
</div>
<div className="mx_EditCommunityPrototypeDialog_rowAvatar">
<input
type="file" style={{display: "none"}}
ref={this.avatarUploadRef} accept="image/*"
onChange={this.onAvatarChanged}
/>
<AccessibleButton
onClick={this.onChangeAvatar}
className="mx_EditCommunityPrototypeDialog_avatarContainer"
>{preview}</AccessibleButton>
<div className="mx_EditCommunityPrototypeDialog_tip">
<b>{_t("Add image (optional)")}</b>
<span>
{_t("An image will help people identify your community.")}
</span>
</div>
</div>
<AccessibleButton kind="primary" onClick={this.onSubmit} disabled={this.state.busy}>
{_t("Save")}
</AccessibleButton>
</div>
</form>
</BaseDialog>
);
}
}

View File

@ -32,11 +32,12 @@ import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize"; import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom"; import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {DefaultTagID} from "../../../stores/room-list/models"; import {DefaultTagID} from "../../../stores/room-list/models";
import RoomListStore from "../../../stores/room-list/RoomListStore"; import RoomListStore from "../../../stores/room-list/RoomListStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -909,12 +910,23 @@ export default class InviteDialog extends React.PureComponent {
this.props.onFinished(); this.props.onFinished();
}; };
_onCommunityInviteClick = (e) => {
this.props.onFinished();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
};
_renderSection(kind: "recents"|"suggestions") { _renderSection(kind: "recents"|"suggestions") {
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions; let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this);
const lastActive = (m) => kind === 'recents' ? m.lastActive : null; const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
let sectionSubname = null;
if (kind === 'suggestions' && CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
sectionSubname = _t("May include members not in %(communityName)s", {communityName});
}
if (this.props.kind === KIND_INVITE) { if (this.props.kind === KIND_INVITE) {
sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions"); sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions");
@ -993,6 +1005,7 @@ export default class InviteDialog extends React.PureComponent {
return ( return (
<div className='mx_InviteDialog_section'> <div className='mx_InviteDialog_section'>
<h3>{sectionName}</h3> <h3>{sectionName}</h3>
{sectionSubname ? <p className="mx_InviteDialog_subname">{sectionSubname}</p> : null}
{tiles} {tiles}
{showMore} {showMore}
</div> </div>
@ -1083,6 +1096,33 @@ export default class InviteDialog extends React.PureComponent {
return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>; return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>;
}}, }},
); );
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
helpText = _t(
"Start a conversation with someone using their name, username (like <userId/>) or email address. " +
"This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click " +
"<a>here</a>.",
{communityName}, {
userId: () => {
return (
<a
href={makeUserPermalink(userId)}
rel="noreferrer noopener"
target="_blank"
>{userId}</a>
);
},
a: (sub) => {
return (
<AccessibleButton
kind="link"
onClick={this._onCommunityInviteClick}
>{sub}</AccessibleButton>
);
},
},
);
}
buttonText = _t("Go"); buttonText = _t("Go");
goButtonFn = this._startDm; goButtonFn = this._startDm;
} else { // KIND_INVITE } else { // KIND_INVITE

View File

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { debounce } from 'lodash'; import {debounce} from "lodash";
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import PropTypes from "prop-types"; import PropTypes from "prop-types";

View File

@ -17,7 +17,7 @@ limitations under the License.
import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react'; import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { debounce } from 'lodash'; import {debounce} from "lodash";
import {IFieldState, IValidationResult} from "./Validation"; import {IFieldState, IValidationResult} from "./Validation";
// Invoke validation from user input (when typing, etc.) at most once every N ms. // Invoke validation from user input (when typing, etc.) at most once every N ms.

View File

@ -331,8 +331,14 @@ export default class ReplyThread extends React.Component {
{ {
_t('<a>In reply to</a> <pill>', {}, { _t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>, 'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room} 'pill': (
url={makeUserPermalink(ev.getSender())} shouldShowPillAvatar={true} />, <Pill
type={Pill.TYPE_USER_MENTION}
room={room}
url={makeUserPermalink(ev.getSender())}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>
),
}) })
} }
</blockquote>; </blockquote>;

View File

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef, KeyboardEvent} from 'react'; import React, {createRef, KeyboardEvent} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import {flatMap} from "lodash";
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter'; import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
import {Room} from 'matrix-js-sdk/src/models/room'; import {Room} from 'matrix-js-sdk/src/models/room';

View File

@ -27,6 +27,7 @@ import rate_limited_func from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import CallHandler from "../../../CallHandler"; import CallHandler from "../../../CallHandler";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; 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"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton = inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!canInvite}> <AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!canInvite}>
<span>{ _t('Invite to this room') }</span> <span>{ inviteButtonText }</span>
</AccessibleButton>; </AccessibleButton>;
} }

View File

@ -45,7 +45,7 @@ import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects"; import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import TagOrderStore from "../../../stores/TagOrderStore"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
interface IProps { interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void; onKeyDown: (ev: React.KeyboardEvent) => void;
@ -130,7 +130,7 @@ const TAG_AESTHETICS: {
}} }}
/> />
<IconizedContextMenuOption <IconizedContextMenuOption
label={TagOrderStore.getSelectedPrototypeTag() label={CommunityPrototypeStore.instance.getSelectedCommunityId()
? _t("Explore community rooms") ? _t("Explore community rooms")
: _t("Explore public rooms")} : _t("Explore public rooms")}
iconClassName="mx_RoomList_iconExplore" iconClassName="mx_RoomList_iconExplore"

View File

@ -24,6 +24,7 @@ import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
@replaceableComponent("views.settings.BridgeTile") @replaceableComponent("views.settings.BridgeTile")
export default class BridgeTile extends React.PureComponent { export default class BridgeTile extends React.PureComponent {
@ -56,7 +57,7 @@ export default class BridgeTile extends React.PureComponent {
type={Pill.TYPE_USER_MENTION} type={Pill.TYPE_USER_MENTION}
room={this.props.room} room={this.props.room}
url={makeUserPermalink(content.creator)} url={makeUserPermalink(content.creator)}
shouldShowPillAvatar={true} shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>, />,
}); });
} }
@ -66,7 +67,7 @@ export default class BridgeTile extends React.PureComponent {
type={Pill.TYPE_USER_MENTION} type={Pill.TYPE_USER_MENTION}
room={this.props.room} room={this.props.room}
url={makeUserPermalink(this.props.ev.getSender())} url={makeUserPermalink(this.props.ev.getSender())}
shouldShowPillAvatar={true} shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>, />,
}); });

View File

@ -1062,6 +1062,7 @@
"and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...", "and %(count)s others...|one": "and one other...",
"Invite to this room": "Invite to this room", "Invite to this room": "Invite to this room",
"Invite to this community": "Invite to this community",
"Invited": "Invited", "Invited": "Invited",
"Filter room members": "Filter room members", "Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
@ -1423,7 +1424,6 @@
"Submit logs": "Submit logs", "Submit logs": "Submit logs",
"Failed to load group members": "Failed to load group members", "Failed to load group members": "Failed to load group members",
"Filter community members": "Filter community members", "Filter community members": "Filter community members",
"Invite to this community": "Invite to this community",
"Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?", "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?",
"Removing a room from the community will also remove it from the community page.": "Removing a room from the community will also remove it from the community page.", "Removing a room from the community will also remove it from the community page.": "Removing a room from the community will also remove it from the community page.",
"Failed to remove room from community": "Failed to remove room from community", "Failed to remove room from community": "Failed to remove room from community",
@ -1684,6 +1684,8 @@
"Verification Requests": "Verification Requests", "Verification Requests": "Verification Requests",
"Toolbox": "Toolbox", "Toolbox": "Toolbox",
"Developer Tools": "Developer Tools", "Developer Tools": "Developer Tools",
"There was an error updating your community. The server is unable to process your request.": "There was an error updating your community. The server is unable to process your request.",
"Update community": "Update community",
"An error has occurred.": "An error has occurred.", "An error has occurred.": "An error has occurred.",
"Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.",
"Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.", "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.",
@ -1706,9 +1708,11 @@
"The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s",
"Recent Conversations": "Recent Conversations", "Recent Conversations": "Recent Conversations",
"Suggestions": "Suggestions", "Suggestions": "Suggestions",
"May include members not in %(communityName)s": "May include members not in %(communityName)s",
"Recently Direct Messaged": "Recently Direct Messaged", "Recently Direct Messaged": "Recently Direct Messaged",
"Direct Messages": "Direct Messages", "Direct Messages": "Direct Messages",
"Start a conversation with someone using their name, username (like <userId/>) or email address.": "Start a conversation with someone using their name, username (like <userId/>) or email address.", "Start a conversation with someone using their name, username (like <userId/>) or email address.": "Start a conversation with someone using their name, username (like <userId/>) or email address.",
"Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.": "Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.",
"Go": "Go", "Go": "Go",
"Invite someone using their name, username (like <userId/>), email address or <a>share this room</a>.": "Invite someone using their name, username (like <userId/>), email address or <a>share this room</a>.", "Invite someone using their name, username (like <userId/>), email address or <a>share this room</a>.": "Invite someone using their name, username (like <userId/>), email address or <a>share this room</a>.",
"a new master key signature": "a new master key signature", "a new master key signature": "a new master key signature",
@ -2107,14 +2111,18 @@
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Switch to light mode": "Switch to light mode", "Failed to find the general chat for this community": "Failed to find the general chat for this community",
"Switch to dark mode": "Switch to dark mode",
"Switch theme": "Switch theme",
"Notification settings": "Notification settings", "Notification settings": "Notification settings",
"Security & privacy": "Security & privacy", "Security & privacy": "Security & privacy",
"All settings": "All settings", "All settings": "All settings",
"Feedback": "Feedback", "Feedback": "Feedback",
"Community settings": "Community settings",
"User settings": "User settings",
"Switch to light mode": "Switch to light mode",
"Switch to dark mode": "Switch to dark mode",
"Switch theme": "Switch theme",
"User menu": "User menu", "User menu": "User menu",
"Community and user menu": "Community and user menu",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login", "Verify this login": "Verify this login",
"Session verified": "Session verified", "Session verified": "Session verified",

View File

@ -26,7 +26,7 @@ limitations under the License.
* on unmount or similar to cancel any pending update. * on unmount or similar to cancel any pending update.
*/ */
import { throttle } from "lodash"; import {throttle} from "lodash";
export default function ratelimitedfunc(fn, time) { export default function ratelimitedfunc(fn, time) {
const throttledFn = throttle(fn, time, { const throttledFn = throttle(fn, time, {

View File

@ -22,6 +22,11 @@ import { EffectiveMembership, getEffectiveMembership } from "../utils/membership
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import * as utils from "matrix-js-sdk/src/utils"; import * as utils from "matrix-js-sdk/src/utils";
import { UPDATE_EVENT } from "./AsyncStore"; import { UPDATE_EVENT } from "./AsyncStore";
import FlairStore from "./FlairStore";
import TagOrderStore from "./TagOrderStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import GroupStore from "./GroupStore";
import dis from "../dispatcher/dispatcher";
interface IState { interface IState {
// nothing of value - we use account data // nothing of value - we use account data
@ -43,6 +48,46 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient<IState> {
return CommunityPrototypeStore.internalInstance; 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<any> { protected async onAction(payload: ActionPayload): Promise<any> {
if (!this.matrixClient || !SettingsStore.getValue("feature_communities_v2_prototypes")) { if (!this.matrixClient || !SettingsStore.getValue("feature_communities_v2_prototypes")) {
return; return;
@ -71,6 +116,15 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient<IState> {
if (payload.event_type.startsWith("im.vector.group_info.")) { if (payload.event_type.startsWith("im.vector.group_info.")) {
this.emit(UPDATE_EVENT, payload.event_type.substring("im.vector.group_info.".length)); 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,
});
}
} }
} }

View File

@ -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) { async getGroupProfileCached(matrixClient, groupId) {
if (this._groupProfiles[groupId]) { if (this._groupProfiles[groupId]) {
return this._groupProfiles[groupId]; return this._groupProfiles[groupId];

View File

@ -166,25 +166,6 @@ class TagOrderStore extends Store {
selectedTags: newTags, 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'); Analytics.trackEvent('FilterStore', 'select_tag');
} }
break; break;
@ -285,13 +266,6 @@ class TagOrderStore extends Store {
getSelectedTags() { getSelectedTags() {
return this._state.selectedTags; 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) { if (global.singletonTagOrderStore === undefined) {

View File

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import _uniq from 'lodash/uniq'; import {uniq} from "lodash";
import {Room} from "matrix-js-sdk/src/matrix"; import {Room} from "matrix-js-sdk/src/matrix";
/** /**
@ -111,7 +111,7 @@ export default class DMRoomMap {
userToRooms[userId] = [roomId]; userToRooms[userId] = [roomId];
} else { } else {
roomIds.push(roomId); roomIds.push(roomId);
userToRooms[userId] = _uniq(roomIds); userToRooms[userId] = uniq(roomIds);
} }
}); });
return true; return true;