Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18088
This commit is contained in:
commit
e17318a364
52 changed files with 1571 additions and 1266 deletions
20
src/@types/global.d.ts
vendored
20
src/@types/global.d.ts
vendored
|
@ -93,6 +93,26 @@ declare global {
|
|||
mxSetupEncryptionStore?: SetupEncryptionStore;
|
||||
mxRoomScrollStateStore?: RoomScrollStateStore;
|
||||
mxOnRecaptchaLoaded?: () => void;
|
||||
electron?: Electron;
|
||||
}
|
||||
|
||||
interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL: string;
|
||||
}
|
||||
|
||||
interface GetSourcesOptions {
|
||||
types: Array<string>;
|
||||
thumbnailSize?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
fetchWindowIcons?: boolean;
|
||||
}
|
||||
|
||||
interface Electron {
|
||||
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
|
|
@ -574,11 +574,12 @@ async function doSetLoggedIn(
|
|||
await abortLogin();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
|
||||
|
|
|
@ -17,8 +17,8 @@ limitations under the License.
|
|||
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
|
||||
import EventEmitter from 'events';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
// XXX: MediaDeviceKind is a union type, so we make our own enum
|
||||
export enum MediaDeviceKindEnum {
|
||||
|
@ -74,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
|
||||
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
|
||||
|
||||
setMatrixCallAudioInput(audioDeviceId);
|
||||
setMatrixCallVideoInput(videoDeviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
|
||||
}
|
||||
|
||||
public setAudioOutput(deviceId: string): void {
|
||||
|
@ -90,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
*/
|
||||
public setAudioInput(deviceId: string): void {
|
||||
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 {
|
||||
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
|
||||
setMatrixCallVideoInput(deviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
|
||||
}
|
||||
|
||||
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
|
||||
|
|
|
@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
|
|||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
/* 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
|
||||
* enabled, events are not sent (this detection is built into posthog and turned on via the
|
||||
* `respect_dnt` flag being passed to `posthog.init`).
|
||||
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
|
||||
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
|
||||
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
|
||||
* redact all matrix identifiers in tracking events.
|
||||
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
|
||||
* a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
|
||||
identify the user.
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
|
@ -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([
|
||||
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||
|
@ -89,7 +86,6 @@ export async function getRedactedCurrentLocation(
|
|||
anonymity: Anonymity,
|
||||
): Promise<string> {
|
||||
// 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
|
||||
if (origin.startsWith('file://')) {
|
||||
pathname = "/<redacted_file_scheme_url>/";
|
||||
|
@ -99,17 +95,13 @@ export async function getRedactedCurrentLocation(
|
|||
if (hash == "") {
|
||||
hashStr = "";
|
||||
} else {
|
||||
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
|
||||
let [beforeFirstSlash, screen] = hash.split("/");
|
||||
|
||||
if (!whitelistedScreens.has(screen)) {
|
||||
screen = "<redacted_screen_name>";
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
||||
}
|
||||
|
||||
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
|
||||
hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
|
||||
}
|
||||
return origin + pathname + hashStr;
|
||||
}
|
||||
|
@ -123,15 +115,15 @@ export class PosthogAnalytics {
|
|||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
* - Anonymity.Disabled means *no data* is passed to posthog
|
||||
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
|
||||
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
|
||||
* to Posthog
|
||||
* - Anonymity.Anonymous means no identifier is passed to posthog
|
||||
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
|
||||
* is passed to posthog.
|
||||
*
|
||||
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
|
||||
*
|
||||
* 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
|
||||
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
||||
*/
|
||||
|
@ -141,6 +133,7 @@ export class PosthogAnalytics {
|
|||
private enabled = false;
|
||||
private static _instance = null;
|
||||
private platformSuperProperties = {};
|
||||
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this._instance) {
|
||||
|
@ -274,9 +267,32 @@ export class PosthogAnalytics {
|
|||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
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
|
||||
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
|
||||
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
|
||||
await this.identifyUser(userId);
|
||||
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,11 +48,6 @@ export default class Resend {
|
|||
// XXX: temporary logging to try to diagnose
|
||||
// https://github.com/vector-im/element-web/issues/3148
|
||||
console.log('Resend got send failure: ' + err.name + '(' + err + ')');
|
||||
|
||||
dis.dispatch({
|
||||
action: 'message_send_failed',
|
||||
event: event,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -322,10 +322,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const menuClasses = classNames({
|
||||
'mx_ContextualMenu': true,
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
/**
|
||||
* In some cases we may get the number of 0, which still means that we're supposed to properly
|
||||
* add the specific position class, but as it was falsy things didn't work as intended.
|
||||
* In addition, defensively check for counter cases where we may get more than one value,
|
||||
* even if we shouldn't.
|
||||
*/
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
|
@ -404,17 +410,27 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
export type ToRightOf = {
|
||||
left: number;
|
||||
top: number;
|
||||
chevronOffset: number;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
return { left, top, chevronOffset };
|
||||
};
|
||||
|
||||
export type AboveLeftOf = IPosition & {
|
||||
chevronFace: ChevronFace;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0): AboveLeftOf => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
|
|
|
@ -143,7 +143,7 @@ export enum Views {
|
|||
SOFT_LOGOUT,
|
||||
}
|
||||
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
|
||||
|
||||
// Actions that are redirected through the onboarding process prior to being
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
|
@ -1892,15 +1892,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
onSendEvent(roomId: string, event: MatrixEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
dis.dispatch({ action: 'message_send_failed' });
|
||||
return;
|
||||
}
|
||||
if (!cli) return;
|
||||
|
||||
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
|
||||
dis.dispatch({ action: 'message_sent' });
|
||||
}, (err) => {
|
||||
dis.dispatch({ action: 'message_send_failed' });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -57,6 +57,7 @@ import { Key } from "../../Keyboard";
|
|||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../hooks/useEventEmitter";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -87,7 +88,8 @@ const Tile: React.FC<ITileProps> = ({
|
|||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
|
||||
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
|
|
|
@ -78,6 +78,7 @@ import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFro
|
|||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -191,6 +192,11 @@ interface ISpacePreviewProps {
|
|||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
useDispatcher(defaultDispatcher, payload => {
|
||||
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
|
||||
setBusy(false); // stop the spinner, join failed
|
||||
}
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
|
|
|
@ -15,43 +15,48 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import classNames from 'classnames';
|
||||
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
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")
|
||||
export default class MemberStatusMessageAvatar extends React.Component {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
private button = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasStatus: this.hasStatus,
|
||||
menuDisplayed: false,
|
||||
};
|
||||
|
||||
this._button = createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this._onStatusMessageCommitted,
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
get hasStatus() {
|
||||
private get hasStatus(): boolean {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return !!user._unstable_statusMessage;
|
||||
return !!user.unstable_statusMessage;
|
||||
}
|
||||
|
||||
_onStatusMessageCommitted = () => {
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
hasStatus: this.hasStatus,
|
||||
});
|
||||
};
|
||||
|
||||
openMenu = () => {
|
||||
private openMenu = (): void => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
};
|
||||
|
||||
closeMenu = () => {
|
||||
private closeMenu = (): void => {
|
||||
this.setState({ menuDisplayed: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
const avatar = <MemberAvatar
|
||||
member={this.props.member}
|
||||
width={this.props.width}
|
||||
|
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
|||
|
||||
let contextMenu;
|
||||
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 chevronMargin = 1; // Add some spacing away from target
|
||||
|
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
|||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronOffset={(elementRect.width - chevronWidth) / 2}
|
||||
chevronFace="bottom"
|
||||
chevronFace={ChevronFace.Bottom}
|
||||
left={elementRect.left + window.pageXOffset}
|
||||
top={elementRect.top + window.pageYOffset - chevronMargin}
|
||||
menuWidth={226}
|
||||
onFinished={this.closeMenu}
|
||||
>
|
||||
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} />
|
||||
<StatusMessageContextMenu user={this.props.member.user} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
|||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
inputRef={this._button}
|
||||
inputRef={this.button}
|
||||
onClick={this.openMenu}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
label={_t("User Status")}
|
|
@ -15,45 +15,41 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
/*
|
||||
interface IProps {
|
||||
element: React.ReactNode;
|
||||
// Function to be called when the parent window is resized
|
||||
// This can be used to reposition or close the menu on resize and
|
||||
// ensure that it is not displayed in a stale position.
|
||||
onResize?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
static propTypes = {
|
||||
element: PropTypes.element.isRequired,
|
||||
// Function to be called when the parent window is resized
|
||||
// This can be used to reposition or close the menu on resize and
|
||||
// ensure that it is not displayed in a stale position.
|
||||
onResize: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
export default class GenericElementContextMenu extends React.Component<IProps> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.resize = this.resize.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resize = this.resize.bind(this);
|
||||
public componentDidMount(): void {
|
||||
window.addEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
resize() {
|
||||
private resize = (): void => {
|
||||
if (this.props.onResize) {
|
||||
this.props.onResize();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
return <div>{ this.props.element }</div>;
|
||||
}
|
||||
}
|
|
@ -15,16 +15,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.context_menus.GenericTextContextMenu")
|
||||
export default class GenericTextContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
render() {
|
||||
@replaceableComponent("views.context_menus.GenericTextContextMenu")
|
||||
export default class GenericTextContextMenu extends React.Component<IProps> {
|
||||
public render(): JSX.Element {
|
||||
return <div>{ this.props.message }</div>;
|
||||
}
|
||||
}
|
|
@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
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")
|
||||
export default class StatusMessageContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
// js-sdk User object. Not required because it might not exist.
|
||||
user: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
message: this.comittedStatusMessage,
|
||||
waiting: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this._onStatusMessageCommitted,
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
get comittedStatusMessage() {
|
||||
return this.props.user ? this.props.user._unstable_statusMessage : "";
|
||||
get comittedStatusMessage(): string {
|
||||
return this.props.user ? this.props.user.unstable_statusMessage : "";
|
||||
}
|
||||
|
||||
_onStatusMessageCommitted = () => {
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
message: this.comittedStatusMessage,
|
||||
|
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_onClearClick = (e) => {
|
||||
private onClearClick = (): void=> {
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage("");
|
||||
this.setState({
|
||||
waiting: true,
|
||||
});
|
||||
};
|
||||
|
||||
_onSubmit = (e) => {
|
||||
private onSubmit = (e: ButtonEvent): void => {
|
||||
e.preventDefault();
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
|
||||
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.
|
||||
this.setState({
|
||||
message: e.target.value,
|
||||
message: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
|
||||
public render(): JSX.Element {
|
||||
let actionButton;
|
||||
if (this.comittedStatusMessage) {
|
||||
if (this.state.message === this.comittedStatusMessage) {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
|
||||
onClick={this._onClearClick}
|
||||
onClick={this.onClearClick}
|
||||
>
|
||||
<span>{ _t("Clear status") }</span>
|
||||
</AccessibleButton>;
|
||||
} else {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
|
||||
onClick={this._onSubmit}
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
<span>{ _t("Update status") }</span>
|
||||
</AccessibleButton>;
|
||||
|
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
actionButton = <AccessibleButton
|
||||
className="mx_StatusMessageContextMenu_submit"
|
||||
disabled={!this.state.message}
|
||||
onClick={this._onSubmit}
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
<span>{ _t("Set status") }</span>
|
||||
</AccessibleButton>;
|
||||
|
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
|
||||
let spinner = null;
|
||||
if (this.state.waiting) {
|
||||
spinner = <Spinner w="24" h="24" />;
|
||||
spinner = <Spinner w={24} h={24} />;
|
||||
}
|
||||
|
||||
const form = <form
|
||||
className="mx_StatusMessageContextMenu_form"
|
||||
autoComplete="off"
|
||||
onSubmit={this._onSubmit}
|
||||
onSubmit={this.onSubmit}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
|
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
key="message"
|
||||
placeholder={_t("Set a new status...")}
|
||||
autoFocus={true}
|
||||
maxLength="60"
|
||||
maxLength={60}
|
||||
value={this.state.message}
|
||||
onChange={this._onStatusChange}
|
||||
onChange={this.onStatusChange}
|
||||
/>
|
||||
<div className="mx_StatusMessageContextMenu_actionContainer">
|
||||
{ actionButton }
|
|
@ -29,10 +29,12 @@ import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
|
||||
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
|
||||
|
||||
export enum SpaceSettingsTab {
|
||||
General = "SPACE_GENERAL_TAB",
|
||||
Visibility = "SPACE_VISIBILITY_TAB",
|
||||
Roles = "SPACE_ROLES_TAB",
|
||||
Advanced = "SPACE_ADVANCED_TAB",
|
||||
}
|
||||
|
||||
|
@ -60,7 +62,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
|
|||
SpaceSettingsTab.Visibility,
|
||||
_td("Visibility"),
|
||||
"mx_SpaceSettingsDialog_visibilityIcon",
|
||||
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
|
||||
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} closeSettingsFn={onFinished} />,
|
||||
),
|
||||
new Tab(
|
||||
SpaceSettingsTab.Roles,
|
||||
_td("Roles & Permissions"),
|
||||
"mx_RoomSettingsDialog_rolesIcon",
|
||||
<RolesRoomSettingsTab roomId={space.roomId} />,
|
||||
),
|
||||
SettingsStore.getValue(UIFeature.AdvancedSettings)
|
||||
? new Tab(
|
||||
|
|
|
@ -20,14 +20,21 @@ import BaseDialog from "..//dialogs/BaseDialog";
|
|||
import DialogButtons from "./DialogButtons";
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
|
||||
export interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL;
|
||||
export function getDesktopCapturerSources(): Promise<Array<DesktopCapturerSource>> {
|
||||
const options: GetSourcesOptions = {
|
||||
thumbnailSize: {
|
||||
height: 176,
|
||||
width: 312,
|
||||
},
|
||||
types: [
|
||||
"screen",
|
||||
"window",
|
||||
],
|
||||
};
|
||||
return window.electron.getDesktopCapturerSources(options);
|
||||
}
|
||||
|
||||
export enum Tabs {
|
||||
|
@ -78,7 +85,7 @@ export interface PickerIState {
|
|||
selectedSource: DesktopCapturerSource | null;
|
||||
}
|
||||
export interface PickerIProps {
|
||||
onFinished(source: DesktopCapturerSource): void;
|
||||
onFinished(sourceId: string): void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
|
||||
|
@ -123,7 +130,7 @@ export default class DesktopCapturerSourcePicker extends React.Component<
|
|||
};
|
||||
|
||||
private onShare = (): void => {
|
||||
this.props.onFinished(this.state.selectedSource);
|
||||
this.props.onFinished(this.state.selectedSource.id);
|
||||
};
|
||||
|
||||
private onTabChange = (): void => {
|
||||
|
|
|
@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
|||
if (canAffectUser && me.powerLevel >= banPowerLevel) {
|
||||
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
|
||||
}
|
||||
if (canAffectUser && me.powerLevel >= editPowerLevel) {
|
||||
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
|
||||
muteButton = (
|
||||
<MuteToggleButton
|
||||
member={member}
|
||||
|
|
|
@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
// 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;
|
||||
|
||||
|
@ -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 range = model.startRange(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) => {
|
||||
const part = model.parts[index];
|
||||
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) {
|
||||
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
|
||||
|
@ -180,18 +181,23 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
|
||||
if (data) {
|
||||
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
|
||||
// because we'll replace the whole range with an emoji,
|
||||
// so move the start forward to the start of the emoticon.
|
||||
// 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
|
||||
// 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 => {
|
||||
renderModel(this.editorRef.current, this.props.model);
|
||||
|
@ -607,8 +613,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
};
|
||||
|
||||
private configureEmoticonAutoReplace = (): void => {
|
||||
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
||||
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
|
||||
this.props.model.setTransformCallback(this.transform);
|
||||
};
|
||||
|
||||
private configureShouldShowPillAvatar = (): void => {
|
||||
|
@ -621,6 +626,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.setState({ surroundWith });
|
||||
};
|
||||
|
||||
private transform = (documentPosition: DocumentPosition): void => {
|
||||
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
|
||||
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
||||
|
|
|
@ -1192,14 +1192,19 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
default: {
|
||||
const thread = ReplyThread.makeThread(
|
||||
this.props.mxEvent,
|
||||
this.props.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this.replyThread,
|
||||
this.props.layout,
|
||||
this.props.alwaysShowTimestamps || this.state.hover,
|
||||
);
|
||||
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.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this.replyThread,
|
||||
this.props.layout,
|
||||
this.props.alwaysShowTimestamps || this.state.hover,
|
||||
);
|
||||
}
|
||||
|
||||
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
ContextMenu,
|
||||
useContextMenu,
|
||||
MenuItem,
|
||||
AboveLeftOf,
|
||||
} from "../../structures/ContextMenu";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import ReplyPreview from "./ReplyPreview";
|
||||
|
@ -56,7 +57,7 @@ let instanceCount = 0;
|
|||
const NARROW_MODE_BREAKPOINT = 500;
|
||||
|
||||
interface IComposerAvatarProps {
|
||||
me: object;
|
||||
me: RoomMember;
|
||||
}
|
||||
|
||||
function ComposerAvatar(props: IComposerAvatarProps) {
|
||||
|
@ -511,7 +512,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
null,
|
||||
];
|
||||
|
||||
let menuPosition;
|
||||
let menuPosition: AboveLeftOf | undefined;
|
||||
if (this.ref.current) {
|
||||
const contentRect = this.ref.current.getBoundingClientRect();
|
||||
menuPosition = aboveLeftOf(contentRect);
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { MouseEvent } from "react";
|
||||
import classNames from "classnames";
|
||||
import { formatCount } from "../../../utils/FormattingUtils";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
|
|||
import { XOR } from "../../../@types/common";
|
||||
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
interface IProps {
|
||||
notification: NotificationState;
|
||||
|
@ -39,6 +42,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
|
||||
showUnsentTooltip?: boolean;
|
||||
/**
|
||||
* If specified will return an AccessibleButton instead of a div.
|
||||
*/
|
||||
|
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
|
|||
|
||||
interface IState {
|
||||
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
|
||||
showTooltip: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.NotificationBadge")
|
||||
|
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
|
|||
|
||||
this.state = {
|
||||
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
|
||||
showTooltip: false,
|
||||
};
|
||||
|
||||
this.countWatcherRef = SettingsStore.watchSetting(
|
||||
|
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
|
|||
this.forceUpdate(); // notification state changed - update
|
||||
};
|
||||
|
||||
private onMouseOver = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
this.setState({
|
||||
showTooltip: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.setState({
|
||||
showTooltip: false,
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { notification, forceCount, roomId, onClick, ...props } = this.props;
|
||||
const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
|
||||
|
||||
// Don't show a badge if we don't need to
|
||||
if (notification.isIdle) return null;
|
||||
|
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
|
|||
});
|
||||
|
||||
if (onClick) {
|
||||
let label: string;
|
||||
let tooltip: JSX.Element;
|
||||
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
|
||||
label = _t("Message didn't send. Click for info.");
|
||||
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton {...props} className={classes} onClick={onClick}>
|
||||
<AccessibleButton
|
||||
aria-label={label}
|
||||
{...props}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<span className="mx_NotificationBadge_count">{ symbol }</span>
|
||||
{ tooltip }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
|
||||
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
|
||||
// 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">
|
||||
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
|
||||
<AccessibleButton
|
||||
|
|
|
@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
|
|||
onClick={this.onBadgeClick}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={ariaLabel}
|
||||
showUnsentTooltip={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
|
||||
import React, { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
|
@ -51,8 +50,6 @@ import IconizedContextMenu, {
|
|||
} from "../context_menus/IconizedContextMenu";
|
||||
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { getUnsentMessages } from "../../structures/RoomStatusBar";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -68,7 +65,6 @@ interface IState {
|
|||
notificationsMenuPosition: PartialDOMRect;
|
||||
generalMenuPosition: PartialDOMRect;
|
||||
messagePreview?: string;
|
||||
hasUnsentEvents: boolean;
|
||||
}
|
||||
|
||||
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
|
||||
|
@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
notificationsMenuPosition: null,
|
||||
generalMenuPosition: null,
|
||||
hasUnsentEvents: this.countUnsentEvents() > 0,
|
||||
|
||||
// generatePreview() will return nothing if the user has previews disabled
|
||||
messagePreview: "",
|
||||
|
@ -106,11 +101,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
this.roomProps = EchoChamber.forRoom(this.props.room);
|
||||
}
|
||||
|
||||
private countUnsentEvents(): number {
|
||||
return getUnsentMessages(this.props.room).length;
|
||||
}
|
||||
|
||||
private onRoomNameUpdate = (room) => {
|
||||
private onRoomNameUpdate = (room: Room) => {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
|
@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
this.forceUpdate(); // notification state changed - update
|
||||
};
|
||||
|
||||
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
|
||||
if (room?.roomId !== this.props.room.roomId) return;
|
||||
this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 });
|
||||
};
|
||||
|
||||
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
|
||||
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
|
||||
// else ignore - not important for this tile
|
||||
|
@ -178,12 +164,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
);
|
||||
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
|
||||
this.roomProps.on("Room.name", this.onRoomNameUpdate);
|
||||
this.props.room?.on("Room.name", this.onRoomNameUpdate);
|
||||
CommunityPrototypeStore.instance.on(
|
||||
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
|
||||
this.onCommunityUpdate,
|
||||
);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
|
||||
this.onCommunityUpdate,
|
||||
);
|
||||
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
|
@ -587,30 +571,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
/>;
|
||||
|
||||
let badge: React.ReactNode;
|
||||
if (!this.props.isMinimized) {
|
||||
if (!this.props.isMinimized && this.notificationState) {
|
||||
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
|
||||
if (this.state.hasUnsentEvents) {
|
||||
// hardcode the badge to a danger state when there's unsent messages
|
||||
badge = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.RED_EXCLAMATION}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (this.notificationState) {
|
||||
badge = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
badge = (
|
||||
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
|
||||
<NotificationBadge
|
||||
notification={this.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let messagePreview = null;
|
||||
|
|
|
@ -31,8 +31,8 @@ import {
|
|||
textSerialize,
|
||||
unescapeMessage,
|
||||
} from '../../../editor/serialize';
|
||||
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
||||
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
|
||||
import BasicMessageComposer from "./BasicMessageComposer";
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||
import SendHistoryManager from "../../../SendHistoryManager";
|
||||
|
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
public async sendMessage(): Promise<void> {
|
||||
if (this.model.isEmpty) {
|
||||
const model = this.model;
|
||||
|
||||
if (model.isEmpty) {
|
||||
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;
|
||||
let shouldSend = true;
|
||||
let content;
|
||||
|
||||
if (!containsEmote(this.model) && this.isSlashCommand()) {
|
||||
if (!containsEmote(model) && this.isSlashCommand()) {
|
||||
const [cmd, args, commandText] = this.getSlashCommand();
|
||||
if (cmd) {
|
||||
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;
|
||||
this.sendQuickReaction();
|
||||
}
|
||||
|
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
|||
const { roomId } = this.props.room;
|
||||
if (!content) {
|
||||
content = createMessageContent(
|
||||
this.model,
|
||||
model,
|
||||
replyToEvent,
|
||||
this.props.replyInThread,
|
||||
this.props.permalinkCreator,
|
||||
|
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
|||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
|
||||
}
|
||||
|
||||
this.sendHistoryManager.save(this.model, replyToEvent);
|
||||
this.sendHistoryManager.save(model, replyToEvent);
|
||||
// clear composer
|
||||
this.model.reset([]);
|
||||
model.reset([]);
|
||||
this.editorRef.current?.clearUndoHistory();
|
||||
this.editorRef.current?.focus();
|
||||
this.clearStoredEditorState();
|
||||
|
|
269
src/components/views/settings/JoinRuleSettings.tsx
Normal file
269
src/components/views/settings/JoinRuleSettings.tsx
Normal file
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
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 from "react";
|
||||
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import StyledRadioGroup, { IDefinition } from "../elements/StyledRadioGroup";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import Modal from "../../../Modal";
|
||||
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
|
||||
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
|
||||
import { upgradeRoom } from "../../../utils/RoomUpgrade";
|
||||
import { arrayHasDiff } from "../../../utils/arrays";
|
||||
import { useLocalEcho } from "../../../hooks/useLocalEcho";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
promptUpgrade?: boolean;
|
||||
closeSettingsFn(): void;
|
||||
onError(error: Error): void;
|
||||
beforeChange?(joinRule: JoinRule): Promise<boolean>; // if returns false then aborts the change
|
||||
}
|
||||
|
||||
const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSettingsFn }: IProps) => {
|
||||
const cli = room.client;
|
||||
|
||||
const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport;
|
||||
const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
|
||||
&& restrictedRoomCapabilities.support.includes(room.getVersion());
|
||||
const preferredRestrictionVersion = !roomSupportsRestricted && promptUpgrade
|
||||
? restrictedRoomCapabilities?.preferred
|
||||
: undefined;
|
||||
|
||||
const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli);
|
||||
|
||||
const [content, setContent] = useLocalEcho<IJoinRuleEventContent>(
|
||||
() => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(),
|
||||
content => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""),
|
||||
onError,
|
||||
);
|
||||
|
||||
const { join_rule: joinRule } = content;
|
||||
const restrictedAllowRoomIds = joinRule === JoinRule.Restricted
|
||||
? content.allow.filter(o => o.type === RestrictedAllowType.RoomMembership).map(o => o.room_id)
|
||||
: undefined;
|
||||
|
||||
const editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
|
||||
let selected = restrictedAllowRoomIds;
|
||||
if (!selected?.length && SpaceStore.instance.activeSpace) {
|
||||
selected = [SpaceStore.instance.activeSpace.roomId];
|
||||
}
|
||||
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
|
||||
matrixClient,
|
||||
room,
|
||||
selected,
|
||||
}, "mx_ManageRestrictedJoinRuleDialog_wrapper");
|
||||
|
||||
const [roomIds] = await finished;
|
||||
return roomIds;
|
||||
};
|
||||
|
||||
const definitions: IDefinition<JoinRule>[] = [{
|
||||
value: JoinRule.Invite,
|
||||
label: _t("Private (invite only)"),
|
||||
description: _t("Only invited people can join."),
|
||||
checked: joinRule === JoinRule.Invite || (joinRule === JoinRule.Restricted && !restrictedAllowRoomIds?.length),
|
||||
}, {
|
||||
value: JoinRule.Public,
|
||||
label: _t("Public"),
|
||||
description: _t("Anyone can find and join."),
|
||||
}];
|
||||
|
||||
if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) {
|
||||
let upgradeRequiredPill;
|
||||
if (preferredRestrictionVersion) {
|
||||
upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
|
||||
{ _t("Upgrade required") }
|
||||
</span>;
|
||||
}
|
||||
|
||||
let description;
|
||||
if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) {
|
||||
// only show the first 4 spaces we know about, so that the UI doesn't grow out of proportion there are lots.
|
||||
const shownSpaces = restrictedAllowRoomIds
|
||||
.map(roomId => cli.getRoom(roomId))
|
||||
.filter(room => room?.isSpaceRoom())
|
||||
.slice(0, 4);
|
||||
|
||||
let moreText;
|
||||
if (shownSpaces.length < restrictedAllowRoomIds.length) {
|
||||
if (shownSpaces.length > 0) {
|
||||
moreText = _t("& %(count)s more", {
|
||||
count: restrictedAllowRoomIds.length - shownSpaces.length,
|
||||
});
|
||||
} else {
|
||||
moreText = _t("Currently, %(count)s spaces have access", {
|
||||
count: restrictedAllowRoomIds.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]) => {
|
||||
if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return;
|
||||
|
||||
if (!newAllowRoomIds.length) {
|
||||
setContent({
|
||||
join_rule: JoinRule.Invite,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setContent({
|
||||
join_rule: JoinRule.Restricted,
|
||||
allow: newAllowRoomIds.map(roomId => ({
|
||||
"type": RestrictedAllowType.RoomMembership,
|
||||
"room_id": roomId,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const onEditRestrictedClick = async () => {
|
||||
const restrictedAllowRoomIds = await editRestrictedRoomIds();
|
||||
if (!Array.isArray(restrictedAllowRoomIds)) return;
|
||||
if (restrictedAllowRoomIds.length > 0) {
|
||||
onRestrictedRoomIdsChange(restrictedAllowRoomIds);
|
||||
} else {
|
||||
onChange(JoinRule.Invite);
|
||||
}
|
||||
};
|
||||
|
||||
description = <div>
|
||||
<span>
|
||||
{ _t("Anyone in a space can find and join. <a>Edit which spaces can access here.</a>", {}, {
|
||||
a: sub => <AccessibleButton
|
||||
disabled={disabled}
|
||||
onClick={onEditRestrictedClick}
|
||||
kind="link"
|
||||
>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
</span>
|
||||
|
||||
<div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
|
||||
<h4>{ _t("Spaces with access") }</h4>
|
||||
{ shownSpaces.map(room => {
|
||||
return <span key={room.roomId}>
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
{ room.name }
|
||||
</span>;
|
||||
}) }
|
||||
{ moreText && <span>{ moreText }</span> }
|
||||
</div>
|
||||
</div>;
|
||||
} else if (SpaceStore.instance.activeSpace) {
|
||||
description = _t("Anyone in <spaceName/> can find and join. You can select other spaces too.", {}, {
|
||||
spaceName: () => <b>{ SpaceStore.instance.activeSpace.name }</b>,
|
||||
});
|
||||
} else {
|
||||
description = _t("Anyone in a space can find and join. You can select multiple spaces.");
|
||||
}
|
||||
|
||||
definitions.splice(1, 0, {
|
||||
value: JoinRule.Restricted,
|
||||
label: <>
|
||||
{ _t("Space members") }
|
||||
{ upgradeRequiredPill }
|
||||
</>,
|
||||
description,
|
||||
// if there are 0 allowed spaces then render it as invite only instead
|
||||
checked: joinRule === JoinRule.Restricted && !!restrictedAllowRoomIds?.length,
|
||||
});
|
||||
}
|
||||
|
||||
const onChange = async (joinRule: JoinRule) => {
|
||||
const beforeJoinRule = content.join_rule;
|
||||
|
||||
let restrictedAllowRoomIds: string[];
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
if (beforeJoinRule === JoinRule.Restricted || roomSupportsRestricted) {
|
||||
// Have the user pick which spaces to allow joins from
|
||||
restrictedAllowRoomIds = await editRestrictedRoomIds();
|
||||
if (!Array.isArray(restrictedAllowRoomIds)) return;
|
||||
} else if (preferredRestrictionVersion) {
|
||||
// Block this action on a room upgrade otherwise it'd make their room unjoinable
|
||||
const targetVersion = preferredRestrictionVersion;
|
||||
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
|
||||
roomId: room.roomId,
|
||||
targetVersion,
|
||||
description: _t("This upgrade will allow members of selected spaces " +
|
||||
"access to this room without an invite."),
|
||||
onFinished: async (resp) => {
|
||||
if (!resp?.continue) return;
|
||||
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
|
||||
closeSettingsFn();
|
||||
// switch to the new room in the background
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
// open new settings on this tab
|
||||
dis.dispatch({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: ROOM_SECURITY_TAB,
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// when setting to 0 allowed rooms/spaces set to invite only instead as per the note
|
||||
if (!restrictedAllowRoomIds.length) {
|
||||
joinRule = JoinRule.Invite;
|
||||
}
|
||||
}
|
||||
|
||||
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
|
||||
if (beforeChange && !await beforeChange(joinRule)) return;
|
||||
|
||||
const newContent: IJoinRuleEventContent = {
|
||||
join_rule: joinRule,
|
||||
};
|
||||
|
||||
// pre-set the accepted spaces with the currently viewed one as per the microcopy
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
newContent.allow = restrictedAllowRoomIds.map(roomId => ({
|
||||
"type": RestrictedAllowType.RoomMembership,
|
||||
"room_id": roomId,
|
||||
}));
|
||||
}
|
||||
|
||||
setContent(newContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRadioGroup
|
||||
name="joinRule"
|
||||
value={joinRule}
|
||||
onChange={onChange}
|
||||
definitions={definitions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JoinRuleSettings;
|
|
@ -28,36 +28,31 @@ import { compare } from "../../../../../utils/strings";
|
|||
import ErrorDialog from '../../../dialogs/ErrorDialog';
|
||||
import PowerSelector from "../../../elements/PowerSelector";
|
||||
|
||||
const plEventsToLabels = {
|
||||
// These will be translated for us later.
|
||||
[EventType.RoomAvatar]: _td("Change room avatar"),
|
||||
[EventType.RoomName]: _td("Change room name"),
|
||||
[EventType.RoomCanonicalAlias]: _td("Change main address for the room"),
|
||||
[EventType.RoomHistoryVisibility]: _td("Change history visibility"),
|
||||
[EventType.RoomPowerLevels]: _td("Change permissions"),
|
||||
[EventType.RoomTopic]: _td("Change topic"),
|
||||
[EventType.RoomTombstone]: _td("Upgrade the room"),
|
||||
[EventType.RoomEncryption]: _td("Enable room encryption"),
|
||||
[EventType.RoomServerAcl]: _td("Change server ACLs"),
|
||||
interface IEventShowOpts {
|
||||
isState?: boolean;
|
||||
hideForSpace?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
|
||||
"im.vector.modular.widgets": _td("Modify widgets"),
|
||||
};
|
||||
interface IPowerLevelDescriptor {
|
||||
desc: string;
|
||||
defaultValue: number;
|
||||
hideForSpace?: boolean;
|
||||
}
|
||||
|
||||
const plEventsToShow = {
|
||||
const plEventsToShow: Record<string, IEventShowOpts> = {
|
||||
// If an event is listed here, it will be shown in the PL settings. Defaults will be calculated.
|
||||
[EventType.RoomAvatar]: { isState: true },
|
||||
[EventType.RoomName]: { isState: true },
|
||||
[EventType.RoomCanonicalAlias]: { isState: true },
|
||||
[EventType.RoomHistoryVisibility]: { isState: true },
|
||||
[EventType.RoomHistoryVisibility]: { isState: true, hideForSpace: true },
|
||||
[EventType.RoomPowerLevels]: { isState: true },
|
||||
[EventType.RoomTopic]: { isState: true },
|
||||
[EventType.RoomTombstone]: { isState: true },
|
||||
[EventType.RoomEncryption]: { isState: true },
|
||||
[EventType.RoomServerAcl]: { isState: true },
|
||||
[EventType.RoomTombstone]: { isState: true, hideForSpace: true },
|
||||
[EventType.RoomEncryption]: { isState: true, hideForSpace: true },
|
||||
[EventType.RoomServerAcl]: { isState: true, hideForSpace: true },
|
||||
|
||||
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
|
||||
"im.vector.modular.widgets": { isState: true },
|
||||
"im.vector.modular.widgets": { isState: true, hideForSpace: true },
|
||||
};
|
||||
|
||||
// parse a string as an integer; if the input is undefined, or cannot be parsed
|
||||
|
@ -145,7 +140,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
|
||||
let plContent = plEvent ? (plEvent.getContent() || {}) : {};
|
||||
|
||||
// Clone the power levels just in case
|
||||
|
@ -173,7 +168,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
parentObj[keyPath[keyPath.length - 1]] = value;
|
||||
}
|
||||
|
||||
client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => {
|
||||
client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch(e => {
|
||||
console.error(e);
|
||||
|
||||
Modal.createTrackedDialog('Power level requirement change failed', '', ErrorDialog, {
|
||||
|
@ -189,7 +184,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
|
||||
let plContent = plEvent ? (plEvent.getContent() || {}) : {};
|
||||
|
||||
// Clone the power levels just in case
|
||||
|
@ -199,7 +194,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
if (!plContent['users']) plContent['users'] = {};
|
||||
plContent['users'][powerLevelKey] = value;
|
||||
|
||||
client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => {
|
||||
client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch(e => {
|
||||
console.error(e);
|
||||
|
||||
Modal.createTrackedDialog('Power level change failed', '', ErrorDialog, {
|
||||
|
@ -215,11 +210,31 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||
const plContent = plEvent ? (plEvent.getContent() || {}) : {};
|
||||
const canChangeLevels = room.currentState.mayClientSendStateEvent('m.room.power_levels', client);
|
||||
const isSpaceRoom = room.isSpaceRoom();
|
||||
|
||||
const powerLevelDescriptors = {
|
||||
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
|
||||
const plContent = plEvent ? (plEvent.getContent() || {}) : {};
|
||||
const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client);
|
||||
|
||||
const plEventsToLabels = {
|
||||
// These will be translated for us later.
|
||||
[EventType.RoomAvatar]: isSpaceRoom ? _td("Change space avatar") : _td("Change room avatar"),
|
||||
[EventType.RoomName]: isSpaceRoom ? _td("Change space name") : _td("Change room name"),
|
||||
[EventType.RoomCanonicalAlias]: isSpaceRoom
|
||||
? _td("Change main address for the space")
|
||||
: _td("Change main address for the room"),
|
||||
[EventType.RoomHistoryVisibility]: _td("Change history visibility"),
|
||||
[EventType.RoomPowerLevels]: _td("Change permissions"),
|
||||
[EventType.RoomTopic]: isSpaceRoom ? _td("Change description") : _td("Change topic"),
|
||||
[EventType.RoomTombstone]: _td("Upgrade the room"),
|
||||
[EventType.RoomEncryption]: _td("Enable room encryption"),
|
||||
[EventType.RoomServerAcl]: _td("Change server ACLs"),
|
||||
|
||||
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
|
||||
"im.vector.modular.widgets": isSpaceRoom ? null : _td("Modify widgets"),
|
||||
};
|
||||
|
||||
const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
|
||||
"users_default": {
|
||||
desc: _t('Default role'),
|
||||
defaultValue: 0,
|
||||
|
@ -227,6 +242,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
"events_default": {
|
||||
desc: _t('Send messages'),
|
||||
defaultValue: 0,
|
||||
hideForSpace: true,
|
||||
},
|
||||
"invite": {
|
||||
desc: _t('Invite users'),
|
||||
|
@ -247,10 +263,12 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
"redact": {
|
||||
desc: _t('Remove messages sent by others'),
|
||||
defaultValue: 50,
|
||||
hideForSpace: true,
|
||||
},
|
||||
"notifications.room": {
|
||||
desc: _t('Notify everyone'),
|
||||
defaultValue: 50,
|
||||
hideForSpace: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -361,6 +379,9 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
|
||||
const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => {
|
||||
const descriptor = powerLevelDescriptors[key];
|
||||
if (isSpaceRoom && descriptor.hideForSpace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyPath = key.split('.');
|
||||
let currentObj = plContent;
|
||||
|
@ -382,14 +403,18 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
onChange={this.onPowerLevelsChanged}
|
||||
/>
|
||||
</div>;
|
||||
});
|
||||
}).filter(Boolean);
|
||||
|
||||
// hide the power level selector for enabling E2EE if it the room is already encrypted
|
||||
if (client.isRoomEncrypted(this.props.roomId)) {
|
||||
delete eventsLevels["m.room.encryption"];
|
||||
delete eventsLevels[EventType.RoomEncryption];
|
||||
}
|
||||
|
||||
const eventPowerSelectors = Object.keys(eventsLevels).map((eventType, i) => {
|
||||
if (isSpaceRoom && plEventsToShow[eventType].hideForSpace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let label = plEventsToLabels[eventType];
|
||||
if (label) {
|
||||
label = _t(label);
|
||||
|
@ -408,7 +433,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab mx_RolesRoomSettingsTab">
|
||||
|
@ -418,7 +443,10 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
|||
{ bannedUsersSection }
|
||||
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
|
||||
<span className='mx_SettingsTab_subheading'>{ _t("Permissions") }</span>
|
||||
<p>{ _t('Select the roles required to change various parts of the room') }</p>
|
||||
<p>{ isSpaceRoom
|
||||
? _t('Select the roles required to change various parts of the space')
|
||||
: _t('Select the roles required to change various parts of the room')
|
||||
}</p>
|
||||
{ powerSelectors }
|
||||
{ eventPowerSelectors }
|
||||
</div>
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
|
@ -24,23 +24,17 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
|||
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
|
||||
import Modal from "../../../../../Modal";
|
||||
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||
import StyledRadioGroup, { IDefinition } from '../../../elements/StyledRadioGroup';
|
||||
import StyledRadioGroup from '../../../elements/StyledRadioGroup';
|
||||
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import SpaceStore from "../../../../../stores/SpaceStore";
|
||||
import RoomAvatar from "../../../avatars/RoomAvatar";
|
||||
import ManageRestrictedJoinRuleDialog from '../../../dialogs/ManageRestrictedJoinRuleDialog';
|
||||
import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog';
|
||||
import { upgradeRoom } from "../../../../../utils/RoomUpgrade";
|
||||
import { arrayHasDiff } from "../../../../../utils/arrays";
|
||||
import SettingsFlag from '../../../elements/SettingsFlag';
|
||||
import createRoom, { IOpts } from '../../../../../createRoom';
|
||||
import CreateRoomDialog from '../../../dialogs/CreateRoomDialog';
|
||||
import dis from "../../../../../dispatcher/dispatcher";
|
||||
import { ROOM_SECURITY_TAB } from "../../../dialogs/RoomSettingsDialog";
|
||||
import JoinRuleSettings from "../../JoinRuleSettings";
|
||||
import ErrorDialog from "../../../dialogs/ErrorDialog";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
|
@ -48,14 +42,11 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
joinRule: JoinRule;
|
||||
restrictedAllowRoomIds?: string[];
|
||||
guestAccess: GuestAccess;
|
||||
history: HistoryVisibility;
|
||||
hasAliases: boolean;
|
||||
encrypted: boolean;
|
||||
roomSupportsRestricted?: boolean;
|
||||
preferredRestrictionVersion?: string;
|
||||
showAdvancedSection: boolean;
|
||||
}
|
||||
|
||||
|
@ -65,7 +56,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
joinRule: JoinRule.Invite,
|
||||
guestAccess: GuestAccess.Forbidden,
|
||||
history: HistoryVisibility.Shared,
|
||||
hasAliases: false,
|
||||
|
@ -106,12 +96,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
);
|
||||
|
||||
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
|
||||
const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport;
|
||||
const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
|
||||
&& restrictedRoomCapabilities.support.includes(room.getVersion());
|
||||
const preferredRestrictionVersion = roomSupportsRestricted ? undefined : restrictedRoomCapabilities?.preferred;
|
||||
this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted,
|
||||
roomSupportsRestricted, preferredRestrictionVersion });
|
||||
this.setState({ restrictedAllowRoomIds, guestAccess, history, encrypted });
|
||||
|
||||
this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
|
||||
}
|
||||
|
@ -135,7 +120,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
};
|
||||
|
||||
private onEncryptionChange = async () => {
|
||||
if (this.state.joinRule == "public") {
|
||||
if (MatrixClientPeg.get().getRoom(this.props.roomId)?.getJoinRule() === JoinRule.Public) {
|
||||
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
|
||||
title: _t('Are you sure you want to add encryption to this public room?'),
|
||||
description: <div>
|
||||
|
@ -202,128 +187,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
});
|
||||
};
|
||||
|
||||
private onJoinRuleChange = async (joinRule: JoinRule) => {
|
||||
const beforeJoinRule = this.state.joinRule;
|
||||
|
||||
let restrictedAllowRoomIds: string[];
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
const room = matrixClient.getRoom(roomId);
|
||||
|
||||
if (beforeJoinRule === JoinRule.Restricted || this.state.roomSupportsRestricted) {
|
||||
// Have the user pick which spaces to allow joins from
|
||||
restrictedAllowRoomIds = await this.editRestrictedRoomIds();
|
||||
if (!Array.isArray(restrictedAllowRoomIds)) return;
|
||||
} else if (this.state.preferredRestrictionVersion) {
|
||||
// Block this action on a room upgrade otherwise it'd make their room unjoinable
|
||||
const targetVersion = this.state.preferredRestrictionVersion;
|
||||
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
|
||||
roomId,
|
||||
targetVersion,
|
||||
description: _t("This upgrade will allow members of selected spaces " +
|
||||
"access to this room without an invite."),
|
||||
onFinished: async (resp) => {
|
||||
if (!resp?.continue) return;
|
||||
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
|
||||
this.props.closeSettingsFn();
|
||||
// switch to the new room in the background
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
// open new settings on this tab
|
||||
dis.dispatch({
|
||||
action: "open_room_settings",
|
||||
initial_tab_id: ROOM_SECURITY_TAB,
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.encrypted &&
|
||||
this.state.joinRule !== JoinRule.Public &&
|
||||
joinRule === JoinRule.Public
|
||||
) {
|
||||
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
|
||||
title: _t("Are you sure you want to make this encrypted room public?"),
|
||||
description: <div>
|
||||
<p> { _t(
|
||||
"<b>It's not recommended to make encrypted rooms public.</b> " +
|
||||
"It will mean anyone can find and join the room, so anyone can read messages. " +
|
||||
"You'll get none of the benefits of encryption. Encrypting messages in a public " +
|
||||
"room will make receiving and sending messages slower.",
|
||||
null,
|
||||
{ "b": (sub) => <b>{ sub }</b> },
|
||||
) } </p>
|
||||
<p> { _t(
|
||||
"To avoid these issues, create a <a>new public room</a> for the conversation " +
|
||||
"you plan to have.",
|
||||
null,
|
||||
{
|
||||
"a": (sub) => <a
|
||||
className="mx_linkButton"
|
||||
onClick={() => {
|
||||
dialog.close();
|
||||
this.createNewRoom(true, false);
|
||||
}}> { sub } </a>,
|
||||
},
|
||||
) } </p>
|
||||
</div>,
|
||||
});
|
||||
|
||||
const { finished } = dialog;
|
||||
const [confirm] = await finished;
|
||||
if (!confirm) return;
|
||||
}
|
||||
|
||||
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
|
||||
|
||||
const content: IContent = {
|
||||
join_rule: joinRule,
|
||||
};
|
||||
|
||||
// pre-set the accepted spaces with the currently viewed one as per the microcopy
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
content.allow = restrictedAllowRoomIds.map(roomId => ({
|
||||
"type": RestrictedAllowType.RoomMembership,
|
||||
"room_id": roomId,
|
||||
}));
|
||||
}
|
||||
|
||||
this.setState({ joinRule, restrictedAllowRoomIds });
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, content, "").catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({
|
||||
joinRule: beforeJoinRule,
|
||||
restrictedAllowRoomIds: undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private onRestrictedRoomIdsChange = (restrictedAllowRoomIds: string[]) => {
|
||||
const beforeRestrictedAllowRoomIds = this.state.restrictedAllowRoomIds;
|
||||
if (!arrayHasDiff(beforeRestrictedAllowRoomIds || [], restrictedAllowRoomIds)) return;
|
||||
this.setState({ restrictedAllowRoomIds });
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
|
||||
join_rule: JoinRule.Restricted,
|
||||
allow: restrictedAllowRoomIds.map(roomId => ({
|
||||
"type": RestrictedAllowType.RoomMembership,
|
||||
"room_id": roomId,
|
||||
})),
|
||||
}, "").catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({ restrictedAllowRoomIds: beforeRestrictedAllowRoomIds });
|
||||
});
|
||||
};
|
||||
|
||||
private onGuestAccessChange = (allowed: boolean) => {
|
||||
const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
|
||||
const beforeGuestAccess = this.state.guestAccess;
|
||||
|
@ -385,42 +248,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
}
|
||||
}
|
||||
|
||||
private editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
|
||||
let selected = this.state.restrictedAllowRoomIds;
|
||||
if (!selected?.length && SpaceStore.instance.activeSpace) {
|
||||
selected = [SpaceStore.instance.activeSpace.roomId];
|
||||
}
|
||||
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
|
||||
matrixClient,
|
||||
room: matrixClient.getRoom(this.props.roomId),
|
||||
selected,
|
||||
}, "mx_ManageRestrictedJoinRuleDialog_wrapper");
|
||||
|
||||
const [restrictedAllowRoomIds] = await finished;
|
||||
return restrictedAllowRoomIds;
|
||||
};
|
||||
|
||||
private onEditRestrictedClick = async () => {
|
||||
const restrictedAllowRoomIds = await this.editRestrictedRoomIds();
|
||||
if (!Array.isArray(restrictedAllowRoomIds)) return;
|
||||
if (restrictedAllowRoomIds.length > 0) {
|
||||
this.onRestrictedRoomIdsChange(restrictedAllowRoomIds);
|
||||
} else {
|
||||
this.onJoinRuleChange(JoinRule.Invite);
|
||||
}
|
||||
};
|
||||
|
||||
private renderJoinRule() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client.getRoom(this.props.roomId);
|
||||
const joinRule = this.state.joinRule;
|
||||
|
||||
const canChangeJoinRule = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client);
|
||||
|
||||
let aliasWarning = null;
|
||||
if (joinRule === JoinRule.Public && !this.state.hasAliases) {
|
||||
if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) {
|
||||
aliasWarning = (
|
||||
<div className='mx_SecurityRoomSettingsTab_warning'>
|
||||
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
|
||||
|
@ -431,111 +264,68 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
);
|
||||
}
|
||||
|
||||
const radioDefinitions: IDefinition<JoinRule>[] = [{
|
||||
value: JoinRule.Invite,
|
||||
label: _t("Private (invite only)"),
|
||||
description: _t("Only invited people can join."),
|
||||
checked: this.state.joinRule === JoinRule.Invite
|
||||
|| (this.state.joinRule === JoinRule.Restricted && !this.state.restrictedAllowRoomIds?.length),
|
||||
}, {
|
||||
value: JoinRule.Public,
|
||||
label: _t("Public"),
|
||||
description: _t("Anyone can find and join."),
|
||||
}];
|
||||
return <div className="mx_SecurityRoomSettingsTab_joinRule">
|
||||
<div className="mx_SettingsTab_subsectionText">
|
||||
<span>{ _t("Decide who can join %(roomName)s.", {
|
||||
roomName: room?.name,
|
||||
}) }</span>
|
||||
</div>
|
||||
|
||||
if (this.state.roomSupportsRestricted ||
|
||||
this.state.preferredRestrictionVersion ||
|
||||
joinRule === JoinRule.Restricted
|
||||
) {
|
||||
let upgradeRequiredPill;
|
||||
if (this.state.preferredRestrictionVersion) {
|
||||
upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
|
||||
{ _t("Upgrade required") }
|
||||
</span>;
|
||||
}
|
||||
{ aliasWarning }
|
||||
|
||||
let description;
|
||||
if (joinRule === JoinRule.Restricted && this.state.restrictedAllowRoomIds?.length) {
|
||||
const shownSpaces = this.state.restrictedAllowRoomIds
|
||||
.map(roomId => client.getRoom(roomId))
|
||||
.filter(room => room?.isSpaceRoom())
|
||||
.slice(0, 4);
|
||||
<JoinRuleSettings
|
||||
room={room}
|
||||
beforeChange={this.onBeforeJoinRuleChange}
|
||||
onError={this.onJoinRuleChangeError}
|
||||
closeSettingsFn={this.props.closeSettingsFn}
|
||||
promptUpgrade={true}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let moreText;
|
||||
if (shownSpaces.length < this.state.restrictedAllowRoomIds.length) {
|
||||
if (shownSpaces.length > 0) {
|
||||
moreText = _t("& %(count)s more", {
|
||||
count: this.state.restrictedAllowRoomIds.length - shownSpaces.length,
|
||||
});
|
||||
} else {
|
||||
moreText = _t("Currently, %(count)s spaces have access", {
|
||||
count: this.state.restrictedAllowRoomIds.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
private onJoinRuleChangeError = (error: Error) => {
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
title: _t("Failed to update the join rules"),
|
||||
description: error.message ?? _t("Unknown failure"),
|
||||
});
|
||||
};
|
||||
|
||||
description = <div>
|
||||
<span>
|
||||
{ _t("Anyone in a space can find and join. <a>Edit which spaces can access here.</a>", {}, {
|
||||
a: sub => <AccessibleButton
|
||||
disabled={!canChangeJoinRule}
|
||||
onClick={this.onEditRestrictedClick}
|
||||
kind="link"
|
||||
>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
</span>
|
||||
|
||||
<div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
|
||||
<h4>{ _t("Spaces with access") }</h4>
|
||||
{ shownSpaces.map(room => {
|
||||
return <span key={room.roomId}>
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
{ room.name }
|
||||
</span>;
|
||||
}) }
|
||||
{ moreText && <span>{ moreText }</span> }
|
||||
</div>
|
||||
</div>;
|
||||
} else if (SpaceStore.instance.activeSpace) {
|
||||
description = _t("Anyone in %(spaceName)s can find and join. You can select other spaces too.", {
|
||||
spaceName: SpaceStore.instance.activeSpace.name,
|
||||
});
|
||||
} else {
|
||||
description = _t("Anyone in a space can find and join. You can select multiple spaces.");
|
||||
}
|
||||
|
||||
radioDefinitions.splice(1, 0, {
|
||||
value: JoinRule.Restricted,
|
||||
label: <>
|
||||
{ _t("Space members") }
|
||||
{ upgradeRequiredPill }
|
||||
</>,
|
||||
description,
|
||||
// if there are 0 allowed spaces then render it as invite only instead
|
||||
checked: this.state.joinRule === JoinRule.Restricted && !!this.state.restrictedAllowRoomIds?.length,
|
||||
private onBeforeJoinRuleChange = async (joinRule: JoinRule): Promise<boolean> => {
|
||||
if (this.state.encrypted && joinRule === JoinRule.Public) {
|
||||
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
|
||||
title: _t("Are you sure you want to make this encrypted room public?"),
|
||||
description: <div>
|
||||
<p> { _t(
|
||||
"<b>It's not recommended to make encrypted rooms public.</b> " +
|
||||
"It will mean anyone can find and join the room, so anyone can read messages. " +
|
||||
"You'll get none of the benefits of encryption. Encrypting messages in a public " +
|
||||
"room will make receiving and sending messages slower.",
|
||||
null,
|
||||
{ "b": (sub) => <b>{ sub }</b> },
|
||||
) } </p>
|
||||
<p> { _t(
|
||||
"To avoid these issues, create a <a>new public room</a> for the conversation " +
|
||||
"you plan to have.",
|
||||
null,
|
||||
{
|
||||
"a": (sub) => <a
|
||||
className="mx_linkButton"
|
||||
onClick={() => {
|
||||
dialog.close();
|
||||
this.createNewRoom(true, false);
|
||||
}}> { sub } </a>,
|
||||
},
|
||||
) } </p>
|
||||
</div>,
|
||||
});
|
||||
|
||||
const { finished } = dialog;
|
||||
const [confirm] = await finished;
|
||||
if (!confirm) return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SecurityRoomSettingsTab_joinRule">
|
||||
<div className="mx_SettingsTab_subsectionText">
|
||||
<span>{ _t("Decide who can join %(roomName)s.", {
|
||||
roomName: client.getRoom(this.props.roomId)?.name,
|
||||
}) }</span>
|
||||
</div>
|
||||
{ aliasWarning }
|
||||
<StyledRadioGroup
|
||||
name="joinRule"
|
||||
value={joinRule}
|
||||
onChange={this.onJoinRuleChange}
|
||||
definitions={radioDefinitions}
|
||||
disabled={!canChangeJoinRule}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private renderHistory() {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -635,7 +425,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
|||
}
|
||||
|
||||
let advanced;
|
||||
if (this.state.joinRule === JoinRule.Public) {
|
||||
if (room.getJoinRule() === JoinRule.Public) {
|
||||
advanced = (
|
||||
<>
|
||||
<AccessibleButton
|
||||
|
|
|
@ -56,7 +56,7 @@ export const createSpace = async (
|
|||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...isPublic ? { invite: 0 } : {},
|
||||
invite: isPublic ? 0 : 50,
|
||||
},
|
||||
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
|
||||
topic,
|
||||
|
@ -97,9 +97,8 @@ const spaceNameValidator = withValidation({
|
|||
],
|
||||
});
|
||||
|
||||
const nameToAlias = (name: string, domain: string): string => {
|
||||
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
|
||||
return `#${localpart}:${domain}`;
|
||||
const nameToLocalpart = (name: string): string => {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
|
||||
};
|
||||
|
||||
// XXX: Temporary for the Spaces release only
|
||||
|
@ -174,8 +173,9 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
|||
value={name}
|
||||
onChange={ev => {
|
||||
const newName = ev.target.value;
|
||||
if (!alias || alias === nameToAlias(name, domain)) {
|
||||
setAlias(nameToAlias(newName, domain));
|
||||
if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
|
||||
setAlias(`#${nameToLocalpart(newName)}:${domain}`);
|
||||
aliasFieldRef.current?.validate({ allowEmpty: true });
|
||||
}
|
||||
setName(newName);
|
||||
}}
|
||||
|
@ -192,7 +192,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
|||
onChange={setAlias}
|
||||
domain={domain}
|
||||
value={alias}
|
||||
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
|
||||
placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
|
||||
label={_t("Address")}
|
||||
disabled={busy}
|
||||
onKeyDown={onKeyDown}
|
||||
|
@ -215,6 +215,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
|||
};
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [visibility, setVisibility] = useState<Visibility>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
|
@ -231,14 +232,18 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
|
||||
setBusy(true);
|
||||
// require & validate the space name field
|
||||
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
|
||||
spaceNameField.current.focus();
|
||||
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// validate the space name alias field but do not require it
|
||||
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
|
||||
|
||||
// validate the space alias field but do not require it
|
||||
const aliasLocalpart = alias.substring(1, alias.length - cli.getDomain().length - 1);
|
||||
if (visibility === Visibility.Public && aliasLocalpart &&
|
||||
(await spaceAliasField.current.validate({ allowEmpty: true })) === false
|
||||
) {
|
||||
spaceAliasField.current.focus();
|
||||
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
|
||||
setBusy(false);
|
||||
|
@ -246,7 +251,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
|
||||
await createSpace(
|
||||
name,
|
||||
visibility === Visibility.Public,
|
||||
aliasLocalpart ? alias : undefined,
|
||||
topic,
|
||||
avatar,
|
||||
);
|
||||
|
||||
onFinished();
|
||||
} catch (e) {
|
||||
|
|
|
@ -25,49 +25,22 @@ import AccessibleButton from "../elements/AccessibleButton";
|
|||
import AliasSettings from "../room_settings/AliasSettings";
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
import { useLocalEcho } from "../../../hooks/useLocalEcho";
|
||||
import JoinRuleSettings from "../settings/JoinRuleSettings";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
space: Room;
|
||||
closeSettingsFn(): void;
|
||||
}
|
||||
|
||||
enum SpaceVisibility {
|
||||
Unlisted = "unlisted",
|
||||
Private = "private",
|
||||
}
|
||||
|
||||
const useLocalEcho = <T extends any>(
|
||||
currentFactory: () => T,
|
||||
setterFn: (value: T) => Promise<unknown>,
|
||||
errorFn: (error: Error) => void,
|
||||
): [value: T, handler: (value: T) => void] => {
|
||||
const [value, setValue] = useState(currentFactory);
|
||||
const handler = async (value: T) => {
|
||||
setValue(value);
|
||||
try {
|
||||
await setterFn(value);
|
||||
} catch (e) {
|
||||
setValue(currentFactory());
|
||||
errorFn(e);
|
||||
}
|
||||
};
|
||||
|
||||
return [value, handler];
|
||||
};
|
||||
|
||||
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
|
||||
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space, closeSettingsFn }: IProps) => {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const userId = cli.getUserId();
|
||||
|
||||
const [visibility, setVisibility] = useLocalEcho<SpaceVisibility>(
|
||||
() => space.getJoinRule() === JoinRule.Invite ? SpaceVisibility.Private : SpaceVisibility.Unlisted,
|
||||
visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, {
|
||||
join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Invite,
|
||||
}, ""),
|
||||
() => setError(_t("Failed to update the visibility of this space")),
|
||||
);
|
||||
const joinRule = useRoomState(space, state => state.getJoinRule());
|
||||
const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>(
|
||||
() => space.currentState.getStateEvents(EventType.RoomGuestAccess, "")
|
||||
?.getContent()?.guest_access === GuestAccess.CanJoin,
|
||||
|
@ -87,14 +60,13 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
|
|||
|
||||
const [showAdvancedSection, toggleAdvancedSection] = useStateToggle();
|
||||
|
||||
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
|
||||
const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId);
|
||||
const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId);
|
||||
const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli);
|
||||
const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
|
||||
|
||||
let advancedSection;
|
||||
if (visibility === SpaceVisibility.Unlisted) {
|
||||
if (joinRule === JoinRule.Public) {
|
||||
if (showAdvancedSection) {
|
||||
advancedSection = <>
|
||||
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
|
||||
|
@ -123,7 +95,7 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
|
|||
}
|
||||
|
||||
let addressesSection;
|
||||
if (visibility !== SpaceVisibility.Private) {
|
||||
if (space.getJoinRule() === JoinRule.Public) {
|
||||
addressesSection = <>
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Address") }</span>
|
||||
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
|
||||
|
@ -149,22 +121,10 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<StyledRadioGroup
|
||||
name="spaceVisibility"
|
||||
value={visibility}
|
||||
onChange={setVisibility}
|
||||
disabled={!canSetJoinRule}
|
||||
definitions={[
|
||||
{
|
||||
value: SpaceVisibility.Unlisted,
|
||||
label: _t("Public"),
|
||||
description: _t("anyone with the link can view and join"),
|
||||
}, {
|
||||
value: SpaceVisibility.Private,
|
||||
label: _t("Invite only"),
|
||||
description: _t("only invited people can view and join"),
|
||||
},
|
||||
]}
|
||||
<JoinRuleSettings
|
||||
room={space}
|
||||
onError={() => setError(_t("Failed to update the visibility of this space"))}
|
||||
closeSettingsFn={closeSettingsFn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -93,6 +93,7 @@ export const SpaceButton: React.FC<IButtonProps> = ({
|
|||
notification={notificationState}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabIndex}
|
||||
showUnsentTooltip={true}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
@ -260,7 +261,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
|
||||
render() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
|
||||
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, dragHandleProps,
|
||||
...otherProps } = this.props;
|
||||
|
||||
const collapsed = this.isCollapsed;
|
||||
|
@ -299,7 +300,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
/> : null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tabIndex, ...dragHandleProps } = this.props.dragHandleProps || {};
|
||||
const { tabIndex, ...restDragHandleProps } = dragHandleProps || {};
|
||||
|
||||
return (
|
||||
<li
|
||||
|
@ -310,7 +311,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
role="treeitem"
|
||||
>
|
||||
<SpaceButton
|
||||
{...dragHandleProps}
|
||||
{...restDragHandleProps}
|
||||
space={space}
|
||||
className={isInvite ? "mx_SpaceButton_invite" : undefined}
|
||||
selected={activeSpaces.includes(space)}
|
||||
|
|
|
@ -273,14 +273,18 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onScreenshareClick = async (): Promise<void> => {
|
||||
const isScreensharing = await this.props.call.setScreensharingEnabled(
|
||||
!this.state.screensharing,
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
let isScreensharing;
|
||||
if (this.state.screensharing) {
|
||||
isScreensharing = await this.props.call.setScreensharingEnabled(false);
|
||||
} else {
|
||||
if (window.electron?.getDesktopCapturerSources) {
|
||||
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
},
|
||||
);
|
||||
isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
|
||||
} else {
|
||||
isScreensharing = await this.props.call.setScreensharingEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
sidebarShown: true,
|
||||
|
|
|
@ -32,13 +32,20 @@ export default class Range {
|
|||
this._end = bIsLarger ? positionB : positionA;
|
||||
}
|
||||
|
||||
public moveStart(delta: number): void {
|
||||
public moveStartForwards(delta: number): void {
|
||||
this._start = this._start.forwardsWhile(this.model, () => {
|
||||
delta -= 1;
|
||||
return delta >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
public moveEndBackwards(delta: number): void {
|
||||
this._end = this._end.backwardsWhile(this.model, () => {
|
||||
delta -= 1;
|
||||
return delta >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
public trim(): void {
|
||||
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
|
||||
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
|
||||
|
|
|
@ -20,7 +20,11 @@ import type { EventEmitter } from "events";
|
|||
type Handler = (...args: any[]) => void;
|
||||
|
||||
// Hook to wrap event emitter on and removeListener in hook lifecycle
|
||||
export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbol, handler: Handler) => {
|
||||
export const useEventEmitter = (
|
||||
emitter: EventEmitter | undefined,
|
||||
eventName: string | symbol,
|
||||
handler: Handler,
|
||||
) => {
|
||||
// Create a ref that stores handler
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
|
@ -51,7 +55,11 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
|
|||
|
||||
type Mapper<T> = (...args: any[]) => T;
|
||||
|
||||
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => {
|
||||
export const useEventEmitterState = <T>(
|
||||
emitter: EventEmitter | undefined,
|
||||
eventName: string | symbol,
|
||||
fn: Mapper<T>,
|
||||
): T => {
|
||||
const [value, setValue] = useState<T>(fn());
|
||||
const handler = useCallback((...args: any[]) => {
|
||||
setValue(fn(...args));
|
||||
|
|
36
src/hooks/useLocalEcho.ts
Normal file
36
src/hooks/useLocalEcho.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export const useLocalEcho = <T extends any>(
|
||||
currentFactory: () => T,
|
||||
setterFn: (value: T) => Promise<unknown>,
|
||||
errorFn: (error: Error) => void,
|
||||
): [value: T, handler: (value: T) => void] => {
|
||||
const [value, setValue] = useState(currentFactory);
|
||||
const handler = async (value: T) => {
|
||||
setValue(value);
|
||||
try {
|
||||
await setterFn(value);
|
||||
} catch (e) {
|
||||
setValue(currentFactory());
|
||||
errorFn(e);
|
||||
}
|
||||
};
|
||||
|
||||
return [value, handler];
|
||||
};
|
|
@ -25,7 +25,7 @@ const defaultMapper: Mapper<RoomState> = (roomState: RoomState) => roomState;
|
|||
|
||||
// Hook to simplify watching Matrix Room state
|
||||
export const useRoomState = <T extends any = RoomState>(
|
||||
room: Room,
|
||||
room?: Room,
|
||||
mapper: Mapper<T> = defaultMapper as Mapper<T>,
|
||||
): T => {
|
||||
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);
|
||||
|
|
|
@ -1060,7 +1060,6 @@
|
|||
"Saving...": "Saving...",
|
||||
"Save Changes": "Save Changes",
|
||||
"Leave Space": "Leave Space",
|
||||
"Failed to update the visibility of this space": "Failed to update the visibility of this space",
|
||||
"Failed to update the guest access of this space": "Failed to update the guest access of this space",
|
||||
"Failed to update the history visibility of this space": "Failed to update the history visibility of this space",
|
||||
"Hide advanced": "Hide advanced",
|
||||
|
@ -1070,9 +1069,7 @@
|
|||
"Show advanced": "Show advanced",
|
||||
"Visibility": "Visibility",
|
||||
"Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.",
|
||||
"anyone with the link can view and join": "anyone with the link can view and join",
|
||||
"Invite only": "Invite only",
|
||||
"only invited people can view and join": "only invited people can view and join",
|
||||
"Failed to update the visibility of this space": "Failed to update the visibility of this space",
|
||||
"Preview Space": "Preview Space",
|
||||
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.",
|
||||
"Recommended for public spaces.": "Recommended for public spaces.",
|
||||
|
@ -1146,6 +1143,20 @@
|
|||
"Connecting to integration manager...": "Connecting to integration manager...",
|
||||
"Cannot connect to integration manager": "Cannot connect to integration manager",
|
||||
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
|
||||
"Private (invite only)": "Private (invite only)",
|
||||
"Only invited people can join.": "Only invited people can join.",
|
||||
"Anyone can find and join.": "Anyone can find and join.",
|
||||
"Upgrade required": "Upgrade required",
|
||||
"& %(count)s more|other": "& %(count)s more",
|
||||
"& %(count)s more|one": "& %(count)s more",
|
||||
"Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
|
||||
"Currently, %(count)s spaces have access|one": "Currently, a space has access",
|
||||
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
|
||||
"Spaces with access": "Spaces with access",
|
||||
"Anyone in <spaceName/> can find and join. You can select other spaces too.": "Anyone in <spaceName/> can find and join. You can select other spaces too.",
|
||||
"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",
|
||||
"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.",
|
||||
"Message layout": "Message layout",
|
||||
"IRC": "IRC",
|
||||
"Modern": "Modern",
|
||||
|
@ -1416,16 +1427,6 @@
|
|||
"Notification sound": "Notification sound",
|
||||
"Set a new custom sound": "Set a new custom sound",
|
||||
"Browse": "Browse",
|
||||
"Change room avatar": "Change room avatar",
|
||||
"Change room name": "Change room name",
|
||||
"Change main address for the room": "Change main address for the room",
|
||||
"Change history visibility": "Change history visibility",
|
||||
"Change permissions": "Change permissions",
|
||||
"Change topic": "Change topic",
|
||||
"Upgrade the room": "Upgrade the room",
|
||||
"Enable room encryption": "Enable room encryption",
|
||||
"Change server ACLs": "Change server ACLs",
|
||||
"Modify widgets": "Modify widgets",
|
||||
"Failed to unban": "Failed to unban",
|
||||
"Unban": "Unban",
|
||||
"Banned by %(displayName)s": "Banned by %(displayName)s",
|
||||
|
@ -1434,6 +1435,20 @@
|
|||
"An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.",
|
||||
"Error changing power level": "Error changing power level",
|
||||
"An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.",
|
||||
"Change space avatar": "Change space avatar",
|
||||
"Change room avatar": "Change room avatar",
|
||||
"Change space name": "Change space name",
|
||||
"Change room name": "Change room name",
|
||||
"Change main address for the space": "Change main address for the space",
|
||||
"Change main address for the room": "Change main address for the room",
|
||||
"Change history visibility": "Change history visibility",
|
||||
"Change permissions": "Change permissions",
|
||||
"Change description": "Change description",
|
||||
"Change topic": "Change topic",
|
||||
"Upgrade the room": "Upgrade the room",
|
||||
"Enable room encryption": "Enable room encryption",
|
||||
"Change server ACLs": "Change server ACLs",
|
||||
"Modify widgets": "Modify widgets",
|
||||
"Default role": "Default role",
|
||||
"Send messages": "Send messages",
|
||||
"Invite users": "Invite users",
|
||||
|
@ -1448,31 +1463,20 @@
|
|||
"Banned users": "Banned users",
|
||||
"Send %(eventType)s events": "Send %(eventType)s events",
|
||||
"Permissions": "Permissions",
|
||||
"Select the roles required to change various parts of the space": "Select the roles required to change various parts of the space",
|
||||
"Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
|
||||
"Are you sure you want to add encryption to this public room?": "Are you sure you want to add encryption to this public room?",
|
||||
"<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.",
|
||||
"To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.",
|
||||
"Enable encryption?": "Enable encryption?",
|
||||
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
|
||||
"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.",
|
||||
"To link to this room, please add an address.": "To link to this room, please add an address.",
|
||||
"Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.",
|
||||
"Failed to update the join rules": "Failed to update the join rules",
|
||||
"Unknown failure": "Unknown failure",
|
||||
"Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?",
|
||||
"<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.",
|
||||
"To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.",
|
||||
"To link to this room, please add an address.": "To link to this room, please add an address.",
|
||||
"Private (invite only)": "Private (invite only)",
|
||||
"Only invited people can join.": "Only invited people can join.",
|
||||
"Anyone can find and join.": "Anyone can find and join.",
|
||||
"Upgrade required": "Upgrade required",
|
||||
"& %(count)s more|other": "& %(count)s more",
|
||||
"& %(count)s more|one": "& %(count)s more",
|
||||
"Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
|
||||
"Currently, %(count)s spaces have access|one": "Currently, a space has access",
|
||||
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
|
||||
"Spaces with access": "Spaces with access",
|
||||
"Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
|
||||
"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",
|
||||
"Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.",
|
||||
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
|
||||
"Members only (since they were invited)": "Members only (since they were invited)",
|
||||
"Members only (since they joined)": "Members only (since they joined)",
|
||||
|
@ -1589,6 +1593,7 @@
|
|||
"Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.",
|
||||
"Enable encryption in settings.": "Enable encryption in settings.",
|
||||
"End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled",
|
||||
"Message didn't send. Click for info.": "Message didn't send. Click for info.",
|
||||
"Unpin": "Unpin",
|
||||
"View message": "View message",
|
||||
"%(duration)ss": "%(duration)ss",
|
||||
|
|
|
@ -629,11 +629,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
};
|
||||
|
||||
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
|
||||
const membership = newMembership || room.getMyMembership();
|
||||
const roomMembership = room.getMyMembership();
|
||||
if (!roomMembership) {
|
||||
// room is still being baked in the js-sdk, we'll process it at Room.myMembership instead
|
||||
return;
|
||||
}
|
||||
const membership = newMembership || roomMembership;
|
||||
|
||||
if (!room.isSpaceRoom()) {
|
||||
// this.onRoomUpdate(room);
|
||||
this.onRoomsUpdate();
|
||||
// this.onRoomsUpdate();
|
||||
// ideally we only need onRoomsUpdate here but it doesn't rebuild parentMap so always adds new rooms to Home
|
||||
this.rebuild();
|
||||
|
||||
if (membership === "join") {
|
||||
// the user just joined a room, remove it from the suggested list if it was there
|
||||
|
@ -845,10 +852,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
|||
break;
|
||||
|
||||
case Action.SwitchSpace:
|
||||
if (payload.num === 0) {
|
||||
// 1 is Home, 2-9 are the spaces after Home
|
||||
if (payload.num === 1) {
|
||||
this.setActiveSpace(null);
|
||||
} else if (this.spacePanelSpaces.length >= payload.num) {
|
||||
this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]);
|
||||
this.setActiveSpace(this.spacePanelSpaces[payload.num - 2]);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ export class ListNotificationState extends NotificationState {
|
|||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
return this._color === NotificationColor.Unsent ? "!" : null;
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
|
|
|
@ -21,4 +21,5 @@ export enum NotificationColor {
|
|||
Bold, // no badge, show as unread
|
||||
Grey, // unread notified messages
|
||||
Red, // unread pings
|
||||
Unsent, // some messages failed to send
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
import * as RoomNotifs from '../../RoomNotifs';
|
||||
import * as Unread from '../../Unread';
|
||||
import { NotificationState } from "./NotificationState";
|
||||
import { getUnsentMessages } from "../../components/structures/RoomStatusBar";
|
||||
|
||||
export class RoomNotificationState extends NotificationState implements IDestroyable {
|
||||
constructor(public readonly room: Room) {
|
||||
|
@ -32,6 +33,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
|
|||
this.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.myMembership", this.handleMembershipUpdate);
|
||||
this.room.on("Room.localEchoUpdated", this.handleLocalEchoUpdated);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate);
|
||||
this.updateNotificationState();
|
||||
|
@ -47,12 +49,17 @@ export class RoomNotificationState extends NotificationState implements IDestroy
|
|||
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.myMembership", this.handleMembershipUpdate);
|
||||
this.room.removeListener("Room.localEchoUpdated", this.handleLocalEchoUpdated);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private handleLocalEchoUpdated = () => {
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
|
||||
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
|
||||
if (room.roomId !== this.room.roomId) return; // not for us - ignore
|
||||
|
@ -79,7 +86,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy
|
|||
private updateNotificationState() {
|
||||
const snapshot = this.snapshot();
|
||||
|
||||
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
|
||||
if (getUnsentMessages(this.room).length > 0) {
|
||||
// When there are unsent messages we show a red `!`
|
||||
this._color = NotificationColor.Unsent;
|
||||
this._symbol = "!";
|
||||
this._count = 1; // not used, technically
|
||||
} else if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
|
||||
// When muted we suppress all notification states, even if we have context on them.
|
||||
this._color = NotificationColor.None;
|
||||
this._symbol = null;
|
||||
|
|
|
@ -31,7 +31,7 @@ export class SpaceNotificationState extends NotificationState {
|
|||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
return this._color === NotificationColor.Unsent ? "!" : null;
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
|
@ -54,7 +54,7 @@ export class SpaceNotificationState extends NotificationState {
|
|||
}
|
||||
|
||||
public getFirstRoomWithNotifications() {
|
||||
return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId;
|
||||
return Object.values(this.states).find(state => state.color >= this.color)?.room.roomId;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
@ -83,4 +83,3 @@ export class SpaceNotificationState extends NotificationState {
|
|||
this.emitIfUpdated(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue