Merge branch 'develop' into sort-imports

Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
Aaron Raimist 2021-12-09 08:34:20 +00:00
commit 7b94e13a84
642 changed files with 30052 additions and 8035 deletions

View file

@ -41,7 +41,7 @@ import UserActivity from "../UserActivity";
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import { SpaceStoreClass } from "../stores/SpaceStore";
import { SpaceStoreClass } from "../stores/spaces/SpaceStore";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
@ -100,6 +100,7 @@ declare global {
mxOnRecaptchaLoaded?: () => void;
electron?: Electron;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;
mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise<void>;
}
interface DesktopCapturerSource {

View file

@ -392,16 +392,26 @@ export class Analytics {
];
// FIXME: Using an import will result in test failures
const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl;
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
const cookiePolicyLink = _t(
"Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.",
{},
{
"CookiePolicyLink": (sub) => {
return <a href={cookiePolicyUrl} target="_blank" rel="noreferrer noopener">{ sub }</a>;
},
});
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div className="mx_AnalyticsModal">
<div>{ _t('The information being sent to us to help make %(brand)s better includes:', {
{ cookiePolicyUrl && <p>{ cookiePolicyLink }</p> }
<div>{ _t('Some examples of the information being sent to us to help make %(brand)s better includes:', {
brand: SdkConfig.get().brand,
}) }</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
<td>{ _t(
<td className="mx_AnalyticsModal_label">{ _t(
customVariables[row[0]].expl,
customVariables[row[0]].getTextVariables ?
customVariables[row[0]].getTextVariables() :

View file

@ -22,7 +22,7 @@ import { split } from "lodash";
import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media";
import SpaceStore from "./stores/SpaceStore";
import SpaceStore from "./stores/spaces/SpaceStore";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(

View file

@ -20,6 +20,8 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import dis from './dispatcher/dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
@ -168,9 +170,9 @@ export default abstract class BasePlatform {
*/
abstract requestNotificationPermission(): Promise<string>;
abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Object);
abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Room);
loudNotification(ev: Event, room: Object) {
loudNotification(ev: MatrixEvent, room: Room) {
}
clearNotification(notif: Notification) {
@ -317,7 +319,9 @@ export default abstract class BasePlatform {
let data;
try {
data = await idbLoad("pickleKey", [userId, deviceId]);
} catch (e) {}
} catch (e) {
logger.error("idbLoad for pickleKey failed", e);
}
if (!data) {
return null;
}
@ -396,6 +400,8 @@ export default abstract class BasePlatform {
async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await idbDelete("pickleKey", [userId, deviceId]);
} catch (e) {}
} catch (e) {
logger.error("idbDelete failed in destroyPickleKey", e);
}
}
}

View file

@ -17,59 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/*
* Manages a list of all the currently active calls.
*
* This handler dispatches when voip calls are added/updated/removed from this list:
* {
* action: 'call_state'
* room_id: <room ID of the call>
* }
*
* To know the state of the call, this handler exposes a getter to
* obtain the call for a room:
* var call = CallHandler.getCall(roomId)
* var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
*
* This handler listens for and handles the following actions:
* {
* action: 'place_call',
* type: 'voice|video',
* room_id: <room that the place call button was pressed in>
* }
*
* {
* action: 'incoming_call'
* call: MatrixCall
* }
*
* {
* action: 'hangup'
* room_id: <room that the hangup button was pressed in>
* }
*
* {
* action: 'answer'
* room_id: <room that the answer button was pressed in>
* }
*/
import React from 'react';
import { base32 } from "rfc4648";
import {
MatrixCall,
CallErrorCode,
CallState,
CallEvent,
CallParty,
CallType,
CallError,
} from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
@ -80,23 +28,33 @@ import SettingsStore from './settings/SettingsStore';
import { Jitsi } from "./widgets/Jitsi";
import { WidgetType } from "./widgets/WidgetType";
import { SettingLevel } from "./settings/SettingLevel";
import { ActionPayload } from "./dispatcher/payloads";
import { base32 } from "rfc4648";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore";
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
import Analytics from './Analytics';
import CountlyAnalytics from "./CountlyAnalytics";
import { UIFeature } from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom';
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import ToastStore from './stores/ToastStore';
import IncomingCallToast from "./toasts/IncomingCallToast";
import { SyncState } from "matrix-js-sdk/src/sync.api";
export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
@ -138,24 +96,24 @@ interface ThirdpartyLookupResponse {
fields: ThirdpartyLookupResponseFields;
}
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
}
export enum CallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
CallState = "call_state",
}
/**
* CallHandler manages all currently active calls. It should be used for
* placing, answering, rejecting and hanging up calls. It also handles ringing,
* PSTN support and other things.
*/
export default class CallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
@ -171,7 +129,7 @@ export default class CallHandler extends EventEmitter {
private silencedCalls = new Set<string>(); // callIds
static sharedInstance() {
public static get instance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler();
}
@ -199,8 +157,7 @@ export default class CallHandler extends EventEmitter {
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
}
start() {
this.dispatcherRef = dis.register(this.onAction);
public start(): void {
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
@ -220,18 +177,14 @@ export default class CallHandler extends EventEmitter {
this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
}
stop() {
public stop(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('Call.incoming', this.onCallIncoming);
}
if (this.dispatcherRef !== null) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
}
}
public silenceCall(callId: string) {
public silenceCall(callId: string): void {
this.silencedCalls.add(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
@ -240,7 +193,7 @@ export default class CallHandler extends EventEmitter {
this.pause(AudioID.Ring);
}
public unSilenceCall(callId: string) {
public unSilenceCall(callId: string): void {
this.silencedCalls.delete(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.play(AudioID.Ring);
@ -266,7 +219,7 @@ export default class CallHandler extends EventEmitter {
return false;
}
private async checkProtocols(maxTries) {
private async checkProtocols(maxTries: number): Promise<void> {
try {
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
@ -301,11 +254,11 @@ export default class CallHandler extends EventEmitter {
}
}
public getSupportsPstnProtocol() {
public getSupportsPstnProtocol(): boolean {
return this.supportsPstnProtocol;
}
public getSupportsVirtualRooms() {
public getSupportsVirtualRooms(): boolean {
return this.supportsSipNativeVirtual;
}
@ -333,14 +286,32 @@ export default class CallHandler extends EventEmitter {
);
}
private onCallIncoming = (call) => {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
private onCallIncoming = (call: MatrixCall): void => {
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
logger.log(
"Got incoming call for room " + mappedRoomId +
" but there's already a call for this room: ignoring",
);
return;
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.addCallForRoom(mappedRoomId, call);
this.setCallListeners(call);
// Explicitly handle first state change
this.onCallStateChanged(call.state, null, call);
// get ready to send encrypted events in the room, so if the user does answer
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
// the mapped one: that's where we'll send the events.
const cli = MatrixClientPeg.get();
cli.prepareToEncrypt(cli.getRoom(call.roomId));
};
public getCallById(callId: string): MatrixCall {
@ -350,11 +321,11 @@ export default class CallHandler extends EventEmitter {
return null;
}
getCallForRoom(roomId: string): MatrixCall {
public getCallForRoom(roomId: string): MatrixCall | null {
return this.calls.get(roomId) || null;
}
getAnyActiveCall() {
public getAnyActiveCall(): MatrixCall | null {
for (const call of this.calls.values()) {
if (call.state !== CallState.Ended) {
return call;
@ -363,7 +334,7 @@ export default class CallHandler extends EventEmitter {
return null;
}
getAllActiveCalls() {
public getAllActiveCalls(): MatrixCall[] {
const activeCalls = [];
for (const call of this.calls.values()) {
@ -374,7 +345,7 @@ export default class CallHandler extends EventEmitter {
return activeCalls;
}
getAllActiveCallsNotInRoom(notInThisRoomId) {
public getAllActiveCallsNotInRoom(notInThisRoomId: string): MatrixCall[] {
const callsNotInThatRoom = [];
for (const [roomId, call] of this.calls.entries()) {
@ -385,11 +356,21 @@ export default class CallHandler extends EventEmitter {
return callsNotInThatRoom;
}
getTransfereeForCallId(callId: string): MatrixCall {
public getAllActiveCallsForPip(roomId: string) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
// This checks if there is space for the call view in the aux panel
// If there is no space any call should be displayed in PiP
return this.getAllActiveCalls();
}
return this.getAllActiveCallsNotInRoom(roomId);
}
public getTransfereeForCallId(callId: string): MatrixCall {
return this.transferees[callId];
}
play(audioId: AudioID) {
public play(audioId: AudioID): void {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -418,7 +399,7 @@ export default class CallHandler extends EventEmitter {
}
}
pause(audioId: AudioID) {
public pause(audioId: AudioID): void {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -432,7 +413,7 @@ export default class CallHandler extends EventEmitter {
}
}
private matchesCallForThisRoom(call: MatrixCall) {
private matchesCallForThisRoom(call: MatrixCall): boolean {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
@ -442,7 +423,7 @@ export default class CallHandler extends EventEmitter {
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
private setCallListeners(call: MatrixCall): void {
let mappedRoomId = this.roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => {
@ -537,6 +518,11 @@ export default class CallHandler extends EventEmitter {
const mappedRoomId = this.roomIdForCall(call);
this.setCallState(call, newState);
dis.dispatch({
action: 'call_state',
room_id: mappedRoomId,
state: newState,
});
switch (oldState) {
case CallState.Ringing:
@ -615,7 +601,7 @@ export default class CallHandler extends EventEmitter {
}
};
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
private async logCallStats(call: MatrixCall, mappedRoomId: string): Promise<void> {
const stats = await call.getCurrentCallStats();
logger.debug(
`Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
@ -658,8 +644,8 @@ export default class CallHandler extends EventEmitter {
}
}
private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
private setCallState(call: MatrixCall, status: CallState): void {
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
logger.log(
`Call state in ${mappedRoomId} changed to ${status}`,
@ -678,20 +664,16 @@ export default class CallHandler extends EventEmitter {
ToastStore.sharedInstance().dismissToast(toastKey);
}
dis.dispatch({
action: 'call_state',
room_id: mappedRoomId,
state: status,
});
this.emit(CallHandlerEvent.CallState, mappedRoomId, status);
}
private removeCallForRoom(roomId: string) {
private removeCallForRoom(roomId: string): void {
logger.log("Removing call for room ", roomId);
this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
}
private showICEFallbackPrompt() {
private showICEFallbackPrompt(): void {
const cli = MatrixClientPeg.get();
const code = sub => <code>{ sub }</code>;
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
@ -720,7 +702,7 @@ export default class CallHandler extends EventEmitter {
}, null, true);
}
private showMediaCaptureError(call: MatrixCall) {
private showMediaCaptureError(call: MatrixCall): void {
let title;
let description;
@ -749,9 +731,9 @@ export default class CallHandler extends EventEmitter {
}, null, true);
}
private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
private async placeMatrixCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise<void> {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.Video, false);
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
@ -777,7 +759,7 @@ export default class CallHandler extends EventEmitter {
this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) {
if (type === CallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall();
@ -786,166 +768,110 @@ export default class CallHandler extends EventEmitter {
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'place_call':
{
// We might be using managed hybrid widgets
if (isManagedHybridWidgetEnabled()) {
addManagedHybridWidget(payload.room_id);
return;
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
// don't allow > 2 calls to be placed.
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
logger.error(`Room ${payload.room_id} does not exist.`);
return;
}
// We leave the check for whether there's already a call in this room until later,
// otherwise it can race.
const members = room.getJoinedMembers();
if (members.length <= 1) {
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
return;
} else if (members.length === 2) {
logger.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall(payload.room_id, payload.type, payload.transferee);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
});
}
}
break;
case 'place_conference_call':
logger.info("Place conference call in " + payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
this.startCallApp(payload.room_id, payload.type);
break;
case 'end_conference':
logger.info("Terminating conference call in " + payload.room_id);
this.terminateCallApp(payload.room_id);
break;
case 'hangup_conference':
logger.info("Leaving conference call in "+ payload.room_id);
this.hangupCallApp(payload.room_id);
break;
case 'incoming_call':
{
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
logger.log(
"Got incoming call for room " + mappedRoomId +
" but there's already a call for this room: ignoring",
);
return;
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.addCallForRoom(mappedRoomId, call);
this.setCallListeners(call);
// Explicitly handle first state change
this.onCallStateChanged(call.state, null, call);
// get ready to send encrypted events in the room, so if the user does answer
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
// the mapped one: that's where we'll send the events.
const cli = MatrixClientPeg.get();
cli.prepareToEncrypt(cli.getRoom(call.roomId));
}
break;
case 'hangup':
case 'reject':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
if (payload.action === 'reject') {
this.calls.get(payload.room_id).reject();
} else {
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
}
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
break;
case 'hangup_all':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const call = this.calls.get(payload.room_id);
call.answer();
this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({
action: "view_room",
room_id: payload.room_id,
});
break;
}
case Action.DialNumber:
this.dialNumber(payload.number);
break;
case Action.TransferCallToMatrixID:
this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
break;
case Action.TransferCallToPhoneNumber:
this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
break;
public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void {
// We might be using managed hybrid widgets
if (isManagedHybridWidgetEnabled()) {
addManagedHybridWidget(roomId);
return;
}
};
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('Calls are unsupported'),
description: _t('You cannot place calls in this browser.'),
});
return;
}
if (MatrixClientPeg.get().getSyncState() === SyncState.Error) {
Modal.createTrackedDialog('Call Handler', 'Sync error', ErrorDialog, {
title: _t('Connectivity to the server has been lost'),
description: _t('You cannot place calls without a connection to the server.'),
});
return;
}
// don't allow > 2 calls to be placed.
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
logger.error(`Room ${roomId} does not exist.`);
return;
}
// We leave the check for whether there's already a call in this room until later,
// otherwise it can race.
const members = room.getJoinedMembers();
if (members.length <= 1) {
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
} else if (members.length === 2) {
logger.info(`Place ${type} call in ${roomId}`);
this.placeMatrixCall(roomId, type, transferee);
} else { // > 2
this.placeJitsiCall(roomId, type);
}
}
public hangupAllCalls(): void {
for (const call of this.calls.values()) {
this.stopRingingIfPossible(call.callId);
call.hangup(CallErrorCode.UserHangup, false);
}
}
public hangupOrReject(roomId: string, reject?: boolean): void {
const call = this.calls.get(roomId);
// no call to hangup
if (!call) return;
this.stopRingingIfPossible(call.callId);
if (reject) {
call.reject();
} else {
call.hangup(CallErrorCode.UserHangup, false);
}
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
}
public answerCall(roomId: string): void {
const call = this.calls.get(roomId);
this.stopRingingIfPossible(call.callId);
// no call to answer
if (!this.calls.has(roomId)) return;
if (this.getAllActiveCalls().length > 1) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Too Many Calls'),
description: _t("You've reached the maximum number of simultaneous calls."),
});
return;
}
call.answer();
this.setActiveCallRoomId(roomId);
CountlyAnalytics.instance.trackJoinCall(roomId, call.type === CallType.Video, false);
dis.dispatch({
action: Action.ViewRoom,
room_id: roomId,
});
}
private stopRingingIfPossible(callId: string): void {
this.silencedCalls.delete(callId);
@ -953,7 +879,7 @@ export default class CallHandler extends EventEmitter {
this.pause(AudioID.Ring);
}
private async dialNumber(number: string) {
public async dialNumber(number: string): Promise<void> {
const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
@ -979,14 +905,16 @@ export default class CallHandler extends EventEmitter {
const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId);
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
});
await this.placeCall(roomId, PlaceCallType.Voice, null);
await this.placeMatrixCall(roomId, CallType.Voice, null);
}
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
public async startTransferToPhoneNumber(
call: MatrixCall, destination: string, consultFirst: boolean,
): Promise<void> {
const results = await this.pstnLookup(destination);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
@ -999,18 +927,15 @@ export default class CallHandler extends EventEmitter {
await this.startTransferToMatrixID(call, results[0].userid, consultFirst);
}
private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) {
public async startTransferToMatrixID(
call: MatrixCall, destination: string, consultFirst: boolean,
): Promise<void> {
if (consultFirst) {
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination);
this.placeCall(dmRoomId, call.type, call);
dis.dispatch({
action: 'place_call',
type: call.type,
room_id: dmRoomId,
transferee: call,
});
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: dmRoomId,
should_peek: false,
joining: false,
@ -1028,7 +953,7 @@ export default class CallHandler extends EventEmitter {
}
}
setActiveCallRoomId(activeCallRoomId: string) {
public setActiveCallRoomId(activeCallRoomId: string): void {
logger.info("Setting call in room " + activeCallRoomId + " active");
for (const [roomId, call] of this.calls.entries()) {
@ -1046,7 +971,7 @@ export default class CallHandler extends EventEmitter {
/**
* @returns true if we are currently in any call where we haven't put the remote party on hold
*/
hasAnyUnheldCall() {
public hasAnyUnheldCall(): boolean {
for (const call of this.calls.values()) {
if (call.state === CallState.Ended) continue;
if (!call.isRemoteOnHold()) return true;
@ -1055,7 +980,11 @@ export default class CallHandler extends EventEmitter {
return false;
}
private async startCallApp(roomId: string, type: string) {
private async placeJitsiCall(roomId: string, type: string): Promise<void> {
logger.info("Place conference call in " + roomId);
Analytics.trackEvent('voip', 'placeConferenceCall');
CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.Video, true);
dis.dispatch({
action: 'appsDrawer',
show: true,
@ -1121,7 +1050,9 @@ export default class CallHandler extends EventEmitter {
});
}
private terminateCallApp(roomId: string) {
public terminateCallApp(roomId: string): void {
logger.info("Terminating conference call in " + roomId);
Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
hasCancelButton: true,
title: _t("End conference"),
@ -1142,7 +1073,9 @@ export default class CallHandler extends EventEmitter {
});
}
private hangupCallApp(roomId: string) {
public hangupCallApp(roomId: string): void {
logger.info("Leaving conference call in " + roomId);
const roomInfo = WidgetStore.instance.getRoom(roomId);
if (!roomInfo) return; // "should never happen" clauses go here

View file

@ -18,19 +18,17 @@ limitations under the License.
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { IUploadOpts } from "matrix-js-sdk/src/@types/requests";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { logger } from "matrix-js-sdk/src/logger";
import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
import { IEncryptedFile, IMediaEventInfo } from "./customisations/models/IMediaEventContent";
import { IUploadOpts } from "matrix-js-sdk/src/@types/requests";
import { MsgType, RelationType } from "matrix-js-sdk/src/@types/event";
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract";
import Spinner from "./components/views/elements/Spinner";
import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
@ -42,10 +40,14 @@ import {
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import { IUpload } from "./models/IUpload";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { BlurhashEncoder } from "./BlurhashEncoder";
import SettingsStore from "./settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
import { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation } from "matrix-js-sdk/src";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -417,9 +419,16 @@ export default class ContentMessages {
private inprogress: IUpload[] = [];
private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
sendStickerContentToRoom(
url: string,
roomId: string,
threadId: string | null,
info: IImageInfo,
text: string,
matrixClient: MatrixClient,
) {
const startTime = CountlyAnalytics.getTimestamp();
const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => {
const prom = matrixClient.sendStickerMessage(roomId, threadId, url, info, text).catch((e) => {
logger.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
});
@ -435,7 +444,12 @@ export default class ContentMessages {
}
}
async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
async sendContentListToRoom(
files: File[],
roomId: string,
relation: IEventRelation | null,
matrixClient: MatrixClient,
) {
if (matrixClient.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
@ -511,11 +525,21 @@ export default class ContentMessages {
uploadAll = true;
}
}
promBefore = this.sendContentToRoom(file, roomId, matrixClient, promBefore);
promBefore = this.sendContentToRoom(file, roomId, relation, matrixClient, promBefore);
}
}
getCurrentUploads() {
getCurrentUploads(relation?: IEventRelation) {
return this.inprogress.filter(upload => {
const noRelation = !relation && !upload.relation;
const matchingRelation = relation && upload.relation
&& relation.rel_type === upload.relation.rel_type
&& relation.event_id === upload.relation.event_id;
return (noRelation || matchingRelation) && !upload.canceled;
});
return this.inprogress.filter(u => !u.canceled);
}
@ -534,7 +558,13 @@ export default class ContentMessages {
}
}
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
private sendContentToRoom(
file: File,
roomId: string,
relation: IEventRelation,
matrixClient: MatrixClient,
promBefore: Promise<any>,
) {
const startTime = CountlyAnalytics.getTimestamp();
const content: IContent = {
body: file.name || 'Attachment',
@ -544,6 +574,10 @@ export default class ContentMessages {
msgtype: "", // set later
};
if (relation) {
content["m.relates_to"] = relation;
}
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
@ -589,7 +623,8 @@ export default class ContentMessages {
const upload: IUpload = {
fileName: file.name || 'Attachment',
roomId: roomId,
roomId,
relation,
total: file.size,
loaded: 0,
promise: prom,
@ -622,7 +657,10 @@ export default class ContentMessages {
return promBefore;
}).then(function() {
if (upload.canceled) throw new UploadCanceledError();
const prom = matrixClient.sendMessage(roomId, content);
const threadId = relation?.rel_type === RelationType.Thread
? relation.event_id
: null;
const prom = matrixClient.sendMessage(roomId, threadId, content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => {
sendRoundTripMetric(matrixClient, roomId, resp.event_id);

View file

@ -149,12 +149,20 @@ export function formatSeconds(inSeconds: number): string {
}
const MILLIS_IN_DAY = 86400000;
function withinPast24Hours(prevDate: Date, nextDate: Date): boolean {
return Math.abs(prevDate.getTime() - nextDate.getTime()) <= MILLIS_IN_DAY;
}
function withinCurrentYear(prevDate: Date, nextDate: Date): boolean {
return prevDate.getFullYear() === nextDate.getFullYear();
}
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEventDate.getTime() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
if (!withinPast24Hours(prevEventDate, nextEventDate)) {
return true;
}
@ -178,3 +186,18 @@ export function formatFullDateNoDayNoTime(date: Date) {
pad(date.getDate())
);
}
export function formatRelativeTime(date: Date, showTwelveHour = false): string {
const now = new Date(Date.now());
if (withinPast24Hours(date, now)) {
return formatTime(date, showTwelveHour);
} else {
const months = getMonthsArray();
let relativeDate = `${months[date.getMonth()]} ${date.getDate()}`;
if (!withinCurrentYear(date, now)) {
relativeDate += `, ${date.getFullYear()}`;
}
return relativeDate;
}
}

View file

@ -25,8 +25,11 @@ export class DecryptionFailure {
}
}
type TrackingFn = (count: number, trackedErrCode: string) => void;
type ErrCodeMapFn = (errcode: string) => string;
type ErrorCode = "OlmKeysNotSentError" | "OlmIndexError" | "UnknownError" | "OlmUnspecifiedError";
type TrackingFn = (count: number, trackedErrCode: ErrorCode) => void;
export type ErrCodeMapFn = (errcode: string) => ErrorCode;
export class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
@ -73,12 +76,12 @@ export class DecryptionFailureTracker {
* @param {function?} errorCodeMapFn The function used to map error codes to the
* trackedErrorCode. If not provided, the `.code` of errors will be used.
*/
constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) {
constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn: ErrCodeMapFn) {
if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function');
}
if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
if (typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
}
}
@ -195,7 +198,7 @@ export class DecryptionFailureTracker {
public trackFailures(): void {
for (const errorCode of Object.keys(this.failureCounts)) {
if (this.failureCounts[errorCode] > 0) {
const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode;
const trackedErrorCode = this.errorCodeMapFn(errorCode);
this.fn(this.failureCounts[errorCode], trackedErrorCode);
this.failureCounts[errorCode] = 0;

View file

@ -20,9 +20,7 @@ limitations under the License.
import React, { ReactNode } from 'react';
import sanitizeHtml from 'sanitize-html';
import cheerio from 'cheerio';
import * as linkify from 'linkifyjs';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import { _linkifyElement, _linkifyString } from './linkify-matrix';
import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex';
import katex from 'katex';
@ -30,14 +28,12 @@ import { AllHtmlEntities } from 'html-entities';
import { IContent } from 'matrix-js-sdk/src/models/event';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import linkifyMatrix from './linkify-matrix';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { getEmojiFromUnicode } from "./emoji";
import ReplyChain from "./components/views/elements/ReplyChain";
import { mediaFromMxc } from "./customisations/Media";
linkifyMatrix(linkify);
import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix';
// Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@ -180,7 +176,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
attribs.target = '_blank'; // by default
const transformed = tryTransformPermalinkToLocalHref(attribs.href);
if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.ELEMENT_URL_PATTERN)) {
if (transformed !== attribs.href || attribs.href.match(ELEMENT_URL_PATTERN)) {
attribs.href = transformed;
delete attribs.target;
}
@ -411,8 +407,9 @@ export interface IOptsReturnString extends IOpts {
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
const isFormattedBody = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
let isHtmlMessage = false;
let sanitizeParams = sanitizeHtmlParams;
if (opts.forComposerQuote) {
@ -449,20 +446,23 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyChain.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyChain.stripPlainReply(plainBody) : plainBody;
bodyHasEmoji = mightContainEmoji(isHtmlMessage ? formattedBody : plainBody);
bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody);
// Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) {
if (isFormattedBody) {
isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody, {
// @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
const phtml = cheerio.load(safeBody, {
// @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
const isPlainText = phtml.html() === phtml.root().text();
isHtmlMessage = isFormattedBody && !isPlainText;
if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) {
// @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
@ -533,10 +533,10 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param {string} str string to linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns {string} Linkified string
*/
export function linkifyString(str: string, options = linkifyMatrix.options): string {
export function linkifyString(str: string, options = linkifyMatrixOptions): string {
return _linkifyString(str, options);
}
@ -544,10 +544,10 @@ export function linkifyString(str: string, options = linkifyMatrix.options): str
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
*
* @param {object} element DOM element to linkify
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrixOptions
* @returns {object}
*/
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement {
export function linkifyElement(element: HTMLElement, options = linkifyMatrixOptions): HTMLElement {
return _linkifyElement(element, options);
}
@ -555,10 +555,10 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt
* Linkify the given string and sanitize the HTML afterwards.
*
* @param {string} dirtyHtml The HTML string to sanitize and linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @param {object} [options] Options for linkifyString. Default: linkifyMatrixOptions
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string {
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrixOptions): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}

View file

@ -14,8 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction,
RoomListAction } from "./KeyBindingsManager";
import {
AutocompleteAction,
IKeyBindingsProvider,
KeyBinding,
MessageComposerAction,
NavigationAction,
RoomAction,
RoomListAction,
} from "./KeyBindingsManager";
import { isMac, Key } from "./Keyboard";
import SettingsStore from "./settings/SettingsStore";
@ -321,6 +328,14 @@ const navigationBindings = (): KeyBinding<NavigationAction>[] => {
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleSpacePanel,
keyCombo: {
key: Key.D,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: NavigationAction.ToggleRoomSidePanel,
keyCombo: {

View file

@ -105,6 +105,8 @@ export enum RoomAction {
export enum NavigationAction {
/** Jump to room search (search for a room) */
FocusRoomSearch = 'FocusRoomSearch',
/** Toggle the space panel */
ToggleSpacePanel = 'ToggleSpacePanel',
/** Toggle the room side panel */
ToggleRoomSidePanel = 'ToggleRoomSidePanel',
/** Toggle the user menu */

View file

@ -59,6 +59,8 @@ import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDis
import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
import { setSentryUser } from "./sentry";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
@ -322,7 +324,9 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
let accessToken;
try {
accessToken = await StorageManager.idbLoad("account", "mx_access_token");
} catch (e) {}
} catch (e) {
logger.error("StorageManager.idbLoad failed for account:mx_access_token", e);
}
if (!accessToken) {
accessToken = localStorage.getItem("mx_access_token");
if (accessToken) {
@ -330,7 +334,9 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
// try to migrate access token to IndexedDB if we can
await StorageManager.idbSave("account", "mx_access_token", accessToken);
localStorage.removeItem("mx_access_token");
} catch (e) {}
} catch (e) {
logger.error("migration of access token to IndexedDB failed", e);
}
}
}
// if we pre-date storing "mx_has_access_token", but we retrieved an access
@ -454,7 +460,7 @@ async function handleLoadSessionFailure(e: Error): Promise<boolean> {
logger.error("Unable to load session", e);
const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
error: e.message,
error: e,
});
const [success] = await modal.finished;
@ -532,8 +538,8 @@ export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixC
* fires on_logging_in, optionally clears localstorage, persists new credentials
* to localstorage, starts the new client.
*
* @param {MatrixClientCreds} credentials
* @param {Boolean} clearStorage
* @param {IMatrixClientCreds} credentials
* @param {Boolean} clearStorageEnabled
*
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/
@ -579,10 +585,13 @@ async function doSetLoggedIn(
MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
setSentryUser(credentials.userId);
if (PosthogAnalytics.instance.isEnabled()) {
PosthogAnalytics.instance.startListeningToSettingsChanges();
}
const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
// If we just logged in, try to rehydrate a device instead of using a
// new device. If it succeeds, we'll get a new device ID, so make sure
@ -786,7 +795,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.instance.start();
CallHandler.sharedInstance().start();
CallHandler.instance.start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
@ -854,7 +863,9 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
try {
await StorageManager.idbDelete("account", "mx_access_token");
} catch (e) {}
} catch (e) {
logger.error("idbDelete failed for account:mx_access_token", e);
}
// now restore those invites
if (!opts?.deleteEverything) {
@ -887,7 +898,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
*/
export function stopMatrixClient(unsetClient = true): void {
Notifier.stop();
CallHandler.sharedInstance().stop();
CallHandler.instance.stop();
UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset();
Presence.stop();
@ -908,3 +919,17 @@ export function stopMatrixClient(unsetClient = true): void {
}
}
}
// Utility method to perform a login with an existing access_token
window.mxLoginWithAccessToken = async (hsUrl: string, accessToken: string): Promise<void> => {
const tempClient = createClient({
baseUrl: hsUrl,
accessToken,
});
const { user_id: userId } = await tempClient.whoami();
await doSetLoggedIn({
homeserverUrl: hsUrl,
accessToken,
userId,
}, true);
};

View file

@ -17,6 +17,8 @@ limitations under the License.
import * as commonmark from 'commonmark';
import { escape } from "lodash";
import { logger } from 'matrix-js-sdk/src/logger';
import { linkify } from './linkify-matrix';
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
@ -29,6 +31,9 @@ interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer {
link: (node: commonmark.Node, entering: boolean) => void;
html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase
html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase
text: (node: commonmark.Node) => void;
out: (text: string) => void;
emph: (node: commonmark.Node) => void;
}
function isAllowedHtmlTag(node: commonmark.Node): boolean {
@ -61,6 +66,38 @@ function isMultiLine(node: commonmark.Node): boolean {
return par.firstChild != par.lastChild;
}
function getTextUntilEndOrLinebreak(node: commonmark.Node) {
let currentNode = node;
let text = '';
while (currentNode !== null && currentNode.type !== 'softbreak' && currentNode.type !== 'linebreak') {
const { literal, type } = currentNode;
if (type === 'text' && literal) {
let n = 0;
let char = literal[n];
while (char !== ' ' && char !== null && n <= literal.length) {
if (char === ' ') {
break;
}
if (char) {
text += char;
}
n += 1;
char = literal[n];
}
if (char === ' ') {
break;
}
}
currentNode = currentNode.next;
}
return text;
}
const formattingChangesByNodeType = {
'emph': '_',
'strong': '__',
};
/**
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
@ -70,11 +107,104 @@ export default class Markdown {
private input: string;
private parsed: commonmark.Node;
constructor(input) {
constructor(input: string) {
this.input = input;
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);
this.parsed = this.repairLinks(this.parsed);
}
/**
* This method is modifying the parsed AST in such a way that links are always
* properly linkified instead of sometimes being wrongly emphasised in case
* if you were to write a link like the example below:
* https://my_weird-link_domain.domain.com
* ^ this link would be parsed to something like this:
* <a href="https://my">https://my</a><b>weird-link</b><a href="https://domain.domain.com">domain.domain.com</a>
* This method makes it so the link gets properly modified to a version where it is
* not emphasised until it actually ends.
* See: https://github.com/vector-im/element-web/issues/4674
* @param parsed
*/
private repairLinks(parsed: commonmark.Node) {
const walker = parsed.walker();
let event: commonmark.NodeWalkingStep = null;
let text = '';
let isInPara = false;
let previousNode: commonmark.Node | null = null;
let shouldUnlinkFormattingNode = false;
while ((event = walker.next())) {
const { node } = event;
if (node.type === 'paragraph') {
if (event.entering) {
isInPara = true;
} else {
isInPara = false;
}
}
if (isInPara) {
// Clear saved string when line ends
if (
node.type === 'softbreak' ||
node.type === 'linebreak' ||
// Also start calculating the text from the beginning on any spaces
(node.type === 'text' && node.literal === ' ')
) {
text = '';
}
if (node.type === 'text') {
text += node.literal;
}
// We should not do this if previous node was not a textnode, as we can't combine it then.
if ((node.type === 'emph' || node.type === 'strong') && previousNode.type === 'text') {
if (event.entering) {
const foundLinks = linkify.find(text);
for (const { value } of foundLinks) {
if (node.firstChild.literal) {
/**
* NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings
* but this solution seems to work well and is hopefully slightly easier to understand too
*/
const format = formattingChangesByNodeType[node.type];
const nonEmphasizedText = `${format}${node.firstChild.literal}${format}`;
const f = getTextUntilEndOrLinebreak(node);
const newText = value + nonEmphasizedText + f;
const newLinks = linkify.find(newText);
// Should always find only one link here, if it finds more it means that the algorithm is broken
if (newLinks.length === 1) {
const emphasisTextNode = new commonmark.Node('text');
emphasisTextNode.literal = nonEmphasizedText;
previousNode.insertAfter(emphasisTextNode);
node.firstChild.literal = '';
event = node.walker().next();
// Remove `em` opening and closing nodes
node.unlink();
previousNode.insertAfter(event.node);
shouldUnlinkFormattingNode = true;
} else {
logger.error(
"Markdown links escaping found too many links for following text: ",
text,
);
logger.error(
"Markdown links escaping found too many links for modified text: ",
newText,
);
}
}
}
} else {
if (shouldUnlinkFormattingNode) {
node.unlink();
shouldUnlinkFormattingNode = false;
}
}
}
}
previousNode = node;
}
return parsed;
}
isPlainText(): boolean {
@ -120,16 +250,17 @@ export default class Markdown {
// you can nest them.
//
// Let's try sending with <p/>s anyway for now, though.
const realParagraph = renderer.paragraph;
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
// If there is only one top level node, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs.
if (isMultiLine(node)) {
// However, if it's a blockquote, adds a p tag anyway
// in order to avoid deviation to commonmark and unexpected
// results when parsing the formatted HTML.
if (node.parent.type === 'block_quote'|| isMultiLine(node)) {
realParagraph.call(this, node, entering);
}
};

View file

@ -40,7 +40,7 @@ import SecurityCustomisations from "./customisations/Security";
export interface IMatrixClientCreds {
homeserverUrl: string;
identityServerUrl: string;
identityServerUrl?: string;
userId: string;
deviceId?: string;
accessToken: string;

View file

@ -19,7 +19,6 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from './MatrixClientPeg';
import SdkConfig from './SdkConfig';
@ -39,6 +38,9 @@ import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import { logger } from "matrix-js-sdk/src/logger";
import { MsgType } from "matrix-js-sdk/src/@types/event";
/*
* Dispatches:
* {
@ -54,8 +56,8 @@ Override both the content body and the TextForEvent handler for specific msgtype
This is useful when the content body contains fallback text that would explain that the client can't handle a particular
type of tile.
*/
const typehandlers = {
"m.key.verification.request": (event) => {
const msgTypeHandlers = {
[MsgType.KeyVerificationRequest]: (event) => {
const name = (event.sender || {}).name;
return _t("%(name)s is requesting verification", { name });
},
@ -70,8 +72,8 @@ export const Notifier = {
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev: MatrixEvent): string {
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
return typehandlers[ev.getContent().msgtype](ev);
if (msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
return msgTypeHandlers[ev.getContent().msgtype](ev);
}
return TextForEvent.textForEvent(ev);
},
@ -96,7 +98,7 @@ export const Notifier = {
title = room.name;
// notificationMessageForEvent includes sender,
// but we already have the sender here
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
if (ev.getContent().body && !msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
} else if (ev.getType() === 'm.room.member') {
@ -107,7 +109,7 @@ export const Notifier = {
title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender,
// but we've just out sender in the title
if (ev.getContent().body && !typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
if (ev.getContent().body && !msgTypeHandlers.hasOwnProperty(ev.getContent().msgtype)) {
msg = ev.getContent().body;
}
}
@ -377,11 +379,14 @@ export const Notifier = {
}
},
_evaluateEvent: function(ev) {
_evaluateEvent: function(ev: MatrixEvent) {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) {
if (RoomViewStore.getRoomId() === room.roomId &&
UserActivity.sharedInstance().userActiveRecently() &&
!Modal.hasDialogs()
) {
// don't bother notifying as user was recently active in this room
return;
}

View file

@ -15,13 +15,13 @@ limitations under the License.
*/
import posthog, { PostHog } from 'posthog-js';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
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";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "./settings/SettingsStore";
/* Posthog analytics tracking.
*
@ -40,12 +40,8 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
*/
interface IEvent {
// The event name that will be used by PostHog. Event names should use snake_case.
// The event name that will be used by PostHog. Event names should use camelCase.
eventName: string;
// The properties of the event that will be stored in PostHog. This is just a placeholder,
// extending interfaces must override this with a concrete definition to do type validation.
properties: {};
}
export enum Anonymity {
@ -54,39 +50,16 @@ export enum Anonymity {
Pseudonymous
}
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
// For example, it might contain hashed user IDs or room IDs.
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
export interface IPseudonymousEvent extends IEvent {}
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
// i.e. no identifiers that can be associated with the user.
export interface IAnonymousEvent extends IEvent {}
export interface IRoomEvent extends IPseudonymousEvent {
hashedRoomId: string;
}
interface IPageView extends IAnonymousEvent {
eventName: "$pageview";
properties: {
durationMs?: number;
screen?: string;
};
}
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",
]);
export async function getRedactedCurrentLocation(
export function getRedactedCurrentLocation(
origin: string,
hash: string,
pathname: string,
anonymity: Anonymity,
): Promise<string> {
): string {
// Redact PII from the current location.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) {
@ -132,10 +105,10 @@ export class PosthogAnalytics {
private anonymity = Anonymity.Disabled;
// set true during the constructor if posthog config is present, otherwise false
private enabled = false;
private readonly enabled: boolean = false;
private static _instance = null;
private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
public static get instance(): PosthogAnalytics {
if (!this._instance) {
@ -160,6 +133,7 @@ export class PosthogAnalytics {
capture_pageview: false,
sanitize_properties: this.sanitizeProperties,
respect_dnt: true,
advanced_disable_decide: true,
});
this.enabled = true;
} else {
@ -196,29 +170,6 @@ export class PosthogAnalytics {
return properties;
};
private static getAnonymityFromSettings(): Anonymity {
// determine the current anonymity level based on current user settings
// "Send anonymous usage data which helps us improve Element. This will use a cookie."
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
//
// TODO: Currently, this is only a labs flag, for testing purposes.
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true);
let anonymity;
if (pseudonumousOptIn) {
anonymity = Anonymity.Pseudonymous;
} else if (analyticsOptIn) {
anonymity = Anonymity.Anonymous;
} else {
anonymity = Anonymity.Disabled;
}
return anonymity;
}
private registerSuperProperties(properties: posthog.Properties) {
if (this.enabled) {
this.posthog.register(properties);
@ -241,13 +192,12 @@ export class PosthogAnalytics {
};
}
private async capture(eventName: string, properties: posthog.Properties) {
private capture(eventName: string, properties: posthog.Properties) {
if (!this.enabled) {
return;
}
const { origin, hash, pathname } = window.location;
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
origin, hash, pathname, this.anonymity);
properties['$redacted_current_url'] = getRedactedCurrentLocation(origin, hash, pathname);
this.posthog.capture(eventName, properties);
}
@ -278,7 +228,7 @@ export class PosthogAnalytics {
// 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);
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_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.
@ -287,7 +237,8 @@ export class PosthogAnalytics {
// 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 });
await client.setAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE,
Object.assign({ id: analyticsID }, accountData));
}
this.posthog.identify(analyticsID);
} catch (e) {
@ -306,38 +257,16 @@ export class PosthogAnalytics {
if (this.enabled) {
this.posthog.reset();
}
this.setAnonymity(Anonymity.Anonymous);
this.setAnonymity(Anonymity.Disabled);
}
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
eventName: E["eventName"],
properties: E["properties"] = {},
) {
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
await this.capture(eventName, properties);
}
public async trackAnonymousEvent<E extends IAnonymousEvent>(
eventName: E["eventName"],
properties: E["properties"] = {},
): Promise<void> {
if (this.anonymity == Anonymity.Disabled) return;
await this.capture(eventName, properties);
}
public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash;
let screen = null;
const split = hash.split("/");
if (split.length >= 2) {
screen = split[1];
}
await this.trackAnonymousEvent<IPageView>("$pageview", {
durationMs,
screen,
});
public trackEvent<E extends IEvent>(
event: E,
): void {
if (this.anonymity == Anonymity.Disabled || this.anonymity == Anonymity.Anonymous) return;
const eventWithoutName = { ...event };
delete eventWithoutName.eventName;
this.capture(event.eventName, eventWithoutName);
}
public async updatePlatformSuperProperties(): Promise<void> {
@ -350,12 +279,31 @@ export class PosthogAnalytics {
this.registerSuperProperties(this.platformSuperProperties);
}
public async updateAnonymityFromSettings(userId?: string): Promise<void> {
public async updateAnonymityFromSettings(pseudonymousOptIn: boolean): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
const anonymity = pseudonymousOptIn ? Anonymity.Pseudonymous : Anonymity.Disabled;
this.setAnonymity(anonymity);
if (anonymity === Anonymity.Pseudonymous) {
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
}
if (anonymity !== Anonymity.Disabled) {
await PosthogAnalytics.instance.updatePlatformSuperProperties();
}
}
public startListeningToSettingsChanges(): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called -
// * On page load, when the account data is first received by sync
// * On login
// * When another device changes account data
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null,
(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => {
this.updateAnonymityFromSettings(!!newValue);
});
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { IAnnotatedPushRule, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
import { ConditionKind, IPushRule, PushRuleActionName, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
import { MatrixClientPeg } from './MatrixClientPeg';
@ -206,14 +206,12 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr
return Promise.all(promises);
}
function findOverrideMuteRule(roomId: string): IAnnotatedPushRule {
function findOverrideMuteRule(roomId: string): IPushRule {
const cli = MatrixClientPeg.get();
if (!cli.pushRules ||
!cli.pushRules['global'] ||
!cli.pushRules['global'].override) {
if (!cli?.pushRules?.global?.override) {
return null;
}
for (const rule of cli.pushRules['global'].override) {
for (const rule of cli.pushRules.global.override) {
if (isRuleForRoom(roomId, rule)) {
if (isMuteRule(rule) && rule.enabled) {
return rule;
@ -223,14 +221,14 @@ function findOverrideMuteRule(roomId: string): IAnnotatedPushRule {
return null;
}
function isRuleForRoom(roomId: string, rule: IAnnotatedPushRule): boolean {
function isRuleForRoom(roomId: string, rule: IPushRule): boolean {
if (rule.conditions.length !== 1) {
return false;
}
const cond = rule.conditions[0];
return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
return (cond.kind === ConditionKind.EventMatch && cond.key === 'room_id' && cond.pattern === roomId);
}
function isMuteRule(rule: IAnnotatedPushRule): boolean {
return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
function isMuteRule(rule: IPushRule): boolean {
return (rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify);
}

View file

@ -18,6 +18,9 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg';
import AliasCustomisations from './customisations/Alias';
import DMRoomMap from "./utils/DMRoomMap";
import SpaceStore from "./stores/spaces/SpaceStore";
import { _t } from "./languageHandler";
/**
* Given a room object, return the alias we should use for it,
@ -80,8 +83,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void>
* Marks or unmarks the given room as being as a DM room.
* @param {string} roomId The ID of the room to modify
* @param {string} userId The user ID of the desired DM
room target user or null to un-mark
this room as a DM room
room target user or null to un-mark
this room as a DM room
* @returns {object} A promise
*/
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
@ -153,3 +156,22 @@ function guessDMRoomTargetId(room: Room, myUserId: string): string {
if (oldestUser === undefined) return myUserId;
return oldestUser.userId;
}
export function roomContextDetailsText(room: Room): string {
if (room.isSpaceRoom()) return undefined;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (dmPartner) {
return room.getMember(dmPartner)?.rawDisplayName;
}
const [parent, ...otherParents] = SpaceStore.instance.getKnownParents(room.roomId);
if (parent) {
return _t("%(spaceName)s and %(count)s others", {
spaceName: room.client.getRoom(parent).name,
count: otherParents.length,
});
}
return room.getCanonicalAlias();
}

View file

@ -183,7 +183,7 @@ Response:
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
room_id: "!foo:bar",
sender: "@alice:localhost"
}
]
@ -202,7 +202,7 @@ Example:
name: "dashboard",
data: {key: "val"}
}
room_id: !foo:bar,
room_id: "!foo:bar",
sender: "@alice:localhost"
}
]
@ -473,10 +473,7 @@ async function setBotPower(
// If the PL is equal to or greater than the requested PL, ignore.
if (ignoreIfGreater === true) {
// As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels
const currentPl = (
powerLevels.content.users && powerLevels.content.users[userId]
) || powerLevels.content.users_default || 0;
const currentPl = powerLevels.users?.[userId] ?? powerLevels.users_default ?? 0;
if (currentPl >= level) {
return sendResponse(event, {
success: true,

View file

@ -17,22 +17,23 @@ limitations under the License.
import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix';
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { logger } from "matrix-js-sdk/src/logger";
import { ComponentType } from "react";
import Modal from './Modal';
import * as sdk from './index';
import { MatrixClientPeg } from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { logger } from "matrix-js-sdk/src/logger";
import { ComponentType } from "react";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
@ -72,7 +73,6 @@ export class AccessCancelledError extends Error {
}
async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description: _t("Are you sure you want to cancel entering passphrase?"),

View file

@ -19,10 +19,9 @@ limitations under the License.
import * as React from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import { _t, _td } from './languageHandler';
@ -38,6 +37,7 @@ import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/I
import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
@ -55,7 +55,11 @@ import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
import { logger } from "matrix-js-sdk/src/logger";
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
import { TimelineRenderingType } from './contexts/RoomContext';
import RoomViewStore from "./stores/RoomViewStore";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
@ -101,6 +105,7 @@ interface ICommandOpts {
category: string;
hideCompletionAfterSpace?: boolean;
isEnabled?(): boolean;
renderingTypes?: TimelineRenderingType[];
}
export class Command {
@ -111,7 +116,8 @@ export class Command {
runFn: undefined | RunFn;
category: string;
hideCompletionAfterSpace: boolean;
_isEnabled?: () => boolean;
private _isEnabled?: () => boolean;
public renderingTypes?: TimelineRenderingType[];
constructor(opts: ICommandOpts) {
this.command = opts.command;
@ -122,6 +128,7 @@ export class Command {
this.category = opts.category || CommandCategories.other;
this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false;
this._isEnabled = opts.isEnabled;
this.renderingTypes = opts.renderingTypes;
}
getCommand() {
@ -132,9 +139,17 @@ export class Command {
return this.getCommand() + " " + this.args;
}
run(roomId: string, args: string) {
run(roomId: string, threadId: string, args: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return reject(_t("Command error"));
const renderingType = threadId
? TimelineRenderingType.Thread
: TimelineRenderingType.Room;
if (this.renderingTypes && !this.renderingTypes?.includes(renderingType)) {
return reject(_t("Command error"));
}
return this.runFn.bind(this)(roomId, args);
}
@ -142,7 +157,7 @@ export class Command {
return _t('Usage') + ': ' + this.getCommandWithArgs();
}
isEnabled() {
isEnabled(): boolean {
return this._isEnabled ? this._isEnabled() : true;
}
}
@ -270,6 +285,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'nick',
@ -282,6 +298,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'myroomnick',
@ -301,6 +318,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'roomavatar',
@ -318,6 +336,7 @@ export const Commands = [
}));
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'myroomavatar',
@ -344,6 +363,7 @@ export const Commands = [
}));
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'myavatar',
@ -361,6 +381,7 @@ export const Commands = [
}));
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'topic',
@ -386,6 +407,7 @@ export const Commands = [
return success();
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'roomname',
@ -398,6 +420,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'invite',
@ -461,6 +484,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'join',
@ -504,7 +528,7 @@ export const Commands = [
}
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_alias: roomAlias,
auto_join: true,
_type: "slash_command", // instrumentation
@ -514,7 +538,7 @@ export const Commands = [
const [roomId, ...viaServers] = params;
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
opts: {
// These are passed down to the js-sdk's /join call
@ -545,7 +569,7 @@ export const Commands = [
const eventId = permalinkParts.eventId;
const dispatch = {
action: 'view_room',
action: Action.ViewRoom,
auto_join: true,
_type: "slash_command", // instrumentation
};
@ -576,6 +600,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'part',
@ -619,6 +644,7 @@ export const Commands = [
return success(leaveRoomBehaviour(targetRoomId));
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'kick',
@ -634,6 +660,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'ban',
@ -649,6 +676,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'unban',
@ -665,6 +693,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'ignore',
@ -729,6 +758,11 @@ export const Commands = [
command: 'op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
isEnabled(): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(RoomViewStore.getRoomId());
return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId());
},
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
@ -754,11 +788,17 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'deop',
args: '<user-id>',
description: _td('Deops user with given id'),
isEnabled(): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(RoomViewStore.getRoomId());
return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId());
},
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
@ -775,6 +815,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'devtools',
@ -837,6 +878,7 @@ export const Commands = [
}
},
category: CommandCategories.admin,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'verify',
@ -902,6 +944,7 @@ export const Commands = [
return reject(this.getUsage());
},
category: CommandCategories.advanced,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: 'discardsession',
@ -915,6 +958,7 @@ export const Commands = [
return success();
},
category: CommandCategories.advanced,
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "rainbow",
@ -993,7 +1037,7 @@ export const Commands = [
return success((async () => {
if (isPhoneNumber) {
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
const results = await CallHandler.instance.pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
throw new Error("Unable to find Matrix ID for phone number");
}
@ -1003,7 +1047,7 @@ export const Commands = [
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
});
})());
@ -1025,7 +1069,7 @@ export const Commands = [
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, userId);
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
});
if (msg) {
@ -1045,26 +1089,28 @@ export const Commands = [
description: _td("Places the call in the current room on hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
const call = CallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(true);
return success();
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "unholdcall",
description: _td("Takes the call in the current room off hold"),
category: CommandCategories.other,
runFn: function(roomId, args) {
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
const call = CallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject("No active call in this room");
}
call.setRemoteOnHold(false);
return success();
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "converttodm",
@ -1074,6 +1120,7 @@ export const Commands = [
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, true));
},
renderingTypes: [TimelineRenderingType.Room],
}),
new Command({
command: "converttoroom",
@ -1083,6 +1130,7 @@ export const Commands = [
const room = MatrixClientPeg.get().getRoom(roomId);
return success(guessAndSetDMRoom(room, false));
},
renderingTypes: [TimelineRenderingType.Room],
}),
// Command definitions for autocompletion ONLY:
@ -1116,6 +1164,7 @@ export const Commands = [
})());
},
category: CommandCategories.effects,
renderingTypes: [TimelineRenderingType.Room],
});
}),
];

View file

@ -495,7 +495,7 @@ function textForPowerEvent(event: MatrixEvent): () => string | null {
const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => {
defaultDispatcher.dispatch({
action: 'view_room',
action: Action.ViewRoom,
event_id: messageId,
highlighted: true,
room_id: roomId,
@ -623,7 +623,7 @@ function textForWidgetEvent(event: MatrixEvent): () => string | null {
function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender?.name || event.getSender();
return () => _t("%(senderName)s has updated the widget layout", { senderName });
return () => _t("%(senderName)s has updated the room layout", { senderName });
}
function textForMjolnirEvent(event: MatrixEvent): () => string | null {

View file

@ -36,7 +36,7 @@ export default class VoipUserMapper {
}
private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
const results = await CallHandler.instance.sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
}
@ -97,11 +97,11 @@ export default class VoipUserMapper {
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
if (!CallHandler.instance.getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
const result = await CallHandler.instance.sipNativeLookup(inviterId);
if (result.length === 0) {
return;
}

View file

@ -253,6 +253,12 @@ const shortcuts: Record<Categories, IShortcut[]> = {
key: Key.SPACE,
}],
description: _td("Activate selected button"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
key: Key.D,
}],
description: _td("Toggle space panel"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
@ -348,7 +354,7 @@ const Shortcut: React.FC<{
}
return <div key={s.key}>
{ s.modifiers && s.modifiers.map(m => {
{ s.modifiers?.map(m => {
return <React.Fragment key={m}>
<kbd>{ modifierIcon[m] || _t(m) }</kbd>+
</React.Fragment>;

View file

@ -123,9 +123,17 @@ export const reducer = (state: IState, action: IAction) => {
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
// we just removed the active ref, need to replace it
// pick the ref which is now in the index the old ref was in
const len = state.refs.length;
state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
// pick the ref closest to the index the old ref was in
if (oldIndex >= state.refs.length) {
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
} else {
state.activeRef = findSiblingElement(state.refs, oldIndex)
|| findSiblingElement(state.refs, oldIndex, true);
}
if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element
state.activeRef?.current?.focus();
}
}
// update the refs list
@ -160,13 +168,13 @@ export const findSiblingElement = (
): RefObject<HTMLElement> => {
if (backwards) {
for (let i = startIndex; i < refs.length && i >= 0; i--) {
if (refs[i].current.offsetParent !== null) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
}
}
} else {
for (let i = startIndex; i < refs.length && i >= 0; i++) {
if (refs[i].current.offsetParent !== null) {
if (refs[i].current?.offsetParent !== null) {
return refs[i];
}
}

View file

@ -40,6 +40,7 @@ export const ContextMenuTooltipButton: React.FC<IProps> = ({
onContextMenu={onContextMenu || onClick}
aria-haspopup={true}
aria-expanded={isExpanded}
forceHide={isExpanded}
>
{ children }
</AccessibleTooltipButton>

View file

@ -16,14 +16,8 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import FileSaver from 'file-saver';
import { logger } from "matrix-js-sdk/src/logger";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { TrustInfo } from "matrix-js-sdk/src/crypto/backup";
import { CrossSigningKeys } from "matrix-js-sdk";
import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api";
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
import FileSaver from 'file-saver';
import { _t, _td } from '../../../../languageHandler';
import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../SecurityManager';
@ -41,11 +35,17 @@ import {
SecureBackupSetupMethod,
} from '../../../../utils/WellKnownUtils';
import SecurityCustomisations from "../../../../customisations/Security";
import { logger } from "matrix-js-sdk/src/logger";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import Field from "../../../../components/views/elements/Field";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Spinner from "../../../../components/views/elements/Spinner";
import { TrustInfo } from "matrix-js-sdk/src/crypto/backup";
import { CrossSigningKeys } from "matrix-js-sdk/src";
import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog";
import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api";
import { IValidationResult } from "../../../../components/views/elements/Validation";
// I made a mistake while converting this and it has to be fixed!
@ -502,7 +502,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
{ _t("Generate a Security Key") }
</div>
<div>{ _t("Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
<div>{ _t("We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
</StyledRadioButton>
);
}
@ -606,8 +606,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
private renderPhasePassPhrase(): JSX.Element {
return <form onSubmit={this.onPassPhraseNextClick}>
<p>{ _t(
"Enter a security phrase only you know, as its used to safeguard your data. " +
"To be secure, you shouldnt re-use your account password.",
"Enter a security phrase only you know, as it's used to safeguard your data. " +
"To be secure, you shouldn't re-use your account password.",
) }</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
@ -714,12 +714,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
<InlineSpinner />
</div>;
}
return <div>
<p>{ _t(
"Store your Security Key somewhere safe, like a password manager or a safe, " +
"as its used to safeguard your encrypted data.",
"as it's used to safeguard your encrypted data.",
) }</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_primaryContainer mx_CreateSecretStorageDialog_recoveryKeyPrimarycontainer">
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKey">
<code ref={this.recoveryKeyNode}>{ this.recoveryKey.encodedPrivateKey }</code>
@ -739,7 +740,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
onClick={this.onCopyClick}
disabled={this.state.phase === Phase.Storing}
>
{ this.state.copied ? _t("Copied!") : _t("Copy") }
<span
className="mx_CreateSecretStorageDialog_recoveryKeyCopyButtonText"
style={{ height: this.state.copied ? '0' : 'auto' }}
aria-hidden={this.state.copied}
>
{ _t("Copy") }
</span>
<span
className="mx_CreateSecretStorageDialog_recoveryKeyCopyButtonText"
style={{ height: this.state.copied ? 'auto' : '0' }}
aria-hidden={!this.state.copied}
>
{ _t("Copied!") }
</span>
</AccessibleButton>
</div>
</div>

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import React from 'react';
import { TimelineRenderingType } from '../contexts/RoomContext';
import type { ICompletion, ISelectionRange } from './Autocompleter';
export interface ICommand {
@ -28,11 +28,19 @@ export interface ICommand {
};
}
export interface IAutocompleteOptions {
commandRegex?: RegExp;
forcedCommandRegex?: RegExp;
renderingType?: TimelineRenderingType;
}
export default abstract class AutocompleteProvider {
commandRegex: RegExp;
forcedCommandRegex: RegExp;
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
protected renderingType: TimelineRenderingType = TimelineRenderingType.Room;
protected constructor({ commandRegex, forcedCommandRegex, renderingType }: IAutocompleteOptions) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
@ -45,6 +53,9 @@ export default abstract class AutocompleteProvider {
}
this.forcedCommandRegex = forcedCommandRegex;
}
if (renderingType) {
this.renderingType = renderingType;
}
}
destroy() {

View file

@ -27,7 +27,8 @@ import NotifProvider from './NotifProvider';
import { timeout } from "../utils/promise";
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
import SpaceProvider from "./SpaceProvider";
import SpaceStore from "../stores/SpaceStore";
import SpaceStore from "../stores/spaces/SpaceStore";
import { TimelineRenderingType } from '../contexts/RoomContext';
export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -75,10 +76,10 @@ export default class Autocompleter {
room: Room;
providers: AutocompleteProvider[];
constructor(room: Room) {
constructor(room: Room, renderingType: TimelineRenderingType = TimelineRenderingType.Room) {
this.room = room;
this.providers = PROVIDERS.map((Prov) => {
return new Prov(room);
return new Prov(room, renderingType);
});
}

View file

@ -25,17 +25,20 @@ import QueryMatcher from './QueryMatcher';
import { TextualCompletion } from './Components';
import { ICompletion, ISelectionRange } from "./Autocompleter";
import { Command, Commands, CommandMap } from '../SlashCommands';
import { Room } from 'matrix-js-sdk/src/models/room';
import { TimelineRenderingType } from '../contexts/RoomContext';
const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
matcher: QueryMatcher<Command>;
constructor() {
super(COMMAND_RE);
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: COMMAND_RE, renderingType });
this.matcher = new QueryMatcher(Commands, {
keys: ['command', 'args', 'description'],
funcs: [({ aliases }) => aliases.join(" ")], // aliases
context: renderingType,
});
}
@ -48,7 +51,7 @@ export default class CommandProvider extends AutocompleteProvider {
const { command, range } = this.getCurrentCommand(query, selection);
if (!command) return [];
let matches = [];
let matches: Command[] = [];
// check if the full match differs from the first word (i.e. returns false if the command has args)
if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match
@ -69,7 +72,10 @@ export default class CommandProvider extends AutocompleteProvider {
}
}
return matches.filter(cmd => cmd.isEnabled()).map((result) => {
return matches.filter(cmd => {
const display = !cmd.renderingTypes || cmd.renderingTypes.includes(this.renderingType);
return cmd.isEnabled() && display;
}).map((result) => {
let completion = result.getCommand() + ' ';
const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]);
// If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments

View file

@ -29,6 +29,8 @@ import { ICompletion, ISelectionRange } from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
import { mediaFromMxc } from "../customisations/Media";
import BaseAvatar from '../components/views/avatars/BaseAvatar';
import { Room } from 'matrix-js-sdk/src/models/room';
import { TimelineRenderingType } from '../contexts/RoomContext';
const COMMUNITY_REGEX = /\B\+\S*/g;
@ -44,8 +46,8 @@ function score(query, space) {
export default class CommunityProvider extends AutocompleteProvider {
matcher: QueryMatcher<Group>;
constructor() {
super(COMMUNITY_REGEX);
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: COMMUNITY_REGEX, renderingType });
this.matcher = new QueryMatcher([], {
keys: ['groupId', 'name', 'shortDescription'],
});

View file

@ -18,17 +18,19 @@ limitations under the License.
*/
import React from 'react';
import { uniq, sortBy } from 'lodash';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import { PillCompletion } from './Components';
import { ICompletion, ISelectionRange } from './Autocompleter';
import { uniq, sortBy } from 'lodash';
import SettingsStore from "../settings/SettingsStore";
import { EMOJI, IEmoji } from '../emoji';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import { Room } from 'matrix-js-sdk/src/models/room';
import { TimelineRenderingType } from '../contexts/RoomContext';
const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
@ -64,8 +66,8 @@ export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<ISortedEmoji>;
nameMatcher: QueryMatcher<ISortedEmoji>;
constructor() {
super(EMOJI_REGEX);
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: EMOJI_REGEX, renderingType });
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
keys: [],
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],

View file

@ -23,15 +23,13 @@ import { MatrixClientPeg } from '../MatrixClientPeg';
import { PillCompletion } from './Components';
import { ICompletion, ISelectionRange } from "./Autocompleter";
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import { TimelineRenderingType } from '../contexts/RoomContext';
const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider {
room: Room;
constructor(room) {
super(AT_ROOM_REGEX);
this.room = room;
constructor(public room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: AT_ROOM_REGEX, renderingType });
}
async getCompletions(

View file

@ -18,6 +18,7 @@ limitations under the License.
import { at, uniq } from 'lodash';
import { removeHiddenChars } from "matrix-js-sdk/src/utils";
import { TimelineRenderingType } from '../contexts/RoomContext';
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
@ -25,6 +26,7 @@ interface IOptions<T extends {}> {
shouldMatchWordsOnly?: boolean;
// whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true
fuzzy?: boolean;
context?: TimelineRenderingType;
}
/**

View file

@ -28,7 +28,8 @@ import { PillCompletion } from './Components';
import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter";
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import SpaceStore from "../stores/SpaceStore";
import SpaceStore from "../stores/spaces/SpaceStore";
import { TimelineRenderingType } from "../contexts/RoomContext";
const ROOM_REGEX = /\B#\S*/g;
@ -48,8 +49,8 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") {
export default class RoomProvider extends AutocompleteProvider {
protected matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: ROOM_REGEX, renderingType });
this.matcher = new QueryMatcher([], {
keys: ['displayedAlias', 'matchName'],
});

View file

@ -33,6 +33,7 @@ import { _t } from '../languageHandler';
import { makeUserPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter";
import MemberAvatar from '../components/views/avatars/MemberAvatar';
import { TimelineRenderingType } from '../contexts/RoomContext';
const USER_REGEX = /\B@\S*/g;
@ -50,8 +51,12 @@ export default class UserProvider extends AutocompleteProvider {
users: RoomMember[];
room: Room;
constructor(room: Room) {
super(USER_REGEX, FORCED_USER_REGEX);
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({
commandRegex: USER_REGEX,
forcedCommandRegex: FORCED_USER_REGEX,
renderingType,
});
this.room = room;
this.matcher = new QueryMatcher([], {
keys: ['name'],

View file

@ -21,7 +21,6 @@ import { EventEmitter } from 'events';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
@ -53,8 +52,8 @@ export default class CallEventGrouper extends EventEmitter {
constructor() {
super();
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private get invite(): MatrixEvent {
@ -115,7 +114,7 @@ export default class CallEventGrouper extends EventEmitter {
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
const newState = CallHandler.instance.isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
@ -123,33 +122,23 @@ export default class CallEventGrouper extends EventEmitter {
this.emit(CallEventGrouperEvent.LengthChanged, length);
};
public answerCall = () => {
defaultDispatcher.dispatch({
action: 'answer',
room_id: this.roomId,
});
public answerCall = (): void => {
CallHandler.instance.answerCall(this.roomId);
};
public rejectCall = () => {
defaultDispatcher.dispatch({
action: 'reject',
room_id: this.roomId,
});
public rejectCall = (): void => {
CallHandler.instance.hangupOrReject(this.roomId, true);
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: this.roomId,
});
public callBack = (): void => {
CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId);
const silenced = CallHandler.instance.isCallSilenced(this.callId);
silenced ?
CallHandler.sharedInstance().unSilenceCall(this.callId) :
CallHandler.sharedInstance().silenceCall(this.callId);
CallHandler.instance.unSilenceCall(this.callId) :
CallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
@ -175,7 +164,7 @@ export default class CallEventGrouper extends EventEmitter {
private setCall = () => {
if (this.call) return;
this.call = CallHandler.sharedInstance().getCallById(this.callId);
this.call = CallHandler.instance.getCallById(this.callId);
this.setCallListeners();
this.setState();
};

View file

@ -25,6 +25,7 @@ import { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import { getInputableElement } from "./LoggedInView";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -49,6 +50,8 @@ export interface IPosition {
bottom?: number;
left?: number;
right?: number;
rightAligned?: boolean;
bottomAligned?: boolean;
}
export enum ChevronFace {
@ -101,7 +104,7 @@ interface IState {
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
export default class ContextMenu extends React.PureComponent<IProps, IState> {
private readonly initialFocus: HTMLElement;
static defaultProps = {
@ -246,6 +249,9 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
return;
}
// only handle escape when in an input field
if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) return;
let handled = true;
switch (ev.key) {
@ -346,6 +352,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true,
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true,
});
const menuStyle: CSSProperties = {};
@ -407,6 +415,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
onClick={this.onClick}
onContextMenu={this.onContextMenuPreventBubbling}
>
{ background }
<div
className={menuClasses}
style={menuStyle}
@ -415,7 +424,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
>
{ body }
</div>
{ background }
</div>
);
}
@ -526,30 +534,22 @@ export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<
return [isOpen, button, open, close, setIsOpen];
};
@replaceableComponent("structures.LegacyContextMenu")
export default class LegacyContextMenu extends ContextMenu {
render() {
return this.renderMenu(false);
}
}
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
export function createMenu(ElementClass, props) {
const onFinished = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
if (props && props.onFinished) {
props.onFinished.apply(null, args);
}
props?.onFinished?.apply(null, args);
};
const menu = <LegacyContextMenu
const menu = <ContextMenu
{...props}
mountAsChild={true}
hasBackground={false}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</LegacyContextMenu>;
</ContextMenu>;
ReactDOM.render(menu, getOrCreateContainer());

View file

@ -35,7 +35,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps {

View file

@ -15,21 +15,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import type { EventSubscription } from "fbemitter";
import React from 'react';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import DNDTagTile from "../views/elements/DNDTagTile";
import ActionButton from "../views/elements/ActionButton";
interface IGroupFilterPanelProps {
@ -127,9 +130,6 @@ class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFil
}
public render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const ActionButton = sdk.getComponent('elements.ActionButton');
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
key={tag}

View file

@ -174,7 +174,7 @@ class FeaturedRoom extends React.Component {
e.stopPropagation();
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_alias: this.props.summaryInfo.profile.canonical_alias,
room_id: this.props.summaryInfo.room_id,
});

View file

@ -114,7 +114,7 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
<h4>{ _t("Own your conversations.") }</h4>
</React.Fragment>;
}

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { ComponentProps, createRef } from "react";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent";
interface IProps {
interface IProps extends Omit<ComponentProps<typeof AutoHideScrollbar>, "onWheel"> {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
@ -56,6 +55,7 @@ export default class IndicatorScrollbar extends React.Component<IProps, IState>
}
private collectScroller = (scroller: HTMLDivElement): void => {
this.props.wrappedRef?.(scroller);
if (scroller && !this.scrollElement) {
this.scrollElement = scroller;
// Using the passive option to not block the main thread
@ -186,10 +186,10 @@ export default class IndicatorScrollbar extends React.Component<IProps, IState>
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
{...otherProps}
>
{ leftOverflowIndicator }
{ children }

View file

@ -17,7 +17,6 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
@ -25,31 +24,41 @@ import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
import RoomListHeader from "../views/rooms/RoomListHeader";
import { Key } from "../../Keyboard";
import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import SettingsStore from "../../settings/SettingsStore";
import UserMenu from "./UserMenu";
interface IProps {
isMinimized: boolean;
resizeNotifier: ResizeNotifier;
}
enum BreadcrumbsMode {
Disabled,
Legacy,
Labs,
}
interface IState {
showBreadcrumbs: boolean;
activeSpace?: Room;
showBreadcrumbs: BreadcrumbsMode;
activeSpace: SpaceKey;
}
@replaceableComponent("structures.LeftPanel")
@ -65,8 +74,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
super(props);
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
activeSpace: SpaceStore.instance.activeSpace,
showBreadcrumbs: LeftPanel.breadcrumbsMode,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -74,6 +83,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
}
private static get breadcrumbsMode(): BreadcrumbsMode {
if (!SettingsStore.getValue("breadcrumbs")) return BreadcrumbsMode.Disabled;
return SettingsStore.getValue("feature_breadcrumbs_v2") ? BreadcrumbsMode.Labs : BreadcrumbsMode.Legacy;
}
public componentDidMount() {
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
@ -98,7 +112,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private updateActiveSpace = (activeSpace: Room) => {
private updateActiveSpace = (activeSpace: SpaceKey) => {
this.setState({ activeSpace });
};
@ -116,7 +130,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
};
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
const newVal = LeftPanel.breadcrumbsMode;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({ showBreadcrumbs: newVal });
@ -300,6 +314,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private onRoomListKeydown = (ev: React.KeyboardEvent) => {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
// we cannot handle Space as that is an activation key for all focusable elements in this widget
if (ev.key.length === 1) {
ev.preventDefault();
ev.stopPropagation();
this.roomSearchRef.current?.appendChar(ev.key);
} else if (ev.key === Key.BACKSPACE) {
ev.preventDefault();
ev.stopPropagation();
this.roomSearchRef.current?.backspace();
}
};
private selectRoom = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
@ -308,16 +336,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private renderHeader(): React.ReactNode {
return (
<div className="mx_LeftPanel_userHeader">
<UserMenu isMinimized={this.props.isMinimized} />
</div>
);
}
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
@ -334,7 +354,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
if (CallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}
@ -343,6 +363,17 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>;
}
let rightButton: JSX.Element;
if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) {
rightButton = <RecentlyViewedButton />;
} else if (this.state.activeSpace === MetaSpace.Home) {
rightButton = <AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>;
}
return (
<div
className="mx_LeftPanel_filterContainer"
@ -350,6 +381,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
{ !SpaceStore.spacesEnabled && <UserMenu isPanelCollapsed={true} /> }
<RoomSearch
isMinimized={this.props.isMinimized}
ref={this.roomSearchRef}
@ -357,16 +389,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>
{ dialPadButton }
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={this.state.activeSpace
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
: _t("Explore rooms")}
/>
{ rightButton }
</div>
);
}
@ -397,10 +420,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses} ref={this.ref}>
<aside className="mx_LeftPanel_roomListContainer">
{ this.renderHeader() }
{ this.renderSearchDialExplore() }
{ this.renderBreadcrumbs() }
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
{ !this.props.isMinimized && (
<RoomListHeader
onVisibilityChange={this.refreshStickyHeaders}
spacePanelDisabled={!SpaceStore.spacesEnabled}
/>
) }
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
@ -408,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
onKeyDown={this.onRoomListKeydown}
>
{ roomList }
</div>

View file

@ -131,7 +131,7 @@ const LeftPanelWidget: React.FC = () => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
title={_t("Maximise")}
/>*/ }
</div>
</div>

View file

@ -14,11 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import React, { ClipboardEvent } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import { Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
@ -27,18 +25,16 @@ import { fixupColorFonts } from '../../utils/FontManager';
import dis from '../../dispatcher/dispatcher';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import ResizeHandle from '../views/elements/ResizeHandle';
import { Resizer, CollapseDistributor } from '../../resizer';
import { CollapseDistributor, Resizer } from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models";
import {
showToast as showServerLimitToast,
hideToast as hideServerLimitToast,
} from "../../toasts/ServerLimitToast";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel";
import CallContainer from '../views/voip/CallContainer';
@ -54,7 +50,8 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import { replaceableComponent } from "../../utils/replaceableComponent";
import CallHandler from '../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
@ -64,7 +61,8 @@ import MyGroups from "./MyGroups";
import UserView from "./UserView";
import GroupView from "./GroupView";
import BackdropPanel from "./BackdropPanel";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
@ -75,11 +73,8 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview";
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
function canElementReceiveInput(el) {
return el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
!!el.getAttribute("contenteditable");
export function getInputableElement(el: HTMLElement): HTMLElement | null {
return el.closest("input, textarea, select, [contenteditable=true]");
}
interface IProps {
@ -140,7 +135,7 @@ interface IState {
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
*
* Currently it's very tightly coupled with MatrixChat. We should try to do
* Currently, it's very tightly coupled with MatrixChat. We should try to do
* something about that.
*
* Components mounted below us can access the matrix client via the react context.
@ -149,7 +144,6 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
private dispatcherRef: string;
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
@ -166,7 +160,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
activeCalls: CallHandler.instance.getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -183,7 +177,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
this.dispatcherRef = dis.register(this.onAction);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -214,7 +208,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
dis.unregister(this.dispatcherRef);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -224,6 +218,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach();
}
private onCallState = (): void => {
const activeCalls = CallHandler.instance.getAllActiveCalls();
if (activeCalls === this.state.activeCalls) return;
this.setState({ activeCalls });
};
private refreshBackgroundImage = async (): Promise<void> => {
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
if (backgroundImage) {
@ -235,18 +235,6 @@ class LoggedInView extends React.Component<IProps, IState> {
this.setState({ backgroundImage });
};
private onAction = (payload): void => {
switch (payload.action) {
case 'call_state': {
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
if (activeCalls !== this.state.activeCalls) {
this.setState({ activeCalls });
}
break;
}
}
};
public canResetTimelineInRoom = (roomId: string) => {
if (!this._roomView.current) {
return true;
@ -414,15 +402,13 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
private onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
while (!canReceiveInput && element) {
canReceiveInput = canElementReceiveInput(element);
element = element.parentElement;
}
if (!canReceiveInput) {
private onPaste = (ev: ClipboardEvent) => {
const element = ev.target as HTMLElement;
const inputableElement = getInputableElement(element);
if (inputableElement) {
inputableElement.focus();
} else {
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// so dispatch synchronously before paste happens
@ -516,6 +502,10 @@ class LoggedInView extends React.Component<IProps, IState> {
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
break;
case NavigationAction.ToggleSpacePanel:
dis.fire(Action.ToggleSpacePanel);
handled = true;
break;
case NavigationAction.ToggleRoomSidePanel:
if (this.props.page_type === "room_view" || this.props.page_type === "group_view") {
dis.dispatch<ToggleRightPanelPayload>({
@ -579,7 +569,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
if (!isClickShortcut && isPrintable && !getInputableElement(ev.target as HTMLElement)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation();

View file

@ -84,7 +84,7 @@ export default class MainSplit extends React.Component<IProps> {
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
handleClasses={{ left: "mx_ResizeHandle_horizontal" }}
>
{ panelView }
</Resizable>;

View file

@ -17,10 +17,10 @@ limitations under the License.
import React, { ComponentType, createRef } from 'react';
import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { Error as ErrorEvent } from "matrix-analytics-events/types/typescript/Error";
import { Screen as ScreenEvent } from "matrix-analytics-events/types/typescript/Screen";
import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible';
@ -35,14 +35,15 @@ import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
import * as Rooms from '../../Rooms';
import linkifyMatrix from "../../linkify-matrix";
import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore';
import PageType from '../../PageTypes';
import createRoom, { IOpts } from "../../createRoom";
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
@ -58,11 +59,11 @@ import { storeRoomAliasInCache } from '../../RoomAliasCache';
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../dispatcher/actions";
import {
showToast as showAnalyticsToast,
hideToast as hideAnalyticsToast,
showAnonymousAnalyticsOptInToast,
showPseudonymousAnalyticsOptInToast,
} from "../../toasts/AnalyticsToast";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
@ -77,11 +78,10 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import { shouldUseLoginForWelcome } from "../../utils/pages";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { replaceableComponent } from "../../utils/replaceableComponent";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { RoomUpdateCause } from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
import Spinner from "../views/elements/Spinner";
import QuestionDialog from "../views/dialogs/QuestionDialog";
@ -100,6 +100,7 @@ import Registration from './auth/Registration';
import Login from "./auth/Login";
import ErrorBoundary from '../views/elements/ErrorBoundary';
import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout';
@ -107,7 +108,15 @@ import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
import CallHandler from "../../CallHandler";
import { logger } from "matrix-js-sdk/src/logger";
import { showSpaceInvite } from "../../utils/space";
import GenericToast from "../views/toasts/GenericToast";
import InfoDialog from "../views/dialogs/InfoDialog";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import AccessibleButton from "../views/elements/AccessibleButton";
import { ActionPayload } from "../../dispatcher/payloads";
/** constants for MatrixChat.state.view */
export enum Views {
@ -339,18 +348,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// we don't do it as react state as i'm scared about triggering needless react refreshes.
this.subTitleStatus = '';
// this can technically be done anywhere but doing this here keeps all
// the routing url path logic together.
if (this.onAliasClick) {
linkifyMatrix.onAliasClick = this.onAliasClick;
}
if (this.onUserClick) {
linkifyMatrix.onUserClick = this.onUserClick;
}
if (this.onGroupClick) {
linkifyMatrix.onGroupClick = this.onGroupClick;
}
// the first thing to do is to try the token params in the query-string
// if the session isn't soft logged out (ie: is a clean session being logged in)
if (!Lifecycle.isSoftLogout()) {
@ -389,13 +386,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
if (SettingsStore.getValue("analyticsOptIn")) {
if (SettingsStore.getValue("pseudonymousAnalyticsOptIn")) {
Analytics.enable();
}
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true);
initSentry(SdkConfig.get()["sentry"]);
@ -454,7 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
this.trackScreenChange(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer);
@ -473,6 +467,36 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
}
public trackScreenChange(durationMs: number): void {
const notLoggedInMap = {};
notLoggedInMap[Views.LOADING] = "WebLoading";
notLoggedInMap[Views.WELCOME] = "WebWelcome";
notLoggedInMap[Views.LOGIN] = "WebLogin";
notLoggedInMap[Views.REGISTER] = "WebRegister";
notLoggedInMap[Views.FORGOT_PASSWORD] = "WebForgotPassword";
notLoggedInMap[Views.COMPLETE_SECURITY] = "WebCompleteSecurity";
notLoggedInMap[Views.E2E_SETUP] = "WebE2ESetup";
notLoggedInMap[Views.SOFT_LOGOUT] = "WebSoftLogout";
const loggedInPageTypeMap = {};
loggedInPageTypeMap[PageType.HomePage] = "Home";
loggedInPageTypeMap[PageType.RoomView] = "Room";
loggedInPageTypeMap[PageType.RoomDirectory] = "RoomDirectory";
loggedInPageTypeMap[PageType.UserView] = "User";
loggedInPageTypeMap[PageType.GroupView] = "Group";
loggedInPageTypeMap[PageType.MyGroups] = "MyGroups";
const screenName = this.state.view === Views.LOGGED_IN ?
loggedInPageTypeMap[this.state.page_type] :
notLoggedInMap[this.state.view];
return PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "Screen",
screenName,
durationMs,
});
}
getFallbackHsUrl() {
if (this.props.serverConfig && this.props.serverConfig.isDefault) {
return this.props.config.fallback_hs_url;
@ -507,8 +531,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
dis.dispatch({ action: "view_welcome_page" });
}
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
});
// Note we don't catch errors from this: we catch everything within
@ -553,13 +575,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState(newState);
}
onAction = (payload) => {
private onAction = (payload: ActionPayload) => {
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
// Start the onboarding process for certain actions
if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() &&
ONBOARDING_FLOW_STARTERS.includes(payload.action)
) {
if (MatrixClientPeg.get()?.isGuest() && ONBOARDING_FLOW_STARTERS.includes(payload.action)) {
// This will cause `payload` to be dispatched later, once a
// sync has reached the "prepared" state. Setting a matrix ID
// will cause a full login and sync and finally the deferred
@ -597,11 +617,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
dis.dispatch({ action: "hangup_all" });
CallHandler.instance.hangupAllCalls();
Lifecycle.logout();
break;
case 'require_registration':
startAnyRegistrationFlow(payload);
startAnyRegistrationFlow(payload as any);
break;
case 'start_registration':
if (Lifecycle.isSoftLogout()) {
@ -672,12 +692,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'view_user_info':
this.viewUser(payload.userId, payload.subAction);
break;
case 'view_room': {
case Action.ViewRoom: {
// Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored.
const promise = this.viewRoom(payload);
const promise = this.viewRoom(payload as any);
if (payload.deferred_action) {
promise.then(() => {
dis.dispatch(payload.deferred_action);
@ -708,16 +728,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case Action.ViewRoomDirectory: {
if (SpaceStore.instance.activeSpace) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: SpaceStore.instance.activeSpace.roomId,
});
} else {
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
}
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -830,10 +843,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
hideToSRUsers: false,
});
break;
case 'accept_cookies':
case Action.AnonymousAnalyticsAccept:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast();
if (Analytics.canEnable()) {
Analytics.enable();
}
@ -841,10 +854,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
break;
case 'reject_cookies':
case Action.AnonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
break;
case Action.PseudonymousAnalyticsAccept:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true);
break;
case Action.PseudonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false);
break;
}
};
@ -907,73 +928,73 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// @param {Object=} roomInfo.oob_data Object of additional data about the room
// that has been passed out-of-band (eg.
// room name and avatar from an invite email)
private viewRoom(roomInfo: IRoomInfo) {
private async viewRoom(roomInfo: IRoomInfo) {
this.focusComposer = true;
if (roomInfo.room_alias) {
logger.log(
`Switching to room alias ${roomInfo.room_alias} at event ` +
roomInfo.event_id,
);
logger.log(`Switching to room alias ${roomInfo.room_alias} at event ${roomInfo.event_id}`);
} else {
logger.log(`Switching to room id ${roomInfo.room_id} at event ` +
roomInfo.event_id,
);
logger.log(`Switching to room id ${roomInfo.room_id} at event ${roomInfo.event_id}`);
}
// Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved.
let waitFor = Promise.resolve(null);
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
logger.warn('Cannot view a room before first sync. room_id:', roomInfo.room_id);
return;
}
waitFor = this.firstSyncPromise.promise;
await this.firstSyncPromise.promise;
}
return waitFor.then(() => {
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
}
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
this.setState({
view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
forceTimeline: roomInfo.forceTimeline,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
this.notifyNewScreen('room/' + presentedId, replaceLast);
});
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
if (roomInfo.room_id === this.state.currentRoomId) {
// if we are re-viewing the same room then copy any state we already know
roomInfo.threepid_invite = roomInfo.threepid_invite ?? this.state.threepidInvite;
roomInfo.oob_data = roomInfo.oob_data ?? this.state.roomOobData;
roomInfo.forceTimeline = roomInfo.forceTimeline ?? this.state.forceTimeline;
roomInfo.justCreatedOpts = roomInfo.justCreatedOpts ?? this.state.roomJustCreatedOpts;
}
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
}
this.setState({
view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
forceTimeline: roomInfo.forceTimeline,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
this.notifyNewScreen('room/' + presentedId, replaceLast);
});
}
@ -1120,7 +1141,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (dmRooms.length > 0) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: dmRooms[0],
});
} else {
@ -1191,12 +1212,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
button: _t("Leave"),
onFinished: (shouldLeave) => {
if (shouldLeave) {
const d = leaveRoomBehaviour(roomId);
leaveRoomBehaviour(roomId);
// FIXME: controller shouldn't be loading a view :(
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
d.finally(() => modal.close());
dis.dispatch({
action: "after_leave_room",
room_id: roomId,
@ -1337,13 +1354,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage();
// defer the following actions by 30 seconds to not throw them at the user immediately
await sleep(30);
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl);
if (PosthogAnalytics.instance.isEnabled()) {
this.initPosthogAnalyticsToast();
} else if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) {
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnonymousAnalyticsOptInToast();
}
}
if (SdkConfig.get().mobileGuideToast) {
// The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc.
@ -1351,6 +1371,34 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
}
private showPosthogToast(analyticsOptIn: boolean) {
showPseudonymousAnalyticsOptInToast(analyticsOptIn);
}
private initPosthogAnalyticsToast() {
// Show the analytics toast if necessary
if (SettingsStore.getValue("pseudonymousAnalyticsOptIn") === null) {
this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true));
}
// Listen to changes in settings and show the toast if appropriate - this is necessary because account
// settings can still be changing at this point in app init (due to the initial sync being cached, then
// subsequent syncs being received from the server)
SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null,
(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => {
if (newValue === null) {
this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true));
} else {
// It's possible for the value to change if a cached sync loads at page load, but then network
// sync contains a new value of the flag with it set to false (e.g. another device set it since last
// loading the page); so hide the toast.
// (this flipping usually happens before first render so the user won't notice it; anyway flicker
// on/off is probably better than showing the toast again when the user already dismissed it)
hideAnalyticsToast();
}
});
}
private showScreenAfterLogin() {
// If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory
@ -1374,7 +1422,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private viewLastRoom() {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: localStorage.getItem('mx_last_room_id'),
});
}
@ -1472,6 +1520,61 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showNotificationsToast(false);
}
if (!localStorage.getItem("mx_seen_ia_1.1_changes_toast")) {
const key = "IA_1.1_TOAST";
ToastStore.sharedInstance().addOrReplaceToast({
key,
title: _t("Testing small changes"),
props: {
description: _t("Your feedback is wanted as we try out some design changes."),
acceptLabel: _t("More info"),
onAccept: () => {
Modal.createDialog(InfoDialog, {
title: _t("We're testing some design changes"),
description: <>
<img
src={require("../../../res/img/ia-design-changes.png")}
width="636"
height="303"
alt=""
/>
<p>{ _t(
"Your ongoing feedback would be very welcome, so if you see anything " +
"different you want to comment on, <a>please let us know about it</a>. " +
"Click your avatar to find a quick feedback link.",
{},
{
a: sub => <AccessibleButton
kind="link"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
}}
>
{ sub }
</AccessibleButton>,
},
) }</p>
<p>{ _t("If you'd like to preview or test some potential upcoming changes, " +
"there's an option in feedback to let us contact you.") }</p>
</>,
}, "mx_DialogDesignChanges_wrapper");
localStorage.setItem("mx_seen_ia_1.1_changes_toast", "true");
ToastStore.sharedInstance().dismissToast(key);
},
rejectLabel: _t("Dismiss"),
onReject: () => {
localStorage.setItem("mx_seen_ia_1.1_changes_toast", "true");
ToastStore.sharedInstance().dismissToast(key);
},
},
icon: "labs",
component: GenericToast,
priority: 9,
});
}
dis.fire(Action.FocusSendMessageComposer);
this.setState({
ready: true,
@ -1524,17 +1627,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
PosthogAnalytics.instance.trackEvent<ErrorEvent>({
eventName: "Error",
domain: "E2EE",
name: errorCode,
});
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case 'MEGOLM_UNKNOWN_INBOUND_SESSION_ID':
return 'olm_keys_not_sent_error';
return 'OlmKeysNotSentError';
case 'OLM_UNKNOWN_MESSAGE_INDEX':
return 'olm_index_error';
return 'OlmIndexError';
case undefined:
return 'unexpected_error';
return 'OlmUnspecifiedError';
default:
return 'unspecified_error';
return 'UnknownError';
}
});
@ -1789,7 +1897,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const payload = {
action: 'view_room',
action: Action.ViewRoom,
event_id: eventId,
via_servers: via,
// If an event ID is given in the URL hash, notify RoomViewStore to mark
@ -1842,28 +1950,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
this.setPageSubtitle();
}
onAliasClick(event: MouseEvent, alias: string) {
event.preventDefault();
dis.dispatch({ action: 'view_room', room_alias: alias });
}
onUserClick(event: MouseEvent, userId: string) {
event.preventDefault();
const member = new RoomMember(null, userId);
if (!member) { return; }
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: member,
});
}
onGroupClick(event: MouseEvent, groupId: string) {
event.preventDefault();
dis.dispatch({ action: 'view_group', group_id: groupId });
}
onLogoutClick(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
dis.dispatch({
action: 'logout',

View file

@ -28,7 +28,7 @@ import { wantsDateSeparator } from '../../DateUtils';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from "../../languageHandler";
import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
import { hasText } from "../../TextForEvent";
@ -179,6 +179,7 @@ interface IProps {
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
}
interface IState {
@ -198,6 +199,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
static defaultProps = {
disableGrouping: false,
};
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations
private readonly readReceiptMap: Record<string, object> = {};
@ -652,7 +657,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
if (Grouper.canStartGroup(this, mxEv) && !this.props.disableGrouping) {
grouper = new Grouper(
this,
mxEv,

View file

@ -24,7 +24,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
interface IProps {
@ -39,7 +39,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
static contextType = RoomContext;
render() {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{ _t('Youre all caught up') }</h2>
<h2>{ _t("You're all caught up") }</h2>
<p>{ _t('You have no visible notifications.') }</p>
</div>);

View file

@ -21,7 +21,6 @@ import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { throttle } from 'lodash';
import dis from '../../dispatcher/dispatcher';
import GroupStore from '../../stores/GroupStore';
@ -50,10 +49,12 @@ import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import SpaceStore from "../../stores/SpaceStore";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/spaces/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
import { dispatchShowThreadsPanelEvent } from '../../dispatcher/dispatch-actions/threads';
import TimelineCard from '../views/right_panel/TimelineCard';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
@ -77,6 +78,7 @@ interface IState {
event: MatrixEvent;
initialEvent?: MatrixEvent;
initialEventHighlighted?: boolean;
searchQuery: string;
}
@replaceableComponent("structures.RightPanel")
@ -92,6 +94,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
phase: this.getPhaseFromProps(),
isUserPrivilegedInGroup: null,
member: this.getUserForPanel(),
searchQuery: "",
};
}
@ -198,7 +201,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
};
private onAction = (payload: ActionPayload) => {
const isChangingRoom = payload.action === 'view_room' && payload.room_id !== this.props.room.roomId;
const isChangingRoom = payload.action === Action.ViewRoom && payload.room_id !== this.props.room.roomId;
const isViewingThread = this.state.phase === RightPanelPhases.ThreadView;
if (isChangingRoom && isViewingThread) {
dispatchShowThreadsPanelEvent();
@ -248,6 +251,10 @@ export default class RightPanel extends React.Component<IProps, IState> {
}
};
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({ searchQuery });
};
public render(): JSX.Element {
let panel = <div />;
const roomId = this.props.room ? this.props.room.roomId : undefined;
@ -255,7 +262,13 @@ export default class RightPanel extends React.Component<IProps, IState> {
switch (this.state.phase) {
case RightPanelPhases.RoomMemberList:
if (roomId) {
panel = <MemberList roomId={roomId} key={roomId} onClose={this.onClose} />;
panel = <MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
}
break;
case RightPanelPhases.SpaceMemberList:
@ -263,6 +276,8 @@ export default class RightPanel extends React.Component<IProps, IState> {
roomId={this.state.space ? this.state.space.roomId : roomId}
key={this.state.space ? this.state.space.roomId : roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
break;
@ -320,7 +335,17 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.Timeline:
if (!SettingsStore.getValue("feature_maximised_widgets")) break;
panel = <TimelineCard
room={this.props.room}
timelineSet={this.props.room.getUnfilteredTimelineSet()}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>;
break;
case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;
@ -341,7 +366,9 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>;
break;
case RightPanelPhases.RoomSummary:

View file

@ -19,7 +19,6 @@ import React from "react";
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
import { Visibility } from "matrix-js-sdk/src/@types/partials";
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
@ -49,6 +48,9 @@ import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import { logger } from "matrix-js-sdk/src/logger";
import { Action } from "../../dispatcher/actions";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
@ -492,7 +494,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished();
const payload: ActionPayload = {
action: 'view_room',
action: Action.ViewRoom,
auto_join: autoJoin,
should_peek: shouldPeek,
_type: "room_directory", // instrumentation
@ -588,9 +590,12 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [
return <div
key={room.room_id}
role="listitem"
className="mx_RoomDirectory_listItem"
>
<div
key={`${room.room_id}_avatar`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomAvatar"
>
@ -602,9 +607,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
idName={name}
url={avatarUrl}
/>
</div>,
</div>
<div
key={`${room.room_id}_description`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomDescription"
>
@ -625,30 +629,27 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
>
{ getDisplayAliasForRoom(room) }
</div>
</div>,
</div>
<div
key={`${room.room_id}_memberCount`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
</div>
<div
key={`${room.room_id}_preview`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
className="mx_RoomDirectory_preview"
>
{ previewButton }
</div>,
</div>
<div
key={`${room.room_id}_join`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_join"
>
{ joinOrViewButton }
</div>,
];
</div>
</div>;
}
private stringLooksLikeId(s: string, fieldType: IFieldType) {

View file

@ -17,7 +17,6 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
@ -28,7 +27,9 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { isMac } from "../../Keyboard";
interface IProps {
isMinimized: boolean;
@ -41,12 +42,11 @@ interface IProps {
interface IState {
query: string;
focused: boolean;
inSpaces: boolean;
}
@replaceableComponent("structures.RoomSearch")
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private readonly dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
private searchFilter: NameFilterCondition = new NameFilterCondition();
@ -56,13 +56,11 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.state = {
query: "",
focused: false,
inSpaces: false,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -83,17 +81,10 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
private onSpaces = (spaces: Room[]) => {
this.setState({
inSpaces: spaces.length > 0,
});
};
private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) {
if (payload.action === Action.ViewRoom && payload.clear_search) {
this.clearInput();
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
this.inputRef.current.focus();
@ -145,9 +136,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
}
};
public focus(): void {
public focus = (): void => {
this.inputRef.current?.focus();
}
};
public render(): React.ReactNode {
const classes = classNames({
@ -162,13 +153,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
});
let placeholder = _t("Filter");
if (this.state.inSpaces) {
placeholder = _t("Filter all spaces");
}
let icon = (
<div className='mx_RoomSearch_icon' />
<div className="mx_RoomSearch_icon" onClick={this.focus} />
);
let input = (
<input
@ -180,7 +166,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={placeholder}
placeholder={_t("Filter")}
autoComplete="off"
/>
);
@ -192,6 +178,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onClick={this.clearInput}
/>
);
let shortcutPrompt = <div className="mx_RoomSearch_shortcutPrompt" onClick={this.focus}>
{ isMac ? "⌘ K" : "Ctrl K" }
</div>;
if (this.props.isMinimized) {
icon = (
@ -203,14 +192,28 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
);
input = null;
clearButton = null;
shortcutPrompt = null;
}
return (
<div className={classes}>
{ icon }
{ input }
{ shortcutPrompt }
{ clearButton }
</div>
);
}
public appendChar(char: string): void {
this.setState({
query: this.state.query + char,
});
}
public backspace(): void {
this.setState({
query: this.state.query.substring(0, this.state.query.length - 1),
});
}
}

View file

@ -27,10 +27,10 @@ import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventSubscription } from "fbemitter";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import shouldHideEvent from '../../shouldHideEvent';
import { _t } from '../../languageHandler';
@ -38,7 +38,7 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import CallHandler, { PlaceCallType } from '../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import dis from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
@ -48,7 +48,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
@ -70,6 +70,7 @@ import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
@ -82,6 +83,7 @@ import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import { replaceableComponent } from "../../utils/replaceableComponent";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { throttle } from "lodash";
import ErrorDialog from '../views/dialogs/ErrorDialog';
import SearchResultTile from '../views/rooms/SearchResultTile';
import Spinner from "../views/elements/Spinner";
@ -90,10 +92,13 @@ import RoomStatusBar from "./RoomStatusBar";
import MessageComposer from '../views/rooms/MessageComposer';
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads';
import { fetchInitialEvent } from "../../utils/EventUtils";
import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
import AppsDrawer from '../views/rooms/AppsDrawer';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { RightPanelPhases } from '../../stores/RightPanelStorePhases';
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -118,6 +123,13 @@ interface IRoomProps extends MatrixClientProps {
onRegistered?(credentials: IMatrixClientCreds): void;
}
// This defines the content of the mainSplit.
// If the mainSplit does not contain the Timeline, the chat is shown in the right panel.
enum MainSplitContentType {
Timeline,
MaximisedWidget,
// Video
}
export interface IRoomState {
room?: Room;
roomId?: string;
@ -187,6 +199,7 @@ export interface IRoomState {
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
mainSplitContentType?: MainSplitContentType;
dragCounter: number;
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
@ -253,6 +266,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
mainSplitContentType: MainSplitContentType.Timeline,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
@ -305,18 +319,59 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onWidgetStoreUpdate = () => {
if (this.state.room) {
this.checkWidgets(this.state.room);
if (!this.state.room) return;
this.checkWidgets(this.state.room);
};
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.checkWidgets(this.state.room);
};
private onWidgetLayoutChange = () => {
if (!this.state.room) return;
if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) {
// Show chat in right panel when a widget is maximised
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.Timeline,
});
}
this.checkWidgets(this.state.room);
this.checkRightPanel(this.state.room);
};
private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room),
mainSplitContentType: this.getMainSplitContentType(room),
showApps: this.shouldShowApps(room),
});
};
private getMainSplitContentType = (room) => {
// TODO-video check if video should be displayed in main panel
return (WidgetLayoutStore.instance.hasMaximisedWidget(room))
? MainSplitContentType.MaximisedWidget
: MainSplitContentType.Timeline;
};
private checkRightPanel = (room) => {
// This is a hack to hide the chat. This should not be necessary once the right panel
// phase is stored per room. (need to be done after check widget so that mainSplitContentType is updated)
if (
RightPanelStore.getSharedInstance().roomPanelPhase === RightPanelPhases.Timeline &&
this.state.showRightPanel &&
!WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)
) {
// Two timelines are shown prevent this by hiding the right panel
dis.dispatch({
action: Action.ToggleRightPanel,
type: "room",
});
}
};
private onReadReceiptsChange = () => {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
@ -392,6 +447,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} else {
newState.initialEventId = initialEventId;
newState.isInitialEventHighlighted = RoomViewStore.isInitialEventHighlighted();
if (thread && initialEvent?.isThreadRoot) {
dispatchShowThreadEvent(
thread.rootEvent,
initialEvent,
RoomViewStore.isInitialEventHighlighted(),
);
}
}
}
@ -503,18 +566,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
}
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0,
showApps: this.shouldShowApps(this.state.room),
});
};
private onWidgetLayoutChange = () => {
this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters
};
private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
@ -582,15 +633,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
const hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
const hideWidgetKey = room.roomId + "_hide_widget_drawer";
const hideWidgetDrawer = localStorage.getItem(hideWidgetKey);
// This is confusing, but it means to say that we default to the tray being
// hidden unless the user clicked to open it.
const isManuallyShown = hideWidgetDrawer === "false";
// If unset show the Tray
// Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter.
const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true;
const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
return widgets.length > 0 || isManuallyShown;
return isManuallyShown && widgets.length > 0;
}
componentDidMount() {
@ -619,6 +670,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
componentDidUpdate() {
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
if (this.roomView.current) {
const roomView = this.roomView.current;
if (!roomView.ondrop) {
@ -649,6 +701,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
// update the scroll map before we get unmounted
if (this.state.roomId) {
RoomScrollStateStore.setScrollState(this.state.roomId, this.getScrollState());
@ -721,7 +775,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
@ -772,6 +826,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
private onCallState = (roomId: string): void => {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
if (!roomId) return;
const call = this.getCallForRoom();
this.setState({ callState: call ? call.state : null });
};
private onAction = payload => {
switch (payload.action) {
case 'message_sent':
@ -781,11 +844,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.injectSticker(
payload.data.content.url,
payload.data.content.info,
payload.data.description || payload.data.name);
payload.data.description || payload.data.name,
payload.data.threadId);
break;
case 'picture_snapshot':
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, this.context);
[payload.file], this.state.room.roomId, null, this.context);
break;
case 'notifier_enabled':
case Action.UploadStarted:
@ -793,21 +857,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case Action.UploadCanceled:
this.forceUpdate();
break;
case 'call_state': {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
if (!payload.room_id) {
return;
}
const call = this.getCallForRoom();
this.setState({
callState: call ? call.state : null,
});
break;
}
case 'appsDrawer':
this.setState({
showApps: payload.show,
@ -830,7 +879,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
setImmediate(() => {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
deferred_action: payload,
});
@ -880,7 +929,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
case "scroll_to_bottom":
if (payload.timelineRenderingType === this.context.timelineRenderingType) {
if (payload.timelineRenderingType === TimelineRenderingType.Room) {
this.messagePanel?.jumpToLiveTimeline();
}
break;
@ -941,7 +990,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
CHAT_EFFECTS.forEach(effect => {
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
dis.dispatch({ action: `effects.${effect.command}` });
// For initial threads launch, chat effects are disabled
// see #19731
if (!SettingsStore.getValue("feature_thread") || !ev.isThreadRelation) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
};
@ -971,7 +1024,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (this.unmounted) return;
// Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update
this.calculatePeekRules(room);
this.updatePreviewUrlVisibility(room);
@ -980,6 +1032,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.updateE2EStatus(room);
this.updatePermissions(room);
this.checkWidgets(room);
this.checkRightPanel(room);
this.setState({
liveTimeline: room.getLiveTimeline(),
@ -1112,12 +1165,21 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
private onRoomStateEvents = (ev: MatrixEvent, state) => {
private onRoomStateEvents = (ev: MatrixEvent, state: RoomState) => {
// ignore if we don't have a room yet
if (!this.state.room || this.state.room.roomId !== state.roomId) {
return;
}
if (ev.getType() === EventType.RoomCanonicalAlias) {
// re-view the room so MatrixChat can manage the alias in the URL properly
dis.dispatch({
action: Action.ViewRoom,
room_id: this.state.room.roomId,
});
return; // this event cannot affect permissions so bail
}
this.updatePermissions(this.state.room);
};
@ -1209,7 +1271,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_room',
action: Action.ViewRoom,
room_id: this.getRoomId(),
},
});
@ -1291,7 +1353,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
ev.dataTransfer.files, this.state.room.roomId, null, this.context,
);
dis.fire(Action.FocusSendMessageComposer);
@ -1301,13 +1363,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
};
private injectSticker(url: string, info: object, text: string) {
private injectSticker(url: string, info: object, text: string, threadId: string | null) {
if (this.context.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
}
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, this.context)
ContentMessages.sharedInstance()
.sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context)
.then(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
@ -1477,16 +1540,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return ret;
}
private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({
action: 'place_call',
type: type,
room_id: this.state.room.roomId,
});
};
private onSettingsClick = () => {
dis.dispatch({ action: "open_room_settings" });
private onCallPlaced = (type: CallType): void => {
CallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => {
@ -1589,7 +1644,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// an event will take care of both clearing the URL fragment and
// jumping to the bottom
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.state.room.roomId,
});
} else {
@ -1698,7 +1753,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) {
return null;
}
return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId);
return CallHandler.instance.getCallForRoom(this.state.room.roomId);
}
// this has to be a proper method rather than an unnamed function,
@ -2079,6 +2134,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel
room={this.state.room}
@ -2097,6 +2153,52 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const showChatEffects = SettingsStore.getValue('showChatEffects');
// Decide what to show in the main split
let mainSplitBody = <React.Fragment>
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</React.Fragment>;
switch (this.state.mainSplitContentType) {
case MainSplitContentType.Timeline:
// keep the timeline in as the mainSplitBody
break;
case MainSplitContentType.MaximisedWidget:
if (!SettingsStore.getValue("feature_maximised_widgets")) break;
mainSplitBody = <AppsDrawer
room={this.state.room}
userId={this.context.credentials.userId}
resizeNotifier={this.props.resizeNotifier}
showApps={true}
/>;
break;
// TODO-video MainSplitContentType.Video:
// break;
}
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
let onAppsClick = this.onAppsClick;
let onForgetClick = this.onForgetClick;
let onSearchClick = this.onSearchClick;
if (this.state.mainSplitContentType === MainSplitContentType.MaximisedWidget) {
// Disable phase buttons and action button to have a simplified header when a widget is maximised
// and enable (not disable) the RightPanelPhases.Timeline button
excludedRightPanelPhaseButtons = [
RightPanelPhases.ThreadPanel,
RightPanelPhases.PinnedMessages,
];
onAppsClick = null;
onForgetClick = null;
onSearchClick = null;
}
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
@ -2109,27 +2211,17 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onSearchClick={onSearchClick}
onForgetClick={(myMembership === "leave") ? onForgetClick : null}
e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
appsShown={this.state.showApps}
onCallPlaced={this.onCallPlaced}
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className="mx_RoomView_body">
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
{ mainSplitBody }
</div>
</MainSplit>
</ErrorBoundary>

View file

@ -750,6 +750,8 @@ export default class ScrollPanel extends React.Component<IProps> {
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this.pages = Math.ceil(height / PAGE_SIZE);
const displayScrollbar = contentHeight > minHeight;
sn.dataset.scrollbar = displayScrollbar.toString();
this.bottomGrowth = 0;
const newHeight = `${this.getListHeight()}px`;

View file

@ -23,6 +23,7 @@ import { Key } from '../../Keyboard';
import dis from '../../dispatcher/dispatcher';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import { replaceableComponent } from "../../utils/replaceableComponent";
import { Action } from '../../dispatcher/actions';
interface IProps {
onSearch?: (query: string) => void;
@ -78,7 +79,7 @@ export default class SearchBox extends React.Component<IProps, IState> {
if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) {
case 'view_room':
case Action.ViewRoom:
if (this.search.current && payload.clear_search) {
this.clearSearch();
}

View file

@ -49,7 +49,7 @@ import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import { getChildOrder } from "../../stores/spaces/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
@ -68,7 +68,6 @@ interface IProps {
initialText?: string;
additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void;
joinRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void;
}
interface ITileProps {
@ -78,7 +77,7 @@ interface ITileProps {
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(): void;
onJoinRoomClick(): void;
onJoinRoomClick(): Promise<unknown>;
onToggleClick?(): void;
}
@ -115,9 +114,9 @@ const Tile: React.FC<ITileProps> = ({
setBusy(true);
ev.preventDefault();
ev.stopPropagation();
onJoinRoomClick();
setJoinedRoom(await awaitRoomDownSync(cli, room.room_id));
setBusy(false);
onJoinRoomClick().then(() => awaitRoomDownSync(cli, room.room_id)).then(setJoinedRoom).finally(() => {
setBusy(false);
});
};
let button;
@ -141,7 +140,7 @@ const Tile: React.FC<ITileProps> = ({
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
} else {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
@ -343,7 +342,7 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
});
};
export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void => {
export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise<unknown> => {
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (cli.isGuest()) {
@ -351,11 +350,15 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
return;
}
cli.joinRoom(roomId, {
const prom = cli.joinRoom(roomId, {
viaServers: Array.from(hierarchy.viaMap.get(roomId) || []),
}).catch(err => {
});
prom.catch(err => {
RoomViewStore.showJoinRoomError(err, roomId);
});
return prom;
};
interface IHierarchyLevelProps {
@ -365,7 +368,7 @@ interface IHierarchyLevelProps {
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, roomType?: RoomType): void;
onJoinRoomClick(roomId: string): void;
onJoinRoomClick(roomId: string): Promise<unknown>;
onToggleClick?(parentId: string, childId: string): void;
}

View file

@ -49,6 +49,7 @@ import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPan
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {
shouldShowSpaceInvite,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
@ -56,9 +57,9 @@ import {
showSpaceInvite,
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {
AddExistingToSpace,
@ -127,6 +128,7 @@ const useMyRoomMembership = (room: Room) => {
};
const SpaceInfo = ({ space }: { space: Room }) => {
// summary will begin as undefined whilst loading and go null if it fails to load.
const summary = useAsyncMemo(async () => {
if (space.getMyMembership() !== "invite") return;
try {
@ -155,7 +157,7 @@ const SpaceInfo = ({ space }: { space: Room }) => {
memberSection = <span className="mx_SpaceRoomView_info_memberCount">
{ _t("%(count)s members", { count: summary.num_joined_members }) }
</span>;
} else if (summary === null) {
} else if (summary !== undefined) { // summary is not still loading
memberSection = <RoomMemberCount room={space}>
{ (count) => count > 0 ? (
<AccessibleButton
@ -394,7 +396,7 @@ const SpaceLandingAddButton = ({ space }) => {
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -438,9 +440,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
const userId = cli.getUserId();
let inviteButton;
if (((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) &&
shouldShowComponent(UIComponent.InviteUsers)
) {
if (shouldShowSpaceInvite(space) && shouldShowComponent(UIComponent.InviteUsers)) {
inviteButton = (
<AccessibleButton
kind="primary"
@ -507,7 +507,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
) }
</RoomTopic>
<SpaceHierarchy space={space} showRoom={showRoom} joinRoom={joinRoom} additionalButtons={addRoomButton} />
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};

View file

@ -14,29 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { Room } from 'matrix-js-sdk/src/models/room';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ResizeNotifier from '../../utils/ResizeNotifier';
import MatrixClientContext from '../../contexts/MatrixClientContext';
import { _t } from '../../languageHandler';
import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
import ContextMenu, { useContextMenu } from './ContextMenu';
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from './ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import { useEventEmitter } from '../../hooks/useEventEmitter';
import AccessibleButton from '../views/elements/AccessibleButton';
import { TileShape } from '../views/rooms/EventTile';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
}
export enum ThreadFilterType {
@ -69,46 +69,21 @@ const useFilteredThreadsTimelinePanel = ({
pendingEvents: false,
}), []);
useEffect(() => {
let filteredThreads = Array.from(threads);
if (filterOption === ThreadFilterType.My) {
filteredThreads = filteredThreads.filter(([id, thread]) => {
return thread.rootEvent.getSender() === userId;
const buildThreadList = useCallback(function(timelineSet: EventTimelineSet) {
timelineSet.resetLiveTimeline("");
Array.from(threads)
.forEach(([, thread]) => {
if (filterOption !== ThreadFilterType.My || thread.hasCurrentUserParticipated) {
timelineSet.addLiveEvent(thread.rootEvent);
}
});
}
// NOTE: Temporarily reverse the list until https://github.com/vector-im/element-web/issues/19393 gets properly resolved
// The proper list order should be top-to-bottom, like in social-media newsfeeds.
filteredThreads.reverse().forEach(([id, thread]) => {
const event = thread.rootEvent;
if (timelineSet.findEventById(event.getId()) || event.status !== null) return;
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
true,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, timelineSet]);
useEventEmitter(room, ThreadEvent.Update, (thread) => {
const event = thread.rootEvent;
if (
// If that's a reply and not an event
event !== thread.replyToEvent &&
timelineSet.findEventById(event.getId()) ||
event.status !== null
) return;
if (event !== thread.events[thread.events.length - 1]) {
timelineSet.removeEvent(thread.events[thread.events.length - 1]);
timelineSet.removeEvent(event);
}
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
false,
);
updateTimeline();
});
}, [filterOption, threads, updateTimeline]);
useEffect(() => { buildThreadList(timelineSet); }, [timelineSet, buildThreadList]);
useEventEmitter(room, ThreadEvent.Update, () => { buildThreadList(timelineSet); });
useEventEmitter(room, ThreadEvent.New, () => { buildThreadList(timelineSet); });
return timelineSet;
};
@ -122,14 +97,14 @@ export const ThreadPanelHeaderFilterOptionItem = ({
onClick: () => void;
isSelected: boolean;
}) => {
return <AccessibleButton
aria-selected={isSelected}
return <MenuItemRadio
active={isSelected}
className="mx_ThreadPanel_Header_FilterOptionItem"
onClick={onClick}
>
<span>{ label }</span>
<span>{ description }</span>
</AccessibleButton>;
</MenuItemRadio>;
};
export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
@ -140,7 +115,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("My threads"),
description: _t("Shows all threads youve participated in"),
description: _t("Shows all threads you've participated in"),
key: ThreadFilterType.My,
},
{
@ -161,19 +136,49 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
}}
isSelected={opt === value}
/>);
const contextMenu = menuDisplayed ? <ContextMenu top={0} right={25} onFinished={closeMenu} managed={false}>
const contextMenu = menuDisplayed ? <ContextMenu
top={0}
right={25}
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
mountAsChild={true}
>
{ contextMenuOptions }
</ContextMenu> : null;
return <div className="mx_ThreadPanel__header">
<span>{ _t("Threads") }</span>
<ContextMenuButton inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
<ContextMenuButton className="mx_ThreadPanel_dropdown" inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
{ `${_t('Show:')} ${value.label}` }
</ContextMenuButton>
{ contextMenu }
</div>;
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
interface EmptyThreadIProps {
filterOption: ThreadFilterType;
showAllThreadsCallback: () => void;
}
const EmptyThread: React.FC<EmptyThreadIProps> = ({ filterOption, showAllThreadsCallback }) => {
return <aside className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{ _t("Keep discussions organised with threads") }</h2>
<p>{ _t("Threads help you keep conversations on-topic and easily "
+ "track them over time. Create the first one by using the "
+ "\"Reply in thread\" button on a message.") }
</p>
<p>
{ /* Always display that paragraph to prevent layout shift
When hiding the button */ }
{ filterOption === ThreadFilterType.My
? <button onClick={showAllThreadsCallback}>{ _t("Show all threads") }</button>
: <>&nbsp;</>
}
</p>
</aside>;
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) => {
const mxClient = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const room = mxClient.getRoom(roomId);
@ -199,7 +204,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
header={<ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
className="mx_ThreadPanel"
onClose={onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
<TimelinePanel
ref={ref}
@ -209,7 +214,10 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
timelineSet={filteredTimelineSet}
showUrlPreview={true}
empty={<div>empty</div>}
empty={<EmptyThread
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
@ -217,7 +225,9 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout"
membersLoaded={true}
permalinkCreator={permalinkCreator}
tileShape={TileShape.ThreadPanel}
disableGrouping={true}
/>
</BaseCard>
</RoomContext.Provider>

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { IEventRelation, MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
@ -26,7 +26,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
@ -36,6 +36,13 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import ContentMessages from '../../ContentMessages';
import UploadBar from './UploadBar';
import { _t } from '../../languageHandler';
import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu';
import RightPanelStore from '../../stores/RightPanelStore';
import SettingsStore from '../../settings/SettingsStore';
import { WidgetLayoutStore } from '../../stores/widgets/WidgetLayoutStore';
interface IProps {
room: Room;
@ -47,7 +54,6 @@ interface IProps {
initialEvent?: MatrixEvent;
initialEventHighlighted?: boolean;
}
interface IState {
thread?: Thread;
editState?: EditorStateTransfer;
@ -78,7 +84,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.teardownThread();
dis.unregister(this.dispatcherRef);
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
room.on(ThreadEvent.New, this.onNewThread);
room.removeListener(ThreadEvent.New, this.onNewThread);
}
public componentDidUpdate(prevProps) {
@ -97,10 +103,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
this.teardownThread();
this.setupThread(payload.event);
}
switch (payload.action) {
case Action.EditEvent:
@ -129,15 +133,18 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = mxEv.getThread();
let thread = this.props.room.threads.get(mxEv.getId());
if (!thread) {
const client = MatrixClientPeg.get();
// Do not attach this thread object to the event for now
// TODO: When local echo gets reintroduced it will be important
// to add that back in, and the threads model should go through the
// same reconciliation algorithm as events
thread = new Thread(
[mxEv],
this.props.room,
client,
);
mxEv.setThread(thread);
}
thread.on(ThreadEvent.Update, this.updateThread);
thread.once(ThreadEvent.Ready, this.updateThread);
@ -159,10 +166,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private updateThread = (thread?: Thread) => {
if (thread) {
if (thread && this.state.thread !== thread) {
this.setState({
thread,
});
thread.emit(ThreadEvent.ViewThread);
}
this.timelinePanelRef.current?.refreshTimeline();
@ -171,7 +179,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
private onScroll = (): void => {
if (this.props.initialEvent && this.props.initialEventHighlighted) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.props.room.roomId,
event_id: this.props.initialEvent?.getId(),
highlighted: false,
@ -180,10 +188,43 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private renderThreadViewHeader = (): JSX.Element => {
return <div className="mx_ThreadPanel__header">
<span>{ _t("Thread") }</span>
<ThreadListContextMenu
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} />
</div>;
};
public render(): JSX.Element {
const highlightedEventId = this.props.initialEventHighlighted
? this.props.initialEvent?.getId()
: null;
const threadRelation: IEventRelation = {
rel_type: RelationType.Thread,
event_id: this.state.thread?.id,
};
let previousPhase = RightPanelStore.getSharedInstance().previousPhase;
if (!SettingsStore.getValue("feature_maximised_widgets")) {
previousPhase = RightPanelPhases.ThreadPanel;
}
// change the previous phase to the threadPanel in case there is no maximised widget anymore
if (!WidgetLayoutStore.instance.hasMaximisedWidget(this.props.room)) {
previousPhase = RightPanelPhases.ThreadPanel;
}
// Make sure the previous Phase is always one of the two: Timeline or ThreadPanel
if (![RightPanelPhases.ThreadPanel, RightPanelPhases.Timeline].includes(previousPhase)) {
previousPhase = RightPanelPhases.ThreadPanel;
}
const previousPhaseLabels = {};
previousPhaseLabels[RightPanelPhases.ThreadPanel] = _t("All threads");
previousPhaseLabels[RightPanelPhases.Timeline] = _t("Chat");
return (
<RoomContext.Provider value={{
...this.context,
@ -192,23 +233,24 @@ export default class ThreadView extends React.Component<IProps, IState> {
}}>
<BaseCard
className="mx_ThreadView"
className="mx_ThreadView mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.ThreadPanel}
previousPhase={previousPhase}
previousPhaseLabel={previousPhaseLabels[previousPhase]}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
showReadReceipts={false} // No RR support in thread's MVP
manageReadReceipts={false} // No RR support in thread's MVP
manageReadMarkers={false} // No RM support in thread's MVP
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
showReadReceipts={false} // Hide the read receipts
// until homeservers speak threads language
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={true}
tileShape={TileShape.Thread}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
@ -223,13 +265,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
/>
) }
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
) }
{ this.state?.thread?.timelineSet && (<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={{
rel_type: RelationType.Thread,
event_id: this.state.thread.id,
}}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}

View file

@ -27,13 +27,14 @@ import { debounce } from 'lodash';
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from '../../languageHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
import { Action } from '../../dispatcher/actions';
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import shouldHideEvent from '../../shouldHideEvent';
@ -132,6 +133,7 @@ interface IProps {
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
}
interface IState {
@ -221,6 +223,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
disableGrouping: false,
};
private lastRRSentEventId: string = undefined;
@ -474,10 +477,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
};
private onMessageListScroll = e => {
if (this.props.onScroll) {
this.props.onScroll(e);
}
this.props.onScroll?.(e);
if (this.props.manageReadMarkers) {
this.doManageReadMarkers();
}
@ -505,7 +505,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
this.readMarkerActivityTimer?.changeTimeout(timeout);
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
private onAction = (payload: ActionPayload): void => {
@ -592,7 +592,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState<null>(updatedState, () => {
this.messagePanel.current.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated();
this.props.onReadMarkerUpdated?.();
}
});
});
@ -1134,7 +1134,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
onFinished = () => {
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.props.timelineSet.room.roomId,
});
};
@ -1309,12 +1309,17 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
private indexForEventId(evId: string): number | null {
for (let i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
/* Threads do not have server side support for read receipts and the concept
is very tied to the main room timeline, we are forcing the timeline to
send read receipts for threaded events */
const isThreadTimeline = this.context.timelineRenderingType === TimelineRenderingType.Thread;
if (SettingsStore.getValue("feature_thread") && isThreadTimeline) {
return 0;
}
return null;
const index = this.state.events.findIndex(ev => ev.getId() === evId);
return index > -1
? index
: null;
}
private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null {
@ -1538,6 +1543,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
disableGrouping={this.props.disableGrouping}
/>
);
}

View file

@ -28,9 +28,11 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload";
import { replaceableComponent } from "../../utils/replaceableComponent";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { IEventRelation } from 'matrix-js-sdk/src';
interface IProps {
room: Room;
relation?: IEventRelation;
}
interface IState {
@ -65,7 +67,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
}
private getUploadsInRoom(): IUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation);
return uploads.filter(u => u.roomId === this.props.room.roomId);
}

View file

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { createRef, useContext, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import * as fbEmitter from "fbemitter";
import { logger } from "matrix-js-sdk/src/logger";
import classNames from "classnames";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -26,7 +25,7 @@ import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import { ChevronFace, ContextMenuButton } from "./ContextMenu";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
@ -43,26 +42,62 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import { showCommunityInviteDialog } from "../../RoomInvite";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
import { UIFeature } from "../../settings/UIFeature";
import HostSignupAction from "./HostSignupAction";
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { replaceableComponent } from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
const CustomStatusSection = () => {
const cli = useContext(MatrixClientContext);
const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || "";
const [value, setValue] = useState(setStatus);
let details: JSX.Element;
if (value !== setStatus) {
details = <>
<p>{ _t("Your status will be shown to people you have a DM with.") }</p>
<AccessibleButton
onClick={() => cli._unstable_setStatusMessage(value)}
kind="primary_outline"
>
{ value ? _t("Set status") : _t("Clear status") }
</AccessibleButton>
</>;
}
return <div className="mx_UserMenu_CustomStatusSection">
<div className="mx_UserMenu_CustomStatusSection_input">
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder={_t("Set a new status")}
autoComplete="off"
/>
<AccessibleButton
tabIndex={-1}
title={_t("Clear")}
className="mx_UserMenu_CustomStatusSection_clear"
onClick={() => setValue("")}
/>
</div>
{ details }
</div>;
};
interface IProps {
isMinimized: boolean;
isPanelCollapsed: boolean;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
@ -72,14 +107,30 @@ interface IState {
isDarkTheme: boolean;
isHighContrast: boolean;
selectedSpace?: Room;
pendingRoomJoin: Set<string>;
dndEnabled: boolean;
}
const toRightOf = (rect: PartialDOMRect) => {
return {
left: rect.width + rect.left + 8,
top: rect.top,
chevronFace: ChevronFace.None,
};
};
const below = (rect: PartialDOMRect) => {
return {
left: rect.left,
top: rect.top + rect.height,
chevronFace: ChevronFace.None,
};
};
@replaceableComponent("structures.UserMenu")
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private dndWatcherRef: string;
private readonly dndWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription;
@ -90,7 +141,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
pendingRoomJoin: new Set<string>(),
dndEnabled: this.doNotDisturb,
selectedSpace: SpaceStore.instance.activeSpaceRoom,
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -98,8 +150,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
// Force update is the easiest way to trigger the UI update (we don't store state for this)
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
SettingsStore.monitorSetting("feature_dnd", null);
SettingsStore.monitorSetting("doNotDisturb", null);
}
private get doNotDisturb(): boolean {
return SettingsStore.getValue("doNotDisturb");
}
private get hasHomePage(): boolean {
@ -110,7 +166,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
}
public componentWillUnmount() {
@ -122,13 +177,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SpaceStore.spacesEnabled) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
};
private onTagStoreUpdate = () => {
this.forceUpdate(); // we don't have anything useful in state to update
};
@ -163,8 +213,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.forceUpdate();
};
private onSelectedSpaceUpdate = async (selectedSpace?: Room) => {
this.setState({ selectedSpace });
private onSelectedSpaceUpdate = async () => {
this.setState({
selectedSpace: SpaceStore.instance.activeSpaceRoom,
});
};
private onThemeChanged = () => {
@ -175,8 +227,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
});
};
private onAction = (ev: ActionPayload) => {
switch (ev.action) {
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
this.setState({ contextMenuPosition: null });
@ -184,36 +236,27 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
case Action.JoinRoom:
this.addPendingJoinRoom(ev.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
this.removePendingJoinRoom(ev.roomId);
break;
case Action.SettingUpdated: {
const settingUpdatedPayload = payload as SettingUpdatedPayload;
switch (settingUpdatedPayload.settingName) {
case "feature_dnd":
case "doNotDisturb": {
const dndEnabled = this.doNotDisturb;
if (this.state.dndEnabled !== dndEnabled) {
this.setState({ dndEnabled });
}
break;
}
}
}
}
};
private addPendingJoinRoom(roomId: string): void {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
.add(roomId),
});
}
private removePendingJoinRoom(roomId: string): void {
if (this.state.pendingRoomJoin.delete(roomId)) {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
});
}
}
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent) => {
@ -259,15 +302,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// TODO: Archived room view: https://github.com/vector-im/element-web/issues/14038
// Note: You'll need to uncomment the button too.
logger.log("TODO: Show archived rooms");
};
private onProvideFeedback = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -309,50 +343,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunitySettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, {
communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(),
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// We'd ideally just pop open a right panel with the member list, but the current
// way the right panel is structured makes this exceedingly difficult. Instead, we'll
// switch to the general room and open the member list there as it should be in sync
// anyways.
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat) {
dis.dispatch({
action: 'view_room',
room_id: chat.roomId,
}, true);
dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList });
} else {
// "This should never happen" clauses go here for the prototype.
Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, {
title: _t('Failed to find the general chat for this community'),
description: _t("Failed to find the general chat for this community"),
});
}
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onDndToggle = (ev) => {
private onDndToggle = (ev: ButtonEvent) => {
ev.stopPropagation();
const current = SettingsStore.getValue("doNotDisturb");
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
@ -361,8 +352,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let topSection;
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
if (MatrixClientPeg.get().isGuest()) {
@ -408,6 +397,24 @@ export default class UserMenu extends React.Component<IProps, IState> {
);
}
let customStatusSection: JSX.Element;
if (SettingsStore.getValue("feature_custom_status")) {
customStatusSection = <CustomStatusSection />;
}
let dndButton: JSX.Element;
if (SettingsStore.getValue("feature_dnd")) {
dndButton = (
<IconizedContextMenuCheckbox
iconClassName={this.state.dndEnabled ? "mx_UserMenu_iconDnd" : "mx_UserMenu_iconDndOff"}
label={_t("Do not disturb")}
onClick={this.onDndToggle}
active={this.state.dndEnabled}
words
/>
);
}
let feedbackButton;
if (SettingsStore.getValue(UIFeature.Feedback)) {
feedbackButton = <IconizedContextMenuOption
@ -417,156 +424,68 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>;
}
let primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
);
let primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
{ dndButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notifications")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
<IconizedContextMenuOption
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
);
if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notification settings")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ /* <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconArchive"
label={_t("Archived rooms")}
onClick={this.onShowArchived}
/> */ }
{ feedbackButton }
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</React.Fragment>
);
let secondarySection = null;
if (prototypeCommunityName) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ prototypeCommunityName }
</span>
</div>
);
let settingsOption;
let inviteOption;
if (CommunityPrototypeStore.instance.canInviteTo(communityId)) {
inviteOption = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconInvite"
label={_t("Invite")}
onClick={this.onCommunityInviteClick}
/>
);
}
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("Community settings")}
onClick={this.onCommunitySettingsClick}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
);
}
primaryOptionList = (
<IconizedContextMenuOptionList>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMembers"
label={_t("Members")}
onClick={this.onCommunityMembersClick}
/>
{ inviteOption }
{ feedbackButton }
</IconizedContextMenuOptionList>
);
secondarySection = (
<React.Fragment>
<hr />
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
</div>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("User settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</React.Fragment>
);
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
</React.Fragment>
);
}
const classes = classNames({
"mx_UserMenu_contextMenu": true,
"mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName,
});
const position = this.props.isPanelCollapsed
? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition);
return <IconizedContextMenu
// numerical adjustments to overlap the context menu by just over the width of the
// menu icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 10}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height + 8}
{...position}
onFinished={this.onCloseMenu}
className={classes}
className="mx_UserMenu_contextMenu"
>
<div className="mx_UserMenu_contextMenu_header">
{ primaryHeader }
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
<AccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
@ -579,9 +498,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</AccessibleTooltipButton>
</div>
{ customStatusSection }
{ topSection }
{ primaryOptionList }
{ secondarySection }
</IconizedContextMenu>;
};
@ -592,102 +511,47 @@ export default class UserMenu extends React.Component<IProps, IState> {
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let badge: JSX.Element;
if (this.state.dndEnabled) {
badge = <div className="mx_UserMenu_dndBadge" />;
}
let isPrototype = false;
let menuName = _t("User menu");
let name = <span className="mx_UserMenu_userName">{ displayName }</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{ /* masked image in CSS */ }
</span>
);
let dnd;
if (this.state.selectedSpace) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ displayName }</span>
<RoomName room={this.state.selectedSpace}>
{ (roomName) => <span className="mx_UserMenu_subUserName">{ roomName }</span> }
</RoomName>
</div>
);
} else if (prototypeCommunityName) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ prototypeCommunityName }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
menuName = _t("Community and user menu");
isPrototype = true;
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ _t("Home") }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
isPrototype = true;
} else if (SettingsStore.getValue("feature_dnd")) {
const isDnd = SettingsStore.getValue("doNotDisturb");
dnd = <AccessibleButton
onClick={this.onDndToggle}
let name: JSX.Element;
if (!this.props.isPanelCollapsed) {
name = <div className="mx_UserMenu_name">
{ displayName }
</div>;
}
return <div className="mx_UserMenu">
<ContextMenuButton
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
className={classNames({
"mx_UserMenu_dnd": true,
"mx_UserMenu_dnd_noisy": !isDnd,
"mx_UserMenu_dnd_muted": isDnd,
mx_UserMenu_cutout: badge,
})}
/>;
}
if (this.props.isMinimized) {
name = null;
buttons = null;
}
>
<div className="mx_UserMenu_userAvatar">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
{ badge }
</div>
{ name }
const classes = classNames({
'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized,
'mx_UserMenu_prototype': isPrototype,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={menuName}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar"
/>
</span>
{ name }
{ this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
) }
{ dnd }
{ buttons }
</div>
</ContextMenuButton>
{ this.renderContextMenu() }
</React.Fragment>
);
</ContextMenuButton>
{ this.props.children }
</div>;
}
}

View file

@ -17,11 +17,12 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from '../../views/elements/AccessibleButton';
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import AuthPage from "../../views/auth/AuthPage";
interface IProps {
onFinished: () => void;
@ -59,8 +60,6 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
}
public render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const { phase, lostKeys } = this.state;
let icon;
let title;

View file

@ -17,14 +17,11 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
@ -32,8 +29,14 @@ import EmailField from "../../views/auth/EmailField";
import PassphraseField from '../../views/auth/PassphraseField';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import { IValidationResult } from "../../views/elements/Validation";
import InlineSpinner from '../../views/elements/InlineSpinner';
import { logger } from "matrix-js-sdk/src/logger";
import Spinner from "../../views/elements/Spinner";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
enum Phase {
// Show the forgot password inputs
@ -68,11 +71,15 @@ interface IState {
serverErrorIsFatal: boolean;
serverDeadError: string;
emailFieldValid: boolean;
passwordFieldValid: boolean;
currentHttpRequest?: Promise<any>;
}
enum ForgotPasswordField {
Email = 'field_email',
Password = 'field_password',
PasswordConfirm = 'field_password_confirm',
}
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component<IProps, IState> {
private reset: PasswordReset;
@ -91,8 +98,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
emailFieldValid: false,
passwordFieldValid: false,
};
constructor(props: IProps) {
@ -171,42 +176,58 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
// refresh the server errors, just in case the server came back online
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
await this['email_field'].validate({ allowEmpty: false });
await this['password_field'].validate({ allowEmpty: false });
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.emailFieldValid) {
this.showErrorDialog(_t("The email address doesn't appear to be valid."));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (!this.state.passwordFieldValid) {
this.showErrorDialog(_t('Please choose a strong password'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your sessions, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another session before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your sessions, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another session before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
};
private async verifyFieldsBeforeSubmit() {
const fieldIdsInDisplayOrder = [
ForgotPasswordField.Email,
ForgotPasswordField.Password,
ForgotPasswordField.PasswordConfirm,
];
const invalidFields = [];
for (const fieldId of fieldIdsInDisplayOrder) {
const valid = await this[fieldId].validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(this[fieldId]);
}
}
if (invalidFields.length === 0) {
return true;
}
// Focus on the first invalid field, then re-validate,
// which will result in the error tooltip being displayed for that field.
invalidFields[0].focus();
invalidFields[0].validate({ allowEmpty: false, focused: true });
return false;
}
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
this.setState({
[stateKey]: ev.currentTarget.value,
@ -220,25 +241,12 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
};
public showErrorDialog(description: string, title?: string) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title,
description,
});
}
private onEmailValidate = (result: IValidationResult) => {
this.setState({
emailFieldValid: result.valid,
});
};
private onPasswordValidate(result: IValidationResult) {
this.setState({
passwordFieldValid: result.valid,
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
@ -251,8 +259,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
let errorText = null;
const err = this.state.errorText;
if (err) {
@ -284,11 +290,12 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
labelRequired={_td('The email address linked to your account must be entered.')}
labelInvalid={_td("The email address doesn't appear to be valid.")}
value={this.state.email}
fieldRef={field => this['email_field'] = field}
fieldRef={field => this[ForgotPasswordField.Email] = field}
autoFocus={true}
onChange={this.onInputChanged.bind(this, "email")}
onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
/>
@ -300,18 +307,20 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
label={_td('New Password')}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={field => this[ForgotPasswordField.Password] = field}
onChange={this.onInputChanged.bind(this, "password")}
fieldRef={field => this['password_field'] = field}
onValidate={(result) => this.onPasswordValidate(result)}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
autoComplete="new-password"
/>
<Field
<PassphraseConfirmField
name="reset_password_confirm"
type="password"
label={_t('Confirm')}
label={_td('Confirm')}
labelRequired={_td("A new password must be entered.")}
labelInvalid={_td("New passwords must match each other.")}
value={this.state.password2}
password={this.state.password}
fieldRef={field => this[ForgotPasswordField.PasswordConfirm] = field}
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
@ -335,7 +344,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
renderSendingEmail() {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
@ -372,9 +380,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
render() {
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
let resetPasswordJsx;
switch (this.state.phase) {
case Phase.Forgot:

View file

@ -303,7 +303,7 @@ export default class Registration extends React.Component<IProps, IState> {
errorText = _t('This server does not support authentication with a phone number.');
}
} else if (response.errcode === "M_USER_IN_USE") {
errorText = _t("That username already exists, please try another.");
errorText = _t("Someone already has that username, please try another.");
} else if (response.errcode === "M_THREEPID_IN_USE") {
errorText = _t("That e-mail address is already in use.");
}
@ -509,6 +509,7 @@ export default class Registration extends React.Component<IProps, IState> {
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
matrixClient={this.state.matrixClient}
/>
</React.Fragment>;
}

View file

@ -39,7 +39,7 @@ function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
}
interface IProps {
onFinished: (boolean) => void;
onFinished: () => void;
}
interface IState {
@ -70,7 +70,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
private onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) {
this.props.onFinished(true);
this.props.onFinished();
return;
}
this.setState({
@ -97,13 +97,16 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
this.props.onFinished(true);
// We need to call onFinished now to close this dialog, and
// again later to signal that the verification is complete.
this.props.onFinished();
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
},
});
};
@ -125,6 +128,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
private onResetConfirmClick = () => {
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
@ -140,7 +144,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
private onEncryptionPanelClose = () => {
this.props.onFinished(false);
this.props.onFinished();
};
public render() {
@ -249,7 +253,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
return (
<div>
<p>{ _t(
"Without verifying, you wont have access to all your messages " +
"Without verifying, you won't have access to all your messages " +
"and may appear as untrusted to others.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">

View file

@ -215,7 +215,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
if (this.state.keyBackupNeeded) {
introText = _t(
"Regain access to your account and recover encryption keys stored in this session. " +
"Without them, you wont be able to read all of your secure messages in any session.");
"Without them, you won't be able to read all of your secure messages in any session.");
}
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {

View file

@ -0,0 +1,83 @@
/*
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, { PureComponent, RefCallback, RefObject } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field, { IInputProps } from "../elements/Field";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t, _td } from "../../../languageHandler";
interface IProps extends Omit<IInputProps, "onValidate"> {
id?: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
autoComplete?: string;
value: string;
password: string; // The password we're confirming
labelRequired?: string;
labelInvalid?: string;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate?(result: IValidationResult);
}
@replaceableComponent("views.auth.EmailField")
class PassphraseConfirmField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Confirm password"),
labelRequired: _td("Confirm password"),
labelInvalid: _td("Passwords don't match"),
};
private validate = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(this.props.labelRequired),
},
{
key: "match",
test: ({ value }) => !value || value === this.props.password,
invalid: () => _t(this.props.labelInvalid),
},
],
});
private onValidate = async (fieldState: IFieldState) => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};
render() {
return <Field
id={this.props.id}
ref={this.props.fieldRef}
type="password"
label={_t(this.props.label)}
autoComplete={this.props.autoComplete}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
}
}
export default PassphraseConfirmField;

View file

@ -38,7 +38,7 @@ interface IProps extends Omit<IInputProps, "onValidate"> {
labelAllowedButUnsafe?: string;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate(result: IValidationResult);
onValidate?(result: IValidationResult);
}
@replaceableComponent("views.auth.PassphraseField")
@ -98,7 +98,9 @@ class PassphraseField extends PureComponent<IProps> {
onValidate = async (fieldState: IFieldState) => {
const result = await this.validate(fieldState);
this.props.onValidate(result);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};

View file

@ -317,6 +317,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <EmailField
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="email"
type="email"
key="email_input"
placeholder="joe@example.com"
value={this.props.username}
@ -333,6 +335,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="username"
key="username_input"
type="text"
label={_t("Username")}
@ -359,6 +362,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <Field
className={classNames(classes)}
name="phoneNumber"
autoComplete="tel-national"
key="phone_input"
type="text"
label={_t("Phone")}
@ -444,6 +448,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
{ loginField }
<Field
className={pwFieldClass}
autoComplete="password"
type="password"
name="password"
label={_t('Password')}

View file

@ -16,12 +16,12 @@ limitations under the License.
*/
import React from 'react';
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation, { IValidationResult } from '../elements/Validation';
@ -34,6 +34,9 @@ import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDia
import { replaceableComponent } from "../../../utils/replaceableComponent";
import CountryDropdown from "./CountryDropdown";
import { logger } from "matrix-js-sdk/src/logger";
import PassphraseConfirmField from "./PassphraseConfirmField";
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
@ -56,6 +59,7 @@ interface IProps {
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
matrixClient: MatrixClient;
onRegisterClick(params: {
username: string;
@ -292,29 +296,10 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
});
};
private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
private onPasswordConfirmValidate = (result: IValidationResult) => {
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
};
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
});
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
@ -365,7 +350,11 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
};
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
description: (_, results) => {
// omit the description if the only failing result is the `available` one as it makes no sense for it.
if (results.every(({ key, valid }) => key === "available" || valid)) return;
return _t("Use lowercase letters, numbers, dashes and underscores only");
},
hideDescriptionIfValid: true,
rules: [
{
@ -378,6 +367,23 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
invalid: () => _t("Some characters not allowed"),
},
{
key: "available",
final: true,
test: async ({ value }) => {
if (!value) {
return true;
}
try {
await this.props.matrixClient.isUsernameAvailable(value);
return true;
} catch (err) {
return false;
}
},
invalid: () => _t("Someone already has that username. Try another or if it is you, sign in below."),
},
],
});
@ -425,8 +431,8 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
return null;
}
const emailLabel = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
_td("Email") :
_td("Email (optional)");
return <EmailField
fieldRef={field => this[RegistrationField.Email] = field}
label={emailLabel}
@ -453,13 +459,12 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
}
renderPasswordConfirm() {
return <Field
return <PassphraseConfirmField
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
fieldRef={field => this[RegistrationField.PasswordConfirm] = field}
autoComplete="new-password"
label={_t("Confirm password")}
value={this.state.passwordConfirm}
password={this.state.password}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}

View file

@ -150,6 +150,7 @@ const BaseAvatar = (props: IProps) => {
return (
<AccessibleButton
aria-label={_t("Avatar")}
aria-live="off"
{...otherProps}
element="span"
className={classNames("mx_BaseAvatar", className)}

View file

@ -1,160 +1 @@
/*
Copyright 2018 New Vector Ltd
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, { createRef } from 'react';
import classNames from 'classnames';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent";
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<IProps, IState> {
public static defaultProps: Partial<IProps> = {
width: 40,
height: 40,
resizeMethod: 'crop',
};
private button = createRef<HTMLDivElement>();
constructor(props: IProps) {
super(props);
this.state = {
hasStatus: this.hasStatus,
menuDisplayed: false,
};
}
public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
if (!SettingsStore.getValue("feature_custom_status")) {
return;
}
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
public componentWillUnmount(): void {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this.onStatusMessageCommitted,
);
}
private get hasStatus(): boolean {
const { user } = this.props.member;
if (!user) {
return false;
}
return !!user.unstable_statusMessage;
}
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
hasStatus: this.hasStatus,
});
};
private openMenu = (): void => {
this.setState({ menuDisplayed: true });
};
private closeMenu = (): void => {
this.setState({ menuDisplayed: false });
};
public render(): JSX.Element {
const avatar = <MemberAvatar
member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod}
/>;
if (!SettingsStore.getValue("feature_custom_status")) {
return avatar;
}
const classes = classNames({
"mx_MemberStatusMessageAvatar": true,
"mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus,
});
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target
contextMenu = (
<ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2}
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} />
</ContextMenu>
);
}
return <React.Fragment>
<ContextMenuButton
className={classes}
inputRef={this.button}
onClick={this.openMenu}
isExpanded={this.state.menuDisplayed}
label={_t("User Status")}
>
{ avatar }
</ContextMenuButton>
{ contextMenu }
</React.Fragment>;
}
}

View file

@ -16,10 +16,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import ContextMenu, { IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
import Modal from '../../../Modal';
@ -46,7 +45,7 @@ export default class CallContextMenu extends React.Component<IProps> {
};
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
CallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
};

View file

@ -16,10 +16,9 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import ContextMenu, { IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import DialPad from '../voip/DialPad';
import { replaceableComponent } from "../../../utils/replaceableComponent";

View file

@ -17,13 +17,13 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import {
import ContextMenu, {
ChevronFace,
ContextMenu,
IProps as IContextMenuProps,
MenuItem,
MenuItemCheckbox, MenuItemRadio,
} from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
interface IProps extends IContextMenuProps {
className?: string;
@ -42,6 +42,7 @@ interface IOptionProps extends React.ComponentProps<typeof MenuItem> {
interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
iconClassName: string;
words?: boolean;
}
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
@ -74,8 +75,21 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
iconClassName,
active,
className,
words,
...props
}) => {
let marker: JSX.Element;
if (words) {
marker = <span className="mx_IconizedContextMenu_activeText">
{ active ? _t("On") : _t("Off") }
</span>;
} else {
marker = <span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />;
}
return <MenuItemCheckbox
{...props}
className={classNames(className, {
@ -86,10 +100,7 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
<span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
{ marker }
</MenuItemCheckbox>;
};

View file

@ -39,6 +39,12 @@ import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
import EndPollDialog from '../dialogs/EndPollDialog';
import { Relations } from 'matrix-js-sdk/src/models/relations';
import { isPollEnded } from '../messages/MPollBody';
export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -66,6 +72,11 @@ interface IProps extends IPosition {
onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog?(): void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
interface IState {
@ -76,6 +87,7 @@ interface IState {
@replaceableComponent("views.context_menus.MessageContextMenu")
export default class MessageContextMenu extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
state = {
canRedact: false,
@ -120,6 +132,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
private canEndPoll(mxEvent: MatrixEvent): boolean {
return (
mxEvent.getType() === POLL_START_EVENT_TYPE.name &&
this.state.canRedact &&
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent)
);
}
private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(reaction);
@ -190,9 +210,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
};
private onQuoteClick = (): void => {
dis.dispatch({
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
event: this.props.mxEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
this.closeMenu();
};
@ -211,6 +232,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu();
};
private onEndPollClick = (): void => {
const matrixClient = MatrixClientPeg.get();
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
matrixClient,
event: this.props.mxEvent,
getRelationsForEvent: this.props.getRelationsForEvent,
}, 'mx_Dialog_endPoll');
this.closeMenu();
};
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
@ -231,7 +262,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
private viewInRoom = () => {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
@ -246,6 +277,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
let endPollButton: JSX.Element;
let resendReactionsButton: JSX.Element;
let redactButton: JSX.Element;
let forwardButton: JSX.Element;
@ -341,6 +373,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
/>
);
if (this.canEndPoll(mxEvent)) {
endPollButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconEndPoll"
label={_t("End Poll")}
onClick={this.onEndPollClick}
/>
);
}
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<IconizedContextMenuOption
@ -401,13 +443,17 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
const isThreadRootEvent = isThread && this.props.mxEvent?.getThread()?.rootEvent === this.props.mxEvent;
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
);
const commonItemsList = (
<IconizedContextMenuOptionList>
{ isThreadRootEvent && <IconizedContextMenuOption
{ (isThreadRootEvent && isMainSplitTimelineShown) && <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconViewInRoom"
label={_t("View in room")}
onClick={this.viewInRoom}
/> }
{ endPollButton }
{ quoteButton }
{ forwardButton }
{ pinButton }

View file

@ -0,0 +1,313 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import dis from "../../../dispatcher/dispatcher";
import RoomListActions from "../../../actions/RoomListActions";
import { Key } from "../../../Keyboard";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState } from "../../../RoomNotifs";
import Modal from "../../../Modal";
import ExportDialog from "../dialogs/ExportDialog";
import { onRoomFilesClick, onRoomMembersClick } from "../right_panel/RoomSummaryCard";
import RoomViewStore from "../../../stores/RoomViewStore";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
interface IProps extends IContextMenuProps {
room: Room;
}
const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const roomTags = useEventEmitterState(
RoomListStore.instance,
LISTS_UPDATE_EVENT,
() => RoomListStore.instance.getTagsForRoom(room),
);
let leaveOption: JSX.Element;
if (roomTags.includes(DefaultTagID.Archived)) {
const onForgetRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "forget_room",
room_id: room.roomId,
});
onFinished();
};
leaveOption = <IconizedContextMenuOption
iconClassName="mx_RoomTile_iconSignOut"
label={_t("Forget")}
className="mx_IconizedContextMenu_option_red"
onClick={onForgetRoomClick}
/>;
} else {
const onLeaveRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "leave_room",
room_id: room.roomId,
});
onFinished();
};
leaveOption = <IconizedContextMenuOption
onClick={onLeaveRoomClick}
label={_t("Leave")}
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_RoomTile_iconSignOut"
/>;
}
let inviteOption: JSX.Element;
if (room.canInvite(cli.getUserId())) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "view_invite",
roomId: room.roomId,
});
onFinished();
};
inviteOption = <IconizedContextMenuOption
onClick={onInviteClick}
label={_t("Invite")}
iconClassName="mx_RoomTile_iconInvite"
/>;
}
let favouriteOption: JSX.Element;
let lowPriorityOption: JSX.Element;
let notificationOption: JSX.Element;
if (room.getMyMembership() === "join") {
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
favouriteOption = <IconizedContextMenuCheckbox
onClick={(e) => onTagRoom(e, DefaultTagID.Favourite)}
active={isFavorite}
label={isFavorite ? _t("Favourited") : _t("Favourite")}
iconClassName="mx_RoomTile_iconStar"
/>;
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
lowPriorityOption = <IconizedContextMenuCheckbox
onClick={(e) => onTagRoom(e, DefaultTagID.LowPriority)}
active={isLowPriority}
label={_t("Low priority")}
iconClassName="mx_RoomTile_iconArrowDown"
/>;
const echoChamber = EchoChamber.forRoom(room);
let notificationLabel: string;
let iconClassName: string;
switch (echoChamber.notificationVolume) {
case RoomNotifState.AllMessages:
notificationLabel = _t("Default");
iconClassName = "mx_RoomTile_iconNotificationsDefault";
break;
case RoomNotifState.AllMessagesLoud:
notificationLabel = _t("All messages");
iconClassName = "mx_RoomTile_iconNotificationsAllMessages";
break;
case RoomNotifState.MentionsOnly:
notificationLabel = _t("Mentions only");
iconClassName = "mx_RoomTile_iconNotificationsMentionsKeywords";
break;
case RoomNotifState.Mute:
notificationLabel = _t("Mute");
iconClassName = "mx_RoomTile_iconNotificationsNone";
break;
}
notificationOption = <IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "open_room_settings",
room_id: room.roomId,
initial_tab_id: ROOM_NOTIFICATIONS_TAB,
});
onFinished();
}}
label={_t("Notifications")}
iconClassName={iconClassName}
>
<span className="mx_IconizedContextMenu_sublabel">
{ notificationLabel }
</span>
</IconizedContextMenuOption>;
}
const onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
ev.preventDefault();
ev.stopPropagation();
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0));
} else {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onFinished();
}
};
const ensureViewingRoom = () => {
if (RoomViewStore.getRoomId() === room.roomId) return;
dis.dispatch({
action: "view_room",
room_id: room.roomId,
}, true);
};
return <IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomTile_contextMenu" compact>
<IconizedContextMenuOptionList>
{ inviteOption }
{ notificationOption }
{ favouriteOption }
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
onRoomMembersClick(false);
onFinished();
}}
label={_t("People")}
iconClassName="mx_RoomTile_iconPeople"
>
<span className="mx_IconizedContextMenu_sublabel">
{ room.getJoinedMemberCount() }
</span>
</IconizedContextMenuOption>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
onRoomFilesClick(false);
onFinished();
}}
label={_t("Files")}
iconClassName="mx_RoomTile_iconFiles"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
allowClose: false,
});
onFinished();
}}
label={_t("Widgets")}
iconClassName="mx_RoomTile_iconWidgets"
/>
{ lowPriorityOption }
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "copy_room",
room_id: room.roomId,
});
onFinished();
}}
label={_t("Copy link")}
iconClassName="mx_RoomTile_iconCopyLink"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "open_room_settings",
room_id: room.roomId,
});
onFinished();
}}
label={_t("Settings")}
iconClassName="mx_RoomTile_iconSettings"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Export room dialog', '', ExportDialog, { room });
onFinished();
}}
label={_t("Export chat")}
iconClassName="mx_RoomTile_iconExport"
/>
{ leaveOption }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
export default RoomContextMenu;

View file

@ -26,7 +26,6 @@ import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
@ -35,18 +34,16 @@ import {
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
import SettingsStore from "../../../settings/SettingsStore";
import { Action } from "../../../dispatcher/actions";
interface IProps extends IContextMenuProps {
space: Room;
hideHeader?: boolean;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
@ -64,14 +61,14 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
label={_t("Invite")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
let leaveOption;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -97,36 +94,37 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
leaveOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
className="mx_IconizedContextMenu_option_red"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
);
}
let devtoolsSection;
let devtoolsOption;
if (SettingsStore.getValue("developerMode")) {
const onViewTimelineClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: space.roomId,
forceTimeline: true,
});
onFinished();
};
devtoolsSection = <IconizedContextMenuOptionList first>
devtoolsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("See room timeline (devtools)")}
onClick={onViewTimelineClick}
/>
</IconizedContextMenuOptionList>;
);
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
@ -141,14 +139,6 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -157,46 +147,25 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
newRoomSection = <>
<div className="mx_SpacePanel_contextMenu_separatorLabel">
{ _t("Add") }
</div>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
label={_t("Room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
label={_t("Space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
</>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -214,26 +183,21 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ !hideHeader && <div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
</div> }
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
{ settingsOption }
{ leaveOption }
{ devtoolsOption }
{ newRoomSection }
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
{ devtoolsSection }
</IconizedContextMenu>;
};

View file

@ -1,156 +1 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent } from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent";
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<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
message: this.comittedStatusMessage,
waiting: false,
};
}
public componentDidMount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
public componentWillUnmount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this.onStatusMessageCommitted,
);
}
get comittedStatusMessage(): string {
return this.props.user ? this.props.user.unstable_statusMessage : "";
}
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
message: this.comittedStatusMessage,
waiting: false,
});
};
private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({
waiting: true,
});
};
private onSubmit = (e: ButtonEvent): void => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({
waiting: true,
});
};
private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed.
this.setState({
message: (e.target as HTMLInputElement).value,
});
};
public render(): JSX.Element {
let actionButton;
if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this.onClearClick}
>
<span>{ _t("Clear status") }</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this.onSubmit}
>
<span>{ _t("Update status") }</span>
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this.onSubmit}
>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
}
let spinner = null;
if (this.state.waiting) {
spinner = <Spinner w={24} h={24} />;
}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this.onSubmit}
>
<input
type="text"
className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength={60}
value={this.state.message}
onChange={this.onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton }
{ spinner }
</div>
</form>;
return <div className="mx_StatusMessageContextMenu">
{ form }
</div>;
}
}

View file

@ -0,0 +1,119 @@
/*
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, { useCallback, useEffect, useState } from "react";
import { MatrixEvent } from "matrix-js-sdk/src";
import { ButtonEvent } from "../elements/AccessibleButton";
import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../../utils/strings";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
mxEvent: MatrixEvent;
permalinkCreator: RoomPermalinkCreator;
onMenuToggle?: (open: boolean) => void;
}
const contextMenuBelow = (elementRect: DOMRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset + elementRect.width;
const top = elementRect.bottom + window.pageYOffset;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, onMenuToggle }) => {
const [optionsPosition, setOptionsPosition] = useState(null);
const closeThreadOptions = useCallback(() => {
setOptionsPosition(null);
}, []);
const viewInRoom = useCallback((evt: ButtonEvent): void => {
evt.preventDefault();
evt.stopPropagation();
dis.dispatch({
action: Action.ViewRoom,
event_id: mxEvent.getId(),
highlighted: true,
room_id: mxEvent.getRoomId(),
});
closeThreadOptions();
}, [mxEvent, closeThreadOptions]);
const copyLinkToThread = useCallback(async (evt: ButtonEvent) => {
evt.preventDefault();
evt.stopPropagation();
const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId());
await copyPlaintext(matrixToUrl);
closeThreadOptions();
}, [mxEvent, closeThreadOptions, permalinkCreator]);
const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => {
if (!!optionsPosition) {
closeThreadOptions();
} else {
const position = ev.currentTarget.getBoundingClientRect();
setOptionsPosition(position);
}
}, [closeThreadOptions, optionsPosition]);
useEffect(() => {
if (onMenuToggle) {
onMenuToggle(!!optionsPosition);
}
}, [optionsPosition, onMenuToggle]);
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
);
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
onClick={toggleOptionsMenu}
title={_t("Thread options")}
isExpanded={!!optionsPosition}
/>
{ !!optionsPosition && (<IconizedContextMenu
onFinished={closeThreadOptions}
className="mx_RoomTile_contextMenu"
compact
rightAligned
{...contextMenuBelow(optionsPosition)}
>
<IconizedContextMenuOptionList>
{ isMainSplitTimelineShown &&
<IconizedContextMenuOption
onClick={(e) => viewInRoom(e)}
label={_t("View in room")}
iconClassName="mx_ThreadPanel_viewInRoom"
/> }
<IconizedContextMenuOption
onClick={(e) => copyLinkToThread(e)}
label={_t("Copy link to thread")}
iconClassName="mx_ThreadPanel_copyLinkToThread"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>) }
</React.Fragment>;
};
export default ThreadListContextMenu;

View file

@ -25,7 +25,7 @@ import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import { getDisplayAliasForRoom } from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";

View file

@ -0,0 +1,109 @@
/*
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 BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import React from "react";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
export enum ButtonClicked {
Primary,
Cancel,
}
interface IProps {
onFinished?(buttonClicked?: ButtonClicked): void;
analyticsOwner: string;
privacyPolicyUrl?: string;
primaryButton?: string;
cancelButton?: string;
hasCancel?: boolean;
}
const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
onFinished,
analyticsOwner,
privacyPolicyUrl,
primaryButton,
cancelButton,
hasCancel,
}) => {
const onPrimaryButtonClick = () => onFinished && onFinished(ButtonClicked.Primary);
const onCancelButtonClick = () => onFinished && onFinished(ButtonClicked.Cancel);
const privacyPolicyLink = privacyPolicyUrl ?
<span>
{
_t("You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>", {}, {
"PrivacyPolicyUrl": (sub) => {
return <a href={privacyPolicyUrl}
rel="norefferer noopener"
target="_blank"
>
{ sub }
<span className="mx_AnalyticsPolicyLink" />
</a>;
},
})
}
</span> : "";
return <BaseDialog
className="mx_AnalyticsLearnMoreDialog"
contentId="mx_AnalyticsLearnMore"
title={_t("Help improve %(analyticsOwner)s", { analyticsOwner })}
onFinished={onFinished}
>
<div className="mx_Dialog_content">
<div className="mx_AnalyticsLearnMore_image_holder" />
<div className="mx_AnalyticsLearnMore_copy">
{ _t("Help us identify issues and improve Element by sharing anonymous usage data. " +
"To understand how people use multiple devices, we'll generate a random identifier, " +
"shared by your devices.",
) }
</div>
<ul className="mx_AnalyticsLearnMore_bullets">
<li>{ _t("We <Bold>don't</Bold> record or profile any account data",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("We <Bold>don't</Bold> share information with third parties",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("You can turn this off anytime in settings") }</li>
</ul>
{ privacyPolicyLink }
</div>
<DialogButtons
primaryButton={primaryButton}
cancelButton={cancelButton}
onPrimaryButtonClick={onPrimaryButtonClick}
onCancel={onCancelButtonClick}
hasCancel={hasCancel}
/>
</BaseDialog>;
};
export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): void => {
const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl;
const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand;
Modal.createTrackedDialog(
"Analytics Learn More",
"",
AnalyticsLearnMoreDialog,
{ privacyPolicyUrl, analyticsOwner, ...props },
"mx_AnalyticsLearnMoreDialog_wrapper",
);
};
export default AnalyticsLearnMoreDialog;

View file

@ -15,10 +15,10 @@ limitations under the License.
*/
import React, { ComponentProps, useMemo, useState } from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { Room } from "matrix-js-sdk/src/models/room";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, ReactNode } from 'react';
import React, { ChangeEvent, FormEvent, ReactNode } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import classNames from "classnames";
@ -76,7 +76,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
};
}
private onOk = (): void => {
private onOk = (ev: FormEvent): void => {
ev.preventDefault();
this.props.onFinished(true, this.state.reason);
};
@ -144,7 +145,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
{ reasonBox }
{ this.props.children }
</div>
<DialogButtons primaryButton={this.props.action}
<DialogButtons
primaryButton={this.props.action}
onPrimaryButtonClick={this.onOk}
primaryButtonClass={confirmButtonClass}
focus={!this.props.askReason}

View file

@ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import InfoTooltip from "../elements/InfoTooltip";
import dis from "../../../dispatcher/dispatcher";
import { Action } from '../../../dispatcher/actions';
import { showCommunityRoomInviteDialog } from "../../../RoomInvite";
import GroupStore from "../../../stores/GroupStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -100,7 +101,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
// Force the group store to update as it might have missed the general chat
await GroupStore.refreshGroupRooms(result.group_id);
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: result.room_id,
});
showCommunityRoomInviteDialog(result.room_id, this.state.name);

View file

@ -32,7 +32,7 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
@ -284,7 +284,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
let microcopy;
if (privateShouldBeEncrypted()) {
if (this.state.canChangeEncryption) {
microcopy = _t("You cant disable this later. Bridges & most bots wont work yet.");
microcopy = _t("You can't disable this later. Bridges & most bots won't work yet.");
} else {
microcopy = _t("Your server requires encryption to be enabled in private rooms.");
}

View file

@ -33,7 +33,7 @@ import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/P
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import Spinner from "../elements/Spinner";
import { mediaFromMxc } from "../../../customisations/Media";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import dis from "../../../dispatcher/dispatcher";

View file

@ -26,7 +26,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";

View file

@ -0,0 +1,103 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import QuestionDialog from "./QuestionDialog";
import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts";
import { findTopAnswer } from "../messages/MPollBody";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
event: MatrixEvent;
onFinished: (success: boolean) => void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
export default class EndPollDialog extends React.Component<IProps> {
private onFinished = (endPoll: boolean) => {
const topAnswer = findTopAnswer(
this.props.event,
this.props.matrixClient,
this.props.getRelationsForEvent,
);
const message = (
(topAnswer === "")
? _t("The poll has ended. No votes were cast.")
: _t(
"The poll has ended. Top answer: %(topAnswer)s",
{ topAnswer },
)
);
if (endPoll) {
const endContent: IPollEndContent = {
[POLL_END_EVENT_TYPE.name]: {},
"m.relates_to": {
"event_id": this.props.event.getId(),
"rel_type": "m.reference",
},
[TEXT_NODE_TYPE.name]: message,
};
this.props.matrixClient.sendEvent(
this.props.event.getRoomId(), POLL_END_EVENT_TYPE.name, endContent,
).catch((e: any) => {
console.error("Failed to submit poll response event:", e);
Modal.createTrackedDialog(
'Failed to end poll',
'',
ErrorDialog,
{
title: _t("Failed to end poll"),
description: _t(
"Sorry, the poll did not end. Please try again."),
},
);
});
}
this.props.onFinished(endPoll);
};
render() {
return (
<QuestionDialog
title={_t("End Poll")}
description={
_t(
"Are you sure you want to end this poll? " +
"This will show the final results of the poll and " +
"stop people from being able to vote.",
)
}
button={_t("End Poll")}
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
/>
);
}
}

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
@ -27,6 +26,9 @@ import BugReportDialog from "./BugReportDialog";
import InfoDialog from "./InfoDialog";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import { useStateToggle } from "../../../hooks/useStateToggle";
import StyledCheckbox from "../elements/StyledCheckbox";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
@ -35,18 +37,33 @@ const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose"
interface IProps extends IDialogProps {}
const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const feedbackRef = useRef<Field>();
const [rating, setRating] = useState<Rating>();
const [comment, setComment] = useState<string>("");
const [canContact, toggleCanContact] = useStateToggle(false);
useEffect(() => {
// autofocus doesn't work on textareas
feedbackRef.current?.focus();
}, []);
const onDebugLogsLinkClick = (): void => {
props.onFinished();
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
const hasFeedback = CountlyAnalytics.instance.canEnable();
const countlyEnabled = CountlyAnalytics.instance.canEnable();
const rageshakeUrl = SdkConfig.get().bug_report_endpoint_url;
const hasFeedback = countlyEnabled || rageshakeUrl;
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
CountlyAnalytics.instance.reportFeedback(rating, comment);
if (rageshakeUrl) {
submitFeedback(rageshakeUrl, "feedback", comment, canContact);
} else if (countlyEnabled) {
CountlyAnalytics.instance.reportFeedback(rating, comment);
}
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
title: _t('Feedback sent'),
description: _t('Thank you!'),
@ -57,56 +74,73 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const brand = SdkConfig.get().brand;
let countlyFeedbackSection;
if (hasFeedback) {
countlyFeedbackSection = <React.Fragment>
<hr />
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
let feedbackSection;
if (rageshakeUrl) {
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Comment") }</h3>
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
<p>{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }</p>
<StyledRadioGroup
name="feedbackRating"
value={String(rating)}
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
ref={feedbackRef}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
/>
</div>
</React.Fragment>;
}
<StyledCheckbox
checked={canContact}
onChange={toggleCanContact}
>
{ _t("You may contact me if you want to follow up or to let me test out upcoming ideas") }
</StyledCheckbox>
</div>;
} else if (countlyEnabled) {
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
let subheading;
if (hasFeedback) {
subheading = (
<h2>{ _t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand }) }</h2>
);
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
<StyledRadioGroup
name="feedbackRating"
value={String(rating)}
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
ref={feedbackRef}
/>
</div>;
}
let bugReports = null;
if (SdkConfig.get().bug_report_endpoint_url) {
if (rageshakeUrl) {
bugReports = (
<p>{
<p className="mx_FeedbackDialog_section_microcopy">{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
@ -122,8 +156,6 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
hasCancelButton={!!hasFeedback}
title={_t("Feedback")}
description={<React.Fragment>
{ subheading }
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
<h3>{ _t("Report a bug") }</h3>
<p>{
@ -139,10 +171,10 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
}</p>
{ bugReports }
</div>
{ countlyFeedbackSection }
{ feedbackSection }
</React.Fragment>}
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
buttonDisabled={hasFeedback && !rating}
buttonDisabled={hasFeedback && !rating && !comment}
onFinished={onFinished}
/>);
};

View file

@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
import { UIFeature } from "../../../settings/UIFeature";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import { avatarUrlForUser } from "../../../Avatar";
@ -43,7 +43,8 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { roomContextDetailsText } from "../../../Rooms";
const AVATAR_SIZE = 30;
@ -121,6 +122,8 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
/>;
}
const detailsText = roomContextDetailsText(room);
return <div className="mx_ForwardList_entry">
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
@ -131,6 +134,9 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
<span className="mx_ForwardList_entry_name">{ room.name }</span>
{ detailsText && <span className="mx_ForwardList_entry_detail">
{ detailsText }
</span> }
</AccessibleTooltipButton>
<AccessibleTooltipButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}

View file

@ -253,8 +253,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
<AccessibleButton
className="mx_HostSignup_maximize_button"
onClick={this.maximizeDialog}
aria-label={_t("Maximize dialog")}
title={_t("Maximize dialog")}
aria-label={_t("Maximise dialog")}
title={_t("Maximise dialog")}
/>
</div>
}
@ -263,8 +263,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
<AccessibleButton
onClick={this.minimizeDialog}
className="mx_HostSignup_minimize_button"
aria-label={_t("Minimize dialog")}
title={_t("Minimize dialog")}
aria-label={_t("Minimise dialog")}
title={_t("Minimise dialog")}
/>
<AccessibleButton
onClick={this.onCloseClick}

View file

@ -39,7 +39,7 @@ const PHASE_VERIFIED = 3;
const PHASE_CANCELLED = 4;
interface IProps extends IDialogProps {
verifier: VerificationBase; // TODO types
verifier: VerificationBase;
}
interface IState {

View file

@ -44,6 +44,7 @@ export default class InfoDialog extends React.Component<IProps> {
};
render() {
// FIXME: Using a regular import will break the app
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (

View file

@ -18,9 +18,10 @@ import React from 'react';
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {}
@ -32,8 +33,6 @@ export default class IntegrationsImpossibleDialog extends React.Component<IProps
public render(): JSX.Element {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog

View file

@ -63,7 +63,6 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import * as ContextMenu from "../../structures/ContextMenu";
import { toRightOf } from "../../structures/ContextMenu";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload';
import Field from '../elements/Field';
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
import Dialpad from '../voip/DialPad';
@ -71,7 +70,8 @@ import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import CallHandler from "../../../CallHandler";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -675,7 +675,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
if (existingRoom) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: existingRoom.roomId,
should_peek: false,
joining: false,
@ -804,19 +804,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return;
}
dis.dispatch({
action: Action.TransferCallToMatrixID,
call: this.props.call,
destination: targetIds[0],
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
CallHandler.instance.startTransferToMatrixID(
this.props.call,
targetIds[0],
this.state.consultFirst,
);
} else {
dis.dispatch({
action: Action.TransferCallToPhoneNumber,
call: this.props.call,
destination: this.state.dialPadValue,
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
CallHandler.instance.startTransferToPhoneNumber(
this.props.call,
this.state.dialPadValue,
this.state.consultFirst,
);
}
this.props.onFinished();
};
@ -1410,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this.startDm;
extraSection = <div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{ _t("Some suggestions may be hidden for privacy.") }</span>
<p>{ _t("If you can't see who youre looking for, send them your invite link below.") }</p>
<p>{ _t("If you can't see who you're looking for, send them your invite link below.") }</p>
</div>;
const link = makeUserPermalink(MatrixClientPeg.get().getUserId());
footer = <div className="mx_InviteDialog_footer">

View file

@ -21,7 +21,7 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
interface IProps {

View file

@ -17,16 +17,19 @@ limitations under the License.
import React, { ComponentType } from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger";
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { logger } from "matrix-js-sdk/src/logger";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished: (success: boolean) => void;
}
@ -133,8 +136,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
render() {
if (this.state.shouldLoadBackupStatus) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const description = <div>
<p>{ _t(
"Encrypted messages are secured with end-to-end encryption. " +
@ -145,11 +146,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
let dialogContent;
if (this.state.loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
dialogContent = <Spinner />;
} else {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let setupButtonCaption;
if (this.state.backupInfo) {
setupButtonCaption = _t("Connect this session to Key Backup");
@ -192,7 +190,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
{ dialogContent }
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}

Some files were not shown because too many files have changed in this diff Show more