Merge remote-tracking branch 'upstream/develop' into task/dialogs-ts

This commit is contained in:
Šimon Brandner 2021-09-17 18:25:14 +02:00
commit 31e1831f02
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
71 changed files with 1742 additions and 1476 deletions

View file

@ -93,10 +93,10 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.2", "react": "17.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.1.3", "react-blurhash": "^0.1.3",
"react-dom": "^17.0.2", "react-dom": "17.0.2",
"react-focus-lock": "^2.5.0", "react-focus-lock": "^2.5.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
@ -142,9 +142,9 @@
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0", "@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^17.0.2", "@types/react": "17.0.14",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.2", "@types/react-dom": "17.0.9",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.3.1", "@types/sanitize-html": "^2.3.1",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
@ -175,9 +175,12 @@
"stylelint": "^13.9.0", "stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"typescript": "^4.1.3", "typescript": "4.3.5",
"walk": "^2.3.14" "walk": "^2.3.14"
}, },
"resolutions": {
"@types/react": "17.0.14"
},
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [

View file

@ -73,12 +73,6 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation
.mx_GroupFilterPanel .mx_TagTile { .mx_GroupFilterPanel .mx_TagTile {
// opacity: 0.5; // opacity: 0.5;
position: relative; position: relative;
.mx_BetaDot {
position: absolute;
right: -13px;
top: -11px;
}
} }
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype {

View file

@ -103,6 +103,16 @@ $activeBorderColor: $secondary-content;
} }
} }
.mx_SpaceItem_new {
position: relative;
.mx_BetaDot {
position: absolute;
left: 33px;
top: -5px;
}
}
.mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton {
margin-left: $gutterSize; margin-left: $gutterSize;
min-width: 40px; min-width: 40px;
@ -194,23 +204,18 @@ $activeBorderColor: $secondary-content;
} }
&.mx_SpaceButton_new .mx_SpaceButton_icon { &.mx_SpaceButton_new .mx_SpaceButton_icon {
background-color: $accent-color; background-color: $roomlist-button-bg-color;
transition: all .1s ease-in-out; // TODO transition
&::before { &::before {
background-color: #ffffff; background-color: $primary-content;
mask-image: url('$(res)/img/element-icons/plus.svg'); mask-image: url('$(res)/img/element-icons/plus.svg');
transition: all .2s ease-in-out; // TODO transition transition: all .2s ease-in-out; // TODO transition
} }
} }
&.mx_SpaceButton_newCancel .mx_SpaceButton_icon { &.mx_SpaceButton_newCancel .mx_SpaceButton_icon::before {
background-color: $icon-button-color;
&::before {
transform: rotate(45deg); transform: rotate(45deg);
} }
}
.mx_BaseAvatar_image { .mx_BaseAvatar_image {
border-radius: 8px; border-radius: 8px;

View file

@ -215,10 +215,11 @@ $SpaceRoomViewInnerWidth: 428px;
} }
} }
> .mx_BaseAvatar_image, > .mx_RoomAvatar_isSpaceRoom {
> .mx_BaseAvatar > .mx_BaseAvatar_image { &.mx_BaseAvatar_image, .mx_BaseAvatar_image {
border-radius: 12px; border-radius: 12px;
} }
}
h1.mx_SpaceRoomView_preview_name { h1.mx_SpaceRoomView_preview_name {
margin: 20px 0 !important; // override default margin from above margin: 20px 0 !important; // override default margin from above

View file

@ -113,6 +113,7 @@ $dot-size: 12px;
animation: mx_Beta_bluePulse 2s infinite; animation: mx_Beta_bluePulse 2s infinite;
animation-iteration-count: 20; animation-iteration-count: 20;
position: relative; position: relative;
pointer-events: none;
&::after { &::after {
content: ""; content: "";

View file

@ -23,11 +23,11 @@ limitations under the License.
} }
.mx_EventTile[data-layout=bubble] { .mx_EventTile[data-layout=bubble] {
position: relative; position: relative;
margin-top: var(--gutterSize); margin-top: var(--gutterSize);
margin-left: 50px; margin-left: 49px;
margin-right: 100px; margin-right: 100px;
font-size: $font-14px;
&.mx_EventTile_continuation { &.mx_EventTile_continuation {
margin-top: 2px; margin-top: 2px;
@ -77,10 +77,11 @@ limitations under the License.
max-width: 70%; max-width: 70%;
} }
.mx_SenderProfile { > .mx_SenderProfile {
position: relative; position: relative;
top: -2px; top: -2px;
left: 2px; left: 2px;
font-size: $font-15px;
} }
&[data-self=false] { &[data-self=false] {
@ -113,8 +114,6 @@ limitations under the License.
.mx_ReplyTile .mx_SenderProfile { .mx_ReplyTile .mx_SenderProfile {
display: block; display: block;
top: unset;
left: unset;
} }
.mx_ReactionsRow { .mx_ReactionsRow {
@ -287,6 +286,8 @@ limitations under the License.
.mx_EventTile_line, .mx_EventTile_line,
.mx_EventTile_info { .mx_EventTile_info {
min-width: 100%; min-width: 100%;
// Preserve alignment with left edge of text in bubbles
margin: 0;
} }
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
@ -294,9 +295,10 @@ limitations under the License.
} }
.mx_EventTile_line > a { .mx_EventTile_line > a {
// Align timestamps with those of normal bubble tiles
right: auto; right: auto;
top: -15px; top: -11px;
left: -68px; left: -95px;
} }
} }
@ -326,11 +328,10 @@ limitations under the License.
} }
.mx_EventTile_line { .mx_EventTile_line {
margin: 0 5px; margin: 0;
> a { > a {
left: auto; // Align timestamps with those of normal bubble tiles
right: 0; left: -76px;
transform: translateX(calc(100% + 5px));
} }
} }
@ -340,7 +341,8 @@ limitations under the License.
} }
.mx_EventListSummary[data-expanded=false][data-layout=bubble] { .mx_EventListSummary[data-expanded=false][data-layout=bubble] {
padding: 0 34px; // Align with left edge of bubble tiles
padding: 0 49px;
} }
/* events that do not require bubble layout */ /* events that do not require bubble layout */

View file

@ -172,14 +172,12 @@ limitations under the License.
} }
} }
// In the general case, we leave height of headers alone even if sticky, so // In the general case, we reserve space for each sublist header to prevent
// that the sublists below them do not jump. However, that leaves a gap // scroll jumps when they become sticky. However, that leaves a gap when
// when scrolled to the top above the first sublist (whose header can only // scrolled to the top above the first sublist (whose header can only ever
// ever stick to top), so we force height to 0 for only that first header. // stick to top), so we make sure to exclude the first visible sublist.
// See also https://github.com/vector-im/element-web/issues/14429. &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
&:first-child .mx_RoomSublist_headerContainer { height: 24px;
height: 0;
padding-bottom: 4px;
} }
.mx_RoomSublist_resizeBox { .mx_RoomSublist_resizeBox {

View file

@ -21,6 +21,17 @@ limitations under the License.
.mx_SettingsTab_section { .mx_SettingsTab_section {
margin-bottom: 30px; margin-bottom: 30px;
> details {
> summary {
cursor: pointer;
color: $primary-content;
}
& + .mx_SettingsFlag {
margin-top: 20px;
}
}
} }
.mx_PreferencesUserSettingsTab_CommunityMigrator { .mx_PreferencesUserSettingsTab_CommunityMigrator {

0
res/img/betas/.gitkeep Normal file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

View file

@ -574,11 +574,12 @@ async function doSetLoggedIn(
await abortLogin(); await abortLogin();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {

View file

@ -17,8 +17,8 @@ limitations under the License.
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel"; import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events'; import EventEmitter from 'events';
import { MatrixClientPeg } from "./MatrixClientPeg";
// XXX: MediaDeviceKind is a union type, so we make our own enum // XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum { export enum MediaDeviceKindEnum {
@ -74,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
} }
public setAudioOutput(deviceId: string): void { public setAudioOutput(deviceId: string): void {
@ -90,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setAudioInput(deviceId: string): void { public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
} }
/** /**
@ -100,7 +100,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setVideoInput(deviceId: string): void { public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
} }
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {

View file

@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -27,10 +29,11 @@ import SettingsStore from './settings/SettingsStore';
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the * enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`). * `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. * a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. identify the user.
* redact all matrix identifiers in tracking events. * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user
using any identifier that would be consistent across devices.
* - If both flags are false or not set, events are not sent. * - If both flags are false or not set, events are not sent.
*/ */
@ -71,12 +74,6 @@ interface IPageView extends IAnonymousEvent {
}; };
} }
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const whitelistedScreens = new Set([ const whitelistedScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
@ -89,7 +86,6 @@ export async function getRedactedCurrentLocation(
anonymity: Anonymity, anonymity: Anonymity,
): Promise<string> { ): Promise<string> {
// Redact PII from the current location. // Redact PII from the current location.
// If anonymous is true, redact entirely, if false, substitute it with a hash.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii // For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) { if (origin.startsWith('file://')) {
pathname = "/<redacted_file_scheme_url>/"; pathname = "/<redacted_file_scheme_url>/";
@ -99,17 +95,13 @@ export async function getRedactedCurrentLocation(
if (hash == "") { if (hash == "") {
hashStr = ""; hashStr = "";
} else { } else {
let [beforeFirstSlash, screen, ...parts] = hash.split("/"); let [beforeFirstSlash, screen] = hash.split("/");
if (!whitelistedScreens.has(screen)) { if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>"; screen = "<redacted_screen_name>";
} }
for (let i = 0; i < parts.length; i++) { hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
} }
return origin + pathname + hashStr; return origin + pathname + hashStr;
} }
@ -123,15 +115,15 @@ export class PosthogAnalytics {
/* Wrapper for Posthog analytics. /* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity * 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog * - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog * - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed * - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* to Posthog * is passed to posthog.
* *
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
* *
* To pass an event to Posthog: * To pass an event to Posthog:
* *
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. * 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent.
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/ */
@ -141,6 +133,7 @@ export class PosthogAnalytics {
private enabled = false; private enabled = false;
private static _instance = null; private static _instance = null;
private platformSuperProperties = {}; private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this._instance) { if (!this._instance) {
@ -274,9 +267,32 @@ export class PosthogAnalytics {
this.anonymity = anonymity; this.anonymity = anonymity;
} }
public async identifyUser(userId: string): Promise<void> { private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) { if (this.anonymity == Anonymity.Pseudonymous) {
this.posthog.identify(await hashHex(userId)); // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID });
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
console.log("Unable to identify user for tracking" + e.toString());
}
} }
} }
@ -307,18 +323,6 @@ export class PosthogAnalytics {
await this.capture(eventName, properties); await this.capture(eventName, properties);
} }
public async trackRoomEvent<E extends IRoomEvent>(
eventName: E["eventName"],
roomId: string,
properties: Omit<E["properties"], "roomId">,
): Promise<void> {
const updatedProperties = {
...properties,
hashedRoomId: roomId ? await hashHex(roomId) : null,
};
await this.trackPseudonymousEvent(eventName, updatedProperties);
}
public async trackPageView(durationMs: number): Promise<void> { public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash; const hash = window.location.hash;
@ -349,7 +353,7 @@ export class PosthogAnalytics {
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId); await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
} }
} }
} }

View file

@ -48,11 +48,6 @@ export default class Resend {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')'); console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
action: 'message_send_failed',
event: event,
});
}); });
} }

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import request from 'browser-request'; import request from 'browser-request';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
@ -26,38 +25,43 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
import classnames from 'classnames'; import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { ActionPayload } from "../../dispatcher/payloads";
export default class EmbeddedPage extends React.PureComponent { interface IProps {
static propTypes = {
// URL to request embedded page content from // URL to request embedded page content from
url: PropTypes.string, url?: string;
// Class name prefix to apply for a given instance // Class name prefix to apply for a given instance
className: PropTypes.string, className?: string;
// Whether to wrap the page in a scrollbar // Whether to wrap the page in a scrollbar
scrollbar: PropTypes.bool, scrollbar?: boolean;
// Map of keys to replace with values, e.g {$placeholder: "value"} // Map of keys to replace with values, e.g {$placeholder: "value"}
replaceMap: PropTypes.object, replaceMap?: Map<string, string>;
}; }
static contextType = MatrixClientContext; interface IState {
page: string;
}
constructor(props, context) { export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
private unmounted = false;
private dispatcherRef: string = null;
constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context); super(props, context);
this._dispatcherRef = null;
this.state = { this.state = {
page: '', page: '',
}; };
} }
translate(s) { protected translate(s: string): string {
// default implementation - skins may wish to extend this // default implementation - skins may wish to extend this
return sanitizeHtml(_t(s)); return sanitizeHtml(_t(s));
} }
componentDidMount() { public componentDidMount(): void {
this._unmounted = false; this.unmounted = false;
if (!this.props.url) { if (!this.props.url) {
return; return;
@ -70,7 +74,7 @@ export default class EmbeddedPage extends React.PureComponent {
request( request(
{ method: "GET", url: this.props.url }, { method: "GET", url: this.props.url },
(err, response, body) => { (err, response, body) => {
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
@ -92,22 +96,22 @@ export default class EmbeddedPage extends React.PureComponent {
}, },
); );
this._dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
this._unmounted = true; this.unmounted = true;
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef); if (this.dispatcherRef !== null) dis.unregister(this.dispatcherRef);
} }
onAction = (payload) => { private onAction = (payload: ActionPayload): void => {
// HACK: Workaround for the context's MatrixClient not being set up at render time. // HACK: Workaround for the context's MatrixClient not being set up at render time.
if (payload.action === 'client_started') { if (payload.action === 'client_started') {
this.forceUpdate(); this.forceUpdate();
} }
}; };
render() { public render(): JSX.Element {
// HACK: Workaround for the context's MatrixClient not updating. // HACK: Workaround for the context's MatrixClient not updating.
const client = this.context || MatrixClientPeg.get(); const client = this.context || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true; const isGuest = client ? client.isGuest() : true;

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.GenericErrorPage") interface IProps {
export default class GenericErrorPage extends React.PureComponent { title: React.ReactNode;
static propTypes = { message: React.ReactNode;
title: PropTypes.object.isRequired, // jsx for title }
message: PropTypes.object.isRequired, // jsx to display
};
@replaceableComponent("structures.GenericErrorPage")
export default class GenericErrorPage extends React.PureComponent<IProps> {
render() { render() {
return <div className='mx_GenericErrorPage'> return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'> <div className='mx_GenericErrorPage_box'>

View file

@ -146,19 +146,13 @@ class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFil
mx_GroupFilterPanel_items_selected: itemsSelected, mx_GroupFilterPanel_items_selected: itemsSelected,
}); });
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = ( let createButton = (
<ActionButton <ActionButton
tooltip tooltip
label={_t("Communities")} label={_t("Communities")}
action="toggle_my_groups" action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus"> className="mx_TagTile mx_TagTile_plus"
{ betaDot } />
</ActionButton>
); );
if (SettingsStore.getValue("feature_communities_v2_prototypes")) { if (SettingsStore.getValue("feature_communities_v2_prototypes")) {

View file

@ -14,34 +14,39 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { createRef } from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.IndicatorScrollbar") interface IProps {
export default class IndicatorScrollbar extends React.Component {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element. // by the parent element.
trackHorizontalOverflow: PropTypes.bool, trackHorizontalOverflow?: boolean;
// If true, when the user tries to use their mouse wheel in the component it will // If true, when the user tries to use their mouse wheel in the component it will
// scroll horizontally rather than vertically. This should only be used on components // scroll horizontally rather than vertically. This should only be used on components
// with no vertical scroll opportunity. // with no vertical scroll opportunity.
verticalScrollsHorizontally: PropTypes.bool, verticalScrollsHorizontally?: boolean;
};
constructor(props) { children: React.ReactNode;
className: string;
}
interface IState {
leftIndicatorOffset: number | string;
rightIndicatorOffset: number | string;
}
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component<IProps, IState> {
private autoHideScrollbar = createRef<AutoHideScrollbar>();
private scrollElement: HTMLDivElement;
private likelyTrackpadUser: boolean = null;
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
constructor(props: IProps) {
super(props); super(props);
this._collectScroller = this._collectScroller.bind(this);
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this._likelyTrackpadUser = null;
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
this.state = { this.state = {
leftIndicatorOffset: 0, leftIndicatorOffset: 0,
@ -49,30 +54,19 @@ export default class IndicatorScrollbar extends React.Component {
}; };
} }
moveToOrigin() { private collectScroller = (scroller: HTMLDivElement): void => {
if (!this._scrollElement) return; if (scroller && !this.scrollElement) {
this.scrollElement = scroller;
this._scrollElement.scrollLeft = 0;
this._scrollElement.scrollTop = 0;
}
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
// Using the passive option to not block the main thread // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true }); this.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow(); this.checkOverflow();
} }
} };
_collectScrollerComponent(autoHideScrollbar) { public componentDidUpdate(prevProps: IProps): void {
this._autoHideScrollbar = autoHideScrollbar; const prevLen = React.Children.count(prevProps.children);
} const curLen = React.Children.count(this.props.children);
componentDidUpdate(prevProps) {
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
const curLen = this.props.children && this.props.children.length || 0;
// check overflow only if amount of children changes. // check overflow only if amount of children changes.
// if we don't guard here, we end up with an infinite // if we don't guard here, we end up with an infinite
// render > componentDidUpdate > checkOverflow > setState > render loop // render > componentDidUpdate > checkOverflow > setState > render loop
@ -81,62 +75,58 @@ export default class IndicatorScrollbar extends React.Component {
} }
} }
componentDidMount() { public componentDidMount(): void {
this.checkOverflow(); this.checkOverflow();
} }
checkOverflow() { private checkOverflow = (): void => {
const hasTopOverflow = this._scrollElement.scrollTop > 0; const hasTopOverflow = this.scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight > const hasBottomOverflow = this.scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight); (this.scrollElement.scrollTop + this.scrollElement.clientHeight);
const hasLeftOverflow = this._scrollElement.scrollLeft > 0; const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
const hasRightOverflow = this._scrollElement.scrollWidth > const hasRightOverflow = this.scrollElement.scrollWidth >
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth); (this.scrollElement.scrollLeft + this.scrollElement.clientWidth);
if (hasTopOverflow) { if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
} }
if (hasBottomOverflow) { if (hasBottomOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
} }
if (hasLeftOverflow) { if (hasLeftOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
} }
if (hasRightOverflow) { if (hasRightOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow"); this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
} else { } else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow"); this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
} }
if (this.props.trackHorizontalOverflow) { if (this.props.trackHorizontalOverflow) {
this.setState({ this.setState({
// Offset from absolute position of the container // Offset from absolute position of the container
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0', leftIndicatorOffset: hasLeftOverflow ? `${this.scrollElement.scrollLeft}px` : '0',
// Negative because we're coming from the right // Negative because we're coming from the right
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0', rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : '0',
}); });
} }
} };
getScrollTop() { public componentWillUnmount(): void {
return this._autoHideScrollbar.getScrollTop(); if (this.scrollElement) {
} this.scrollElement.removeEventListener("scroll", this.checkOverflow);
componentWillUnmount() {
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
} }
} }
onMouseWheel = (e) => { private onMouseWheel = (e: React.WheelEvent): void => {
if (this.props.verticalScrollsHorizontally && this._scrollElement) { if (this.props.verticalScrollsHorizontally && this.scrollElement) {
// xyThreshold is the amount of horizontal motion required for the component to // xyThreshold is the amount of horizontal motion required for the component to
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in // ignore the vertical delta in a scroll. Used to stop trackpads from acting in
// strange ways. Should be positive. // strange ways. Should be positive.
@ -150,19 +140,19 @@ export default class IndicatorScrollbar extends React.Component {
// for at least the next 1 minute. // for at least the next 1 minute.
const now = new Date().getTime(); const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) { if (Math.abs(e.deltaX) > 0) {
this._likelyTrackpadUser = true; this.likelyTrackpadUser = true;
this._checkAgainForTrackpad = now + (1 * 60 * 1000); this.checkAgainForTrackpad = now + (1 * 60 * 1000);
} else { } else {
// if we haven't seen any horizontal scrolling for a while, assume // if we haven't seen any horizontal scrolling for a while, assume
// the user might have plugged in a mousewheel // the user might have plugged in a mousewheel
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) { if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
this._likelyTrackpadUser = false; this.likelyTrackpadUser = false;
} }
} }
// don't mess with the horizontal scroll for trackpad users // don't mess with the horizontal scroll for trackpad users
// See https://github.com/vector-im/element-web/issues/10005 // See https://github.com/vector-im/element-web/issues/10005
if (this._likelyTrackpadUser) { if (this.likelyTrackpadUser) {
return; return;
} }
@ -178,13 +168,13 @@ export default class IndicatorScrollbar extends React.Component {
// noinspection JSSuspiciousNameCombination // noinspection JSSuspiciousNameCombination
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY; const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
this._scrollElement.scrollLeft += val * yRetention; this.scrollElement.scrollLeft += val * yRetention;
} }
} }
}; };
render() { public render(): JSX.Element {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props; const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset }; const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
@ -195,8 +185,8 @@ export default class IndicatorScrollbar extends React.Component {
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null; ? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar return (<AutoHideScrollbar
ref={this._collectScrollerComponent} ref={this.autoHideScrollbar}
wrappedRef={this._collectScroller} wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel} onWheel={this.onMouseWheel}
{...otherProps} {...otherProps}
> >

View file

@ -0,0 +1,115 @@
/*
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, { useContext } from "react";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
import { linkifyElement } from "../../HtmlUtils";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
interface IProps {
groupId: string;
}
const onSwapClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
};
// XXX: temporary community migration component, reuses SpaceRoomView & SpacePreview classes for simplicity
const LegacyCommunityPreview = ({ groupId }: IProps) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) {
return <main className="mx_SpaceRoomView">
<div className="mx_MainSplit">
<div className="mx_SpaceRoomView_preview">
<Spinner />
</div>
</div>
</main>;
}
let visibilitySection: JSX.Element;
if (groupSummary.profile.is_public) {
visibilitySection = <span className="mx_SpaceRoomView_info_public">
{ _t("Public community") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_info_private">
{ _t("Private community") }
</span>;
}
return <main className="mx_SpaceRoomView">
<ErrorBoundary>
<div className="mx_MainSplit">
<div className="mx_SpaceRoomView_preview">
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
height={80}
width={80}
resizeMethod='crop'
/>
<h1 className="mx_SpaceRoomView_preview_name">
{ groupSummary.profile.name }
</h1>
<div className="mx_SpaceRoomView_info">
{ visibilitySection }
</div>
<div className="mx_SpaceRoomView_preview_topic" ref={e => e && linkifyElement(e)}>
{ groupSummary.profile.short_description }
</div>
<div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ groupSummary.user?.membership === "join"
? _t("To view %(communityName)s, swap to communities in your <a>preferences</a>", {
communityName: groupSummary.profile.name,
}, {
a: sub => (
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
),
})
: _t("To join %(communityName)s, swap to communities in your <a>preferences</a>", {
communityName: groupSummary.profile.name,
}, {
a: sub => (
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
),
})
}
</div>
</div>
</div>
</ErrorBoundary>
</main>;
};
export default LegacyCommunityPreview;

View file

@ -69,6 +69,7 @@ import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel'; import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel'; import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";
import LegacyCommunityPreview from "./LegacyCommunityPreview";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -629,11 +630,15 @@ class LoggedInView extends React.Component<IProps, IState> {
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />; pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
break; break;
case PageTypes.GroupView: case PageTypes.GroupView:
if (SpaceStore.spacesEnabled) {
pageElement = <LegacyCommunityPreview groupId={this.props.currentGroupId} />;
} else {
pageElement = <GroupView pageElement = <GroupView
groupId={this.props.currentGroupId} groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew} isNew={this.props.currentGroupIsNew}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/>; />;
}
break; break;
} }

View file

@ -16,25 +16,35 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { Resizable } from 're-resizable'; import { NumberSize, Resizable } from 're-resizable';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { Direction } from "re-resizable/lib/resizer";
interface IProps {
resizeNotifier: ResizeNotifier;
collapsedRhs?: boolean;
panel?: JSX.Element;
}
@replaceableComponent("structures.MainSplit") @replaceableComponent("structures.MainSplit")
export default class MainSplit extends React.Component { export default class MainSplit extends React.Component<IProps> {
_onResizeStart = () => { private onResizeStart = (): void => {
this.props.resizeNotifier.startResizing(); this.props.resizeNotifier.startResizing();
}; };
_onResize = () => { private onResize = (): void => {
this.props.resizeNotifier.notifyRightHandleResized(); this.props.resizeNotifier.notifyRightHandleResized();
}; };
_onResizeStop = (event, direction, refToElement, delta) => { private onResizeStop = (
event: MouseEvent | TouchEvent, direction: Direction, elementRef: HTMLElement, delta: NumberSize,
): void => {
this.props.resizeNotifier.stopResizing(); this.props.resizeNotifier.stopResizing();
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width); window.localStorage.setItem("mx_rhs_size", (this.loadSidePanelSize().width + delta.width).toString());
}; };
_loadSidePanelSize() { private loadSidePanelSize(): {height: string | number, width: number} {
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10); let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
if (isNaN(rhsSize)) { if (isNaN(rhsSize)) {
@ -47,7 +57,7 @@ export default class MainSplit extends React.Component {
}; };
} }
render() { public render(): JSX.Element {
const bodyView = React.Children.only(this.props.children); const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel; const panelView = this.props.panel;
@ -56,7 +66,7 @@ export default class MainSplit extends React.Component {
let children; let children;
if (hasResizer) { if (hasResizer) {
children = <Resizable children = <Resizable
defaultSize={this._loadSidePanelSize()} defaultSize={this.loadSidePanelSize()}
minWidth={264} minWidth={264}
maxWidth="50%" maxWidth="50%"
enable={{ enable={{
@ -69,9 +79,9 @@ export default class MainSplit extends React.Component {
bottomLeft: false, bottomLeft: false,
topLeft: false, topLeft: false,
}} }}
onResizeStart={this._onResizeStart} onResizeStart={this.onResizeStart}
onResize={this._onResize} onResize={this.onResize}
onResizeStop={this._onResizeStop} onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper" className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }} handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
> >

View file

@ -1800,11 +1800,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action, subAction: params.action,
}); });
} else if (screen.indexOf('group/') === 0) { } else if (screen.indexOf('group/') === 0) {
if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6); const groupId = screen.substring(6);
// TODO: Check valid group ID // TODO: Check valid group ID
@ -1897,15 +1892,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) { onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli) { if (!cli) return;
dis.dispatch({ action: 'message_send_failed' });
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({ action: 'message_sent' }); dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({ action: 'message_send_failed' });
}); });
} }

View file

@ -25,7 +25,6 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups") @replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component { export default class MyGroups extends React.Component {
@ -138,7 +137,6 @@ export default class MyGroups extends React.Component {
</div> </div>
</div>*/ } </div>*/ }
</div> </div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content"> <div className="mx_MyGroups_content">
{ contentHeader } { contentHeader }
{ content } { content }

View file

@ -15,95 +15,110 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../languageHandler'; import { _t, _td } from '../../languageHandler';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import Resend from '../../Resend'; import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils'; import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { EventStatus } from "matrix-js-sdk/src/models/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge"; import NotificationBadge from "../views/rooms/NotificationBadge";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner"; import InlineSpinner from "../views/elements/InlineSpinner";
import { SyncState } from "matrix-js-sdk/src/sync.api";
import { ISyncStateData } from "matrix-js-sdk/src/sync";
import { Room } from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../contexts/MatrixClientContext";
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2; const STATUS_BAR_EXPANDED_LARGE = 2;
export function getUnsentMessages(room) { export function getUnsentMessages(room: Room): MatrixEvent[] {
if (!room) { return []; } if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) { return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
}); });
} }
@replaceableComponent("structures.RoomStatusBar") interface IProps {
export default class RoomStatusBar extends React.PureComponent {
static propTypes = {
// the room this statusbar is representing. // the room this statusbar is representing.
room: PropTypes.object.isRequired, room: Room;
// true if the room is being peeked at. This affects components that shouldn't // true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room. // logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool, isPeeking?: boolean;
// callback for when the user clicks on the 'resend all' button in the // callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onResendAllClick: PropTypes.func, onResendAllClick?: () => void;
// callback for when the user clicks on the 'cancel all' button in the // callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onCancelAllClick: PropTypes.func, onCancelAllClick?: () => void;
// callback for when the user clicks on the 'invite others' button in the // callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar // 'you are alone' bar
onInviteClick: PropTypes.func, onInviteClick?: () => void;
// callback for when we do something that changes the size of the // callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent // status bar. This is used to trigger a re-layout in the parent
// component. // component.
onResize: PropTypes.func, onResize?: () => void;
// callback for when the status bar can be hidden from view, as it is // callback for when the status bar can be hidden from view, as it is
// not displaying anything // not displaying anything
onHidden: PropTypes.func, onHidden?: () => void;
// callback for when the status bar is displaying something and should // callback for when the status bar is displaying something and should
// be visible // be visible
onVisible: PropTypes.func, onVisible?: () => void;
}; }
state = { interface IState {
syncState: MatrixClientPeg.get().getSyncState(), syncState: SyncState;
syncStateData: MatrixClientPeg.get().getSyncStateData(), syncStateData: ISyncStateData;
unsentMessages: MatrixEvent[];
isResending: boolean;
}
@replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
this.state = {
syncState: this.context.getSyncState(),
syncStateData: this.context.getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room), unsentMessages: getUnsentMessages(this.props.room),
isResending: false, isResending: false,
}; };
componentDidMount() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
} }
componentDidUpdate() { public componentDidMount(): void {
this._checkSize(); const client = this.context;
client.on("sync", this.onSyncStateChange);
client.on("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
this.checkSize();
} }
componentWillUnmount() { public componentDidUpdate(): void {
this.checkSize();
}
public componentWillUnmount(): void {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar... // we may have entirely lost our client as we're logging out before clicking login on the guest bar...
const client = MatrixClientPeg.get(); const client = this.context;
if (client) { if (client) {
client.removeListener("sync", this.onSyncStateChange); client.removeListener("sync", this.onSyncStateChange);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated); client.removeListener("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
} }
} }
onSyncStateChange = (state, prevState, data) => { private onSyncStateChange = (state: SyncState, prevState: SyncState, data: ISyncStateData): void => {
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
} }
@ -113,7 +128,7 @@ export default class RoomStatusBar extends React.PureComponent {
}); });
}; };
_onResendAllClick = () => { private onResendAllClick = (): void => {
Resend.resendUnsentEvents(this.props.room).then(() => { Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({ isResending: false }); this.setState({ isResending: false });
}); });
@ -121,12 +136,12 @@ export default class RoomStatusBar extends React.PureComponent {
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onCancelAllClick = () => { private onCancelAllClick = (): void => {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room.roomId !== this.props.room.roomId) return; if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room); const messages = getUnsentMessages(this.props.room);
this.setState({ this.setState({
@ -136,8 +151,8 @@ export default class RoomStatusBar extends React.PureComponent {
}; };
// Check whether current size is greater than 0, if yes call props.onVisible // Check whether current size is greater than 0, if yes call props.onVisible
_checkSize() { private checkSize(): void {
if (this._getSize()) { if (this.getSize()) {
if (this.props.onVisible) this.props.onVisible(); if (this.props.onVisible) this.props.onVisible();
} else { } else {
if (this.props.onHidden) this.props.onHidden(); if (this.props.onHidden) this.props.onHidden();
@ -147,8 +162,8 @@ export default class RoomStatusBar extends React.PureComponent {
// We don't need the actual height - just whether it is likely to have // We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
_getSize() { private getSize(): number {
if (this._shouldShowConnectionError()) { if (this.shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) { } else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
@ -156,7 +171,7 @@ export default class RoomStatusBar extends React.PureComponent {
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
} }
_shouldShowConnectionError() { private shouldShowConnectionError(): boolean {
// no conn bar trumps the "some not sent" msg since you can't resend without // no conn bar trumps the "some not sent" msg since you can't resend without
// a connection! // a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's // There's one situation in which we don't show this 'no connection' bar, and that's
@ -164,12 +179,12 @@ export default class RoomStatusBar extends React.PureComponent {
const errorIsMauError = Boolean( const errorIsMauError = Boolean(
this.state.syncStateData && this.state.syncStateData &&
this.state.syncStateData.error && this.state.syncStateData.error &&
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED', this.state.syncStateData.error.name === 'M_RESOURCE_LIMIT_EXCEEDED',
); );
return this.state.syncState === "ERROR" && !errorIsMauError; return this.state.syncState === "ERROR" && !errorIsMauError;
} }
_getUnsentMessageContent() { private getUnsentMessageContent(): JSX.Element {
const unsentMessages = this.state.unsentMessages; const unsentMessages = this.state.unsentMessages;
let title; let title;
@ -221,10 +236,10 @@ export default class RoomStatusBar extends React.PureComponent {
} }
let buttonRow = <> let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn"> <AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") } { _t("Delete all") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn"> <AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{ _t("Retry all") } { _t("Retry all") }
</AccessibleButton> </AccessibleButton>
</>; </>;
@ -260,8 +275,8 @@ export default class RoomStatusBar extends React.PureComponent {
</>; </>;
} }
render() { public render(): JSX.Element {
if (this._shouldShowConnectionError()) { if (this.shouldShowConnectionError()) {
return ( return (
<div className="mx_RoomStatusBar"> <div className="mx_RoomStatusBar">
<div role="alert"> <div role="alert">
@ -287,7 +302,7 @@ export default class RoomStatusBar extends React.PureComponent {
} }
if (this.state.unsentMessages.length > 0 || this.state.isResending) { if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent(); return this.getUnsentMessageContent();
} }
return null; return null;

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
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';
@ -24,106 +23,116 @@ import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames'; import classNames from 'classnames';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.SearchBox") interface IProps {
export default class SearchBox extends React.Component { onSearch?: (query: string) => void;
static propTypes = { onCleared?: (source?: string) => void;
onSearch: PropTypes.func, onKeyDown?: (ev: React.KeyboardEvent) => void;
onCleared: PropTypes.func, onFocus?: (ev: React.FocusEvent) => void;
onKeyDown: PropTypes.func, onBlur?: (ev: React.FocusEvent) => void;
className: PropTypes.string, className?: string;
placeholder: PropTypes.string.isRequired, placeholder: string;
autoFocus: PropTypes.bool, blurredPlaceholder?: string;
initialValue: PropTypes.string, autoFocus?: boolean;
initialValue?: string;
collapsed?: boolean;
// If true, the search box will focus and clear itself // If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take // on room search focus action (it would be nicer to take
// this functionality out, but not obvious how that would work) // this functionality out, but not obvious how that would work)
enableRoomSearchFocus: PropTypes.bool, enableRoomSearchFocus?: boolean;
}; }
static defaultProps = { interface IState {
searchTerm: string;
blurred: boolean;
}
@replaceableComponent("structures.SearchBox")
export default class SearchBox extends React.Component<IProps, IState> {
private dispatcherRef: string;
private search = createRef<HTMLInputElement>();
static defaultProps: Partial<IProps> = {
enableRoomSearchFocus: false, enableRoomSearchFocus: false,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._search = createRef();
this.state = { this.state = {
searchTerm: this.props.initialValue || "", searchTerm: props.initialValue || "",
blurred: true, blurred: true,
}; };
} }
componentDidMount() { public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
onAction = payload => { private onAction = (payload): void => {
if (!this.props.enableRoomSearchFocus) return; if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) { switch (payload.action) {
case 'view_room': case 'view_room':
if (this._search.current && payload.clear_search) { if (this.search.current && payload.clear_search) {
this._clearSearch(); this.clearSearch();
} }
break; break;
case 'focus_room_filter': case 'focus_room_filter':
if (this._search.current) { if (this.search.current) {
this._search.current.focus(); this.search.current.focus();
} }
break; break;
} }
}; };
onChange = () => { private onChange = (): void => {
if (!this._search.current) return; if (!this.search.current) return;
this.setState({ searchTerm: this._search.current.value }); this.setState({ searchTerm: this.search.current.value });
this.onSearch(); this.onSearch();
}; };
onSearch = throttle(() => { private onSearch = throttle((): void => {
this.props.onSearch(this._search.current.value); this.props.onSearch(this.search.current.value);
}, 200, { trailing: true, leading: true }); }, 200, { trailing: true, leading: true });
_onKeyDown = ev => { private onKeyDown = (ev: React.KeyboardEvent): void => {
switch (ev.key) { switch (ev.key) {
case Key.ESCAPE: case Key.ESCAPE:
this._clearSearch("keyboard"); this.clearSearch("keyboard");
break; break;
} }
if (this.props.onKeyDown) this.props.onKeyDown(ev); if (this.props.onKeyDown) this.props.onKeyDown(ev);
}; };
_onFocus = ev => { private onFocus = (ev: React.FocusEvent): void => {
this.setState({ blurred: false }); this.setState({ blurred: false });
ev.target.select(); (ev.target as HTMLInputElement).select();
if (this.props.onFocus) { if (this.props.onFocus) {
this.props.onFocus(ev); this.props.onFocus(ev);
} }
}; };
_onBlur = ev => { private onBlur = (ev: React.FocusEvent): void => {
this.setState({ blurred: true }); this.setState({ blurred: true });
if (this.props.onBlur) { if (this.props.onBlur) {
this.props.onBlur(ev); this.props.onBlur(ev);
} }
}; };
_clearSearch(source) { private clearSearch(source?: string): void {
this._search.current.value = ""; this.search.current.value = "";
this.onChange(); this.onChange();
if (this.props.onCleared) { if (this.props.onCleared) {
this.props.onCleared(source); this.props.onCleared(source);
} }
} }
render() { public render(): JSX.Element {
// check for collapsed here and // check for collapsed here and
// not at parent so we keep // not at parent so we keep
// searchTerm in our state // searchTerm in our state
@ -136,7 +145,7 @@ export default class SearchBox extends React.Component {
key="button" key="button"
tabIndex={-1} tabIndex={-1}
className="mx_SearchBox_closeButton" className="mx_SearchBox_closeButton"
onClick={() => {this._clearSearch("button"); }} onClick={() => {this.clearSearch("button"); }}
/>) : undefined; />) : undefined;
// show a shorter placeholder when blurred, if requested // show a shorter placeholder when blurred, if requested
@ -151,13 +160,13 @@ export default class SearchBox extends React.Component {
<input <input
key="searchfield" key="searchfield"
type="text" type="text"
ref={this._search} ref={this.search}
className={"mx_textinput_icon mx_textinput_search " + className} className={"mx_textinput_icon mx_textinput_search " + className}
value={this.state.searchTerm} value={this.state.searchTerm}
onFocus={this._onFocus} onFocus={this.onFocus}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this._onKeyDown} onKeyDown={this.onKeyDown}
onBlur={this._onBlur} onBlur={this.onBlur}
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" autoComplete="off"
autoFocus={this.props.autoFocus} autoFocus={this.props.autoFocus}

View file

@ -58,12 +58,19 @@ import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessi
import { getDisplayAliasForRoom } from "./RoomDirectory"; import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../hooks/useEventEmitter";
import { IOOBData } from "../../stores/ThreepidInviteStore";
interface IProps { interface IProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
additionalButtons?: ReactNode; additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void; showRoom(
cli: MatrixClient,
hierarchy: RoomHierarchy,
roomId: string,
autoJoin?: boolean,
roomType?: RoomType,
): void;
} }
interface ITileProps { interface ITileProps {
@ -72,7 +79,7 @@ interface ITileProps {
selected?: boolean; selected?: boolean;
numChildRooms?: number; numChildRooms?: number;
hasPermissions?: boolean; hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void; onViewRoomClick(autoJoin: boolean, roomType: RoomType): void;
onToggleClick?(): void; onToggleClick?(): void;
} }
@ -98,12 +105,12 @@ const Tile: React.FC<ITileProps> = ({
const onPreviewClick = (ev: ButtonEvent) => { const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(false); onViewRoomClick(false, room.room_type as RoomType);
}; };
const onJoinClick = (ev: ButtonEvent) => { const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(true); onViewRoomClick(true, room.room_type as RoomType);
}; };
let button; let button;
@ -280,7 +287,13 @@ const Tile: React.FC<ITileProps> = ({
</li>; </li>;
}; };
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => { export const showRoom = (
cli: MatrixClient,
hierarchy: RoomHierarchy,
roomId: string,
autoJoin = false,
roomType?: RoomType,
) => {
const room = hierarchy.roomMap.get(roomId); const room = hierarchy.roomMap.get(roomId);
// Don't let the user view a room they won't be able to either peek or join: // Don't let the user view a room they won't be able to either peek or join:
@ -305,7 +318,8 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
avatarUrl: room.avatar_url, avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is. // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"), name: room.name || roomAlias || _t("Unnamed room"),
}, roomType,
} as IOOBData,
}); });
}; };
@ -315,7 +329,7 @@ interface IHierarchyLevelProps {
hierarchy: RoomHierarchy; hierarchy: RoomHierarchy;
parents: Set<string>; parents: Set<string>;
selectedMap?: Map<string, Set<string>>; selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void; onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void;
onToggleClick?(parentId: string, childId: string): void; onToggleClick?(parentId: string, childId: string): void;
} }
@ -353,8 +367,8 @@ export const HierarchyLevel = ({
room={room} room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)} suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(room.room_id, autoJoin); onViewRoomClick(room.room_id, autoJoin, roomType);
}} }}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
@ -373,8 +387,8 @@ export const HierarchyLevel = ({
}).length} }).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)} suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin, roomType) => {
onViewRoomClick(space.room_id, autoJoin); onViewRoomClick(space.room_id, autoJoin, roomType);
}} }}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
@ -576,7 +590,7 @@ const SpaceHierarchy = ({
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => { const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set(); if (!rooms?.length) return new Set();
const lcQuery = query.toLowerCase().trim(); const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms); if (!lcQuery) return new Set(rooms);
@ -652,8 +666,8 @@ const SpaceHierarchy = ({
parents={new Set()} parents={new Set()}
selectedMap={selected} selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined} onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => { onViewRoomClick={(roomId, autoJoin, roomType) => {
showRoom(cli, hierarchy, roomId, autoJoin); showRoom(cli, hierarchy, roomId, autoJoin, roomType);
}} }}
/> />
</>; </>;

View file

@ -156,10 +156,10 @@ const SpaceInfo = ({ space }) => {
</div>; </div>;
}; };
const onBetaClick = () => { const onPreferencesClick = () => {
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
initialTabId: UserTab.Labs, initialTabId: UserTab.Preferences,
}); });
}; };
@ -286,15 +286,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
if (!spacesEnabled) { if (!spacesEnabled) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt"> footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join" { myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", { ? _t("To view this Space, hide communities in your <a>preferences</a>", {}, {
spaceName: space.name, a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
}) })
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", { : _t("To join this Space, hide communities in your <a>preferences</a>", {}, {
spaceName: space.name, a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
}) })
} }
</div>; </div>;
@ -731,7 +727,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div> </div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer"> <div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} /> <BetaPill />
{ _t("<b>This is an experimental feature.</b> For now, " + { _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, { "new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>, b: sub => <b>{ sub }</b>,

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src'; import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -46,13 +46,13 @@ export default class ThreadPanel extends React.Component<IProps, IState> {
} }
public componentDidMount(): void { public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived); this.room.on(ThreadEvent.Update, this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived); this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived); this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived); this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived);
} }
private onThreadEventReceived = () => this.updateThreads(); private onThreadEventReceived = () => this.updateThreads();

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src'; import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -99,15 +99,15 @@ export default class ThreadView extends React.Component<IProps, IState> {
thread = new Thread([mxEv], this.props.room, client); thread = new Thread([mxEv], this.props.room, client);
mxEv.setThread(thread); mxEv.setThread(thread);
} }
thread.on("Thread.update", this.updateThread); thread.on(ThreadEvent.Update, this.updateThread);
thread.once("Thread.ready", this.updateThread); thread.once(ThreadEvent.Ready, this.updateThread);
this.updateThread(thread); this.updateThread(thread);
}; };
private teardownThread = () => { private teardownThread = () => {
if (this.state.thread) { if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread); this.state.thread.removeListener(ThreadEvent.Update, this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread); this.state.thread.removeListener(ThreadEvent.Ready, this.updateThread);
} }
}; };

View file

@ -16,52 +16,60 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal'; import Modal from '../../Modal';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import HomePage from "./HomePage"; import HomePage from "./HomePage";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import MainSplit from "./MainSplit";
import RightPanel from "./RightPanel";
import Spinner from "../views/elements/Spinner";
import ResizeNotifier from "../../utils/ResizeNotifier";
interface IProps {
userId?: string;
resizeNotifier: ResizeNotifier;
}
interface IState {
loading: boolean;
member?: RoomMember;
}
@replaceableComponent("structures.UserView") @replaceableComponent("structures.UserView")
export default class UserView extends React.Component { export default class UserView extends React.Component<IProps, IState> {
static get propTypes() { constructor(props: IProps) {
return { super(props);
userId: PropTypes.string, this.state = {
loading: true,
}; };
} }
constructor(props) { public componentDidMount(): void {
super(props);
this.state = {};
}
componentDidMount() {
if (this.props.userId) { if (this.props.userId) {
this._loadProfileInfo(); this.loadProfileInfo();
} }
} }
componentDidUpdate(prevProps) { public componentDidUpdate(prevProps: IProps): void {
// XXX: We shouldn't need to null check the userId here, but we declare // XXX: We shouldn't need to null check the userId here, but we declare
// it as optional and MatrixChat sometimes fires in a way which results // it as optional and MatrixChat sometimes fires in a way which results
// in an NPE when we try to update the profile info. // in an NPE when we try to update the profile info.
if (prevProps.userId !== this.props.userId && this.props.userId) { if (prevProps.userId !== this.props.userId && this.props.userId) {
this._loadProfileInfo(); this.loadProfileInfo();
} }
} }
async _loadProfileInfo() { private async loadProfileInfo(): Promise<void> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
this.setState({ loading: true }); this.setState({ loading: true });
let profileInfo; let profileInfo;
try { try {
profileInfo = await cli.getProfileInfo(this.props.userId); profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) { } catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, { Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'), title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
@ -75,14 +83,11 @@ export default class UserView extends React.Component {
this.setState({ member, loading: false }); this.setState({ member, loading: false });
} }
render() { public render(): JSX.Element {
if (this.state.loading) { if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <Spinner />;
} else if (this.state.member) { } else if (this.state.member?.user) {
const RightPanel = sdk.getComponent('structures.RightPanel'); const panel = <RightPanel user={this.state.member.user} resizeNotifier={this.props.resizeNotifier} />;
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}> return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<HomePage /> <HomePage />
</MainSplit>); </MainSplit>);

View file

@ -17,24 +17,28 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import SyntaxHighlight from "../views/elements/SyntaxHighlight"; import SyntaxHighlight from "../views/elements/SyntaxHighlight";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import * as sdk from "../../index";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
import { canEditContent } from "../../utils/EventUtils"; import { canEditContent } from "../../utils/EventUtils";
import { MatrixClientPeg } from '../../MatrixClientPeg'; import { MatrixClientPeg } from '../../MatrixClientPeg';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import BaseDialog from "../views/dialogs/BaseDialog";
interface IProps extends IDialogProps {
mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu
}
interface IState {
isEditing: boolean;
}
@replaceableComponent("structures.ViewSource") @replaceableComponent("structures.ViewSource")
export default class ViewSource extends React.Component { export default class ViewSource extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
onFinished: PropTypes.func.isRequired,
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -42,19 +46,20 @@ export default class ViewSource extends React.Component {
}; };
} }
onBack() { private onBack(): void {
// TODO: refresh the "Event ID:" modal header // TODO: refresh the "Event ID:" modal header
this.setState({ isEditing: false }); this.setState({ isEditing: false });
} }
onEdit() { private onEdit(): void {
this.setState({ isEditing: true }); this.setState({ isEditing: true });
} }
// returns the dialog body for viewing the event source // returns the dialog body for viewing the event source
viewSourceContent() { private viewSourceContent(): JSX.Element {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted(); const isEncrypted = mxEvent.isEncrypted();
// @ts-ignore
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event; const originalEventSource = mxEvent.event;
@ -86,7 +91,7 @@ export default class ViewSource extends React.Component {
} }
// returns the id of the initial message, not the id of the previous edit // returns the id of the initial message, not the id of the previous edit
getBaseEventId() { private getBaseEventId(): string {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted(); const isEncrypted = mxEvent.isEncrypted();
const baseMxEvent = this.props.mxEvent; const baseMxEvent = this.props.mxEvent;
@ -100,7 +105,7 @@ export default class ViewSource extends React.Component {
} }
// returns the SendCustomEvent component prefilled with the correct details // returns the SendCustomEvent component prefilled with the correct details
editSourceContent() { private editSourceContent(): JSX.Element {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isStateEvent = mxEvent.isState(); const isStateEvent = mxEvent.isState();
@ -159,14 +164,13 @@ export default class ViewSource extends React.Component {
} }
} }
canSendStateEvent(mxEvent) { private canSendStateEvent(mxEvent: MatrixEvent): boolean {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(mxEvent.getRoomId()); const room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
} }
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEditing = this.state.isEditing; const isEditing = this.state.isEditing;

View file

@ -15,43 +15,48 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames'; import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar") @replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component { export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
static propTypes = { public static defaultProps: Partial<IProps> = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
width: 40, width: 40,
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
}; };
private button = createRef<HTMLDivElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
menuDisplayed: false, menuDisplayed: false,
}; };
this._button = createRef();
} }
componentDidMount() { public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
} }
@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get hasStatus() { private get hasStatus(): boolean {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return false; return false;
} }
return !!user._unstable_statusMessage; return !!user.unstable_statusMessage;
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
}); });
}; };
openMenu = () => { private openMenu = (): void => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
}; };
closeMenu = () => { private closeMenu = (): void => {
this.setState({ menuDisplayed: false }); this.setState({ menuDisplayed: false });
}; };
render() { public render(): JSX.Element {
const avatar = <MemberAvatar const avatar = <MemberAvatar
member={this.props.member} member={this.props.member}
width={this.props.width} width={this.props.width}
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect(); const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target const chevronMargin = 1; // Add some spacing away from target
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2} chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace="bottom" chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset} left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin} top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226} menuWidth={226}
onFinished={this.closeMenu} onFinished={this.closeMenu}
> >
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} /> <StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu> </ContextMenu>
); );
} }
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
return <React.Fragment> return <React.Fragment>
<ContextMenuButton <ContextMenuButton
className={classes} className={classes}
inputRef={this._button} inputRef={this.button}
onClick={this.openMenu} onClick={this.openMenu}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
label={_t("User Status")} label={_t("User Status")}

View file

@ -15,45 +15,41 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
/* interface IProps {
* This component can be used to display generic HTML content in a contextual element: React.ReactNode;
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component {
static propTypes = {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized // Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and // This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position. // ensure that it is not displayed in a stale position.
onResize: PropTypes.func, onResize?: () => void;
}; }
constructor(props) { /**
* This component can be used to display generic HTML content in a contextual
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component<IProps> {
constructor(props: IProps) {
super(props); super(props);
this.resize = this.resize.bind(this);
} }
componentDidMount() { public componentDidMount(): void {
this.resize = this.resize.bind(this);
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
} }
componentWillUnmount() { public componentWillUnmount(): void {
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
} }
resize() { private resize = (): void => {
if (this.props.onResize) { if (this.props.onResize) {
this.props.onResize(); this.props.onResize();
} }
} };
render() { public render(): JSX.Element {
return <div>{ this.props.element }</div>; return <div>{ this.props.element }</div>;
} }
} }

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu") interface IProps {
export default class GenericTextContextMenu extends React.Component { message: string;
static propTypes = { }
message: PropTypes.string.isRequired,
};
render() { @replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component<IProps> {
public render(): JSX.Element {
return <div>{ this.props.message }</div>; return <div>{ this.props.message }</div>;
} }
} }

View file

@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { User } from "matrix-js-sdk/src/models/user";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu") @replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component { export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
waiting: false,
}; };
} }
componentDidMount() { public componentDidMount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get comittedStatusMessage() { get comittedStatusMessage(): string {
return this.props.user ? this.props.user._unstable_statusMessage : ""; return this.props.user ? this.props.user.unstable_statusMessage : "";
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onClearClick = (e) => { private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage(""); MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({ this.setState({
waiting: true, waiting: true,
}); });
}; };
_onSubmit = (e) => { private onSubmit = (e: ButtonEvent): void => {
e.preventDefault(); e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({ this.setState({
@ -83,27 +89,25 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onStatusChange = (e) => { private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed. // The input field's value was changed.
this.setState({ this.setState({
message: e.target.value, message: (e.target as HTMLInputElement).value,
}); });
}; };
render() { public render(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
let actionButton; let actionButton;
if (this.comittedStatusMessage) { if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) { if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick} onClick={this.onClearClick}
> >
<span>{ _t("Clear status") }</span> <span>{ _t("Clear status") }</span>
</AccessibleButton>; </AccessibleButton>;
} else { } else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Update status") }</span> <span>{ _t("Update status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit" className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} disabled={!this.state.message}
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Set status") }</span> <span>{ _t("Set status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
let spinner = null; let spinner = null;
if (this.state.waiting) { if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />; spinner = <Spinner w={24} h={24} />;
} }
const form = <form const form = <form
className="mx_StatusMessageContextMenu_form" className="mx_StatusMessageContextMenu_form"
autoComplete="off" autoComplete="off"
onSubmit={this._onSubmit} onSubmit={this.onSubmit}
> >
<input <input
type="text" type="text"
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
key="message" key="message"
placeholder={_t("Set a new status...")} placeholder={_t("Set a new status...")}
autoFocus={true} autoFocus={true}
maxLength="60" maxLength={60}
value={this.state.message} value={this.state.message}
onChange={this._onStatusChange} onChange={this.onStatusChange}
/> />
<div className="mx_StatusMessageContextMenu_actionContainer"> <div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton } { actionButton }

View file

@ -258,7 +258,6 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder} placeholder={filterPlaceholder}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content"> <AutoHideScrollbar className="mx_AddExistingToSpace_content">

View file

@ -243,7 +243,6 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search for rooms or people")} placeholder={_t("Search for rooms or people")}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_ForwardList_content"> <AutoHideScrollbar className="mx_ForwardList_content">

View file

@ -57,7 +57,6 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder} placeholder={filterPlaceholder}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content"> <AutoHideScrollbar className="mx_LeaveSpaceDialog_content">

View file

@ -126,7 +126,6 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")} placeholder={_t("Search spaces")}
onSearch={setQuery} onSearch={setQuery}
autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content"> <AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">

View file

@ -135,7 +135,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
const desc = formatCommaSeparatedList(descs); const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc }); return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
}); });
if (!summaries) { if (!summaries) {

View file

@ -106,31 +106,20 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
} }
const room = this.context.getRoom(mxEvent.getRoomId()); const room = this.context.getRoom(mxEvent.getRoomId());
let label; let label: string;
if (room) { if (room) {
const senders = []; const senders = [];
for (const reactionEvent of reactionEvents) { for (const reactionEvent of reactionEvents) {
const member = room.getMember(reactionEvent.getSender()); const member = room.getMember(reactionEvent.getSender());
const name = member ? member.name : reactionEvent.getSender(); senders.push(member?.name || reactionEvent.getSender());
senders.push(name);
} }
label = _t(
"<reactors/><reactedWith> reacted with %(content)s</reactedWith>", const reactors = formatCommaSeparatedList(senders, 6);
{ if (content) {
content, label = _t("%(reactors)s reacted with %(content)s", { reactors, content });
}, } else {
{ label = reactors;
reactors: () => {
return formatCommaSeparatedList(senders, 6);
},
reactedWith: (sub) => {
if (!content) {
return null;
} }
return sub;
},
},
);
} }
const isPeeking = room.getMyMembership() !== "join"; const isPeeking = room.getMyMembership() !== "join";
return <AccessibleButton return <AccessibleButton

View file

@ -429,7 +429,7 @@ const UserOptionsSection: React.FC<{
if (!isMe) { if (!isMe) {
directMessageButton = ( directMessageButton = (
<AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field"> <AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') } { _t("Message") }
</AccessibleButton> </AccessibleButton>
); );
} }

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace // matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$');
export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
private replaceEmoticon = (caretPosition: DocumentPosition): number => { public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number {
const { model } = this.props; const { model } = this.props;
const range = model.startRange(caretPosition); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition, // expand range max 8 characters backwards from caretPosition,
@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index]; const part = model.parts[index];
n -= 1; n -= 1;
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate); return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
}); });
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); const emoticonMatch = regex.exec(range.text);
if (emoticonMatch) { if (emoticonMatch) {
const query = emoticonMatch[1].replace("-", ""); const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p // try both exact match and lower-case, this means that xd won't match xD but :P will match :p
@ -180,18 +181,23 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) { if (data) {
const { partCreator } = model; const { partCreator } = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const moveStart = emoticonMatch[0][0] === " " ? 1 : 0;
const moveEnd = emoticonMatch[0].length - emoticonMatch.length - moveStart;
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon. // so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space. // Take + 1 because index is reported without the possible preceding space.
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); range.moveStartForwards(emoticonMatch.index + moveStart);
// and move end backwards so that we don't replace the trailing space/newline
range.moveEndBackwards(moveEnd);
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // so the caret position can be adjusted.
return range.replace([partCreator.plain(data.unicode + " ")]); return range.replace([partCreator.plain(data.unicode)]);
}
} }
} }
};
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model); renderModel(this.editorRef.current, this.props.model);
@ -607,8 +613,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}; };
private configureEmoticonAutoReplace = (): void => { private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(this.transform);
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
}; };
private configureShouldShowPillAvatar = (): void => { private configureShouldShowPillAvatar = (): void => {
@ -621,6 +626,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith }); this.setState({ surroundWith });
}; };
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

@ -21,7 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -464,8 +464,8 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
if (SettingsStore.getValue("feature_thread")) { if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread); this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread); this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
} }
} }
@ -1192,7 +1192,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
default: { default: {
const thread = ReplyThread.makeThread( let thread;
// When the "showHiddenEventsInTimeline" lab is enabled,
// avoid showing replies for hidden events (events without tiles)
if (haveTileForEvent(this.props.mxEvent)) {
thread = ReplyThread.makeThread(
this.props.mxEvent, this.props.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -1200,6 +1204,7 @@ export default class EventTile extends React.Component<IProps, IState> {
this.props.layout, this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover, this.props.alwaysShowTimestamps || this.state.hover,
); );
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();

View file

@ -57,7 +57,7 @@ let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps { interface IComposerAvatarProps {
me: object; me: RoomMember;
} }
function ComposerAvatar(props: IComposerAvatarProps) { function ComposerAvatar(props: IComposerAvatarProps) {

View file

@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || []; const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || []; const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical // show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) { if (unfilteredRooms.length < 1 && unfilteredHistorical.length < 1 && unfilteredFavourite.length < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt"> explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div> <div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
<AccessibleButton <AccessibleButton

View file

@ -14,8 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IJoinRuleEventContent, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
@ -27,91 +32,102 @@ import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason"; import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
const MemberEventHtmlReasonField = "io.element.html_reason"; const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({ enum MessageCase {
NotLoggedIn: "NotLoggedIn", NotLoggedIn = "NotLoggedIn",
Joining: "Joining", Joining = "Joining",
Loading: "Loading", Loading = "Loading",
Rejecting: "Rejecting", Rejecting = "Rejecting",
Kicked: "Kicked", Kicked = "Kicked",
Banned: "Banned", Banned = "Banned",
OtherThreePIDError: "OtherThreePIDError", OtherThreePIDError = "OtherThreePIDError",
InvitedEmailNotFoundInAccount: "InvitedEmailNotFoundInAccount", InvitedEmailNotFoundInAccount = "InvitedEmailNotFoundInAccount",
InvitedEmailNoIdentityServer: "InvitedEmailNoIdentityServer", InvitedEmailNoIdentityServer = "InvitedEmailNoIdentityServer",
InvitedEmailMismatch: "InvitedEmailMismatch", InvitedEmailMismatch = "InvitedEmailMismatch",
Invite: "Invite", Invite = "Invite",
ViewingRoom: "ViewingRoom", ViewingRoom = "ViewingRoom",
RoomNotFound: "RoomNotFound", RoomNotFound = "RoomNotFound",
OtherError: "OtherError", OtherError = "OtherError",
}); }
@replaceableComponent("views.rooms.RoomPreviewBar") interface IProps {
export default class RoomPreviewBar extends React.Component {
static propTypes = {
onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room. // if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName // You should also specify onRejectClick if specifying inviterName
inviterName: PropTypes.string, inviterName?: string;
// If invited by 3rd party invite, the email address the invite was sent to // If invited by 3rd party invite, the email address the invite was sent to
invitedEmail: PropTypes.string, invitedEmail?: string;
// For third party invites, information passed about the room out-of-band // For third party invites, information passed about the room out-of-band
oobData: PropTypes.object, oobData?: IOOBData;
// For third party invites, a URL for a 3pid invite signing service // For third party invites, a URL for a 3pid invite signing service
signUrl: PropTypes.string, signUrl?: string;
// A standard client/server API error object. If supplied, indicates that the // A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason. // caller was unable to fetch details about the room for the given reason.
error: PropTypes.object, error?: MatrixError;
canPreview: PropTypes.bool, canPreview?: boolean;
previewLoading: PropTypes.bool, previewLoading?: boolean;
room: PropTypes.object, room?: Room;
// When a spinner is present, a spinnerState can be specified to indicate the loading?: boolean;
// purpose of the spinner. joining?: boolean;
spinner: PropTypes.bool, rejecting?: boolean;
spinnerState: PropTypes.oneOf(["joining"]),
loading: PropTypes.bool,
joining: PropTypes.bool,
rejecting: PropTypes.bool,
// The alias that was used to access this room, if appropriate // The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg. // If given, this will be how the room is referred to (eg.
// in error messages). // in error messages).
roomAlias: PropTypes.string, roomAlias?: string;
};
onJoinClick?(): void;
onRejectClick?(): void;
onRejectAndIgnoreClick?(): void;
onForgetClick?(): void;
}
interface IState {
busy: boolean;
accountEmails?: string[];
invitedEmailMxid?: string;
threePidFetchError?: MatrixError;
}
@replaceableComponent("views.rooms.RoomPreviewBar")
export default class RoomPreviewBar extends React.Component<IProps, IState> {
static defaultProps = { static defaultProps = {
onJoinClick() {}, onJoinClick() {},
}; };
state = { constructor(props) {
super(props);
this.state = {
busy: false, busy: false,
}; };
}
componentDidMount() { componentDidMount() {
this._checkInvitedEmail(); this.checkInvitedEmail();
CommunityPrototypeStore.instance.on(UPDATE_EVENT, this._onCommunityUpdate); CommunityPrototypeStore.instance.on(UPDATE_EVENT, this.onCommunityUpdate);
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) { if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
this._checkInvitedEmail(); this.checkInvitedEmail();
} }
} }
componentWillUnmount() { componentWillUnmount() {
CommunityPrototypeStore.instance.off(UPDATE_EVENT, this._onCommunityUpdate); CommunityPrototypeStore.instance.off(UPDATE_EVENT, this.onCommunityUpdate);
} }
async _checkInvitedEmail() { private async checkInvitedEmail() {
// If this is an invite and we've been told what email address was // If this is an invite and we've been told what email address was
// invited, fetch the user's account emails and discovery bindings so we // invited, fetch the user's account emails and discovery bindings so we
// can check them against the email that was invited. // can check them against the email that was invited.
@ -121,8 +137,7 @@ export default class RoomPreviewBar extends React.Component {
// Gather the account 3PIDs // Gather the account 3PIDs
const account3pids = await MatrixClientPeg.get().getThreePids(); const account3pids = await MatrixClientPeg.get().getThreePids();
this.setState({ this.setState({
accountEmails: account3pids.threepids accountEmails: account3pids.threepids.filter(b => b.medium === 'email').map(b => b.address),
.filter(b => b.medium === 'email').map(b => b.address),
}); });
// If we have an IS connected, use that to lookup the email and // If we have an IS connected, use that to lookup the email and
// check the bound MXID. // check the bound MXID.
@ -146,21 +161,21 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_onCommunityUpdate = (roomId) => { private onCommunityUpdate = (roomId: string): void => {
if (this.props.room && this.props.room.roomId !== roomId) { if (this.props.room && this.props.room.roomId !== roomId) {
return; return;
} }
this.forceUpdate(); // we have nothing to update this.forceUpdate(); // we have nothing to update
}; };
_getMessageCase() { private getMessageCase(): MessageCase {
const isGuest = MatrixClientPeg.get().isGuest(); const isGuest = MatrixClientPeg.get().isGuest();
if (isGuest) { if (isGuest) {
return MessageCase.NotLoggedIn; return MessageCase.NotLoggedIn;
} }
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (myMember) { if (myMember) {
if (myMember.isKicked()) { if (myMember.isKicked()) {
@ -195,7 +210,7 @@ export default class RoomPreviewBar extends React.Component {
} }
return MessageCase.Invite; return MessageCase.Invite;
} else if (this.props.error) { } else if (this.props.error) {
if (this.props.error.errcode == 'M_NOT_FOUND') { if ((this.props.error as MatrixError).errcode == 'M_NOT_FOUND') {
return MessageCase.RoomNotFound; return MessageCase.RoomNotFound;
} else { } else {
return MessageCase.OtherError; return MessageCase.OtherError;
@ -205,8 +220,8 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_getKickOrBanInfo() { private getKickOrBanInfo(): { memberName?: string, reason?: string } {
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (!myMember) { if (!myMember) {
return {}; return {};
} }
@ -219,24 +234,19 @@ export default class RoomPreviewBar extends React.Component {
return { memberName, reason }; return { memberName, reason };
} }
_joinRule() { private joinRule(): JoinRule {
const room = this.props.room; return this.props.room?.currentState
if (room) { .getStateEvents(EventType.RoomJoinRules, "")?.getContent<IJoinRuleEventContent>().join_rule;
const joinRules = room.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
return joinRules.getContent().join_rule;
}
}
} }
_communityProfile() { private communityProfile(): { displayName?: string, avatarMxc?: string } {
if (this.props.room) return CommunityPrototypeStore.instance.getInviteProfile(this.props.room.roomId); if (this.props.room) return CommunityPrototypeStore.instance.getInviteProfile(this.props.room.roomId);
return { displayName: null, avatarMxc: null }; return { displayName: null, avatarMxc: null };
} }
_roomName(atStart = false) { private roomName(atStart = false): string {
let name = this.props.room ? this.props.room.name : this.props.roomAlias; let name = this.props.room ? this.props.room.name : this.props.roomAlias;
const profile = this._communityProfile(); const profile = this.communityProfile();
if (profile.displayName) name = profile.displayName; if (profile.displayName) name = profile.displayName;
if (name) { if (name) {
return name; return name;
@ -247,14 +257,11 @@ export default class RoomPreviewBar extends React.Component {
} }
} }
_getMyMember() { private getMyMember(): RoomMember {
return ( return this.props.room?.getMember(MatrixClientPeg.get().getUserId());
this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId())
);
} }
_getInviteMember() { private getInviteMember(): RoomMember {
const { room } = this.props; const { room } = this.props;
if (!room) { if (!room) {
return; return;
@ -268,8 +275,8 @@ export default class RoomPreviewBar extends React.Component {
return room.currentState.getMember(inviterUserId); return room.currentState.getMember(inviterUserId);
} }
_isDMInvite() { private isDMInvite(): boolean {
const myMember = this._getMyMember(); const myMember = this.getMyMember();
if (!myMember) { if (!myMember) {
return false; return false;
} }
@ -278,7 +285,7 @@ export default class RoomPreviewBar extends React.Component {
return memberContent.membership === "invite" && memberContent.is_direct; return memberContent.membership === "invite" && memberContent.is_direct;
} }
_makeScreenAfterLogin() { private makeScreenAfterLogin(): { screen: string, params: Record<string, any> } {
return { return {
screen: 'room', screen: 'room',
params: { params: {
@ -291,18 +298,16 @@ export default class RoomPreviewBar extends React.Component {
}; };
} }
onLoginClick = () => { private onLoginClick = () => {
dis.dispatch({ action: 'start_login', screenAfterLogin: this._makeScreenAfterLogin() }); dis.dispatch({ action: 'start_login', screenAfterLogin: this.makeScreenAfterLogin() });
}; };
onRegisterClick = () => { private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration', screenAfterLogin: this._makeScreenAfterLogin() }); dis.dispatch({ action: 'start_registration', screenAfterLogin: this.makeScreenAfterLogin() });
}; };
render() { render() {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false; let showSpinner = false;
let title; let title;
@ -315,10 +320,10 @@ export default class RoomPreviewBar extends React.Component {
let footer; let footer;
const extraComponents = []; const extraComponents = [];
const messageCase = this._getMessageCase(); const messageCase = this.getMessageCase();
switch (messageCase) { switch (messageCase) {
case MessageCase.Joining: { case MessageCase.Joining: {
title = _t("Joining room …"); title = this.props.oobData.roomType === RoomType.Space ? _t("Joining space …") : _t("Joining room …");
showSpinner = true; showSpinner = true;
break; break;
} }
@ -349,12 +354,12 @@ export default class RoomPreviewBar extends React.Component {
break; break;
} }
case MessageCase.Kicked: { case MessageCase.Kicked: {
const { memberName, reason } = this._getKickOrBanInfo(); const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were kicked from %(roomName)s by %(memberName)s", title = _t("You were kicked from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() }); { memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
if (this._joinRule() === "invite") { if (this.joinRule() === "invite") {
primaryActionLabel = _t("Forget this room"); primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick; primaryActionHandler = this.props.onForgetClick;
} else { } else {
@ -366,9 +371,9 @@ export default class RoomPreviewBar extends React.Component {
break; break;
} }
case MessageCase.Banned: { case MessageCase.Banned: {
const { memberName, reason } = this._getKickOrBanInfo(); const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were banned from %(roomName)s by %(memberName)s", title = _t("You were banned from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() }); { memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null; subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
primaryActionLabel = _t("Forget this room"); primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick; primaryActionHandler = this.props.onForgetClick;
@ -376,8 +381,8 @@ export default class RoomPreviewBar extends React.Component {
} }
case MessageCase.OtherThreePIDError: { case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to %(roomName)s", title = _t("Something went wrong with your invite to %(roomName)s",
{ roomName: this._roomName() }); { roomName: this.roomName() });
const joinRule = this._joinRule(); const joinRule = this.joinRule();
const errCodeMessage = _t( const errCodeMessage = _t(
"An error (%(errcode)s) was returned while trying to validate your " + "An error (%(errcode)s) was returned while trying to validate your " +
"invite. You could try to pass this information on to a room admin.", "invite. You could try to pass this information on to a room admin.",
@ -410,7 +415,7 @@ export default class RoomPreviewBar extends React.Component {
"This invite to %(roomName)s was sent to %(email)s which is not " + "This invite to %(roomName)s was sent to %(email)s which is not " +
"associated with your account", "associated with your account",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -427,7 +432,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t( title = _t(
"This invite to %(roomName)s was sent to %(email)s", "This invite to %(roomName)s was sent to %(email)s",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -443,7 +448,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t( title = _t(
"This invite to %(roomName)s was sent to %(email)s", "This invite to %(roomName)s was sent to %(email)s",
{ {
roomName: this._roomName(), roomName: this.roomName(),
email: this.props.invitedEmail, email: this.props.invitedEmail,
}, },
); );
@ -458,11 +463,11 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.Invite: { case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar"); const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const oobData = Object.assign({}, this.props.oobData, { const oobData = Object.assign({}, this.props.oobData, {
avatarUrl: this._communityProfile().avatarMxc, avatarUrl: this.communityProfile().avatarMxc,
}); });
const avatar = <RoomAvatar room={this.props.room} oobData={oobData} />; const avatar = <RoomAvatar room={this.props.room} oobData={oobData} />;
const inviteMember = this._getInviteMember(); const inviteMember = this.getInviteMember();
let inviterElement; let inviterElement;
if (inviteMember) { if (inviteMember) {
inviterElement = <span> inviterElement = <span>
@ -474,7 +479,7 @@ export default class RoomPreviewBar extends React.Component {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>); inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>);
} }
const isDM = this._isDMInvite(); const isDM = this.isDMInvite();
if (isDM) { if (isDM) {
title = _t("Do you want to chat with %(user)s?", title = _t("Do you want to chat with %(user)s?",
{ user: inviteMember.name }); { user: inviteMember.name });
@ -485,7 +490,7 @@ export default class RoomPreviewBar extends React.Component {
primaryActionLabel = _t("Start chatting"); primaryActionLabel = _t("Start chatting");
} else { } else {
title = _t("Do you want to join %(roomName)s?", title = _t("Do you want to join %(roomName)s?",
{ roomName: this._roomName() }); { roomName: this.roomName() });
subTitle = [ subTitle = [
avatar, avatar,
_t("<userName/> invited you", {}, { userName: () => inviterElement }), _t("<userName/> invited you", {}, { userName: () => inviterElement }),
@ -519,22 +524,22 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.ViewingRoom: { case MessageCase.ViewingRoom: {
if (this.props.canPreview) { if (this.props.canPreview) {
title = _t("You're previewing %(roomName)s. Want to join it?", title = _t("You're previewing %(roomName)s. Want to join it?",
{ roomName: this._roomName() }); { roomName: this.roomName() });
} else { } else {
title = _t("%(roomName)s can't be previewed. Do you want to join it?", title = _t("%(roomName)s can't be previewed. Do you want to join it?",
{ roomName: this._roomName(true) }); { roomName: this.roomName(true) });
} }
primaryActionLabel = _t("Join the discussion"); primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick; primaryActionHandler = this.props.onJoinClick;
break; break;
} }
case MessageCase.RoomNotFound: { case MessageCase.RoomNotFound: {
title = _t("%(roomName)s does not exist.", { roomName: this._roomName(true) }); title = _t("%(roomName)s does not exist.", { roomName: this.roomName(true) });
subTitle = _t("This room doesn't exist. Are you sure you're at the right place?"); subTitle = _t("This room doesn't exist. Are you sure you're at the right place?");
break; break;
} }
case MessageCase.OtherError: { case MessageCase.OtherError: {
title = _t("%(roomName)s is not accessible at this time.", { roomName: this._roomName(true) }); title = _t("%(roomName)s is not accessible at this time.", { roomName: this.roomName(true) });
subTitle = [ subTitle = [
_t("Try again later, or ask a room admin to check if you have access."), _t("Try again later, or ask a room admin to check if you have access."),
_t( _t(

View file

@ -31,8 +31,8 @@ import {
textSerialize, textSerialize,
unescapeMessage, unescapeMessage,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
public async sendMessage(): Promise<void> { public async sendMessage(): Promise<void> {
if (this.model.isEmpty) { const model = this.model;
if (model.isEmpty) {
return; return;
} }
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent; const replyToEvent = this.props.replyToEvent;
let shouldSend = true; let shouldSend = true;
let content; let content;
if (!containsEmote(this.model) && this.isSlashCommand()) { if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
} }
if (isQuickReaction(this.model)) { if (isQuickReaction(model)) {
shouldSend = false; shouldSend = false;
this.sendQuickReaction(); this.sendQuickReaction();
} }
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room; const { roomId } = this.props.room;
if (!content) { if (!content) {
content = createMessageContent( content = createMessageContent(
this.model, model,
replyToEvent, replyToEvent,
this.props.replyInThread, this.props.replyInThread,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); model.reset([]);
this.editorRef.current?.clearUndoHistory(); this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus(); this.editorRef.current?.focus();
this.clearStoredEditorState(); this.clearStoredEditorState();

View file

@ -28,6 +28,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog"; import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog"; import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade"; import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays"; import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho"; import { useLocalEcho } from "../../../hooks/useLocalEcho";
@ -207,13 +208,37 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
} else if (preferredRestrictionVersion) { } else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable // Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion; const targetVersion = preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
const modal = Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId: room.roomId, roomId: room.roomId,
targetVersion, targetVersion,
description: _t("This upgrade will allow members of selected spaces " + description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."), "access to this room without an invite."),
onFinished: async (resp) => { });
const [resp] = await modal.finished;
if (!resp?.continue) return; if (!resp?.continue) return;
const userId = cli.getUserId();
const unableToUpdateSomeParents = Array.from(SpaceStore.instance.getKnownParents(room.roomId))
.some(roomId => !cli.getRoom(roomId)?.currentState.maySendStateEvent(EventType.SpaceChild, userId));
if (unableToUpdateSomeParents) {
const modal = Modal.createTrackedDialog<[boolean]>('Parent relink warning', '', QuestionDialog, {
title: _t("Before you upgrade"),
description: (
<div>{ _t("This room is in some spaces youre not an admin of. " +
"In those spaces, the old room will still be shown, " +
"but people will be prompted to join the new one.") }</div>
),
hasCancelButton: true,
button: _t("Upgrade anyway"),
danger: true,
});
const [shouldUpgrade] = await modal.finished;
if (!shouldUpgrade) return;
}
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true); const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn(); closeSettingsFn();
// switch to the new room in the background // switch to the new room in the background
@ -226,8 +251,7 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
action: "open_room_settings", action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB, initial_tab_id: ROOM_SECURITY_TAB,
}); });
},
});
return; return;
} }

View file

@ -28,7 +28,6 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import GroupAvatar from "../../../avatars/GroupAvatar"; import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher"; import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions"; import GroupActions from "../../../../../actions/GroupActions";
@ -145,7 +144,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
]; ];
static COMMUNITIES_SETTINGS = [ static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088 "showCommunitiesInsteadOfSpaces",
]; ];
static KEYBINDINGS_SETTINGS = [ static KEYBINDINGS_SETTINGS = [
@ -286,9 +285,17 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
}; };
private renderGroup(settingIds: string[]): React.ReactNodeArray { private renderGroup(
return settingIds.filter(SettingsStore.isEnabled).map(i => { settingIds: string[],
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />; level = SettingLevel.ACCOUNT,
includeDisabled = false,
): React.ReactNodeArray {
if (!includeDisabled) {
settingIds = settingIds.filter(SettingsStore.isEnabled);
}
return settingIds.map(i => {
return <SettingsFlag key={i} name={i} level={level} />;
}); });
} }
@ -334,10 +341,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div> </div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span> <span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT, true) }
</div> } </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Communities") }</span> <span className="mx_SettingsTab_subheading">{ _t("Communities") }</span>
@ -349,7 +356,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p> <p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
<CommunityMigrator onFinished={this.props.closeSettingsFn} /> <CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details> </details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS, SettingLevel.DEVICE) }
</div> </div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">

View file

@ -117,9 +117,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
"Your feedback will help inform the next versions."), "Your feedback will help inform the next versions."),
rageshakeLabel: "spaces-feedback", rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([ rageshakeData: Object.fromEntries([
"feature_spaces.all_rooms", "Spaces.allRoomsInHome",
"feature_spaces.space_member_dms",
"feature_spaces.space_dm_badges",
].map(k => [k, SettingsStore.getValue(k)])), ].map(k => [k, SettingsStore.getValue(k)])),
}); });
}} }}
@ -301,13 +299,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
/> />
<p> <p>
{ _t("You can also create a Space from a <a>community</a>.", {}, { { _t("You can also make Spaces from <a>communities</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}> a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub } { sub }
</AccessibleButton>, </AccessibleButton>,
}) } }) }
<br /> <br />
{ _t("To join an existing space you'll need an invite.") } { _t("To join a space you'll need an invite.") }
</p> </p>
<SpaceFeedbackPrompt onClick={onFinished} /> <SpaceFeedbackPrompt onClick={onFinished} />

View file

@ -151,12 +151,19 @@ const CreateSpaceButton = ({
} }
const onNewClick = menuDisplayed ? closeMenu : () => { const onNewClick = menuDisplayed ? closeMenu : () => {
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpaces", "1");
if (!isPanelCollapsed) setPanelCollapsed(true); if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu(); openMenu();
}; };
let betaDot: JSX.Element;
if (!localStorage.getItem("mx_seenSpaces") && !SpaceStore.instance.spacePanelSpaces.length) {
betaDot = <div className="mx_BetaDot" />;
}
return <li return <li
className={classNames("mx_SpaceItem", { className={classNames("mx_SpaceItem mx_SpaceItem_new", {
"collapsed": isPanelCollapsed, "collapsed": isPanelCollapsed,
})} })}
role="treeitem" role="treeitem"
@ -169,6 +176,7 @@ const CreateSpaceButton = ({
onClick={onNewClick} onClick={onNewClick}
isNarrow={isPanelCollapsed} isNarrow={isPanelCollapsed}
/> />
{ betaDot }
{ contextMenu } { contextMenu }
</li>; </li>;

View file

@ -277,9 +277,13 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.state.screensharing) { if (this.state.screensharing) {
isScreensharing = await this.props.call.setScreensharingEnabled(false); isScreensharing = await this.props.call.setScreensharingEnabled(false);
} else { } else {
if (window.electron?.getDesktopCapturerSources) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source); isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
} }
this.setState({ this.setState({

View file

@ -32,13 +32,20 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA; this._end = bIsLarger ? positionB : positionA;
} }
public moveStart(delta: number): void { public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => { this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1; delta -= 1;
return delta >= 0; return delta >= 0;
}); });
} }
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void { public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);

View file

@ -799,15 +799,6 @@
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
"Change notification settings": "Change notification settings", "Change notification settings": "Change notification settings",
"Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators", "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators",
"Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.",
"Spaces": "Spaces",
"Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.",
"If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.",
"Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta available for web, desktop and Android. Thank you for trying the beta.",
"%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.",
"You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.",
"Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.",
"Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.",
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
"Render LaTeX maths in messages": "Render LaTeX maths in messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
@ -883,6 +874,8 @@
"Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)",
"Show all rooms in Home": "Show all rooms in Home", "Show all rooms in Home": "Show all rooms in Home",
"All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.", "All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.",
"Display Communities instead of Spaces": "Display Communities instead of Spaces",
"Temporarily show communities instead of Spaces for this session. Support for this will be removed in the near future. This will reload Element.": "Temporarily show communities instead of Spaces for this session. Support for this will be removed in the near future. This will reload Element.",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading logs": "Uploading logs", "Uploading logs": "Uploading logs",
@ -1033,14 +1026,15 @@
"e.g. my-space": "e.g. my-space", "e.g. my-space": "e.g. my-space",
"Address": "Address", "Address": "Address",
"Create a space": "Create a space", "Create a space": "Create a space",
"Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.",
"What kind of Space do you want to create?": "What kind of Space do you want to create?", "What kind of Space do you want to create?": "What kind of Space do you want to create?",
"You can change this later.": "You can change this later.", "You can change this later.": "You can change this later.",
"Public": "Public", "Public": "Public",
"Open space for anyone, best for communities": "Open space for anyone, best for communities", "Open space for anyone, best for communities": "Open space for anyone, best for communities",
"Private": "Private", "Private": "Private",
"Invite only, best for yourself or teams": "Invite only, best for yourself or teams", "Invite only, best for yourself or teams": "Invite only, best for yourself or teams",
"You can also create a Space from a <a>community</a>.": "You can also create a Space from a <a>community</a>.", "You can also make Spaces from <a>communities</a>.": "You can also make Spaces from <a>communities</a>.",
"To join an existing space you'll need an invite.": "To join an existing space you'll need an invite.", "To join a space you'll need an invite.": "To join a space you'll need an invite.",
"Go back": "Go back", "Go back": "Go back",
"Your public space": "Your public space", "Your public space": "Your public space",
"Your private space": "Your private space", "Your private space": "Your private space",
@ -1052,6 +1046,7 @@
"Show all rooms": "Show all rooms", "Show all rooms": "Show all rooms",
"All rooms": "All rooms", "All rooms": "All rooms",
"Options": "Options", "Options": "Options",
"Spaces": "Spaces",
"Expand space panel": "Expand space panel", "Expand space panel": "Expand space panel",
"Collapse space panel": "Collapse space panel", "Collapse space panel": "Collapse space panel",
"Click to copy": "Click to copy", "Click to copy": "Click to copy",
@ -1162,6 +1157,9 @@
"Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.", "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
"Space members": "Space members", "Space members": "Space members",
"This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.",
"Before you upgrade": "Before you upgrade",
"This room is in some spaces youre not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "This room is in some spaces youre not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.",
"Upgrade anyway": "Upgrade anyway",
"Message layout": "Message layout", "Message layout": "Message layout",
"IRC": "IRC", "IRC": "IRC",
"Modern": "Modern", "Modern": "Modern",
@ -1662,6 +1660,7 @@
"%(count)s results|other": "%(count)s results", "%(count)s results|other": "%(count)s results",
"%(count)s results|one": "%(count)s result", "%(count)s results|one": "%(count)s result",
"This room": "This room", "This room": "This room",
"Joining space …": "Joining space …",
"Joining room …": "Joining room …", "Joining room …": "Joining room …",
"Loading …": "Loading …", "Loading …": "Loading …",
"Rejecting invite …": "Rejecting invite …", "Rejecting invite …": "Rejecting invite …",
@ -1839,7 +1838,7 @@
"Mention": "Mention", "Mention": "Mention",
"Invite": "Invite", "Invite": "Invite",
"Share Link to User": "Share Link to User", "Share Link to User": "Share Link to User",
"Direct message": "Direct message", "Message": "Message",
"Demote yourself?": "Demote yourself?", "Demote yourself?": "Demote yourself?",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
@ -1978,7 +1977,7 @@
"Add reaction": "Add reaction", "Add reaction": "Add reaction",
"Show all": "Show all", "Show all": "Show all",
"Reactions": "Reactions", "Reactions": "Reactions",
"<reactors/><reactedWith> reacted with %(content)s</reactedWith>": "<reactors/><reactedWith> reacted with %(content)s</reactedWith>", "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>", "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
"Message deleted": "Message deleted", "Message deleted": "Message deleted",
"Message deleted by %(name)s": "Message deleted by %(name)s", "Message deleted by %(name)s": "Message deleted by %(name)s",
@ -2788,6 +2787,10 @@
"Create a Group Chat": "Create a Group Chat", "Create a Group Chat": "Create a Group Chat",
"Upgrade to %(hostSignupBrand)s": "Upgrade to %(hostSignupBrand)s", "Upgrade to %(hostSignupBrand)s": "Upgrade to %(hostSignupBrand)s",
"Open dial pad": "Open dial pad", "Open dial pad": "Open dial pad",
"Public community": "Public community",
"Private community": "Private community",
"To view %(communityName)s, swap to communities in your <a>preferences</a>": "To view %(communityName)s, swap to communities in your <a>preferences</a>",
"To join %(communityName)s, swap to communities in your <a>preferences</a>": "To join %(communityName)s, swap to communities in your <a>preferences</a>",
"Failed to reject invitation": "Failed to reject invitation", "Failed to reject invitation": "Failed to reject invitation",
"Cannot create rooms in this community": "Cannot create rooms in this community", "Cannot create rooms in this community": "Cannot create rooms in this community",
"You do not have permission to create rooms in this community.": "You do not have permission to create rooms in this community.", "You do not have permission to create rooms in this community.": "You do not have permission to create rooms in this community.",
@ -2818,7 +2821,6 @@
"Error whilst fetching joined communities": "Error whilst fetching joined communities", "Error whilst fetching joined communities": "Error whilst fetching joined communities",
"Create a new community": "Create a new community", "Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.", "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"Communities are changing to Spaces": "Communities are changing to Spaces",
"Youre all caught up": "Youre all caught up", "Youre all caught up": "Youre all caught up",
"You have no visible notifications.": "You have no visible notifications.", "You have no visible notifications.": "You have no visible notifications.",
"%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.", "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.",
@ -2885,8 +2887,8 @@
"Search names and descriptions": "Search names and descriptions", "Search names and descriptions": "Search names and descriptions",
"Private space": "Private space", "Private space": "Private space",
"<inviter/> invites you": "<inviter/> invites you", "<inviter/> invites you": "<inviter/> invites you",
"To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>", "To view this Space, hide communities in your <a>preferences</a>": "To view this Space, hide communities in your <a>preferences</a>",
"To join %(spaceName)s, turn on the <a>Spaces beta</a>": "To join %(spaceName)s, turn on the <a>Spaces beta</a>", "To join this Space, hide communities in your <a>preferences</a>": "To join this Space, hide communities in your <a>preferences</a>",
"To view %(spaceName)s, you need an invite": "To view %(spaceName)s, you need an invite", "To view %(spaceName)s, you need an invite": "To view %(spaceName)s, you need an invite",
"Created from <Community />": "Created from <Community />", "Created from <Community />": "Created from <Community />",
"Welcome to <name/>": "Welcome to <name/>", "Welcome to <name/>": "Welcome to <name/>",

View file

@ -16,9 +16,9 @@ limitations under the License.
*/ */
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import React, { ReactNode } from "react"; import { ReactNode } from "react";
import { _t, _td } from '../languageHandler'; import { _td } from '../languageHandler';
import { import {
NotificationBodyEnabledController, NotificationBodyEnabledController,
NotificationsEnabledController, NotificationsEnabledController,
@ -40,7 +40,6 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController";
import { Layout } from "./Layout"; import { Layout } from "./Layout";
import ReducedMotionController from './controllers/ReducedMotionController'; import ReducedMotionController from './controllers/ReducedMotionController';
import IncompatibleController from "./controllers/IncompatibleController"; import IncompatibleController from "./controllers/IncompatibleController";
import SdkConfig from "../SdkConfig";
import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController'; import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController';
import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController'; import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController';
@ -145,44 +144,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
"feature_spaces": {
isFeature: true,
displayName: _td("Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. " +
"Requires compatible homeserver for some features."),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new ReloadOnChangeController(),
betaInfo: {
title: _td("Spaces"),
caption: _td("Spaces are a new way to group rooms and people."),
disclaimer: (enabled) => {
if (enabled) {
return <>
<p>{ _t("If you leave, %(brand)s will reload with Spaces disabled. " +
"Communities and custom tags will be visible again.", {
brand: SdkConfig.get().brand,
}) }</p>
<p>{ _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }</p>
</>;
}
return <>
<p>{ _t("%(brand)s will reload with Spaces enabled. " +
"Communities and custom tags will be hidden.", {
brand: SdkConfig.get().brand,
}) }</p>
<b>{ _t("You can leave the beta any time from settings or tapping on a beta badge, " +
"like the one above.") }</b>
<p>{ _t("Beta available for web, desktop and Android. " +
"Some features may be unavailable on your homeserver.") }</p>
</>;
},
image: require("../../res/img/betas/spaces.png"),
feedbackSubheading: _td("Your feedback will help make spaces better. " +
"The more detail you can go into, the better."),
feedbackLabel: "spaces-feedback",
},
},
"feature_dnd": { "feature_dnd": {
isFeature: true, isFeature: true,
displayName: _td("Show options to enable 'Do not disturb' mode"), displayName: _td("Show options to enable 'Do not disturb' mode"),
@ -203,7 +164,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
), ),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
controller: new IncompatibleController("feature_spaces"), controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", false, false),
}, },
"feature_pinning": { "feature_pinning": {
isFeature: true, isFeature: true,
@ -232,7 +193,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Group & filter rooms by custom tags (refresh to apply changes)"), displayName: _td("Group & filter rooms by custom tags (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
controller: new IncompatibleController("feature_spaces"), controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", false, false),
}, },
"feature_state_counters": { "feature_state_counters": {
isFeature: true, isFeature: true,
@ -780,6 +741,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
description: _td("All rooms you're in will appear in Home."), description: _td("All rooms you're in will appear in Home."),
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: false, default: false,
controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", null),
},
"showCommunitiesInsteadOfSpaces": {
displayName: _td("Display Communities instead of Spaces"),
description: _td("Temporarily show communities instead of Spaces for this session. " +
"Support for this will be removed in the near future. This will reload Element."),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: false,
controller: new ReloadOnChangeController(),
}, },
[UIFeature.RoomHistorySettings]: { [UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
@ -844,7 +814,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
[UIFeature.Communities]: { [UIFeature.Communities]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,
controller: new IncompatibleController("feature_spaces"), controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", false, false),
}, },
[UIFeature.AdvancedSettings]: { [UIFeature.AdvancedSettings]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,

View file

@ -467,6 +467,10 @@ export default class SettingsStore {
throw new Error("Setting '" + settingName + "' does not appear to be a setting."); throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
} }
if (!SettingsStore.isEnabled(settingName)) {
return false;
}
// When non-beta features are specified in the config.json, we force them as enabled or disabled. // When non-beta features are specified in the config.json, we force them as enabled or disabled.
if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) { if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) {
const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true); const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true);

View file

@ -24,7 +24,11 @@ import SettingsStore from "../SettingsStore";
* labs flags. * labs flags.
*/ */
export default class IncompatibleController extends SettingController { export default class IncompatibleController extends SettingController {
public constructor(private settingName: string, private forcedValue = false) { public constructor(
private settingName: string,
private forcedValue: any = false,
private incompatibleValue: any = true,
) {
super(); super();
} }
@ -34,13 +38,17 @@ export default class IncompatibleController extends SettingController {
calculatedValue: any, calculatedValue: any,
calculatedAtLevel: SettingLevel, calculatedAtLevel: SettingLevel,
): any { ): any {
if (this.incompatibleSettingEnabled) { if (this.incompatibleSetting) {
return this.forcedValue; return this.forcedValue;
} }
return null; // no override return null; // no override
} }
public get incompatibleSettingEnabled(): boolean { public get settingDisabled(): boolean {
return SettingsStore.getValue(this.settingName); return this.incompatibleSetting;
}
public get incompatibleSetting(): boolean {
return SettingsStore.getValue(this.settingName) === this.incompatibleValue;
} }
} }

View file

@ -71,7 +71,7 @@ export interface ISuggestedRoom extends IHierarchyRoom {
const MAX_SUGGESTED_ROOMS = 20; const MAX_SUGGESTED_ROOMS = 20;
// This setting causes the page to reload and can be costly if read frequently, so read it here only // This setting causes the page to reload and can be costly if read frequently, so read it here only
const spacesEnabled = SettingsStore.getValue("feature_spaces"); const spacesEnabled = !SettingsStore.getValue("showCommunitiesInsteadOfSpaces");
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`; const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`;
@ -852,10 +852,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
break; break;
case Action.SwitchSpace: case Action.SwitchSpace:
if (payload.num === 0) { // 1 is Home, 2-9 are the spaces after Home
if (payload.num === 1) {
this.setActiveSpace(null); this.setActiveSpace(null);
} else if (this.spacePanelSpaces.length >= payload.num) { } else if (this.spacePanelSpaces.length >= payload.num) {
this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); this.setActiveSpace(this.spacePanelSpaces[payload.num - 2]);
} }
break; break;

View file

@ -16,6 +16,7 @@ limitations under the License.
import EventEmitter from "events"; import EventEmitter from "events";
import { base32 } from "rfc4648"; import { base32 } from "rfc4648";
import { RoomType } from "matrix-js-sdk/src/@types/event";
// Dev note: the interface is split in two so we don't have to disable the // Dev note: the interface is split in two so we don't have to disable the
// linter across the whole project. // linter across the whole project.
@ -53,6 +54,9 @@ export interface IOOBData {
name?: string; // The room's name name?: string; // The room's name
avatarUrl?: string; // The mxc:// avatar URL for the room avatarUrl?: string; // The mxc:// avatar URL for the room
inviterName?: string; // The display name of the person who invited us to the room inviterName?: string; // The display name of the person who invited us to the room
// eslint-disable-next-line camelcase
room_name?: string; // The name of the room, to be used until we are told better by the server
roomType?: RoomType; // The type of the room, to be used until we are told better by the server
} }
const STORAGE_PREFIX = "mx_threepid_invite_"; const STORAGE_PREFIX = "mx_threepid_invite_";

View file

@ -104,7 +104,10 @@ export function getUserNameColorClass(userId: string): string {
* @returns {string} a string constructed by joining `items` with a comma * @returns {string} a string constructed by joining `items` with a comma
* between each item, but with the last item appended as " and [lastItem]". * between each item, but with the last item appended as " and [lastItem]".
*/ */
export function formatCommaSeparatedList(items: Array<string | JSX.Element>, itemLimit?: number): string | JSX.Element { export function formatCommaSeparatedList(items: string[], itemLimit?: number): string;
export function formatCommaSeparatedList(items: JSX.Element[], itemLimit?: number): JSX.Element;
export function formatCommaSeparatedList(items: Array<JSX.Element | string>, itemLimit?: number): JSX.Element | string;
export function formatCommaSeparatedList(items: Array<JSX.Element | string>, itemLimit?: number): JSX.Element | string {
const remaining = itemLimit === undefined ? 0 : Math.max( const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0, items.length - itemLimit, 0,
); );
@ -112,11 +115,25 @@ export function formatCommaSeparatedList(items: Array<string | JSX.Element>, ite
return ""; return "";
} else if (items.length === 1) { } else if (items.length === 1) {
return items[0]; return items[0];
} else if (remaining > 0) {
items = items.slice(0, itemLimit);
return _t("%(items)s and %(count)s others", { items: jsxJoin(items, ', '), count: remaining } );
} else { } else {
const lastItem = items.pop(); let lastItem;
return _t("%(items)s and %(lastItem)s", { items: jsxJoin(items, ', '), lastItem: lastItem }); if (remaining > 0) {
items = items.slice(0, itemLimit);
} else {
lastItem = items.pop();
}
let joinedItems;
if (items.every(e => typeof e === "string")) {
joinedItems = items.join(", ");
} else {
joinedItems = jsxJoin(items, ", ");
}
if (remaining > 0) {
return _t("%(items)s and %(count)s others", { items: joinedItems, count: remaining } );
} else {
return _t("%(items)s and %(lastItem)s", { items: joinedItems, lastItem });
}
} }
} }

View file

@ -136,18 +136,6 @@ describe("PosthogAnalytics", () => {
expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar");
}); });
it("Should pass trackRoomEvent to posthog", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
const roomId = "42";
await analytics.trackRoomEvent<IRoomEvent>("jest_test_event", roomId, {
foo: "bar",
});
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar");
expect(fakePosthog.capture.mock.calls[0][1]["hashedRoomId"])
.toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049");
});
it("Should pass trackPseudonymousEvent() to posthog", async () => { it("Should pass trackPseudonymousEvent() to posthog", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous); analytics.setAnonymity(Anonymity.Pseudonymous);
await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_pseudo_event", { await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_pseudo_event", {
@ -173,9 +161,6 @@ describe("PosthogAnalytics", () => {
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", { await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
foo: "bar", foo: "bar",
}); });
await analytics.trackRoomEvent<ITestRoomEvent>("room id", "foo", {
foo: "bar",
});
await analytics.trackPageView(200); await analytics.trackPageView(200);
expect(fakePosthog.capture.mock.calls.length).toBe(0); expect(fakePosthog.capture.mock.calls.length).toBe(0);
}); });
@ -183,31 +168,25 @@ describe("PosthogAnalytics", () => {
it("Should pseudonymise a location of a known screen", async () => { it("Should pseudonymise a location of a known screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous);
expect(location).toBe( expect(location).toBe("https://foo.bar/#/register/<redacted>");
`https://foo.bar/#/register/\
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
}); });
it("Should anonymise a location of a known screen", async () => { it("Should anonymise a location of a known screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous);
expect(location).toBe("https://foo.bar/#/register/<redacted>/<redacted>"); expect(location).toBe("https://foo.bar/#/register/<redacted>");
}); });
it("Should pseudonymise a location of an unknown screen", async () => { it("Should pseudonymise a location of an unknown screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous);
expect(location).toBe( expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
`https://foo.bar/#/<redacted_screen_name>/\
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
}); });
it("Should anonymise a location of an unknown screen", async () => { it("Should anonymise a location of an unknown screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous);
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>/<redacted>"); expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
}); });
it("Should handle an empty hash", async () => { it("Should handle an empty hash", async () => {
@ -218,15 +197,28 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
it("Should identify the user to posthog if pseudonymous", async () => { it("Should identify the user to posthog if pseudonymous", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous); analytics.setAnonymity(Anonymity.Pseudonymous);
await analytics.identifyUser("foo"); class FakeClient {
expect(fakePosthog.identify.mock.calls[0][0]) getAccountDataFromServer = jest.fn().mockResolvedValue(null);
.toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("analytics_id");
}); });
it("Should not identify the user to posthog if anonymous", async () => { it("Should not identify the user to posthog if anonymous", async () => {
analytics.setAnonymity(Anonymity.Anonymous); analytics.setAnonymity(Anonymity.Anonymous);
await analytics.identifyUser("foo"); await analytics.identifyUser(null);
expect(fakePosthog.identify.mock.calls.length).toBe(0); expect(fakePosthog.identify.mock.calls.length).toBe(0);
}); });
it("Should identify using the server's analytics id if present", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
class FakeClient {
getAccountDataFromServer = jest.fn().mockResolvedValue({ id: "existing_analytics_id" });
setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "new_analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("existing_analytics_id");
});
}); });
}); });

View file

@ -9,23 +9,16 @@ import sdk from '../../../skinned-sdk';
import dis from '../../../../src/dispatcher/dispatcher'; import dis from '../../../../src/dispatcher/dispatcher';
import DMRoomMap from '../../../../src/utils/DMRoomMap'; import DMRoomMap from '../../../../src/utils/DMRoomMap';
import GroupStore from '../../../../src/stores/GroupStore';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { DefaultTagID } from "../../../../src/stores/room-list/models"; import { DefaultTagID } from "../../../../src/stores/room-list/models";
import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore";
function generateRoomId() { function generateRoomId() {
return '!' + Math.random().toString().slice(2, 10) + ':domain'; return '!' + Math.random().toString().slice(2, 10) + ':domain';
} }
function waitForRoomListStoreUpdate() {
return new Promise((resolve) => {
RoomListStore.instance.once(LISTS_UPDATE_EVENT, () => resolve());
});
}
describe('RoomList', () => { describe('RoomList', () => {
function createRoom(opts) { function createRoom(opts) {
const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), {
@ -239,73 +232,6 @@ describe('RoomList', () => {
}); });
} }
describe('when no tags are selected', () => {
itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); itDoesCorrectOptimisticUpdatesForDraggedRoomTiles();
});
describe('when tags are selected', () => {
function setupSelectedTag() {
// Simulate a complete sync BEFORE dispatching anything else
dis.dispatch({
action: 'MatrixActions.sync',
prevState: null,
state: 'PREPARED',
matrixClient: client,
}, true);
// Simulate joined groups being received
dis.dispatch({
action: 'GroupActions.fetchJoinedGroups.success',
result: {
groups: ['+group:domain'],
},
}, true);
// Simulate receiving tag ordering account data
dis.dispatch({
action: 'MatrixActions.accountData',
event_type: 'im.vector.web.tag_ordering',
event_content: {
tags: ['+group:domain'],
},
}, true);
// GroupStore is not flux, mock and notify
GroupStore.getGroupRooms = (groupId) => {
return [movingRoom];
};
GroupStore._notifyListeners();
// We also have to mock the client's getGroup function for the room list to filter it.
// It's not smart enough to tell the difference between a real group and a template though.
client.getGroup = (groupId) => {
return { groupId };
};
// Select tag
dis.dispatch({ action: 'select_tag', tag: '+group:domain' }, true);
}
beforeEach(() => {
setupSelectedTag();
});
it('displays the correct rooms when the groups rooms are changed', async () => {
GroupStore.getGroupRooms = (groupId) => {
return [movingRoom, otherRoom];
};
GroupStore._notifyListeners();
await waitForRoomListStoreUpdate();
// XXX: Even though the store updated, it can take a bit before the update makes
// it to the components. This gives it plenty of time to figure out what to do.
await (new Promise(resolve => setTimeout(resolve, 500)));
expectRoomInSubList(otherRoom, (s) => s.props.tagId === DefaultTagID.Untagged);
});
itDoesCorrectOptimisticUpdatesForDraggedRoomTiles();
});
}); });

View file

@ -43,6 +43,9 @@ module.exports = async function scenario(createSession, restCreator) {
console.log("create REST users:"); console.log("create REST users:");
const charlies = await createRestUsers(restCreator); const charlies = await createRestUsers(restCreator);
await lazyLoadingScenarios(alice, bob, charlies); await lazyLoadingScenarios(alice, bob, charlies);
// do spaces scenarios last as the rest of the tests may get confused by spaces
// XXX: disabled for now as fails in CI but succeeds locally
// await spacesScenarios(alice, bob);
}; };
async function createRestUsers(restCreator) { async function createRestUsers(restCreator) {

View file

@ -0,0 +1,32 @@
/*
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.
*/
const { createSpace, inviteSpace } = require("../usecases/create-space");
module.exports = async function spacesScenarios(alice, bob) {
console.log(" creating a space for spaces scenarios:");
await alice.delay(1000); // wait for dialogs to close
await setupSpaceUsingAliceAndInviteBob(alice, bob);
};
const space = "Test Space";
async function setupSpaceUsingAliceAndInviteBob(alice, bob) {
await createSpace(alice, space);
await inviteSpace(alice, space, "@bob:localhost");
await bob.query(`.mx_SpaceButton[aria-label="${space}"]`); // assert invite received
}

View file

@ -0,0 +1,80 @@
/*
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.
*/
async function openSpaceCreateMenu(session) {
const spaceCreateButton = await session.query('.mx_SpaceButton_new');
await spaceCreateButton.click();
}
async function createSpace(session, name, isPublic = false) {
session.log.step(`creates space "${name}"`);
await openSpaceCreateMenu(session);
const className = isPublic ? ".mx_SpaceCreateMenuType_public" : ".mx_SpaceCreateMenuType_private";
const visibilityButton = await session.query(className);
await visibilityButton.click();
const nameInput = await session.query('input[name="spaceName"]');
await session.replaceInputText(nameInput, name);
await session.delay(100);
const createButton = await session.query('.mx_SpaceCreateMenu_wrapper .mx_AccessibleButton_kind_primary');
await createButton.click();
if (!isPublic) {
const justMeButton = await session.query('.mx_SpaceRoomView_privateScope_justMeButton');
await justMeButton.click();
const continueButton = await session.query('.mx_AddExistingToSpace_footer .mx_AccessibleButton_kind_primary');
await continueButton.click();
} else {
for (let i = 0; i < 2; i++) {
const continueButton = await session.query('.mx_SpaceRoomView_buttons .mx_AccessibleButton_kind_primary');
await continueButton.click();
}
}
session.log.done();
}
async function inviteSpace(session, spaceName, userId) {
session.log.step(`invites "${userId}" to space "${spaceName}"`);
const spaceButton = await session.query(`.mx_SpaceButton[aria-label="${spaceName}"]`);
await spaceButton.click({
button: 'right',
});
const inviteButton = await session.query('[aria-label="Invite people"]');
await inviteButton.click();
try {
const button = await session.query('.mx_SpacePublicShare_inviteButton');
await button.click();
} catch (e) {
// ignore
}
const inviteTextArea = await session.query(".mx_InviteDialog_editor input");
await inviteTextArea.type(userId);
const selectUserItem = await session.query(".mx_InviteDialog_roomTile");
await selectUserItem.click();
const confirmButton = await session.query(".mx_InviteDialog_goButton");
await confirmButton.click();
session.log.done();
}
module.exports = { openSpaceCreateMenu, createSpace, inviteSpace };

View file

@ -161,8 +161,8 @@ async function changeRoomSettings(session, settings) {
if (settings.visibility) { if (settings.visibility) {
session.log.step(`sets visibility to ${settings.visibility}`); session.log.step(`sets visibility to ${settings.visibility}`);
const radios = await session.queryAll(".mx_RoomSettingsDialog label"); const radios = await session.queryAll(".mx_RoomSettingsDialog label");
assert.equal(radios.length, 6); assert.equal(radios.length, 7);
const [inviteOnlyRoom, publicRoom] = radios; const [inviteOnlyRoom,, publicRoom] = radios;
if (settings.visibility === "invite_only") { if (settings.visibility === "invite_only") {
await inviteOnlyRoom.click(); await inviteOnlyRoom.click();

View file

@ -1,20 +0,0 @@
/*
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.
*/
// This needs to be executed before the SpaceStore gets imported but due to ES6 import hoisting we have to do this here.
// SpaceStore reads the SettingsStore which needs the localStorage values set at init time.
localStorage.setItem("mx_labs_feature_feature_spaces", "true");

View file

@ -18,7 +18,6 @@ import { EventEmitter } from "events";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import "./SpaceStore-setup"; // enable space lab
import "../skinned-sdk"; // Must be first for skinning to work import "../skinned-sdk"; // Must be first for skinning to work
import SpaceStore, { import SpaceStore, {
UPDATE_HOME_BEHAVIOUR, UPDATE_HOME_BEHAVIOUR,

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../SpaceStore-setup"; // enable space lab
import "../../skinned-sdk"; // Must be first for skinning to work import "../../skinned-sdk"; // Must be first for skinning to work
import { SpaceWatcher } from "../../../src/stores/room-list/SpaceWatcher"; import { SpaceWatcher } from "../../../src/stores/room-list/SpaceWatcher";
import type { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; import type { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore";

1108
yarn.lock

File diff suppressed because it is too large Load diff