Merge remote-tracking branch 'upstream/develop' into feature/image-view-load-anim/18186
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
commit
1a128c3a00
567 changed files with 23292 additions and 9067 deletions
20
src/@types/global.d.ts
vendored
20
src/@types/global.d.ts
vendored
|
@ -93,6 +93,26 @@ declare global {
|
|||
mxSetupEncryptionStore?: SetupEncryptionStore;
|
||||
mxRoomScrollStateStore?: RoomScrollStateStore;
|
||||
mxOnRecaptchaLoaded?: () => void;
|
||||
electron?: Electron;
|
||||
}
|
||||
|
||||
interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL: string;
|
||||
}
|
||||
|
||||
interface GetSourcesOptions {
|
||||
types: Array<string>;
|
||||
thumbnailSize?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
fetchWindowIcons?: boolean;
|
||||
}
|
||||
|
||||
interface Electron {
|
||||
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
|
||||
type Listener = (isActive: boolean) => void;
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -56,12 +57,10 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
|
@ -80,7 +79,6 @@ 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 DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker";
|
||||
import { Action } from './dispatcher/actions';
|
||||
import VoipUserMapper from './VoipUserMapper';
|
||||
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
|
||||
|
@ -88,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
|
|||
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 { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
||||
import ToastStore from './stores/ToastStore';
|
||||
import IncomingCallToast from "./toasts/IncomingCallToast";
|
||||
|
||||
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
||||
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
||||
|
@ -129,14 +133,9 @@ interface ThirdpartyLookupResponse {
|
|||
fields: ThirdpartyLookupResponseFields;
|
||||
}
|
||||
|
||||
// Unlike 'CallType' in js-sdk, this one includes screen sharing
|
||||
// (because a screen sharing call is only a screen sharing call to the caller,
|
||||
// to the callee it's just a video call, at least as far as the current impl
|
||||
// is concerned).
|
||||
export enum PlaceCallType {
|
||||
Voice = 'voice',
|
||||
Video = 'video',
|
||||
ScreenSharing = 'screensharing',
|
||||
}
|
||||
|
||||
export enum CallHandlerEvent {
|
||||
|
@ -251,7 +250,15 @@ export default class CallHandler extends EventEmitter {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
private areAnyCallsUnsilenced(): boolean {
|
||||
return this.calls.size > this.silencedCalls.size;
|
||||
for (const call of this.calls.values()) {
|
||||
if (
|
||||
call.state === CallState.Ringing &&
|
||||
!this.isCallSilenced(call.callId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkProtocols(maxTries) {
|
||||
|
@ -465,77 +472,7 @@ export default class CallHandler extends EventEmitter {
|
|||
this.removeCallForRoom(mappedRoomId);
|
||||
});
|
||||
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
case CallState.Ringing:
|
||||
this.pause(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.pause(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
|
||||
if (newState !== CallState.Ringing) {
|
||||
this.silencedCalls.delete(call.callId);
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
{
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && (
|
||||
call.hangupParty === CallParty.Remote ||
|
||||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
|
||||
)) {
|
||||
this.play(AudioID.Busy);
|
||||
let title;
|
||||
let description;
|
||||
if (call.hangupReason === CallErrorCode.UserHangup) {
|
||||
title = _t("Call Declined");
|
||||
description = _t("The other party declined the call.");
|
||||
} else if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
title = _t("User Busy");
|
||||
description = _t("The user you called is busy.");
|
||||
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
|
||||
title = _t("Call Failed");
|
||||
// XXX: full stop appended as some relic here, but these
|
||||
// strings need proper input from design anyway, so let's
|
||||
// not change this string until we have a proper one.
|
||||
description = _t('The remote side failed to pick up') + '.';
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else if (
|
||||
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
description: _t("The call was answered on another device."),
|
||||
});
|
||||
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
|
||||
// don't play the end-call sound for calls that never got off the ground
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
|
||||
this.logCallStats(call, mappedRoomId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.onCallStateChanged(newState, oldState, call);
|
||||
});
|
||||
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
@ -548,8 +485,8 @@ export default class CallHandler extends EventEmitter {
|
|||
this.pause(AudioID.Ringback);
|
||||
}
|
||||
|
||||
this.calls.set(mappedRoomId, newCall);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
this.addCallForRoom(mappedRoomId, newCall);
|
||||
this.setCallListeners(newCall);
|
||||
this.setCallState(newCall, newCall.state);
|
||||
});
|
||||
|
@ -584,13 +521,95 @@ export default class CallHandler extends EventEmitter {
|
|||
this.removeCallForRoom(mappedRoomId);
|
||||
mappedRoomId = newMappedRoomId;
|
||||
console.log("Moving call to room " + mappedRoomId);
|
||||
this.calls.set(mappedRoomId, call);
|
||||
this.emit(CallHandlerEvent.CallChangeRoom, call);
|
||||
this.addCallForRoom(mappedRoomId, call, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onCallStateChanged = (newState: CallState, oldState: CallState, call: MatrixCall): void => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
const mappedRoomId = this.roomIdForCall(call);
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
case CallState.Ringing:
|
||||
this.pause(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.pause(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
|
||||
if (newState !== CallState.Ringing) {
|
||||
this.silencedCalls.delete(call.callId);
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing: {
|
||||
const incomingCallPushRule = (
|
||||
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall)
|
||||
);
|
||||
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
||||
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
||||
action.set_tweak === TweakName.Sound &&
|
||||
action.value === "ring"
|
||||
));
|
||||
|
||||
if (pushRuleEnabled && tweakSetToRing) {
|
||||
this.play(AudioID.Ring);
|
||||
} else {
|
||||
this.silenceCall(call.callId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallState.InviteSent: {
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
case CallState.Ended: {
|
||||
const hangupReason = call.hangupReason;
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
||||
this.play(AudioID.Busy);
|
||||
|
||||
// Don't show a modal when we got rejected/the call was hung up
|
||||
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
|
||||
|
||||
let title;
|
||||
let description;
|
||||
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
||||
if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
title = _t("User Busy");
|
||||
description = _t("The user you called is busy.");
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else if (
|
||||
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
description: _t("The call was answered on another device."),
|
||||
});
|
||||
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
|
||||
// don't play the end-call sound for calls that never got off the ground
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
|
||||
this.logCallStats(call, mappedRoomId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
|
||||
const stats = await call.getCurrentCallStats();
|
||||
logger.debug(
|
||||
|
@ -641,6 +660,19 @@ export default class CallHandler extends EventEmitter {
|
|||
`Call state in ${mappedRoomId} changed to ${status}`,
|
||||
);
|
||||
|
||||
const toastKey = getIncomingCallToastKey(call.callId);
|
||||
if (status === CallState.Ringing) {
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key: toastKey,
|
||||
priority: 100,
|
||||
component: IncomingCallToast,
|
||||
bodyClassName: "mx_IncomingCallToast",
|
||||
props: { call },
|
||||
});
|
||||
} else {
|
||||
ToastStore.sharedInstance().dismissToast(toastKey);
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: 'call_state',
|
||||
room_id: mappedRoomId,
|
||||
|
@ -723,9 +755,15 @@ export default class CallHandler extends EventEmitter {
|
|||
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
const call = MatrixClientPeg.get().createCall(mappedRoomId);
|
||||
|
||||
console.log("Adding call for room ", roomId);
|
||||
this.calls.set(roomId, call);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
try {
|
||||
this.addCallForRoom(roomId, call);
|
||||
} catch (e) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
|
||||
title: _t('Already in call'),
|
||||
description: _t("You're already in a call with this person."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (transferee) {
|
||||
this.transferees[call.callId] = transferee;
|
||||
}
|
||||
|
@ -738,25 +776,6 @@ export default class CallHandler extends EventEmitter {
|
|||
call.placeVoiceCall();
|
||||
} else if (type === 'video') {
|
||||
call.placeVideoCall();
|
||||
} else if (type === PlaceCallType.ScreenSharing) {
|
||||
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
|
||||
if (screenCapErrorString) {
|
||||
this.removeCallForRoom(roomId);
|
||||
console.log("Can't capture screen: " + screenCapErrorString);
|
||||
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
|
||||
title: _t('Unable to capture screen'),
|
||||
description: screenCapErrorString,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
call.placeScreenSharingCall(
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error("Unknown conf call type: " + type);
|
||||
}
|
||||
|
@ -796,13 +815,8 @@ export default class CallHandler extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.getCallForRoom(room.roomId)) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
|
||||
title: _t('Already in call'),
|
||||
description: _t("You're already in a call with this person."),
|
||||
});
|
||||
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) {
|
||||
|
@ -856,10 +870,11 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
|
||||
console.log("Adding call for room ", mappedRoomId);
|
||||
this.calls.set(mappedRoomId, call);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
|
||||
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
|
||||
|
@ -870,6 +885,8 @@ export default class CallHandler extends EventEmitter {
|
|||
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
|
||||
}
|
||||
|
@ -882,11 +899,15 @@ export default class CallHandler extends EventEmitter {
|
|||
// 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
|
||||
}
|
||||
|
@ -921,6 +942,12 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
};
|
||||
|
||||
private stopRingingIfPossible(callId: string): void {
|
||||
this.silencedCalls.delete(callId);
|
||||
if (this.areAnyCallsUnsilenced()) return;
|
||||
this.pause(AudioID.Ring);
|
||||
}
|
||||
|
||||
private async dialNumber(number: string) {
|
||||
const results = await this.pstnLookup(number);
|
||||
if (!results || results.length === 0 || !results[0].userid) {
|
||||
|
@ -950,6 +977,8 @@ export default class CallHandler extends EventEmitter {
|
|||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
|
||||
await this.placeCall(roomId, PlaceCallType.Voice, null);
|
||||
}
|
||||
|
||||
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
||||
|
@ -1029,14 +1058,10 @@ export default class CallHandler extends EventEmitter {
|
|||
|
||||
// prevent double clicking the call button
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
||||
const hasJitsi = currentJitsiWidgets.length > 0
|
||||
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
|
||||
if (hasJitsi) {
|
||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is currently being placed!'),
|
||||
});
|
||||
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
|
||||
if (jitsiWidget) {
|
||||
// If there already is a Jitsi widget pin it
|
||||
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1124,4 +1149,21 @@ export default class CallHandler extends EventEmitter {
|
|||
messaging.transport.send(ElementWidgetActions.HangupCall, {});
|
||||
});
|
||||
}
|
||||
|
||||
private addCallForRoom(roomId: string, call: MatrixCall, changedRooms = false): void {
|
||||
if (this.calls.has(roomId)) {
|
||||
console.log(`Couldn't add call to room ${roomId}: already have a call for this room`);
|
||||
throw new Error("Already have a call for room " + roomId);
|
||||
}
|
||||
|
||||
console.log("setting call for room " + roomId);
|
||||
this.calls.set(roomId, call);
|
||||
|
||||
// Should we always emit CallsChanged too?
|
||||
if (changedRooms) {
|
||||
this.emit(CallHandlerEvent.CallChangeRoom, call);
|
||||
} else {
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,8 @@ import {
|
|||
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";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -209,6 +211,14 @@ async function loadImageElement(imageFile: File) {
|
|||
return { width, height, img };
|
||||
}
|
||||
|
||||
// Minimum size for image files before we generate a thumbnail for them.
|
||||
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
|
||||
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
|
||||
// We don't apply these thresholds to video thumbnails as a poster image is always useful
|
||||
// and videos tend to be much larger.
|
||||
|
||||
/**
|
||||
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
||||
*
|
||||
|
@ -217,23 +227,33 @@ async function loadImageElement(imageFile: File) {
|
|||
* @param {File} imageFile The image to read and thumbnail.
|
||||
* @return {Promise} A promise that resolves with the attachment info.
|
||||
*/
|
||||
function infoForImageFile(matrixClient, roomId, imageFile) {
|
||||
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
|
||||
let thumbnailType = "image/png";
|
||||
if (imageFile.type === "image/jpeg") {
|
||||
thumbnailType = "image/jpeg";
|
||||
}
|
||||
|
||||
let imageInfo;
|
||||
return loadImageElement(imageFile).then((r) => {
|
||||
return createThumbnail(r.img, r.width, r.height, thumbnailType);
|
||||
}).then((result) => {
|
||||
imageInfo = result.info;
|
||||
return uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
}).then((result) => {
|
||||
imageInfo.thumbnail_url = result.url;
|
||||
imageInfo.thumbnail_file = result.file;
|
||||
const imageElement = await loadImageElement(imageFile);
|
||||
|
||||
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
|
||||
const imageInfo = result.info;
|
||||
|
||||
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
|
||||
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
|
||||
if (
|
||||
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
|
||||
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
|
||||
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
|
||||
) {
|
||||
delete imageInfo["thumbnail_info"];
|
||||
return imageInfo;
|
||||
});
|
||||
}
|
||||
|
||||
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
|
||||
imageInfo["thumbnail_url"] = uploadResult.url;
|
||||
imageInfo["thumbnail_file"] = uploadResult.file;
|
||||
return imageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -521,6 +541,10 @@ export default class ContentMessages {
|
|||
msgtype: "", // set later
|
||||
};
|
||||
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
decorateStartSendingTime(content);
|
||||
}
|
||||
|
||||
// if we have a mime type for the file, add it to the message metadata
|
||||
if (file.type) {
|
||||
content.info.mimetype = file.type;
|
||||
|
@ -596,6 +620,11 @@ export default class ContentMessages {
|
|||
}).then(function() {
|
||||
if (upload.canceled) throw new UploadCanceledError();
|
||||
const prom = matrixClient.sendMessage(roomId, content);
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
prom.then(resp => {
|
||||
sendRoundTripMetric(matrixClient, roomId, resp.event_id);
|
||||
});
|
||||
}
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
|
||||
return prom;
|
||||
}, function(err) {
|
||||
|
|
|
@ -123,6 +123,31 @@ export function formatTime(date: Date, showTwelveHour = false): string {
|
|||
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
export function formatCallTime(delta: Date): string {
|
||||
const hours = delta.getUTCHours();
|
||||
const minutes = delta.getUTCMinutes();
|
||||
const seconds = delta.getUTCSeconds();
|
||||
|
||||
let output = "";
|
||||
if (hours) output += `${hours}h `;
|
||||
if (minutes || output) output += `${minutes}m `;
|
||||
if (seconds || output) output += `${seconds}s`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatSeconds(inSeconds: number): string {
|
||||
const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0).padStart(2, '0');
|
||||
const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(((inSeconds % (60 * 60)) % 60)).toFixed(0).padStart(2, '0');
|
||||
|
||||
let output = "";
|
||||
if (hours !== "00") output += `${hours}:`;
|
||||
output += `${minutes}:${seconds}`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const MILLIS_IN_DAY = 86400000;
|
||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||
if (!nextEventDate || !prevEventDate) {
|
||||
|
|
|
@ -100,6 +100,7 @@ export default class DeviceListener {
|
|||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
|
||||
console.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
|
||||
for (const d of deviceIds) {
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
|
@ -285,6 +286,9 @@ export default class DeviceListener {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(','));
|
||||
console.log("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(','));
|
||||
|
||||
// Display or hide the batch toast for old unverified sessions
|
||||
if (oldUnverifiedDeviceIds.size > 0) {
|
||||
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
|
||||
|
|
|
@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
|||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix'];
|
||||
export const PERMITTED_URL_SCHEMES = [
|
||||
"bitcoin",
|
||||
"ftp",
|
||||
"geo",
|
||||
"http",
|
||||
"https",
|
||||
"im",
|
||||
"irc",
|
||||
"ircs",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"mms",
|
||||
"news",
|
||||
"nntp",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sftp",
|
||||
"sms",
|
||||
"smsto",
|
||||
"ssh",
|
||||
"tel",
|
||||
"urn",
|
||||
"webcal",
|
||||
"wtai",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
|
||||
|
|
|
@ -146,23 +146,23 @@ export default class IdentityAuthClient {
|
|||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
|
||||
QuestionDialog, {
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"This action requires accessing the default identity server " +
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"This action requires accessing the default identity server " +
|
||||
"<server /> to validate an email address or phone number, " +
|
||||
"but the server does not have any terms of service.", {},
|
||||
{
|
||||
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
|
||||
},
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
) }</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
{
|
||||
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
|
||||
},
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
) }</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (confirmed) {
|
||||
|
|
|
@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
|
|||
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
||||
return [
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
shiftKey: true,
|
||||
key: Key.ENTER,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
key: Key.ENTER,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -52,13 +52,11 @@ export enum MessageComposerAction {
|
|||
|
||||
/** Actions for text editing autocompletion */
|
||||
export enum AutocompleteAction {
|
||||
/**
|
||||
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
|
||||
* selection.
|
||||
*/
|
||||
CompleteOrPrevSelection = 'ApplySelection',
|
||||
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
|
||||
CompleteOrNextSelection = 'CompleteOrNextSelection',
|
||||
/** Accepts chosen autocomplete selection */
|
||||
Complete = 'Complete',
|
||||
/** Accepts chosen autocomplete selection or,
|
||||
* if the autocompletion window is not shown, open the window and select the first selection */
|
||||
ForceComplete = 'ForceComplete',
|
||||
/** Move to the previous autocomplete selection */
|
||||
PrevSelection = 'PrevSelection',
|
||||
/** Move to the next autocomplete selection */
|
||||
|
|
|
@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
|
|||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
|
||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import CallHandler from './CallHandler';
|
||||
import LifecycleCustomisations from "./customisations/Lifecycle";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
@ -573,6 +574,8 @@ async function doSetLoggedIn(
|
|||
await abortLogin();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
@ -700,6 +703,8 @@ export function logout(): void {
|
|||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.logout();
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// logout doesn't work for guest sessions
|
||||
// Also we sometimes want to re-log in a guest session if we abort the login.
|
||||
|
|
|
@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
|||
opts.pendingEventOrdering = PendingEventOrdering.Detached;
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
|
||||
|
||||
// Connect the matrix client to the dispatcher and setting handlers
|
||||
MatrixActionCreators.start(this.matrixClient);
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
/**
|
||||
|
@ -26,12 +26,18 @@ import { _t } from './languageHandler';
|
|||
* API on the homeserver in question with the new password.
|
||||
*/
|
||||
export default class PasswordReset {
|
||||
private client: MatrixClient;
|
||||
private clientSecret: string;
|
||||
private identityServerDomain: string;
|
||||
private password: string;
|
||||
private sessionId: string;
|
||||
|
||||
/**
|
||||
* Configure the endpoints for password resetting.
|
||||
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
|
||||
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
|
||||
*/
|
||||
constructor(homeserverUrl, identityUrl) {
|
||||
constructor(homeserverUrl: string, identityUrl: string) {
|
||||
this.client = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
idBaseUrl: identityUrl,
|
||||
|
@ -47,7 +53,7 @@ export default class PasswordReset {
|
|||
* @param {string} newPassword The new password for the account.
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
resetPassword(emailAddress, newPassword) {
|
||||
public resetPassword(emailAddress: string, newPassword: string): Promise<IRequestTokenResponse> {
|
||||
this.password = newPassword;
|
||||
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
|
@ -69,7 +75,7 @@ export default class PasswordReset {
|
|||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
|
||||
*/
|
||||
async checkEmailLinkClicked() {
|
||||
public async checkEmailLinkClicked(): Promise<void> {
|
||||
const creds = {
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
355
src/PosthogAnalytics.ts
Normal file
355
src/PosthogAnalytics.ts
Normal file
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
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 posthog, { PostHog } from 'posthog-js';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
* Anonymity behaviour is as follows:
|
||||
*
|
||||
* - If Posthog isn't configured in `config.json`, events are not sent.
|
||||
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
|
||||
* enabled, events are not sent (this detection is built into posthog and turned on via the
|
||||
* `respect_dnt` flag being passed to `posthog.init`).
|
||||
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
|
||||
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
|
||||
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
|
||||
* redact all matrix identifiers in tracking events.
|
||||
* - If both flags are false or not set, events are not sent.
|
||||
*/
|
||||
|
||||
interface IEvent {
|
||||
// The event name that will be used by PostHog. Event names should use snake_case.
|
||||
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 {
|
||||
Disabled,
|
||||
Anonymous,
|
||||
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 hashHex = async (input: string): Promise<string> => {
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
||||
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const whitelistedScreens = new Set([
|
||||
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||
]);
|
||||
|
||||
export async function getRedactedCurrentLocation(
|
||||
origin: string,
|
||||
hash: string,
|
||||
pathname: string,
|
||||
anonymity: Anonymity,
|
||||
): Promise<string> {
|
||||
// Redact PII from the current location.
|
||||
// If anonymous is true, redact entirely, if false, substitute it with a hash.
|
||||
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
|
||||
if (origin.startsWith('file://')) {
|
||||
pathname = "/<redacted_file_scheme_url>/";
|
||||
}
|
||||
|
||||
let hashStr;
|
||||
if (hash == "") {
|
||||
hashStr = "";
|
||||
} else {
|
||||
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
|
||||
|
||||
if (!whitelistedScreens.has(screen)) {
|
||||
screen = "<redacted_screen_name>";
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
||||
}
|
||||
|
||||
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
|
||||
}
|
||||
return origin + pathname + hashStr;
|
||||
}
|
||||
|
||||
interface PlatformProperties {
|
||||
appVersion: string;
|
||||
appPlatform: string;
|
||||
}
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
* - Anonymity.Disabled means *no data* is passed to posthog
|
||||
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
|
||||
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
|
||||
* to Posthog
|
||||
*
|
||||
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
|
||||
*
|
||||
* To pass an event to Posthog:
|
||||
*
|
||||
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
|
||||
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
|
||||
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
||||
*/
|
||||
|
||||
private anonymity = Anonymity.Disabled;
|
||||
// set true during the constructor if posthog config is present, otherwise false
|
||||
private enabled = false;
|
||||
private static _instance = null;
|
||||
private platformSuperProperties = {};
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this._instance) {
|
||||
this._instance = new PosthogAnalytics(posthog);
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
constructor(private readonly posthog: PostHog) {
|
||||
const posthogConfig = SdkConfig.get()["posthog"];
|
||||
if (posthogConfig) {
|
||||
this.posthog.init(posthogConfig.projectApiKey, {
|
||||
api_host: posthogConfig.apiHost,
|
||||
autocapture: false,
|
||||
mask_all_text: true,
|
||||
mask_all_element_attributes: true,
|
||||
// This only triggers on page load, which for our SPA isn't particularly useful.
|
||||
// Plus, the .capture call originating from somewhere in posthog makes it hard
|
||||
// to redact URLs, which requires async code.
|
||||
//
|
||||
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
|
||||
capture_pageview: false,
|
||||
sanitize_properties: this.sanitizeProperties,
|
||||
respect_dnt: true,
|
||||
});
|
||||
this.enabled = true;
|
||||
} else {
|
||||
this.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => {
|
||||
// Callback from posthog to sanitize properties before sending them to the server.
|
||||
//
|
||||
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
||||
// See utils.js _.info.properties in posthog-js.
|
||||
|
||||
// Replace the $current_url with a redacted version.
|
||||
// $redacted_current_url is injected by this class earlier in capture(), as its generation
|
||||
// is async and can't be done in this non-async callback.
|
||||
if (!properties['$redacted_current_url']) {
|
||||
console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
|
||||
}
|
||||
properties['$current_url'] = properties['$redacted_current_url'];
|
||||
delete properties['$redacted_current_url'];
|
||||
|
||||
if (this.anonymity == Anonymity.Anonymous) {
|
||||
// drop referrer information for anonymous users
|
||||
properties['$referrer'] = null;
|
||||
properties['$referring_domain'] = null;
|
||||
properties['$initial_referrer'] = null;
|
||||
properties['$initial_referring_domain'] = null;
|
||||
|
||||
// drop device ID, which is a UUID persisted in local storage
|
||||
properties['$device_id'] = null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static async getPlatformProperties(): Promise<PlatformProperties> {
|
||||
const platform = PlatformPeg.get();
|
||||
let appVersion;
|
||||
try {
|
||||
appVersion = await platform.getAppVersion();
|
||||
} catch (e) {
|
||||
// this happens if no version is set i.e. in dev
|
||||
appVersion = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
appVersion,
|
||||
appPlatform: platform.getHumanReadableName(),
|
||||
};
|
||||
}
|
||||
|
||||
private async 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);
|
||||
this.posthog.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
public setAnonymity(anonymity: Anonymity): void {
|
||||
// Update this.anonymity.
|
||||
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
|
||||
// to ensure this value is in step with the user's settings.
|
||||
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
|
||||
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
|
||||
// set in posthog e.g. distinct ID
|
||||
this.posthog.reset();
|
||||
// Restore any previously set platform super properties
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
this.anonymity = anonymity;
|
||||
}
|
||||
|
||||
public async identifyUser(userId: string): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
this.posthog.identify(await hashHex(userId));
|
||||
}
|
||||
}
|
||||
|
||||
public getAnonymity(): Anonymity {
|
||||
return this.anonymity;
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
if (this.enabled) {
|
||||
this.posthog.reset();
|
||||
}
|
||||
this.setAnonymity(Anonymity.Anonymous);
|
||||
}
|
||||
|
||||
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 trackRoomEvent<E extends IRoomEvent>(
|
||||
eventName: E["eventName"],
|
||||
roomId: string,
|
||||
properties: Omit<E["properties"], "roomId">,
|
||||
): Promise<void> {
|
||||
const updatedProperties = {
|
||||
...properties,
|
||||
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
||||
};
|
||||
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
||||
}
|
||||
|
||||
public async trackPageView(durationMs: number): Promise<void> {
|
||||
const hash = window.location.hash;
|
||||
|
||||
let screen = null;
|
||||
const split = hash.split("/");
|
||||
if (split.length >= 2) {
|
||||
screen = split[1];
|
||||
}
|
||||
|
||||
await this.trackAnonymousEvent<IPageView>("$pageview", {
|
||||
durationMs,
|
||||
screen,
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatformSuperProperties(): Promise<void> {
|
||||
// Update super properties in posthog with our platform (app version, platform).
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
// is async and can involve a network request if we are running in a browser.
|
||||
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
|
||||
public async updateAnonymityFromSettings(userId?: string): 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) {
|
||||
await this.identifyUser(userId);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,10 +51,15 @@ export async function startAnyRegistrationFlow(options) {
|
|||
description: _t("Use your account or create a new one to continue."),
|
||||
button: _t("Create Account"),
|
||||
extraButtons: [
|
||||
<button key="start_login" onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
|
||||
}}>{ _t('Sign In') }</button>,
|
||||
<button
|
||||
key="start_login"
|
||||
onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
|
||||
}}
|
||||
>
|
||||
{ _t('Sign In') }
|
||||
</button>,
|
||||
],
|
||||
onFinished: (proceed) => {
|
||||
if (proceed) {
|
||||
|
|
|
@ -34,7 +34,6 @@ import { getAddressType } from './UserAddress';
|
|||
import { abbreviateUrl } from './utils/UrlUtils';
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
|
||||
import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
|
||||
import { inviteUsersToRoom } from "./RoomInvite";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
|
||||
|
@ -49,8 +48,8 @@ import { UIFeature } from "./settings/UIFeature";
|
|||
import { CHAT_EFFECTS } from "./effects";
|
||||
import CallHandler from "./CallHandler";
|
||||
import { guessAndSetDMRoom } from "./Rooms";
|
||||
import { upgradeRoom } from './utils/RoomUpgrade';
|
||||
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
|
||||
import ErrorDialog from './components/views/dialogs/ErrorDialog';
|
||||
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
|
||||
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
|
||||
import InfoDialog from "./components/views/dialogs/InfoDialog";
|
||||
|
@ -245,21 +244,6 @@ export const Commands = [
|
|||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: 'ddg',
|
||||
args: '<query>',
|
||||
description: _td('Searches DuckDuckGo for results'),
|
||||
runFn: function() {
|
||||
// TODO Don't explain this away, actually show a search UI here.
|
||||
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
|
||||
title: _t('/ddg is not a command'),
|
||||
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
|
||||
});
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
hideCompletionAfterSpace: true,
|
||||
}),
|
||||
new Command({
|
||||
command: 'upgraderoom',
|
||||
args: '<new_version>',
|
||||
|
@ -277,50 +261,8 @@ export const Commands = [
|
|||
/*isPriority=*/false, /*isStatic=*/true);
|
||||
|
||||
return success(finished.then(async ([resp]) => {
|
||||
if (!resp.continue) return;
|
||||
|
||||
let checkForUpgradeFn;
|
||||
try {
|
||||
const upgradePromise = cli.upgradeRoom(roomId, args);
|
||||
|
||||
// We have to wait for the js-sdk to give us the room back so
|
||||
// we can more effectively abuse the MultiInviter behaviour
|
||||
// which heavily relies on the Room object being available.
|
||||
if (resp.invite) {
|
||||
checkForUpgradeFn = async (newRoom) => {
|
||||
// The upgradePromise should be done by the time we await it here.
|
||||
const { replacement_room: newRoomId } = await upgradePromise;
|
||||
if (newRoom.roomId !== newRoomId) return;
|
||||
|
||||
const toInvite = [
|
||||
...room.getMembersWithMembership("join"),
|
||||
...room.getMembersWithMembership("invite"),
|
||||
].map(m => m.userId).filter(m => m !== cli.getUserId());
|
||||
|
||||
if (toInvite.length > 0) {
|
||||
// Errors are handled internally to this function
|
||||
await inviteUsersToRoom(newRoomId, toInvite);
|
||||
}
|
||||
|
||||
cli.removeListener('Room', checkForUpgradeFn);
|
||||
};
|
||||
cli.on('Room', checkForUpgradeFn);
|
||||
}
|
||||
|
||||
// We have to await after so that the checkForUpgradesFn has a proper reference
|
||||
// to the new room's ID.
|
||||
await upgradePromise;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
|
||||
|
||||
Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
|
||||
title: _t('Error upgrading room'),
|
||||
description: _t(
|
||||
'Double check that your server supports the room version chosen and try again.'),
|
||||
});
|
||||
}
|
||||
if (!resp?.continue) return;
|
||||
await upgradeRoom(room, args, resp.invite);
|
||||
}));
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
|
|
|
@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
|
|||
import defaultDispatcher from './dispatcher/dispatcher';
|
||||
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
// These functions are frequently used just to check whether an event has
|
||||
// any text to display at all. For this reason they return deferred values
|
||||
// to avoid the expense of looking up translations when they're not needed.
|
||||
|
||||
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
let isVoice = true;
|
||||
if (event.getContent().offer && event.getContent().offer.sdp &&
|
||||
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
|
||||
isVoice = false;
|
||||
}
|
||||
const isSupported = MatrixClientPeg.get().supportsVoip();
|
||||
|
||||
// This ladder could be reduced down to a couple string variables, however other languages
|
||||
// can have a hard time translating those strings. In an effort to make translations easier
|
||||
// and more accurate, we break out the string-based variables to a couple booleans.
|
||||
if (isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
|
||||
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
||||
const senderName = ev.sender ? ev.sender.name : ev.getSender();
|
||||
|
@ -408,6 +441,15 @@ function textForPowerEvent(event: MatrixEvent): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: messageId,
|
||||
highlighted: true,
|
||||
room_id: roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const onPinnedMessagesClick = (): void => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
|
@ -419,17 +461,77 @@ const onPinnedMessagesClick = (): void => {
|
|||
function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null {
|
||||
if (!SettingsStore.getValue("feature_pinning")) return null;
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
const pinned = event.getContent().pinned ?? [];
|
||||
const previouslyPinned = event.getPrevContent().pinned ?? [];
|
||||
const newlyPinned = pinned.filter(item => previouslyPinned.indexOf(item) < 0);
|
||||
const newlyUnpinned = previouslyPinned.filter(item => pinned.indexOf(item) < 0);
|
||||
|
||||
if (newlyPinned.length === 1 && newlyUnpinned.length === 0) {
|
||||
// A single message was pinned, include a link to that message.
|
||||
if (allowJSX) {
|
||||
const messageId = newlyPinned.pop();
|
||||
|
||||
return () => (
|
||||
<span>
|
||||
{ _t(
|
||||
"%(senderName)s pinned <a>a message</a> to this room. See all <b>pinned messages</b>.",
|
||||
{ senderName },
|
||||
{
|
||||
"a": (sub) =>
|
||||
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
|
||||
{ sub }
|
||||
</a>,
|
||||
"b": (sub) =>
|
||||
<a onClick={onPinnedMessagesClick}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return () => _t("%(senderName)s pinned a message to this room. See all pinned messages.", { senderName });
|
||||
}
|
||||
|
||||
if (newlyUnpinned.length === 1 && newlyPinned.length === 0) {
|
||||
// A single message was unpinned, include a link to that message.
|
||||
if (allowJSX) {
|
||||
const messageId = newlyUnpinned.pop();
|
||||
|
||||
return () => (
|
||||
<span>
|
||||
{ _t(
|
||||
"%(senderName)s unpinned <a>a message</a> from this room. See all <b>pinned messages</b>.",
|
||||
{ senderName },
|
||||
{
|
||||
"a": (sub) =>
|
||||
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
|
||||
{ sub }
|
||||
</a>,
|
||||
"b": (sub) =>
|
||||
<a onClick={onPinnedMessagesClick}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return () => _t("%(senderName)s unpinned a message from this room. See all pinned messages.", { senderName });
|
||||
}
|
||||
|
||||
if (allowJSX) {
|
||||
return () => (
|
||||
<span>
|
||||
{
|
||||
_t(
|
||||
"%(senderName)s changed the <a>pinned messages</a> for the room.",
|
||||
{ senderName },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
|
||||
)
|
||||
}
|
||||
{ _t(
|
||||
"%(senderName)s changed the <a>pinned messages</a> for the room.",
|
||||
{ senderName },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -567,6 +669,7 @@ interface IHandlers {
|
|||
|
||||
const handlers: IHandlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
};
|
||||
|
||||
const stateHandlers: IHandlers = {
|
||||
|
|
|
@ -163,7 +163,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
|
|||
modifiers: [Modifiers.SHIFT],
|
||||
key: Key.PAGE_UP,
|
||||
}],
|
||||
description: _td("Jump to oldest unread message"),
|
||||
description: _td("Jump to oldest unread message"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
|
||||
|
|
|
@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
|
|||
|
||||
interface IProps {
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
children(renderProps: {
|
||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||
});
|
||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||
}
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
|
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
const onKeyDownHandler = useCallback((ev) => {
|
||||
let handled = false;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.END:
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx > 0) {
|
||||
context.state.refs[idx - 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_DOWN:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx < context.state.refs.length - 1) {
|
||||
context.state.refs[idx + 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{ children({ onKeyDownHandler }) }
|
||||
|
|
|
@ -269,7 +269,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
|
||||
<details>
|
||||
<summary>{ _t("Advanced") }</summary>
|
||||
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
|
||||
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}>
|
||||
{ _t("Set up with a Security Key") }
|
||||
</AccessibleButton>
|
||||
</details>
|
||||
|
|
|
@ -474,7 +474,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
|
||||
{ _t("Generate a Security Key") }
|
||||
</div>
|
||||
<div>{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
|
||||
|
@ -493,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase" />
|
||||
{ _t("Enter a Security Phrase") }
|
||||
</div>
|
||||
<div>{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }</div>
|
||||
|
@ -701,7 +701,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code>
|
||||
</div>
|
||||
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
|
||||
<AccessibleButton kind='primary' className="mx_Dialog_primary"
|
||||
<AccessibleButton kind='primary'
|
||||
className="mx_Dialog_primary"
|
||||
onClick={this._onDownloadClick}
|
||||
disabled={this.state.phase === PHASE_STORING}
|
||||
>
|
||||
|
|
|
@ -148,8 +148,12 @@ export default class ExportE2eKeysDialog extends React.Component {
|
|||
</label>
|
||||
</div>
|
||||
<div className='mx_E2eKeysDialog_inputCell'>
|
||||
<input ref={this._passphrase1} id='passphrase1'
|
||||
autoFocus={true} size='64' type='password'
|
||||
<input
|
||||
ref={this._passphrase1}
|
||||
id='passphrase1'
|
||||
autoFocus={true}
|
||||
size='64'
|
||||
type='password'
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</div>
|
||||
|
@ -161,8 +165,10 @@ export default class ExportE2eKeysDialog extends React.Component {
|
|||
</label>
|
||||
</div>
|
||||
<div className='mx_E2eKeysDialog_inputCell'>
|
||||
<input ref={this._passphrase2} id='passphrase2'
|
||||
size='64' type='password'
|
||||
<input ref={this._passphrase2}
|
||||
id='passphrase2'
|
||||
size='64'
|
||||
type='password'
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -174,7 +174,10 @@ export default class ImportE2eKeysDialog extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
<div className='mx_Dialog_buttons'>
|
||||
<input className='mx_Dialog_primary' type='submit' value={_t('Import')}
|
||||
<input
|
||||
className='mx_Dialog_primary'
|
||||
type='submit'
|
||||
value={_t('Import')}
|
||||
disabled={!this.state.enableSubmit || disableForm}
|
||||
/>
|
||||
<button onClick={this._onCancelClick} disabled={disableForm}>
|
||||
|
|
|
@ -26,7 +26,7 @@ export class ManagedPlayback extends Playback {
|
|||
}
|
||||
|
||||
public async play(): Promise<void> {
|
||||
this.manager.playOnly(this);
|
||||
this.manager.pauseAllExcept(this);
|
||||
return super.play();
|
||||
}
|
||||
|
|
@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
|
|||
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
||||
const noiseWaveform = input.map(v => Math.abs(v));
|
||||
|
||||
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 for the remaining function logic.
|
||||
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
|
||||
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
|
||||
// waveform. Most speech happens below the 0.5 mark.
|
||||
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
|
||||
|
||||
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
|
||||
// sensible. This is what we return to keep our contract of "values between zero and one".
|
||||
return arrayRescale(filtered, 0, 1);
|
||||
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
|
||||
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
}
|
||||
|
||||
export class Playback extends EventEmitter implements IDestroyable {
|
||||
|
@ -125,6 +117,8 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
}
|
||||
|
||||
public destroy() {
|
||||
// Dev note: It's critical that we call stop() during cleanup to ensure that downstream callers
|
||||
// are aware of the final clock position before the user triggered an unload.
|
||||
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
|
||||
this.stop();
|
||||
this.removeAllListeners();
|
||||
|
@ -185,9 +179,12 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
|
||||
this.waveformObservable.update(this.resampledWaveform);
|
||||
|
||||
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
|
||||
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
|
||||
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
|
||||
|
||||
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
|
||||
// when the downstream callers try to use it.
|
||||
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
|
||||
}
|
||||
|
||||
private onPlaybackEnd = async () => {
|
|
@ -89,9 +89,9 @@ export class PlaybackClock implements IDestroyable {
|
|||
return this.observable;
|
||||
}
|
||||
|
||||
private checkTime = () => {
|
||||
private checkTime = (force = false) => {
|
||||
const now = this.timeSeconds; // calculated dynamically
|
||||
if (this.lastCheck !== now) {
|
||||
if (this.lastCheck !== now || force) {
|
||||
this.observable.update([now, this.durationSeconds]);
|
||||
this.lastCheck = now;
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ export class PlaybackClock implements IDestroyable {
|
|||
public syncTo(contextTime: number, clipTime: number) {
|
||||
this.clipStart = contextTime - clipTime;
|
||||
this.stopped = false; // count as a mid-stream pause (if we were stopped)
|
||||
this.checkTime();
|
||||
this.checkTime(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DEFAULT_WAVEFORM, Playback } from "./Playback";
|
||||
import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
|
||||
import { ManagedPlayback } from "./ManagedPlayback";
|
||||
|
||||
/**
|
||||
|
@ -34,12 +34,14 @@ export class PlaybackManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stops all other playback instances. If no playback is provided, all instances
|
||||
* are stopped.
|
||||
* Pauses all other playback instances. If no playback is provided, all playing
|
||||
* instances are paused.
|
||||
* @param playback Optional. The playback to leave untouched.
|
||||
*/
|
||||
public playOnly(playback?: Playback) {
|
||||
this.instances.filter(p => p !== playback).forEach(p => p.stop());
|
||||
public pauseAllExcept(playback?: Playback) {
|
||||
this.instances
|
||||
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
|
||||
.forEach(p => p.pause());
|
||||
}
|
||||
|
||||
public destroyPlaybackInstance(playback: ManagedPlayback) {
|
219
src/audio/PlaybackQueue.ts
Normal file
219
src/audio/PlaybackQueue.ts
Normal file
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { Playback, PlaybackState } from "./Playback";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { arrayFastClone } from "../utils/arrays";
|
||||
import { PlaybackManager } from "./PlaybackManager";
|
||||
import { isVoiceMessage } from "../utils/EventUtils";
|
||||
import RoomViewStore from "../stores/RoomViewStore";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
/**
|
||||
* Audio playback queue management for a given room. This keeps track of where the user
|
||||
* was at for each playback, what order the playbacks were played in, and triggers subsequent
|
||||
* playbacks.
|
||||
*
|
||||
* Currently this is only intended to be used by voice messages.
|
||||
*
|
||||
* The primary mechanics are:
|
||||
* * Persisted clock state for each playback instance (tied to Event ID).
|
||||
* * Limited memory of playback order (see code; not persisted).
|
||||
* * Autoplay of next eligible playback instance.
|
||||
*/
|
||||
export class PlaybackQueue {
|
||||
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
|
||||
|
||||
private playbacks = new Map<string, Playback>(); // keyed by event ID
|
||||
private clockStates = new Map<string, number>(); // keyed by event ID
|
||||
private playbackIdOrder: string[] = []; // event IDs, last == current
|
||||
private currentPlaybackId: string; // event ID, broken out from above for ease of use
|
||||
private recentFullPlays = new Set<string>(); // event IDs
|
||||
|
||||
constructor(private client: MatrixClient, private room: Room) {
|
||||
this.loadClocks();
|
||||
|
||||
RoomViewStore.addListener(() => {
|
||||
if (RoomViewStore.getRoomId() === this.room.roomId) {
|
||||
// Reset the state of the playbacks before they start mounting and enqueuing updates.
|
||||
// We reset the entirety of the queue, including order, to ensure the user isn't left
|
||||
// confused with what order the messages are playing in.
|
||||
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static forRoom(roomId: string): PlaybackQueue {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) throw new Error("Unknown room");
|
||||
if (PlaybackQueue.queues.has(room.roomId)) {
|
||||
return PlaybackQueue.queues.get(room.roomId);
|
||||
}
|
||||
const queue = new PlaybackQueue(cli, room);
|
||||
PlaybackQueue.queues.set(room.roomId, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
private persistClocks() {
|
||||
localStorage.setItem(
|
||||
`mx_voice_message_clocks_${this.room.roomId}`,
|
||||
JSON.stringify(Array.from(this.clockStates.entries())),
|
||||
);
|
||||
}
|
||||
|
||||
private loadClocks() {
|
||||
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
|
||||
if (!!val) {
|
||||
this.clockStates = new Map<string, number>(JSON.parse(val));
|
||||
}
|
||||
}
|
||||
|
||||
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
|
||||
// We don't ever detach our listeners: we expect the Playback to clean up for us
|
||||
this.playbacks.set(mxEvent.getId(), playback);
|
||||
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
|
||||
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
|
||||
}
|
||||
|
||||
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
|
||||
// Remember where the user got to in playback
|
||||
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
|
||||
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
playback.skipTo(this.clockStates.get(mxEvent.getId()));
|
||||
} else if (newState === PlaybackState.Stopped) {
|
||||
// Remove the now-useless clock for some space savings
|
||||
this.clockStates.delete(mxEvent.getId());
|
||||
|
||||
if (wasLastPlaying) {
|
||||
this.recentFullPlays.add(this.currentPlaybackId);
|
||||
const orderClone = arrayFastClone(this.playbackIdOrder);
|
||||
const last = orderClone.pop();
|
||||
if (last === this.currentPlaybackId) {
|
||||
const next = orderClone.pop();
|
||||
if (next) {
|
||||
const instance = this.playbacks.get(next);
|
||||
if (!instance) {
|
||||
console.warn(
|
||||
"Voice message queue desync: Missing playback for next message: "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
|
||||
);
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
} else {
|
||||
// else no explicit next event, so find an event we haven't played that comes next. The live
|
||||
// timeline is already most recent last, so we can iterate down that.
|
||||
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
|
||||
let scanForVoiceMessage = false;
|
||||
let nextEv: MatrixEvent;
|
||||
for (const event of timeline) {
|
||||
if (event.getId() === mxEvent.getId()) {
|
||||
scanForVoiceMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (!scanForVoiceMessage) continue;
|
||||
|
||||
if (!isVoiceMessage(event)) {
|
||||
const evType = event.getType();
|
||||
if (evType !== EventType.RoomMessage && evType !== EventType.Sticker) {
|
||||
continue; // Event can be skipped for automatic playback consideration
|
||||
}
|
||||
break; // Stop automatic playback: next useful event is not a voice message
|
||||
}
|
||||
|
||||
const havePlayback = this.playbacks.has(event.getId());
|
||||
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
|
||||
if (havePlayback && !isRecentlyCompleted) {
|
||||
nextEv = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nextEv) {
|
||||
// if we don't have anywhere to go, reset the recent playback queue so the user
|
||||
// can start a new chain of playbacks.
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
|
||||
const instance = this.playbacks.get(nextEv.getId());
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Voice message queue desync: Expected playback stop to be last in order. "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newState === PlaybackState.Playing) {
|
||||
const order = this.playbackIdOrder;
|
||||
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
const lastInstance = this.playbacks.get(this.currentPlaybackId);
|
||||
if (
|
||||
lastInstance.currentState === PlaybackState.Playing
|
||||
|| lastInstance.currentState === PlaybackState.Paused
|
||||
) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlaybackId = mxEvent.getId();
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
|
||||
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
|
||||
// This should get triggered from normal voice message component unmount due to the playback
|
||||
// stopping itself for cleanup.
|
||||
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
|
||||
this.persistClocks();
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
|
||||
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
|
||||
|
||||
if (playback.currentState !== PlaybackState.Stopped) {
|
||||
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
|
||||
}
|
||||
}
|
||||
}
|
|
@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
|
|||
|
||||
process(inputs, outputs, parameters) {
|
||||
const currentSecond = roundTimeToTargetFreq(currentTime);
|
||||
if (currentSecond === this.nextAmplitudeSecond) {
|
||||
// We special case the first ping because there's a fairly good chance that we'll miss the zeroth
|
||||
// update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first
|
||||
// time. Edge and Chrome occasionally lag behind too, but for the most part are on time.
|
||||
//
|
||||
// When this doesn't work properly we end up producing a waveform of nulls and no live preview
|
||||
// of the recorded message.
|
||||
if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) {
|
||||
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||
// samples for the analysis.
|
||||
const monoChan = inputs[0][0];
|
|
@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
|||
import { uploadFile } from "../ContentMessages";
|
||||
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||
import { clamp } from "../utils/numbers";
|
||||
import mxRecorderWorkletPath from "./RecorderWorklet";
|
||||
|
||||
const CHANNELS = 1; // stereo isn't important
|
||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||
|
@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
});
|
||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
||||
if (!mxRecorderWorkletPath) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Unable to create recorder: no worklet script registered");
|
||||
}
|
||||
|
||||
// Connect our inputs and outputs
|
||||
if (this.recorderContext.audioWorklet) {
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||
this.recorderSource.connect(this.recorderWorklet);
|
|
@ -27,11 +27,11 @@ export interface ICommand {
|
|||
};
|
||||
}
|
||||
|
||||
export default class AutocompleteProvider {
|
||||
export default abstract class AutocompleteProvider {
|
||||
commandRegex: RegExp;
|
||||
forcedCommandRegex: RegExp;
|
||||
|
||||
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
if (commandRegex) {
|
||||
if (!commandRegex.global) {
|
||||
throw new Error('commandRegex must have global flag set');
|
||||
|
@ -93,23 +93,16 @@ export default class AutocompleteProvider {
|
|||
};
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
abstract getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
return [];
|
||||
}
|
||||
force: boolean,
|
||||
limit: number,
|
||||
): Promise<ICompletion[]>;
|
||||
|
||||
getName(): string {
|
||||
return 'Default Provider';
|
||||
}
|
||||
abstract getName(): string;
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
|
||||
console.error('stub; should be implemented in subclasses');
|
||||
return null;
|
||||
}
|
||||
abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null;
|
||||
|
||||
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
||||
shouldForceComplete(): boolean {
|
||||
|
|
|
@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
|
|||
|
||||
import CommandProvider from './CommandProvider';
|
||||
import CommunityProvider from './CommunityProvider';
|
||||
import DuckDuckGoProvider from './DuckDuckGoProvider';
|
||||
import RoomProvider from './RoomProvider';
|
||||
import UserProvider from './UserProvider';
|
||||
import EmojiProvider from './EmojiProvider';
|
||||
|
@ -55,7 +54,6 @@ const PROVIDERS = [
|
|||
EmojiProvider,
|
||||
NotifProvider,
|
||||
CommandProvider,
|
||||
DuckDuckGoProvider,
|
||||
];
|
||||
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
|
|
|
@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
// The input looks like a command with arguments, perform exact match
|
||||
const name = command[1].substr(1); // strip leading `/`
|
||||
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
|
||||
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
|
||||
// some commands, namely `me` don't suit having the usage shown whilst typing their arguments
|
||||
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
|
||||
matches = [CommandMap.get(name)];
|
||||
}
|
||||
|
@ -95,8 +95,8 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="presentation"
|
||||
aria-label={_t("Command Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -116,7 +116,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Community Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 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 from 'react';
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
|
||||
import { TextualCompletion } from './Components';
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
|
||||
const DDG_REGEX = /\/ddg\s+(.+)$/g;
|
||||
const REFERRER = 'vector';
|
||||
|
||||
export default class DuckDuckGoProvider extends AutocompleteProvider {
|
||||
constructor() {
|
||||
super(DDG_REGEX);
|
||||
}
|
||||
|
||||
static getQueryUri(query: string) {
|
||||
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
|
||||
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (!query || !command) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
|
||||
method: 'GET',
|
||||
});
|
||||
const json = await response.json();
|
||||
const maxLength = limit > -1 ? limit : json.Results.length;
|
||||
const results = json.Results.slice(0, maxLength).map((result) => {
|
||||
return {
|
||||
completion: result.Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={result.Text}
|
||||
description={result.Result} />
|
||||
),
|
||||
range,
|
||||
};
|
||||
});
|
||||
if (json.Answer) {
|
||||
results.unshift({
|
||||
completion: json.Answer,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.Answer}
|
||||
description={json.AnswerType} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
|
||||
results.unshift({
|
||||
completion: json.RelatedTopics[0].Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.RelatedTopics[0].Text} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.AbstractText) {
|
||||
results.unshift({
|
||||
completion: json.AbstractText,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.AbstractText} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return '🔍 ' + _t('Results from DuckDuckGo');
|
||||
}
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
aria-label={_t("DuckDuckGo Results")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -67,7 +67,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
constructor() {
|
||||
super(EMOJI_REGEX);
|
||||
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
|
||||
keys: ['emoji.emoticon'],
|
||||
keys: [],
|
||||
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
|
||||
// For matching against ascii equivalents
|
||||
shouldMatchWordsOnly: false,
|
||||
|
@ -91,7 +91,8 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
|
||||
let completions = [];
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
|
||||
if (command && command[0].length > 2) {
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
|
||||
|
@ -139,7 +140,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Emoji Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -70,7 +70,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Notification Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -134,7 +134,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Room Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -181,7 +181,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("User Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
|||
style={style}
|
||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||
onWheel={onWheel}
|
||||
tabIndex={tabIndex}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order by default.
|
||||
tabIndex={tabIndex ?? -1}
|
||||
>
|
||||
{ children }
|
||||
</div>);
|
||||
|
|
44
src/components/structures/BackdropPanel.tsx
Normal file
44
src/components/structures/BackdropPanel.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
Copyright 2021 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, { CSSProperties } from "react";
|
||||
|
||||
interface IProps {
|
||||
backgroundImage?: string;
|
||||
blurMultiplier?: number;
|
||||
}
|
||||
|
||||
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
|
||||
if (!backgroundImage) return null;
|
||||
|
||||
const styles: CSSProperties = {};
|
||||
if (blurMultiplier) {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
|
||||
const pixelsValue = blurValue.replace('px', '');
|
||||
const parsed = parseInt(pixelsValue, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
styles.filter = `blur(${parsed * blurMultiplier}px)`;
|
||||
}
|
||||
}
|
||||
return <div className="mx_BackdropPanel">
|
||||
<img
|
||||
style={styles}
|
||||
className="mx_BackdropPanel--image"
|
||||
src={backgroundImage} />
|
||||
</div>;
|
||||
};
|
||||
export default BackdropPanel;
|
|
@ -25,11 +25,18 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
|
|||
export enum CallEventGrouperEvent {
|
||||
StateChanged = "state_changed",
|
||||
SilencedChanged = "silenced_changed",
|
||||
LengthChanged = "length_changed",
|
||||
}
|
||||
|
||||
const CONNECTING_STATES = [
|
||||
CallState.Connecting,
|
||||
CallState.WaitLocalMedia,
|
||||
CallState.CreateOffer,
|
||||
CallState.CreateAnswer,
|
||||
];
|
||||
|
||||
const SUPPORTED_STATES = [
|
||||
CallState.Connected,
|
||||
CallState.Connecting,
|
||||
CallState.Ringing,
|
||||
];
|
||||
|
||||
|
@ -61,6 +68,10 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
||||
}
|
||||
|
||||
private get selectAnswer(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
|
||||
}
|
||||
|
||||
public get isVoice(): boolean {
|
||||
const invite = this.invite;
|
||||
if (!invite) return;
|
||||
|
@ -74,6 +85,19 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
return this.hangup?.getContent()?.reason;
|
||||
}
|
||||
|
||||
public get rejectParty(): string {
|
||||
return this.reject?.getSender();
|
||||
}
|
||||
|
||||
public get gotRejected(): boolean {
|
||||
return Boolean(this.reject);
|
||||
}
|
||||
|
||||
public get duration(): Date {
|
||||
if (!this.hangup || !this.selectAnswer) return;
|
||||
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are only events from the other side - we missed the call
|
||||
*/
|
||||
|
@ -81,8 +105,12 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
|
||||
}
|
||||
|
||||
private get callId(): string {
|
||||
return [...this.events][0].getContent().call_id;
|
||||
private get callId(): string | undefined {
|
||||
return [...this.events][0]?.getContent()?.call_id;
|
||||
}
|
||||
|
||||
private get roomId(): string | undefined {
|
||||
return [...this.events][0]?.getRoomId();
|
||||
}
|
||||
|
||||
private onSilencedCallsChanged = () => {
|
||||
|
@ -90,19 +118,29 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
|
||||
};
|
||||
|
||||
private onLengthChanged = (length: number): void => {
|
||||
this.emit(CallEventGrouperEvent.LengthChanged, length);
|
||||
};
|
||||
|
||||
public answerCall = () => {
|
||||
this.call?.answer();
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'answer',
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
public rejectCall = () => {
|
||||
this.call?.reject();
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'reject',
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
public callBack = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'place_call',
|
||||
type: this.isVoice ? CallType.Voice : CallType.Video,
|
||||
room_id: [...this.events][0]?.getRoomId(),
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -116,10 +154,13 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
private setCallListeners() {
|
||||
if (!this.call) return;
|
||||
this.call.addListener(CallEvent.State, this.setState);
|
||||
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
|
||||
}
|
||||
|
||||
private setState = () => {
|
||||
if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||
if (CONNECTING_STATES.includes(this.call?.state)) {
|
||||
this.state = CallState.Connecting;
|
||||
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||
this.state = this.call.state;
|
||||
} else {
|
||||
if (this.callWasMissed) this.state = CustomCallState.Missed;
|
||||
|
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, RefObject, useRef, useState } from "react";
|
||||
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
|
@ -80,6 +80,10 @@ export interface IProps extends IPosition {
|
|||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
|
||||
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
||||
// it will be mounted to a container at the root of the DOM.
|
||||
mountAsChild?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
// on resize callback
|
||||
|
@ -390,7 +394,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
render(): React.ReactChild {
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
if (this.props.mountAsChild) {
|
||||
// Render as a child of the current parent
|
||||
return this.renderMenu();
|
||||
} else {
|
||||
// Render as a child of a container at the root of the DOM
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -461,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
|
|||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
const open = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const close = () => {
|
||||
const close = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
|
|
|
@ -120,8 +120,7 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
|
||||
const content = <div className={`${className}_body`}
|
||||
dangerouslySetInnerHTML={{ __html: this.state.page }}
|
||||
>
|
||||
</div>;
|
||||
/>;
|
||||
|
||||
if (this.props.scrollbar) {
|
||||
return <AutoHideScrollbar className={classes}>
|
||||
|
|
|
@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { EventSubscription } from "fbemitter";
|
||||
import React from 'react';
|
||||
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
|
||||
|
||||
|
@ -30,22 +31,43 @@ 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";
|
||||
|
||||
interface IGroupFilterPanelProps {
|
||||
|
||||
}
|
||||
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
type OrderedTagsTemporaryType = Array<{}>;
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
type SelectedTagsTemporaryType = Array<{}>;
|
||||
|
||||
interface IGroupFilterPanelState {
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
orderedTags: OrderedTagsTemporaryType;
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
selectedTags: SelectedTagsTemporaryType;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.GroupFilterPanel")
|
||||
class GroupFilterPanel extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFilterPanelState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
|
||||
state = {
|
||||
public state = {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
private ref = React.createRef<HTMLDivElement>();
|
||||
private unmounted = false;
|
||||
private groupFilterOrderStoreToken?: EventSubscription;
|
||||
|
||||
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
public componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this.onGroupMyMembership);
|
||||
this.context.on("sync", this.onClientSync);
|
||||
|
||||
this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
@ -56,23 +78,25 @@ class GroupFilterPanel extends React.Component {
|
|||
});
|
||||
// This could be done by anything with a matrix client
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._groupFilterOrderStoreToken) {
|
||||
this._groupFilterOrderStoreToken.remove();
|
||||
this.context.removeListener("Group.myMembership", this.onGroupMyMembership);
|
||||
this.context.removeListener("sync", this.onClientSync);
|
||||
if (this.groupFilterOrderStoreToken) {
|
||||
this.groupFilterOrderStoreToken.remove();
|
||||
}
|
||||
UIStore.instance.stopTrackingElementDimensions("GroupPanel");
|
||||
}
|
||||
|
||||
_onGroupMyMembership = () => {
|
||||
private onGroupMyMembership = () => {
|
||||
if (this.unmounted) return;
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
};
|
||||
|
||||
_onClientSync = (syncState, prevState) => {
|
||||
private onClientSync = (syncState, prevState) => {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
|
@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onClick = e => {
|
||||
private onClick = e => {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
}
|
||||
};
|
||||
|
||||
onClearFilterClick = ev => {
|
||||
private onClearFilterClick = ev => {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
};
|
||||
|
||||
renderGlobalIcon() {
|
||||
private renderGlobalIcon() {
|
||||
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
|
||||
|
||||
return (
|
||||
|
@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
|
||||
|
@ -147,7 +171,7 @@ class GroupFilterPanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
return <div className={classes} onClick={this.onClearFilterClick}>
|
||||
return <div className={classes} onClick={this.onClearFilterClick} ref={this.ref}>
|
||||
<AutoHideScrollbar
|
||||
className="mx_GroupFilterPanel_scroller"
|
||||
onClick={this.onClick}
|
|
@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
|
|||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { createSpaceFromCommunity } from "../../utils/space";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
|
@ -222,7 +225,7 @@ class FeaturedRoom extends React.Component {
|
|||
|
||||
let roomNameNode = null;
|
||||
if (permalink) {
|
||||
roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>;
|
||||
roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
|
||||
} else {
|
||||
roomNameNode = <span>{ roomName }</span>;
|
||||
}
|
||||
|
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
|
|||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
|
||||
|
||||
@replaceableComponent("structures.GroupView")
|
||||
export default class GroupView extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
|
|||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
|
|||
showGroupAddRoomDialog(this.props.groupId);
|
||||
};
|
||||
|
||||
_dismissUpgradeNotice = () => {
|
||||
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
|
||||
this.setState({ showUpgradeNotice: false });
|
||||
}
|
||||
|
||||
_onCreateSpaceClick = () => {
|
||||
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
|
||||
};
|
||||
|
||||
_onAdminsLinkClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.GroupMemberList,
|
||||
});
|
||||
};
|
||||
|
||||
_getGroupSection() {
|
||||
const groupSettingsSectionClasses = classnames({
|
||||
"mx_GroupView_group": this.state.editing,
|
||||
|
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
|
|||
},
|
||||
) }
|
||||
</div> : <div />;
|
||||
|
||||
let communitiesUpgradeNotice;
|
||||
if (this.state.showUpgradeNotice) {
|
||||
let text;
|
||||
if (this.state.isUserPrivileged) {
|
||||
text = _t("You can create a Space from this community <a>here</a>.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
} else {
|
||||
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
|
||||
"and keep a look out for the invite.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
}
|
||||
|
||||
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
|
||||
<h2>{ _t("Communities can now be made into Spaces") }</h2>
|
||||
<p>
|
||||
{ _t("Spaces are a new way to make a community, with new features coming.") }
|
||||
|
||||
{ text }
|
||||
|
||||
{ _t("Communities won't receive further updates.") }
|
||||
</p>
|
||||
<AccessibleButton
|
||||
className="mx_GroupView_spaceUpgradePrompt_close"
|
||||
onClick={this._dismissUpgradeNotice}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className={groupSettingsSectionClasses}>
|
||||
{ header }
|
||||
{ hostingSignup }
|
||||
{ changeDelayWarning }
|
||||
{ communitiesUpgradeNotice }
|
||||
{ this._getJoinableNode() }
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
|
@ -1185,10 +1243,13 @@ export default class GroupView extends React.Component {
|
|||
avatarImage = <Spinner />;
|
||||
} else {
|
||||
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
|
||||
avatarImage = <GroupAvatar groupId={this.props.groupId}
|
||||
avatarImage = <GroupAvatar
|
||||
groupId={this.props.groupId}
|
||||
groupName={this.state.profileForm.name}
|
||||
groupAvatarUrl={this.state.profileForm.avatar_url}
|
||||
width={28} height={28} resizeMethod='crop'
|
||||
width={28}
|
||||
height={28}
|
||||
resizeMethod='crop'
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -1199,9 +1260,12 @@ export default class GroupView extends React.Component {
|
|||
</label>
|
||||
<div className="mx_GroupView_avatarPicker_edit">
|
||||
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
|
||||
<img src={require("../../../res/img/camera.svg")}
|
||||
alt={_t("Upload avatar")} title={_t("Upload avatar")}
|
||||
width="17" height="15" />
|
||||
<img
|
||||
src={require("../../../res/img/camera.svg")}
|
||||
alt={_t("Upload avatar")}
|
||||
title={_t("Upload avatar")}
|
||||
width="17"
|
||||
height="15" />
|
||||
</label>
|
||||
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
|
||||
</div>
|
||||
|
@ -1238,7 +1302,8 @@ export default class GroupView extends React.Component {
|
|||
groupAvatarUrl={groupAvatarUrl}
|
||||
groupName={groupName}
|
||||
onClick={onGroupHeaderItemClick}
|
||||
width={28} height={28}
|
||||
width={28}
|
||||
height={28}
|
||||
/>;
|
||||
if (summary.profile && summary.profile.name) {
|
||||
nameNode = <div onClick={onGroupHeaderItemClick}>
|
||||
|
@ -1269,28 +1334,32 @@ export default class GroupView extends React.Component {
|
|||
key="_cancelButton"
|
||||
onClick={this._onCancelClick}
|
||||
>
|
||||
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
|
||||
width="18" height="18" alt={_t("Cancel")} />
|
||||
<img
|
||||
src={require("../../../res/img/cancel.svg")}
|
||||
className="mx_filterFlipColor"
|
||||
width="18"
|
||||
height="18"
|
||||
alt={_t("Cancel")} />
|
||||
</AccessibleButton>,
|
||||
);
|
||||
} else {
|
||||
if (summary.user && summary.user.membership === 'join') {
|
||||
rightButtons.push(
|
||||
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
|
||||
<AccessibleButton
|
||||
className="mx_GroupHeader_button mx_GroupHeader_editButton"
|
||||
key="_editButton"
|
||||
onClick={this._onEditClick}
|
||||
title={_t("Community Settings")}
|
||||
>
|
||||
</AccessibleButton>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
rightButtons.push(
|
||||
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
|
||||
<AccessibleButton
|
||||
className="mx_GroupHeader_button mx_GroupHeader_shareButton"
|
||||
key="_shareButton"
|
||||
onClick={this._onShareClick}
|
||||
title={_t('Share Community')}
|
||||
>
|
||||
</AccessibleButton>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,300 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
|
||||
// response from initial request. If not supplied, will do a request on
|
||||
// mount.
|
||||
authData: PropTypes.shape({
|
||||
flows: PropTypes.array,
|
||||
params: PropTypes.object,
|
||||
session: PropTypes.string,
|
||||
}),
|
||||
|
||||
// callback
|
||||
makeRequest: PropTypes.func.isRequired,
|
||||
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {bool} status True if the operation requiring
|
||||
// auth was completed sucessfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the identity server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs: PropTypes.object,
|
||||
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken: PropTypes.func,
|
||||
sessionId: PropTypes.string,
|
||||
clientSecret: PropTypes.string,
|
||||
emailSid: PropTypes.string,
|
||||
|
||||
// If true, poll to see if the auth flow has been completed
|
||||
// out-of-band
|
||||
poll: PropTypes.bool,
|
||||
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged: PropTypes.bool,
|
||||
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange: PropTypes.func,
|
||||
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this._unmounted = false;
|
||||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this._requestCallback,
|
||||
busyChanged: this._onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this._authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this._authLogic.getEmailSid(),
|
||||
clientSecret: this._authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
|
||||
if (this._intervalId !== null) {
|
||||
clearInterval(this._intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
_requestEmailToken = async (...args) => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(...args);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tryContinue = () => {
|
||||
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
|
||||
this._stageComponent.current.tryContinue();
|
||||
}
|
||||
};
|
||||
|
||||
_authStateUpdated = (stageType, stageState) => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this._setFocus();
|
||||
} else if (
|
||||
!stageState.error && this._stageComponent.current &&
|
||||
this._stageComponent.current.attemptFailed
|
||||
) {
|
||||
this._stageComponent.current.attemptFailed();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_requestCallback = (auth) => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
_onBusyChanged = (busy) => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
_setFocus() {
|
||||
if (this._stageComponent.current && this._stageComponent.current.focus) {
|
||||
this._stageComponent.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_submitAuthDict = authData => {
|
||||
this._authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
_onPhaseChange = newPhase => {
|
||||
if (this.props.onStagePhaseChange) {
|
||||
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
|
||||
}
|
||||
};
|
||||
|
||||
_onStageCancel = () => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
_renderCurrentStage() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this._stageComponent}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this._authLogic.getSessionId()}
|
||||
clientSecret={this._authLogic.getClientSecret()}
|
||||
stageParams={this._authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this._submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this._onAuthStageFailed}
|
||||
setEmailSid={this._setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this._onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this._onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onAuthStageFailed = e => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
_setEmailSid = sid => {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this._renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
300
src/components/structures/InteractiveAuth.tsx
Normal file
300
src/components/structures/InteractiveAuth.tsx
Normal file
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
Copyright 2017 - 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 {
|
||||
AuthType,
|
||||
IAuthData,
|
||||
IAuthDict,
|
||||
IInputs,
|
||||
InteractiveAuth,
|
||||
IStageStatus,
|
||||
} from "matrix-js-sdk/src/interactive-auth";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import React, { createRef } from 'react';
|
||||
|
||||
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
interface IProps {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: MatrixClient;
|
||||
// response from initial request. If not supplied, will do a request on mount.
|
||||
authData?: IAuthData;
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs?: IInputs;
|
||||
sessionId?: string;
|
||||
clientSecret?: string;
|
||||
emailSid?: string;
|
||||
// If true, poll to see if the auth flow has been completed out-of-band
|
||||
poll?: boolean;
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged?: boolean;
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
// callback
|
||||
makeRequest(auth: IAuthData): Promise<IAuthData>;
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {boolean} status True if the operation requiring
|
||||
// auth was completed successfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
onAuthFinished(
|
||||
status: boolean,
|
||||
result: IAuthData | Error,
|
||||
extra?: { emailSid?: string, clientSecret?: string },
|
||||
): void;
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange?(stage: string, phase: string | number): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
authStage?: AuthType;
|
||||
stageState?: IStageStatus;
|
||||
busy: boolean;
|
||||
errorText?: string;
|
||||
stageErrorText?: string;
|
||||
submitButtonEnabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
|
||||
private readonly authLogic: InteractiveAuth;
|
||||
private readonly intervalId: number = null;
|
||||
private readonly stageComponent = createRef<IStageComponent>();
|
||||
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this.authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this.requestCallback,
|
||||
busyChanged: this.onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this.authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this.requestEmailToken,
|
||||
});
|
||||
|
||||
if (this.props.poll) {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
|
||||
this.authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this.authLogic.getEmailSid(),
|
||||
clientSecret: this.authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmailToken = async (
|
||||
email: string,
|
||||
secret: string,
|
||||
attempt: number,
|
||||
session: string,
|
||||
): Promise<{sid: string}> => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(email, secret, attempt, session);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private tryContinue = (): void => {
|
||||
this.stageComponent.current?.tryContinue?.();
|
||||
};
|
||||
|
||||
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this.setFocus();
|
||||
} else if (!stageState.error) {
|
||||
this.stageComponent.current?.attemptFailed?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
private onBusyChanged = (busy: boolean): void => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
private setFocus(): void {
|
||||
this.stageComponent.current?.focus?.();
|
||||
}
|
||||
|
||||
private submitAuthDict = (authData: IAuthDict): void => {
|
||||
this.authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
private onPhaseChange = (newPhase: number): void => {
|
||||
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
|
||||
};
|
||||
|
||||
private onStageCancel = (): void => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
private renderCurrentStage(): JSX.Element {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this.stageComponent as any}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this.authLogic.getSessionId()}
|
||||
clientSecret={this.authLogic.getClientSecret()}
|
||||
stageParams={this.authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this.submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this.onAuthStageFailed}
|
||||
setEmailSid={this.setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this.onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this.onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private onAuthStageFailed = (e: Error): void => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
private setEmailSid = (sid: string): void => {
|
||||
this.authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this.renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,8 +19,6 @@ import { createRef } from "react";
|
|||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
|
@ -33,15 +31,12 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
|
|||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
|
||||
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
@ -53,7 +48,6 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
|
@ -70,8 +64,6 @@ const cssClasses = [
|
|||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
|
@ -80,22 +72,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
activeSpace: SpaceStore.instance.activeSpace,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
|
||||
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
|
||||
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
|
||||
// Using the passive option to not block the main thread
|
||||
|
@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
|
@ -149,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onBackgroundImageUpdate = () => {
|
||||
// Note: we do this in the LeftPanel as it uses this variable most prominently.
|
||||
const avatarSize = 32; // arbitrary
|
||||
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (settingBgMxc) {
|
||||
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
||||
private handleStickyHeaders(list: HTMLDivElement) {
|
||||
if (this.isDoingStickyHeaders) return;
|
||||
this.isDoingStickyHeaders = true;
|
||||
|
@ -392,9 +358,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RoomBreadcrumbs />
|
||||
</IndicatorScrollbar>
|
||||
|
@ -436,23 +399,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
|
||||
})}
|
||||
onClick={this.onExplore}
|
||||
title={_t("Explore rooms")}
|
||||
title={this.state.activeSpace
|
||||
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
|
||||
: _t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let leftLeftPanel;
|
||||
if (this.state.showGroupFilterPanel) {
|
||||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
|
@ -476,7 +431,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{ leftLeftPanel }
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{ this.renderHeader() }
|
||||
{ this.renderSearchDialExplore() }
|
||||
|
|
|
@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
|
|||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018, 2020 New Vector Ltd
|
||||
Copyright 2015 - 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.
|
||||
|
@ -55,15 +53,22 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
|
|||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import CallHandler 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";
|
||||
import RoomView from './RoomView';
|
||||
import ToastContainer from './ToastContainer';
|
||||
import MyGroups from "./MyGroups";
|
||||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import BackdropPanel from "./BackdropPanel";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import classNames from 'classnames';
|
||||
import GroupFilterPanel from './GroupFilterPanel';
|
||||
import CustomRoomTagPanel from './CustomRoomTagPanel';
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -127,6 +132,7 @@ interface IState {
|
|||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -142,10 +148,13 @@ 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<ResizeHandle>;
|
||||
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
|
||||
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
|
||||
protected compactLayoutWatcherRef: string;
|
||||
protected backgroundImageWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
|
@ -156,7 +165,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: [],
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
|
@ -168,11 +177,12 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
this.resizeHandler = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
this.updateServerNoticeEvents();
|
||||
|
||||
|
@ -189,26 +199,51 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
|
||||
);
|
||||
|
||||
this.resizer = this.createResizer();
|
||||
this.resizer.attach();
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
this.loadResizerPreferences();
|
||||
this.refreshBackgroundImage();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
private onCallsChanged = () => {
|
||||
this.setState({
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
});
|
||||
private refreshBackgroundImage = async (): Promise<void> => {
|
||||
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (backgroundImage) {
|
||||
// convert to http before going much further
|
||||
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
|
||||
} else {
|
||||
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
|
||||
}
|
||||
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) => {
|
||||
|
@ -247,6 +282,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
isItemCollapsed: domNode => {
|
||||
return domNode.classList.contains("mx_LeftPanel_minimized");
|
||||
},
|
||||
handler: this.resizeHandler.current,
|
||||
};
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames({
|
||||
|
@ -262,7 +298,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
if (isNaN(lhsSize)) {
|
||||
lhsSize = 350;
|
||||
}
|
||||
this.resizer.forHandleAt(0).resize(lhsSize);
|
||||
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
|
@ -529,24 +565,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// Do not capture the context menu key to improve keyboard accessibility
|
||||
if (ev.key === Key.CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
// We explicitly allow alt to be held due to it being a common accent modifier.
|
||||
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
|
||||
// move focus to the composer so the user can re-type the dead key correctly.
|
||||
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// 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)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -601,10 +637,14 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
break;
|
||||
}
|
||||
|
||||
let bodyClasses = 'mx_MatrixChat';
|
||||
if (this.state.useCompactLayout) {
|
||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
const wrapperClasses = classNames({
|
||||
'mx_MatrixChat_wrapper': true,
|
||||
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
|
||||
});
|
||||
const bodyClasses = classNames({
|
||||
'mx_MatrixChat': true,
|
||||
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
|
||||
});
|
||||
|
||||
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
|
||||
return (
|
||||
|
@ -617,18 +657,47 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
<div
|
||||
onPaste={this.onPaste}
|
||||
onKeyDown={this.onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
className={wrapperClasses}
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
>
|
||||
<ToastContainer />
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
<div className={bodyClasses}>
|
||||
<div className='mx_LeftPanel_wrapper'>
|
||||
{ SettingsStore.getValue('TagPanel.enableTagPanel') &&
|
||||
(<div className="mx_GroupFilterPanelContainer">
|
||||
<BackdropPanel
|
||||
blurMultiplier={0.5}
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<GroupFilterPanel />
|
||||
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
|
||||
</div>)
|
||||
}
|
||||
{ SpaceStore.spacesEnabled ? <>
|
||||
<BackdropPanel
|
||||
blurMultiplier={0.5}
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<SpacePanel />
|
||||
</> : null }
|
||||
<BackdropPanel
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<div
|
||||
className="mx_LeftPanel_wrapper--user"
|
||||
ref={this._resizeContainer}
|
||||
data-collapsed={this.props.collapseLhs ? true : undefined}
|
||||
>
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
|
||||
<div className="mx_RoomView_wrapper">
|
||||
{ pageElement }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CallContainer />
|
||||
|
|
|
@ -107,6 +107,8 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
|||
import SoftLogout from './auth/SoftLogout';
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
import { PosthogAnalytics } from '../../PosthogAnalytics';
|
||||
import { initSentry } from "../../sentry";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -387,7 +389,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings();
|
||||
PosthogAnalytics.instance.updatePlatformSuperProperties();
|
||||
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
|
||||
initSentry(SdkConfig.get()["sentry"]);
|
||||
}
|
||||
|
||||
private async postLoginSetup() {
|
||||
|
@ -443,6 +451,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);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
|
@ -1007,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.setStateForNewView({
|
||||
view: Views.LOGGED_IN,
|
||||
justRegistered,
|
||||
currentRoomId: null,
|
||||
});
|
||||
this.setPage(PageTypes.HomePage);
|
||||
this.notifyNewScreen('home');
|
||||
|
|
|
@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
|||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
|
||||
const groupedEvents = [
|
||||
EventType.RoomMember,
|
||||
EventType.RoomThirdPartyInvite,
|
||||
EventType.RoomServerAcl,
|
||||
EventType.RoomPinnedEvents,
|
||||
];
|
||||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
|
@ -168,6 +173,8 @@ interface IProps {
|
|||
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
|
||||
|
||||
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
|
||||
|
||||
hideThreadedMessages?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -260,6 +267,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
componentDidMount() {
|
||||
this.calculateRoomMembersCount();
|
||||
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
|
||||
if (SettingsStore.getValue("feature_thread")) {
|
||||
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
|
||||
}
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
|
@ -438,6 +448,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
// Always show highlighted event
|
||||
if (this.props.highlightedEventId === mxEv.getId()) return true;
|
||||
|
||||
if (mxEv.replyInThread
|
||||
&& this.props.hideThreadedMessages
|
||||
&& SettingsStore.getValue("feature_thread")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideEvent(mxEv, this.context);
|
||||
}
|
||||
|
||||
|
@ -618,7 +634,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
|
||||
for (const Grouper of groupers) {
|
||||
if (Grouper.canStartGroup(this, mxEv)) {
|
||||
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
|
||||
grouper = new Grouper(
|
||||
this,
|
||||
mxEv,
|
||||
prevEvent,
|
||||
lastShownEvent,
|
||||
this.props.layout,
|
||||
nextEvent,
|
||||
nextTile,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!grouper) {
|
||||
|
@ -681,9 +705,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
|
||||
let willWantDateSeparator = false;
|
||||
let lastInSection = true;
|
||||
if (nextEvent) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
|
||||
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender();
|
||||
if (nextEventWithTile) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date());
|
||||
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender();
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
|
@ -981,6 +1005,7 @@ abstract class BaseGrouper {
|
|||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
public readonly nextEvent?: MatrixEvent,
|
||||
public readonly nextEventTile?: MatrixEvent,
|
||||
) {
|
||||
|
@ -1107,6 +1132,7 @@ class CreationGrouper extends BaseGrouper {
|
|||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
@ -1134,10 +1160,11 @@ class RedactionGrouper extends BaseGrouper {
|
|||
ev: MatrixEvent,
|
||||
prevEvent: MatrixEvent,
|
||||
lastShownEvent: MatrixEvent,
|
||||
layout: Layout,
|
||||
nextEvent: MatrixEvent,
|
||||
nextEventTile: MatrixEvent,
|
||||
) {
|
||||
super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
|
||||
super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile);
|
||||
this.events = [ev];
|
||||
}
|
||||
|
||||
|
@ -1202,6 +1229,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={Array.from(senders)}
|
||||
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
@ -1222,7 +1250,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||
class MemberGrouper extends BaseGrouper {
|
||||
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
||||
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
|
||||
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
@ -1230,8 +1258,9 @@ class MemberGrouper extends BaseGrouper {
|
|||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
) {
|
||||
super(panel, event, prevEvent, lastShownEvent);
|
||||
super(panel, event, prevEvent, lastShownEvent, layout);
|
||||
this.events = [event];
|
||||
}
|
||||
|
||||
|
@ -1239,7 +1268,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||
return false;
|
||||
}
|
||||
return membershipTypes.includes(ev.getType() as EventType);
|
||||
return groupedEvents.includes(ev.getType() as EventType);
|
||||
}
|
||||
|
||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||
|
@ -1306,6 +1335,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
events={this.events}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</MemberEventListSummary>,
|
||||
|
|
|
@ -109,8 +109,7 @@ export default class MyGroups extends React.Component {
|
|||
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
|
||||
<div className='mx_MyGroups_header'>
|
||||
<div className="mx_MyGroups_headerCard">
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
|
||||
<div className="mx_MyGroups_headerCard_content">
|
||||
<div className="mx_MyGroups_headerCard_header">
|
||||
{ _t('Create a new community') }
|
||||
|
|
|
@ -45,17 +45,23 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo";
|
|||
import UserInfo from "../views/right_panel/UserInfo";
|
||||
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
|
||||
import FilePanel from "./FilePanel";
|
||||
import ThreadView from "./ThreadView";
|
||||
import ThreadPanel from "./ThreadPanel";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
|
||||
import { E2EStatus } from '../../utils/ShieldUtils';
|
||||
|
||||
interface IProps {
|
||||
room?: Room; // if showing panels for a given room, this is set
|
||||
groupId?: string; // if showing panels for a given group, this is set
|
||||
user?: User; // used if we know the user ahead of opening the panel
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -265,7 +271,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
case RightPanelPhases.EncryptionPanel:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
|
||||
room={this.context.getRoom(this.state.member.roomId) ?? this.props.room}
|
||||
key={roomId || this.state.member.userId}
|
||||
onClose={this.onClose}
|
||||
phase={this.state.phase}
|
||||
|
@ -309,6 +315,23 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.ThreadView:
|
||||
panel = <ThreadView
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose}
|
||||
mxEvent={this.state.event}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.ThreadPanel:
|
||||
panel = <ThreadPanel
|
||||
roomId={roomId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
|
||||
break;
|
||||
|
|
|
@ -347,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
|
||||
// If room was shift-clicked, remove it from the room directory
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
|
@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
|
|
@ -266,8 +266,12 @@ export default class RoomStatusBar extends React.PureComponent {
|
|||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
|
||||
height="24" title="/!\ " alt="/!\ " />
|
||||
<img
|
||||
src={require("../../../res/img/feather-customised/warning-triangle.svg")}
|
||||
width="24"
|
||||
height="24"
|
||||
title="/!\ "
|
||||
alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
|
|
|
@ -1740,7 +1740,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
|
@ -1847,6 +1848,19 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
/>;
|
||||
}
|
||||
|
||||
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
|
||||
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
|
||||
});
|
||||
|
||||
// if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen
|
||||
// show statusBarArea only if statusBar is present
|
||||
const statusBarArea = statusBar && <div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line" />
|
||||
{ statusBar }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const roomVersionRecommendation = this.state.upgradeRecommendation;
|
||||
const showRoomUpgradeBar = (
|
||||
roomVersionRecommendation &&
|
||||
|
@ -1866,7 +1880,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
|
||||
/>;
|
||||
} else if (showRoomUpgradeBar) {
|
||||
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
|
||||
aux = <RoomUpgradeWarningBar room={this.state.room} />;
|
||||
} else if (myMembership !== "join") {
|
||||
// We do have a room object for this room, but we're not currently in it.
|
||||
// We may have a 3rd party invite to it.
|
||||
|
@ -2041,17 +2055,16 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
|
||||
numUnreadMessages={this.state.numUnreadMessages}
|
||||
onScrollToBottomClick={this.jumpToLiveTimeline}
|
||||
roomId={this.state.roomId}
|
||||
/>);
|
||||
}
|
||||
|
||||
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
|
||||
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
|
||||
});
|
||||
|
||||
const showRightPanel = this.state.room && this.state.showRightPanel;
|
||||
const rightPanel = showRightPanel
|
||||
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
||||
? <RightPanel
|
||||
room={this.state.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
e2eStatus={this.state.e2eStatus} />
|
||||
: null;
|
||||
|
||||
const timelineClasses = classNames("mx_RoomView_timeline", {
|
||||
|
@ -2094,12 +2107,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
{ messagePanel }
|
||||
{ searchResultsPanel }
|
||||
</div>
|
||||
<div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line" />
|
||||
{ statusBar }
|
||||
</div>
|
||||
</div>
|
||||
{ statusBarArea }
|
||||
{ previewBar }
|
||||
{ messageComposer }
|
||||
</div>
|
||||
|
|
|
@ -183,8 +183,14 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
private readonly itemlist = createRef<HTMLOListElement>();
|
||||
private unmounted = false;
|
||||
private scrollTimeout: Timer;
|
||||
// Are we currently trying to backfill?
|
||||
private isFilling: boolean;
|
||||
// Is the current fill request caused by a props update?
|
||||
private isFillingDueToPropsUpdate = false;
|
||||
// Did another request to check the fill state arrive while we were trying to backfill?
|
||||
private fillRequestWhileRunning: boolean;
|
||||
// Is that next fill request scheduled because of a props update?
|
||||
private pendingFillDueToPropsUpdate: boolean;
|
||||
private scrollState: IScrollState;
|
||||
private preventShrinkingState: IPreventShrinkingState;
|
||||
private unfillDebouncer: number;
|
||||
|
@ -213,7 +219,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
// adding events to the top).
|
||||
//
|
||||
// This will also re-check the fill state, in case the paginate was inadequate
|
||||
this.checkScroll();
|
||||
this.checkScroll(true);
|
||||
this.updatePreventShrinking();
|
||||
}
|
||||
|
||||
|
@ -251,12 +257,12 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
public checkScroll = () => {
|
||||
public checkScroll = (isFromPropsUpdate = false) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
this.checkFillState(0, isFromPropsUpdate);
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
|
@ -319,7 +325,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
public checkFillState = async (depth = 0): Promise<void> => {
|
||||
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
@ -355,14 +361,20 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
// don't allow more than 1 chain of calls concurrently
|
||||
// do make a note when a new request comes in while already running one,
|
||||
// so we can trigger a new chain of calls once done.
|
||||
// However, we make an exception for when we're already filling due to a
|
||||
// props (or children) update, because very often the children include
|
||||
// spinners to say whether we're paginating or not, so this would cause
|
||||
// infinite paginating.
|
||||
if (isFirstCall) {
|
||||
if (this.isFilling) {
|
||||
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
|
||||
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this.fillRequestWhileRunning = true;
|
||||
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
|
||||
return;
|
||||
}
|
||||
debuglog("isFilling: setting");
|
||||
this.isFilling = true;
|
||||
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
|
||||
}
|
||||
|
||||
const itemlist = this.itemlist.current;
|
||||
|
@ -393,11 +405,14 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
if (isFirstCall) {
|
||||
debuglog("isFilling: clearing");
|
||||
this.isFilling = false;
|
||||
this.isFillingDueToPropsUpdate = false;
|
||||
}
|
||||
|
||||
if (this.fillRequestWhileRunning) {
|
||||
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
|
||||
this.fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
this.pendingFillDueToPropsUpdate = false;
|
||||
this.checkFillState(0, refillDueToPropsUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
|
|||
key="button"
|
||||
tabIndex={-1}
|
||||
className="mx_SearchBox_closeButton"
|
||||
onClick={() => {this._clearSearch("button"); }}>
|
||||
</AccessibleButton>) : undefined;
|
||||
onClick={() => {this._clearSearch("button"); }}
|
||||
/>) : undefined;
|
||||
|
||||
// show a shorter placeholder when blurred, if requested
|
||||
// this is used for the room filter field that has
|
||||
|
|
717
src/components/structures/SpaceHierarchy.tsx
Normal file
717
src/components/structures/SpaceHierarchy.tsx
Normal file
|
@ -0,0 +1,717 @@
|
|||
/*
|
||||
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, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
useContext,
|
||||
SetStateAction,
|
||||
Dispatch,
|
||||
} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
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 AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: IHierarchyRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
onViewRoomClick(autoJoin: boolean): void;
|
||||
onToggleClick?(): void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<ITileProps> = ({
|
||||
room,
|
||||
suggested,
|
||||
selected,
|
||||
hasPermissions,
|
||||
onToggleClick,
|
||||
onViewRoomClick,
|
||||
numChildRooms,
|
||||
children,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(false);
|
||||
};
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true);
|
||||
};
|
||||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton
|
||||
onClick={onPreviewClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton
|
||||
onClick={onJoinClick}
|
||||
kind="primary"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar;
|
||||
if (joinedRoom) {
|
||||
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
||||
} else {
|
||||
avatar = <BaseAvatar
|
||||
name={name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||
width={20}
|
||||
height={20}
|
||||
/>;
|
||||
}
|
||||
|
||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||
}
|
||||
|
||||
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
||||
if (topic) {
|
||||
description += " · " + topic;
|
||||
}
|
||||
|
||||
let suggestedSection;
|
||||
if (suggested) {
|
||||
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
||||
{ _t("Suggested") }
|
||||
</InfoTooltip>;
|
||||
}
|
||||
|
||||
const content = <React.Fragment>
|
||||
{ avatar }
|
||||
<div className="mx_SpaceHierarchy_roomTile_name">
|
||||
{ name }
|
||||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx_SpaceHierarchy_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceHierarchy_actions">
|
||||
{ button }
|
||||
{ checkbox }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle: JSX.Element;
|
||||
let childSection: JSX.Element;
|
||||
let onKeyDown: KeyboardEventHandler;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
|
||||
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
|
||||
})}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
|
||||
if (showChildren) {
|
||||
const onChildrenKeyDown = (e) => {
|
||||
if (e.key === Key.ARROW_LEFT) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
childSection = <div
|
||||
className="mx_SpaceHierarchy_subspace_children"
|
||||
onKeyDown={onChildrenKeyDown}
|
||||
role="group"
|
||||
>
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
let handled = false;
|
||||
|
||||
switch (e.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
if (showChildren) {
|
||||
handled = true;
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return <li
|
||||
className="mx_SpaceHierarchy_roomTileWrapper"
|
||||
role="treeitem"
|
||||
aria-expanded={children ? showChildren : undefined}
|
||||
>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceHierarchy_roomTile", {
|
||||
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
onKeyDown={onKeyDown}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</li>;
|
||||
};
|
||||
|
||||
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
|
||||
const room = hierarchy.roomMap.get(roomId);
|
||||
|
||||
// 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()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
auto_join: autoJoin,
|
||||
should_peek: true,
|
||||
_type: "room_directory", // instrumentation
|
||||
room_alias: roomAlias,
|
||||
room_id: room.room_id,
|
||||
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
|
||||
oob_data: {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t("Unnamed room"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
root: IHierarchyRoom;
|
||||
roomSet: Set<IHierarchyRoom>;
|
||||
hierarchy: RoomHierarchy;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
||||
onToggleClick?(parentId: string, childId: string): void;
|
||||
}
|
||||
|
||||
export const HierarchyLevel = ({
|
||||
root,
|
||||
roomSet,
|
||||
hierarchy,
|
||||
parents,
|
||||
selectedMap,
|
||||
onViewRoomClick,
|
||||
onToggleClick,
|
||||
}: IHierarchyLevelProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const space = cli.getRoom(root.room_id);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
const sortedChildren = sortBy(root.children_state, ev => {
|
||||
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
|
||||
});
|
||||
|
||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
|
||||
const room = hierarchy.roomMap.get(ev.state_key);
|
||||
if (room && roomSet.has(room)) {
|
||||
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
|
||||
}
|
||||
return result;
|
||||
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
|
||||
|
||||
const newParents = new Set(parents).add(root.room_id);
|
||||
return <React.Fragment>
|
||||
{
|
||||
childRooms.map(room => (
|
||||
<Tile
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
|
||||
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(room.room_id, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
|
||||
<Tile
|
||||
key={space.room_id}
|
||||
room={space}
|
||||
numChildRooms={space.children_state.filter(ev => {
|
||||
const room = hierarchy.roomMap.get(ev.state_key);
|
||||
return room && roomSet.has(room) && !room.room_type;
|
||||
}).length}
|
||||
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
|
||||
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(space.room_id, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
|
||||
>
|
||||
<HierarchyLevel
|
||||
root={space}
|
||||
roomSet={roomSet}
|
||||
hierarchy={hierarchy}
|
||||
parents={newParents}
|
||||
selectedMap={selectedMap}
|
||||
onViewRoomClick={onViewRoomClick}
|
||||
onToggleClick={onToggleClick}
|
||||
/>
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const INITIAL_PAGE_SIZE = 20;
|
||||
|
||||
export const useSpaceSummary = (space: Room): {
|
||||
loading: boolean;
|
||||
rooms: IHierarchyRoom[];
|
||||
hierarchy: RoomHierarchy;
|
||||
loadMore(pageSize?: number): Promise <void>;
|
||||
} => {
|
||||
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||
|
||||
const resetHierarchy = useCallback(() => {
|
||||
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
|
||||
setHierarchy(hierarchy);
|
||||
|
||||
let discard = false;
|
||||
hierarchy.load().then(() => {
|
||||
if (discard) return;
|
||||
setRooms(hierarchy.rooms);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
discard = true;
|
||||
};
|
||||
}, [space]);
|
||||
useEffect(resetHierarchy, [resetHierarchy]);
|
||||
|
||||
useDispatcher(defaultDispatcher, (payload => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setLoading(true);
|
||||
setRooms([]); // TODO
|
||||
resetHierarchy();
|
||||
}
|
||||
}));
|
||||
|
||||
const loadMore = useCallback(async (pageSize?: number) => {
|
||||
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
|
||||
|
||||
setLoading(true);
|
||||
await hierarchy.load(pageSize);
|
||||
setRooms(hierarchy.rooms);
|
||||
setLoading(false);
|
||||
}, [hierarchy]);
|
||||
|
||||
return { loading, rooms, hierarchy, loadMore };
|
||||
};
|
||||
|
||||
const useIntersectionObserver = (callback: () => void) => {
|
||||
const handleObserver = (entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const observerRef = useRef<IntersectionObserver>();
|
||||
return (element: HTMLDivElement) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
} else if (element) {
|
||||
observerRef.current = new IntersectionObserver(handleObserver, {
|
||||
root: element.parentElement,
|
||||
rootMargin: "0px 0px 600px 0px",
|
||||
});
|
||||
}
|
||||
|
||||
if (observerRef.current && element) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface IManageButtonsProps {
|
||||
hierarchy: RoomHierarchy;
|
||||
selected: Map<string, Set<string>>;
|
||||
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [
|
||||
...selected.get(parentId).values(),
|
||||
].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return hierarchy.isSuggested(parentId, childId);
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
|
||||
hierarchy.removeRelation(parentId, childId);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
// mutate the local state to save us having to refetch the world
|
||||
existingContent.suggested = content.suggested;
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
};
|
||||
|
||||
const SpaceHierarchy = ({
|
||||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
additionalButtons,
|
||||
}: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [query, setQuery] = useState(initialText);
|
||||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
|
||||
|
||||
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
|
||||
if (!rooms.length) return new Set();
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
if (!lcQuery) return new Set(rooms);
|
||||
|
||||
const directMatches = rooms.filter(r => {
|
||||
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
||||
});
|
||||
|
||||
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
||||
const visited = new Set<string>();
|
||||
const queue = [...directMatches.map(r => r.room_id)];
|
||||
while (queue.length) {
|
||||
const roomId = queue.pop();
|
||||
visited.add(roomId);
|
||||
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
|
||||
if (!visited.has(parentId)) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Set(rooms.filter(r => visited.has(r.room_id)));
|
||||
}, [rooms, hierarchy, query]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loaderRef = useIntersectionObserver(loadMore);
|
||||
|
||||
if (!loading && hierarchy.noSupport) {
|
||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||
}
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleClick = (parentId: string, childId: string): void => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
};
|
||||
|
||||
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||
{ ({ onKeyDownHandler }) => {
|
||||
let content: JSX.Element;
|
||||
let loader: JSX.Element;
|
||||
|
||||
if (loading && !rooms.length) {
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
const hasPermissions = space?.getMyMembership() === "join" &&
|
||||
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
let results: JSX.Element;
|
||||
if (filteredRoomSet.size) {
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
root={hierarchy.roomMap.get(space.roomId)}
|
||||
roomSet={filteredRoomSet}
|
||||
hierarchy={hierarchy}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? onToggleClick : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(cli, hierarchy, roomId, autoJoin);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
|
||||
if (hierarchy.canLoadMore) {
|
||||
loader = <div ref={loaderRef}>
|
||||
<Spinner />
|
||||
</div>;
|
||||
}
|
||||
} else {
|
||||
results = <div className="mx_SpaceHierarchy_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceHierarchy_listHeader">
|
||||
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ hasPermissions && (
|
||||
<ManageButtons
|
||||
hierarchy={hierarchy}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
setError={setError}
|
||||
/>
|
||||
) }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceHierarchy_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<ul
|
||||
className="mx_SpaceHierarchy_list"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
role="tree"
|
||||
aria-label={_t("Space")}
|
||||
>
|
||||
{ results }
|
||||
</ul>
|
||||
{ loader }
|
||||
</>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
} }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
export default SpaceHierarchy;
|
|
@ -1,642 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import { EnhancedMap } from "../../utils/maps";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
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 AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: ISpaceSummaryRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
onViewRoomClick(autoJoin: boolean): void;
|
||||
onToggleClick?(): void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<ITileProps> = ({
|
||||
room,
|
||||
suggested,
|
||||
selected,
|
||||
hasPermissions,
|
||||
onToggleClick,
|
||||
onViewRoomClick,
|
||||
numChildRooms,
|
||||
children,
|
||||
}) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(false);
|
||||
};
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true);
|
||||
};
|
||||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar;
|
||||
if (joinedRoom) {
|
||||
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
||||
} else {
|
||||
avatar = <BaseAvatar
|
||||
name={name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||
width={20}
|
||||
height={20}
|
||||
/>;
|
||||
}
|
||||
|
||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||
}
|
||||
|
||||
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
||||
if (topic) {
|
||||
description += " · " + topic;
|
||||
}
|
||||
|
||||
let suggestedSection;
|
||||
if (suggested) {
|
||||
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
||||
{ _t("Suggested") }
|
||||
</InfoTooltip>;
|
||||
}
|
||||
|
||||
const content = <React.Fragment>
|
||||
{ avatar }
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_name">
|
||||
{ name }
|
||||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx_SpaceRoomDirectory_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
{ button }
|
||||
{ checkbox }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle;
|
||||
let childSection;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
|
||||
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
|
||||
})}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
if (showChildren) {
|
||||
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</>;
|
||||
};
|
||||
|
||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
// 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 (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
auto_join: autoJoin,
|
||||
should_peek: true,
|
||||
_type: "room_directory", // instrumentation
|
||||
room_alias: roomAlias,
|
||||
room_id: room.room_id,
|
||||
via_servers: viaServers,
|
||||
oob_data: {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t("Unnamed room"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
spaceId: string;
|
||||
rooms: Map<string, ISpaceSummaryRoom>;
|
||||
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
||||
onToggleClick?(parentId: string, childId: string): void;
|
||||
}
|
||||
|
||||
export const HierarchyLevel = ({
|
||||
spaceId,
|
||||
rooms,
|
||||
relations,
|
||||
parents,
|
||||
selectedMap,
|
||||
onViewRoomClick,
|
||||
onToggleClick,
|
||||
}: IHierarchyLevelProps) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const space = cli.getRoom(spaceId);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
const children = Array.from(relations.get(spaceId)?.values() || []);
|
||||
const sortedChildren = sortBy(children, ev => {
|
||||
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
|
||||
return getChildOrder(ev.content.order, null, ev.state_key);
|
||||
});
|
||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
|
||||
const roomId = ev.state_key;
|
||||
if (!rooms.has(roomId)) return result;
|
||||
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
|
||||
return result;
|
||||
}, [[], []]) || [[], []];
|
||||
|
||||
const newParents = new Set(parents).add(spaceId);
|
||||
return <React.Fragment>
|
||||
{
|
||||
childRooms.map(roomId => (
|
||||
<Tile
|
||||
key={roomId}
|
||||
room={rooms.get(roomId)}
|
||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(roomId, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
|
||||
<Tile
|
||||
key={roomId}
|
||||
room={rooms.get(roomId)}
|
||||
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
|
||||
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
|
||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(roomId, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
|
||||
>
|
||||
<HierarchyLevel
|
||||
spaceId={roomId}
|
||||
rooms={rooms}
|
||||
relations={relations}
|
||||
parents={newParents}
|
||||
selectedMap={selectedMap}
|
||||
onViewRoomClick={onViewRoomClick}
|
||||
onToggleClick={onToggleClick}
|
||||
/>
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
||||
const viaMap = new EnhancedMap<string, Set<string>>();
|
||||
data.events.map((ev: ISpaceSummaryEvent) => {
|
||||
if (ev.type === EventType.SpaceChild) {
|
||||
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
|
||||
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
|
||||
}
|
||||
if (Array.isArray(ev.content.via)) {
|
||||
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
||||
ev.content.via.forEach(via => set.add(via));
|
||||
}
|
||||
});
|
||||
|
||||
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
return [e];
|
||||
}
|
||||
}, [space, refreshToken], [undefined]);
|
||||
};
|
||||
|
||||
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
||||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
const [query, setQuery] = useState(initialText);
|
||||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
|
||||
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
|
||||
if (!lcQuery) return roomsMap;
|
||||
|
||||
const directMatches = rooms.filter(r => {
|
||||
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
||||
});
|
||||
|
||||
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
||||
const visited = new Set<string>();
|
||||
const queue = [...directMatches.map(r => r.room_id)];
|
||||
while (queue.length) {
|
||||
const roomId = queue.pop();
|
||||
visited.add(roomId);
|
||||
childParentMap.get(roomId)?.forEach(parentId => {
|
||||
if (!visited.has(parentId)) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove any mappings for rooms which were not visited in the walk
|
||||
Array.from(roomsMap.keys()).forEach(roomId => {
|
||||
if (!visited.has(roomId)) {
|
||||
roomsMap.delete(roomId);
|
||||
}
|
||||
});
|
||||
return roomsMap;
|
||||
}, [rooms, childParentMap, query]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
|
||||
const onCreateRoomClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={space} height={32} width={32} />
|
||||
<div>
|
||||
<h1>{ _t("Explore rooms") }</h1>
|
||||
<div><RoomName room={space} /></div>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
|
||||
<div className="mx_Dialog_content">
|
||||
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||
null,
|
||||
{ a: sub => {
|
||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{ sub }</AccessibleButton>;
|
||||
} },
|
||||
) }
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
showRoom(room, viaServers, autoJoin);
|
||||
onFinished();
|
||||
}}
|
||||
initialText={initialText}
|
||||
>
|
||||
<AccessibleButton
|
||||
onClick={onCreateRoomClick}
|
||||
kind="primary"
|
||||
className="mx_SpaceRoomDirectory_createRoom"
|
||||
>
|
||||
{ _t("Create room") }
|
||||
</AccessibleButton>
|
||||
</SpaceHierarchy>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceRoomDirectory;
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import React, { RefObject, useContext, useRef, useState } from "react";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
|
||||
|
@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
|||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
|
||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceSettings,
|
||||
} from "../../utils/space";
|
||||
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
defaultSpacesRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
|
@ -62,11 +72,12 @@ import IconizedContextMenu, {
|
|||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
|
||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -78,7 +89,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
createdRooms?: boolean; // internal state for the creation wizard
|
||||
firstRoomId?: string; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
|
@ -93,26 +104,6 @@ enum Phase {
|
|||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
@ -171,7 +162,33 @@ const onBetaClick = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
// XXX: temporary community migration component
|
||||
const GroupTile = ({ groupId }: { groupId: string }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||
|
||||
if (!groupSummary) return <Spinner />;
|
||||
|
||||
return <>
|
||||
<GroupAvatar
|
||||
groupId={groupId}
|
||||
groupName={groupSummary.profile.name}
|
||||
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||
width={16}
|
||||
height={16}
|
||||
resizeMethod='crop'
|
||||
/>
|
||||
{ groupSummary.profile.name }
|
||||
</>;
|
||||
};
|
||||
|
||||
interface ISpacePreviewProps {
|
||||
space: Room;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
|
@ -205,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
|
||||
if (inviteSender) {
|
||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||
<MemberAvatar member={inviter} width={32} height={32} />
|
||||
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||
<div>
|
||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||
{ _t("<inviter/> invites you", {}, {
|
||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
||||
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
|
||||
}) }
|
||||
</div>
|
||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||
|
@ -283,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
}
|
||||
|
||||
let migratedCommunitySection: JSX.Element;
|
||||
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
|
||||
if (createContent[CreateEventField]) {
|
||||
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
|
||||
{ _t("Created from <Community />", {}, {
|
||||
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ migratedCommunitySection }
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
|
@ -306,8 +333,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const SpaceLandingAddButton = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
|
@ -330,25 +356,33 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
|||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(cli, space)) {
|
||||
onNewRoomAdded();
|
||||
if (await showCreateNewRoom(space)) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add space")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
@ -389,19 +423,17 @@ const SpaceLanding = ({ space }) => {
|
|||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
settingsButton = <AccessibleTooltipButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
showSpaceSettings(space);
|
||||
}}
|
||||
title={_t("Settings")}
|
||||
/>;
|
||||
|
@ -416,6 +448,7 @@ const SpaceLanding = ({ space }) => {
|
|||
};
|
||||
|
||||
return <div className="mx_SpaceRoomView_landing">
|
||||
<SpaceFeedbackPrompt />
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
|
@ -440,15 +473,8 @@ const SpaceLanding = ({ space }) => {
|
|||
</div>
|
||||
) }
|
||||
</RoomTopic>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -470,6 +496,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
/>;
|
||||
});
|
||||
|
||||
|
@ -479,11 +506,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const isPublic = space.getJoinRule() === JoinRule.Public;
|
||||
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
|
||||
await Promise.all(filteredRoomNames.map(name => {
|
||||
const roomIds = await Promise.all(filteredRoomNames.map(name => {
|
||||
return createRoom({
|
||||
createOpts: {
|
||||
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
|
||||
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
},
|
||||
spinner: false,
|
||||
|
@ -491,9 +519,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
andView: false,
|
||||
inlineErrors: true,
|
||||
parentSpace: space,
|
||||
joinRule: !isPublic ? JoinRule.Restricted : undefined,
|
||||
suggested: true,
|
||||
});
|
||||
}));
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
onFinished(roomIds[0]);
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
|
@ -503,7 +533,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished(false);
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
|
@ -531,7 +561,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -550,17 +579,20 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("Search for rooms or spaces")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
|
||||
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => {
|
||||
return <div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>{ _t("Share %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
|
@ -573,10 +605,9 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
|
|||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -605,9 +636,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
|||
</AccessibleButton>
|
||||
<div className="mx_SpaceRoomView_betaWarning">
|
||||
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
|
||||
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
|
||||
<p>{ _t("We're working on this, but just want to let you know.") }</p>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -730,7 +760,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -784,6 +813,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "view_room" && payload.room_id === this.props.space.roomId) {
|
||||
this.setState({ phase: Phase.Landing });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
|
||||
|
||||
if (payload.action === Action.ViewUser && payload.member) {
|
||||
|
@ -814,35 +848,10 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
private goToFirstRoom = async () => {
|
||||
// TODO actually go to the first room
|
||||
|
||||
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
|
||||
if (childRooms.length) {
|
||||
const room = childRooms[0];
|
||||
if (this.state.firstRoomId) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let suggestedRooms = SpaceStore.instance.suggestedRooms;
|
||||
if (SpaceStore.instance.activeSpace !== this.props.space) {
|
||||
// the space store has the suggested rooms loaded for a different space, fetch the right ones
|
||||
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
|
||||
}
|
||||
|
||||
if (suggestedRooms.length) {
|
||||
const room = suggestedRooms[0];
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.room_id,
|
||||
room_alias: room.canonical_alias || room.aliases?.[0],
|
||||
via_servers: room.viaServers,
|
||||
oobData: {
|
||||
avatarUrl: room.avatar_url,
|
||||
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
|
||||
},
|
||||
room_id: this.state.firstRoomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -872,14 +881,14 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
_t("Let's create a room for each of them.") + "\n" +
|
||||
_t("You can add more later too, including already existing ones.")
|
||||
}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
|
||||
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
space={this.props.space}
|
||||
onFinished={this.goToFirstRoom}
|
||||
createdRooms={this.state.createdRooms}
|
||||
firstRoomId={this.state.firstRoomId}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
|
@ -901,7 +910,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. " +
|
||||
"You can add more later too, including already existing ones.")}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
|
||||
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })}
|
||||
/>;
|
||||
case Phase.PrivateExistingRooms:
|
||||
return <SpaceAddExistingRooms
|
||||
|
|
93
src/components/structures/ThreadPanel.tsx
Normal file
93
src/components/structures/ThreadPanel.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
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, Room } from 'matrix-js-sdk/src';
|
||||
import { Thread } from 'matrix-js-sdk/src/models/thread';
|
||||
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import EventTile from '../views/rooms/EventTile';
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
threads?: Thread[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ThreadView")
|
||||
export default class ThreadPanel extends React.Component<IProps, IState> {
|
||||
private room: Room;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.room.on("Thread.update", this.onThreadEventReceived);
|
||||
this.room.on("Thread.ready", this.onThreadEventReceived);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.room.removeListener("Thread.update", this.onThreadEventReceived);
|
||||
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
|
||||
}
|
||||
|
||||
private onThreadEventReceived = () => this.updateThreads();
|
||||
|
||||
private updateThreads = (callback?: () => void): void => {
|
||||
this.setState({
|
||||
threads: this.room.getThreads(),
|
||||
}, callback);
|
||||
};
|
||||
|
||||
private renderEventTile(event: MatrixEvent): JSX.Element {
|
||||
return <EventTile
|
||||
key={event.getId()}
|
||||
mxEvent={event}
|
||||
enableFlair={false}
|
||||
showReadReceipts={false}
|
||||
as="div"
|
||||
/>;
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_ThreadPanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
{
|
||||
this.state?.threads.map((thread: Thread) => {
|
||||
if (thread.ready) {
|
||||
return this.renderEventTile(thread.rootEvent);
|
||||
}
|
||||
})
|
||||
}
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
}
|
160
src/components/structures/ThreadView.tsx
Normal file
160
src/components/structures/ThreadView.tsx
Normal file
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
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, Room } from 'matrix-js-sdk/src';
|
||||
import { Thread } from 'matrix-js-sdk/src/models/thread';
|
||||
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
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 TimelinePanel from './TimelinePanel';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from '../../dispatcher/payloads';
|
||||
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { Action } from '../../dispatcher/actions';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import { E2EStatus } from '../../utils/ShieldUtils';
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
replyToEvent?: MatrixEvent;
|
||||
thread?: Thread;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ThreadView")
|
||||
export default class ThreadView extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.setupThread(this.props.mxEvent);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.teardownThread();
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps) {
|
||||
if (prevProps.mxEvent !== this.props.mxEvent) {
|
||||
this.teardownThread();
|
||||
this.setupThread(this.props.mxEvent);
|
||||
}
|
||||
|
||||
if (prevProps.room !== this.props.room) {
|
||||
dis.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomSummary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
|
||||
if (payload.event !== this.props.mxEvent) {
|
||||
this.teardownThread();
|
||||
this.setupThread(payload.event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private setupThread = (mxEv: MatrixEvent) => {
|
||||
let thread = mxEv.getThread();
|
||||
if (!thread) {
|
||||
const client = MatrixClientPeg.get();
|
||||
thread = new Thread([mxEv], this.props.room, client);
|
||||
mxEv.setThread(thread);
|
||||
}
|
||||
thread.on("Thread.update", this.updateThread);
|
||||
thread.once("Thread.ready", this.updateThread);
|
||||
this.updateThread(thread);
|
||||
};
|
||||
|
||||
private teardownThread = () => {
|
||||
if (this.state.thread) {
|
||||
this.state.thread.removeListener("Thread.update", this.updateThread);
|
||||
this.state.thread.removeListener("Thread.ready", this.updateThread);
|
||||
}
|
||||
};
|
||||
|
||||
private updateThread = (thread?: Thread) => {
|
||||
if (thread) {
|
||||
this.setState({
|
||||
thread,
|
||||
replyToEvent: thread.replyToEvent,
|
||||
});
|
||||
}
|
||||
|
||||
this.timelinePanelRef.current?.refreshTimeline();
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_ThreadView"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer={true}
|
||||
>
|
||||
{ this.state.thread && (
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanelRef}
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state?.thread?.timelineSet}
|
||||
showUrlPreview={false}
|
||||
tileShape={TileShape.Notif}
|
||||
empty={<div>empty</div>}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
/>
|
||||
) }
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyInThread={true}
|
||||
replyToEvent={this.state?.thread?.replyToEvent}
|
||||
showReplyPreview={false}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -47,11 +47,14 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
|||
import Spinner from "../views/elements/Spinner";
|
||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
import ErrorDialog from '../views/dialogs/ErrorDialog';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
const READ_RECEIPT_INTERVAL_MS = 500;
|
||||
|
||||
const READ_MARKER_DEBOUNCE_MS = 100;
|
||||
|
||||
const DEBUG = false;
|
||||
|
||||
let debuglog = function(...s: any[]) {};
|
||||
|
@ -126,6 +129,8 @@ interface IProps {
|
|||
|
||||
// callback which is called when we wish to paginate the timeline window.
|
||||
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
|
||||
|
||||
hideThreadedMessages?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -214,6 +219,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
sendReadReceiptOnLoad: true,
|
||||
hideThreadedMessages: true,
|
||||
};
|
||||
|
||||
private lastRRSentEventId: string = undefined;
|
||||
|
@ -472,22 +478,35 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (this.props.manageReadMarkers) {
|
||||
const rmPosition = this.getReadMarkerPosition();
|
||||
// we hide the read marker when it first comes onto the screen, but if
|
||||
// it goes back off the top of the screen (presumably because the user
|
||||
// clicks on the 'jump to bottom' button), we need to re-enable it.
|
||||
if (rmPosition < 0) {
|
||||
this.setState({ readMarkerVisible: true });
|
||||
}
|
||||
|
||||
// if read marker position goes between 0 and -1/1,
|
||||
// (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.doManageReadMarkers();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Debounced function to manage read markers because we don't need to
|
||||
* do this on every tiny scroll update. It also sets state which causes
|
||||
* a component update, which can in turn reset the scroll position, so
|
||||
* it's important we allow the browser to scroll a bit before running this
|
||||
* (hence trailing edge only and debounce rather than throttle because
|
||||
* we really only need to update this once the user has finished scrolling,
|
||||
* not periodically while they scroll).
|
||||
*/
|
||||
private doManageReadMarkers = debounce(() => {
|
||||
const rmPosition = this.getReadMarkerPosition();
|
||||
// we hide the read marker when it first comes onto the screen, but if
|
||||
// it goes back off the top of the screen (presumably because the user
|
||||
// clicks on the 'jump to bottom' button), we need to re-enable it.
|
||||
if (rmPosition < 0) {
|
||||
this.setState({ readMarkerVisible: true });
|
||||
}
|
||||
|
||||
// if read marker position goes between 0 and -1/1,
|
||||
// (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);
|
||||
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case "ignore_state_changed":
|
||||
|
@ -757,16 +776,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
|
||||
|
||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||
this.props.timelineSet.room.roomId,
|
||||
'rm', this.state.readMarkerEventId,
|
||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||
' hidden:' + hiddenRR,
|
||||
);
|
||||
MatrixClientPeg.get().setRoomReadMarkers(
|
||||
this.props.timelineSet.room.roomId,
|
||||
roomId,
|
||||
this.state.readMarkerEventId,
|
||||
lastReadEvent, // Could be null, in which case no RR is sent
|
||||
{},
|
||||
{ hidden: hiddenRR },
|
||||
).catch((e) => {
|
||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||
|
@ -1172,6 +1195,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
this.setState(this.getEvents());
|
||||
}
|
||||
|
||||
// Force refresh the timeline before threads support pending events
|
||||
public refreshTimeline(): void {
|
||||
this.loadTimeline();
|
||||
this.reloadEvents();
|
||||
}
|
||||
|
||||
// get the list of events from the timeline window and the pending event list
|
||||
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
|
||||
const events: MatrixEvent[] = this.timelineWindow.getEvents();
|
||||
|
@ -1507,6 +1536,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
showReactions={this.props.showReactions}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
hideThreadedMessages={this.props.hideThreadedMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
let containerClasses;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const { title, icon, key, component, className, props } = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
const { title, icon, key, component, className, bodyClassName, props } = topToast;
|
||||
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
|
||||
const toastClasses = classNames("mx_Toast_toast", className, {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
});
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
toast = (<div className={toastClasses}>
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{ title }</h2>
|
||||
<span>{ countIndicator }</span>
|
||||
const content = React.createElement(component, toastProps);
|
||||
|
||||
let countIndicator;
|
||||
if (title && isStacked || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
let titleElement;
|
||||
if (title) {
|
||||
titleElement = (
|
||||
<div className="mx_Toast_title">
|
||||
<h2>{ title }</h2>
|
||||
<span>{ countIndicator }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={toastClasses}>
|
||||
{ titleElement }
|
||||
<div className={bodyClasses}>{ content }</div>
|
||||
</div>
|
||||
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
|
||||
</div>);
|
||||
);
|
||||
|
||||
containerClasses = classNames("mx_ToastContainer", {
|
||||
"mx_ToastContainer_stacked": isStacked,
|
||||
|
|
|
@ -31,6 +31,7 @@ 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';
|
||||
|
||||
enum Phase {
|
||||
// Show the forgot password inputs
|
||||
|
@ -66,13 +67,14 @@ interface IState {
|
|||
serverDeadError: string;
|
||||
|
||||
passwordFieldValid: boolean;
|
||||
currentHttpRequest?: Promise<any>;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.ForgotPassword")
|
||||
export default class ForgotPassword extends React.Component<IProps, IState> {
|
||||
private reset: PasswordReset;
|
||||
|
||||
state = {
|
||||
state: IState = {
|
||||
phase: Phase.Forgot,
|
||||
email: "",
|
||||
password: "",
|
||||
|
@ -148,8 +150,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
console.error("onVerify called before submitPasswordReset!");
|
||||
return;
|
||||
}
|
||||
if (this.state.currentHttpRequest) return;
|
||||
|
||||
try {
|
||||
await this.reset.checkEmailLinkClicked();
|
||||
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
|
||||
this.setState({ phase: Phase.Done });
|
||||
} catch (err) {
|
||||
this.showErrorDialog(err.message);
|
||||
|
@ -158,9 +162,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
|
||||
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (this.state.currentHttpRequest) return;
|
||||
|
||||
// refresh the server errors, just in case the server came back online
|
||||
await this.checkServerLiveliness(this.props.serverConfig);
|
||||
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
|
||||
|
||||
await this['password_field'].validate({ allowEmpty: false });
|
||||
|
||||
|
@ -221,6 +226,17 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
|
||||
this.setState({
|
||||
currentHttpRequest: request,
|
||||
});
|
||||
return request.finally(() => {
|
||||
this.setState({
|
||||
currentHttpRequest: undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderForgot() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
|
@ -315,8 +331,14 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
|
||||
"link it contains, click below.", { emailAddress: this.state.email }) }
|
||||
<br />
|
||||
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="button"
|
||||
onClick={this.onVerify}
|
||||
value={_t('I have verified my email address')} />
|
||||
{ this.state.currentHttpRequest && (
|
||||
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -328,7 +350,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
"push notifications. To re-enable notifications, sign in again on each " +
|
||||
"device.",
|
||||
) }</p>
|
||||
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="button"
|
||||
onClick={this.props.onComplete}
|
||||
value={_t('Return to login screen')} />
|
||||
</div>;
|
||||
}
|
||||
|
@ -351,6 +376,8 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||
case Phase.Done:
|
||||
resetPasswordJsx = this.renderDone();
|
||||
break;
|
||||
default:
|
||||
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -463,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
|||
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
return <a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
|
|
|
@ -557,12 +557,16 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
) }</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}>
|
||||
<p><AccessibleButton
|
||||
element="span"
|
||||
className="mx_linkButton"
|
||||
onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ _t("Continue with previous account") }
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
|
|
|
@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { createRef, ReactNode, RefObject } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
|
@ -25,47 +23,13 @@ import { Key } from "../../../Keyboard";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
error?: boolean;
|
||||
}
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
export default class AudioPlayer extends AudioPlayerBase {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.props.playback.prepare().catch(e => {
|
||||
console.error("Error processing audio file:", e);
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
|
@ -91,10 +55,10 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
|||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
protected renderComponent(): ReactNode {
|
||||
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
|
||||
// events for accessibility
|
||||
return <>
|
||||
return (
|
||||
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<div className='mx_AudioPlayer_primaryContainer'>
|
||||
<PlayPauseButton
|
||||
|
@ -124,7 +88,6 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
|||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>
|
||||
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||
</>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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 { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName?: string;
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayerBase")
|
||||
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.props.playback.prepare().catch(e => {
|
||||
console.error("Error processing audio file:", e);
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
protected abstract renderComponent(): ReactNode;
|
||||
|
||||
public render(): ReactNode {
|
||||
return <>
|
||||
{ this.renderComponent() }
|
||||
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||
</>;
|
||||
}
|
||||
}
|
|
@ -15,34 +15,30 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { formatSeconds } from "../../../DateUtils";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export interface IProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simply converts seconds into minutes and seconds. Note that hours will not be
|
||||
* displayed, making it possible to see "82:29".
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.Clock")
|
||||
export default class Clock extends React.Component<IProps, IState> {
|
||||
export default class Clock extends React.Component<IProps> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
|
||||
const currentFloor = Math.floor(this.props.seconds);
|
||||
const nextFloor = Math.floor(nextProps.seconds);
|
||||
return currentFloor !== nextFloor;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
|
||||
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
|
||||
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import { Playback } from "../../../audio/Playback";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
|
|
@ -15,10 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
|
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
|||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
waveform: [],
|
||||
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
|
||||
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import classNames from "classnames";
|
||||
|
||||
// omitted props are handled by render function
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -18,7 +18,7 @@ import React from "react";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -14,68 +14,30 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
error?: boolean;
|
||||
}
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
playbackPhase: PlaybackState.Decoding, // default assumption
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.props.playback.prepare().catch(e => {
|
||||
console.error("Error processing audio file:", e);
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
export default class RecordingPlayback extends AudioPlayerBase {
|
||||
private get isWaveformable(): boolean {
|
||||
return this.props.tileShape !== TileShape.Notif
|
||||
&& this.props.tileShape !== TileShape.FileGrid
|
||||
&& this.props.tileShape !== TileShape.Pinned;
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
protected renderComponent(): ReactNode {
|
||||
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
|
||||
return <>
|
||||
return (
|
||||
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
|
||||
</div>
|
||||
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
|
||||
</>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
|
|
@ -54,9 +54,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
|
|||
'mx_Waveform_bar': true,
|
||||
'mx_Waveform_bar_100pct': isCompleteBar,
|
||||
});
|
||||
return <span key={i} style={{
|
||||
"--barHeight": h,
|
||||
} as WaveformCSSProperties} className={classes} />;
|
||||
return <span
|
||||
key={i}
|
||||
style={{
|
||||
"--barHeight": h,
|
||||
} as WaveformCSSProperties}
|
||||
className={classes}
|
||||
/>;
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
|
|||
}
|
||||
|
||||
private resetRecaptcha() {
|
||||
if (this.captchaWidgetId !== null) {
|
||||
global.grecaptcha.reset(this.captchaWidgetId);
|
||||
if (this.captchaWidgetId) {
|
||||
global?.grecaptcha?.reset(this.captchaWidgetId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -74,33 +75,6 @@ import CaptchaForm from "./CaptchaForm";
|
|||
* focus: set the input focus appropriately in the form.
|
||||
*/
|
||||
|
||||
enum AuthType {
|
||||
Password = "m.login.password",
|
||||
Recaptcha = "m.login.recaptcha",
|
||||
Terms = "m.login.terms",
|
||||
Email = "m.login.email.identity",
|
||||
Msisdn = "m.login.msisdn",
|
||||
Sso = "m.login.sso",
|
||||
SsoUnstable = "org.matrix.login.sso",
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IAuthDict {
|
||||
type?: AuthType;
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user?: string;
|
||||
identifier?: any;
|
||||
password?: string;
|
||||
response?: string;
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds?: any;
|
||||
threepidCreds?: any;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export const DEFAULT_PHASE = 0;
|
||||
|
||||
interface IAuthEntryProps {
|
||||
|
@ -416,8 +390,10 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
|
|||
let submitButton;
|
||||
if (this.props.showContinue !== false) {
|
||||
// XXX: button classes
|
||||
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
|
||||
onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }</button>;
|
||||
submitButton = <button
|
||||
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
|
||||
onClick={this.trySubmit}
|
||||
disabled={!allChecked}>{ _t("Accept") }</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -616,7 +592,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
|
|||
aria-label={_t("Code")}
|
||||
/>
|
||||
<br />
|
||||
<input type="submit" value={_t("Submit")}
|
||||
<input
|
||||
type="submit"
|
||||
value={_t("Submit")}
|
||||
className={submitClasses}
|
||||
disabled={!enableSubmit}
|
||||
/>
|
||||
|
@ -831,7 +809,26 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
|
|||
}
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
|
||||
export interface IStageComponentProps extends IAuthEntryProps {
|
||||
clientSecret?: string;
|
||||
stageParams?: Record<string, any>;
|
||||
inputs?: IInputs;
|
||||
stageState?: IStageStatus;
|
||||
showContinue?: boolean;
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
fail?(e: Error): void;
|
||||
setEmailSid?(sid: string): void;
|
||||
onCancel?(): void;
|
||||
}
|
||||
|
||||
export interface IStageComponent extends React.ComponentClass<React.PropsWithRef<IStageComponentProps>> {
|
||||
tryContinue?(): void;
|
||||
attemptFailed?(): void;
|
||||
focus?(): void;
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
|
||||
switch (loginType) {
|
||||
case AuthType.Password:
|
||||
return PasswordAuthEntry;
|
||||
|
|
|
@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
|
|||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt={_t("Avatar")}
|
||||
title={title}
|
||||
alt={_t("Avatar")}
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
|
@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
|
|||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
title={title}
|
||||
alt=""
|
||||
ref={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
|
|
|
@ -36,6 +36,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
|
|||
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
|
||||
viewUserOnClick?: boolean;
|
||||
title?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -102,8 +103,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
|
||||
idName={userId} url={this.state.imageUrl} onClick={onClick} />
|
||||
<BaseAvatar {...otherProps}
|
||||
name={this.state.name}
|
||||
title={this.state.title}
|
||||
idName={userId}
|
||||
url={this.state.imageUrl}
|
||||
onClick={onClick} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,11 @@ 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, { ComponentProps } from 'react';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
|
||||
import classNames from "classnames";
|
||||
|
||||
import BaseAvatar from './BaseAvatar';
|
||||
import ImageView from '../elements/ImageView';
|
||||
|
@ -32,11 +34,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
|
|||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
room?: Room;
|
||||
oobData?: IOOBData;
|
||||
oobData?: IOOBData & {
|
||||
roomId?: string;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
viewAvatarOnClick?: boolean;
|
||||
className?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
|
@ -129,15 +134,19 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
|
||||
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null;
|
||||
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps}
|
||||
<BaseAvatar
|
||||
{...otherProps}
|
||||
className={classNames(className, {
|
||||
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
|
||||
})}
|
||||
name={roomName}
|
||||
idName={idName}
|
||||
urls={this.state.urls}
|
||||
|
|
|
@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
|
||||
// XXX: Keep this around for re-use in future Betas
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
featureId: string;
|
||||
|
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import Field from "../elements/Field";
|
||||
|
@ -32,6 +33,8 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.context_menus.DialpadContextMenu")
|
||||
export default class DialpadContextMenu extends React.Component<IProps, IState> {
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -40,15 +43,29 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
};
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.props.call.sendDtmfDigit(digit);
|
||||
this.setState({ value: this.state.value + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onCancelClick = () => {
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
// Prevent Backspace and Delete keys from functioning in the entry field
|
||||
if (ev.code === "Backspace" || ev.code === "Delete") {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (ev) => {
|
||||
this.setState({ value: ev.target.value });
|
||||
};
|
||||
|
@ -60,8 +77,12 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<Field className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value} autoFocus={true}
|
||||
<Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -86,14 +86,18 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
|||
>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", {
|
||||
mx_IconizedContextMenu_checked: active,
|
||||
mx_IconizedContextMenu_unchecked: !active,
|
||||
})} />
|
||||
</MenuItemCheckbox>;
|
||||
};
|
||||
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
|
||||
return <MenuItem {...props} label={label}>
|
||||
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ children }
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
|
|
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
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 { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import {
|
||||
IProps as IContextMenuProps,
|
||||
} from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {
|
||||
leaveSpace,
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceInvite,
|
||||
showSpaceSettings,
|
||||
} from "../../../utils/space";
|
||||
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";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
space: Room;
|
||||
}
|
||||
|
||||
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let inviteOption;
|
||||
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
|
||||
const onInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceInvite(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
inviteOption = (
|
||||
<IconizedContextMenuOption
|
||||
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||
iconClassName="mx_SpacePanel_iconInvite"
|
||||
label={_t("Invite people")}
|
||||
onClick={onInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let settingsOption;
|
||||
let leaveSection;
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
const onSettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showSpaceSettings(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
settingsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconSettings"
|
||||
label={_t("Settings")}
|
||||
onClick={onSettingsClick}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const onLeaveClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
leaveSpace(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
leaveSection = <IconizedContextMenuOptionList red first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconLeave"
|
||||
label={_t("Leave space")}
|
||||
onClick={onLeaveClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
let newRoomSection;
|
||||
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const onNewRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewRoom(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onAddExistingRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showAddExistingRooms(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onNewSubspaceClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewSubspace(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
newRoomSection = <IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Create new room")}
|
||||
onClick={onNewRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconHash"
|
||||
label={_t("Add existing room")}
|
||||
onClick={onAddExistingRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Add 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();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
return <IconizedContextMenu
|
||||
{...props}
|
||||
onFinished={onFinished}
|
||||
className="mx_SpacePanel_contextMenu"
|
||||
compact
|
||||
>
|
||||
<div className="mx_SpacePanel_contextMenu_header">
|
||||
{ space.name }
|
||||
</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}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
{ newRoomSection }
|
||||
{ leaveSection }
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
export default SpaceContextMenu;
|
||||
|
|
@ -109,8 +109,10 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
</AccessibleButton>;
|
||||
}
|
||||
} else {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
|
||||
disabled={!this.state.message} onClick={this._onSubmit}
|
||||
actionButton = <AccessibleButton
|
||||
className="mx_StatusMessageContextMenu_submit"
|
||||
disabled={!this.state.message}
|
||||
onClick={this._onSubmit}
|
||||
>
|
||||
<span>{ _t("Set status") }</span>
|
||||
</AccessibleButton>;
|
||||
|
@ -121,12 +123,19 @@ export default class StatusMessageContextMenu extends React.Component {
|
|||
spinner = <Spinner w="24" h="24" />;
|
||||
}
|
||||
|
||||
const form = <form className="mx_StatusMessageContextMenu_form"
|
||||
autoComplete="off" onSubmit={this._onSubmit}
|
||||
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}
|
||||
<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">
|
||||
|
|
|
@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
||||
import { createSpaceFromCommunity } from "../../../utils/space";
|
||||
import GroupStore from "../../../stores/GroupStore";
|
||||
|
||||
@replaceableComponent("views.context_menus.TagTileContextMenu")
|
||||
export default class TagTileContextMenu extends React.Component {
|
||||
|
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
|
|||
this.props.onFinished();
|
||||
};
|
||||
|
||||
_onCreateSpaceClick = () => {
|
||||
createSpaceFromCommunity(this.context, this.props.tag);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
_onMoveUp = () => {
|
||||
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
|
||||
this.props.onFinished();
|
||||
|
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
let createSpaceOption;
|
||||
if (GroupStore.isUserPrivileged(this.props.tag)) {
|
||||
createSpaceOption = <>
|
||||
<hr className="mx_TagTileContextMenu_separator" role="separator" />
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
|
||||
{ _t("Create Space") }
|
||||
</MenuItem>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
|
||||
{ _t('View Community') }
|
||||
|
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
|
|||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
||||
{ _t("Unpin") }
|
||||
</MenuItem>
|
||||
{ createSpaceOption }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
|
|||
onFinished();
|
||||
};
|
||||
streamAudioStreamButton = <IconizedContextMenuOption
|
||||
onClick={onStreamAudioClick} label={_t("Start audio stream")}
|
||||
onClick={onStreamAudioClick}
|
||||
label={_t("Start audio stream")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
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, { useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing space")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new space instead?") }</div>
|
||||
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
|
||||
{ _t("Create a new space") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for spaces")}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default AddExistingSubspaceDialog;
|
||||
|
|
@ -17,11 +17,10 @@ limitations under the License.
|
|||
import React, { ReactNode, useContext, useMemo, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
|
@ -36,20 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import ProgressBar from "../elements/ProgressBar";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateRoomClick(cli: MatrixClient, space: Room): void;
|
||||
onCreateRoomClick(): void;
|
||||
onAddSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
export const Entry = ({ room, checked, onChange }) => {
|
||||
return <label className="mx_AddExistingToSpace_entry">
|
||||
{ room?.isSpaceRoom()
|
||||
? <RoomAvatar room={room} height={32} width={32} />
|
||||
|
@ -67,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
|
|||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
footerPrompt?: ReactNode;
|
||||
filterPlaceholder: string;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
roomsRenderer?(
|
||||
rooms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange: undefined | ((checked: boolean, room: Room) => void),
|
||||
truncateAt: number,
|
||||
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
|
||||
): ReactNode;
|
||||
spacesRenderer?(
|
||||
spaces: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
dmsRenderer?(
|
||||
dms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
}
|
||||
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
emptySelectionButton,
|
||||
filterPlaceholder,
|
||||
roomsRenderer,
|
||||
dmsRenderer,
|
||||
spacesRenderer,
|
||||
onFinished,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
@ -198,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
|
@ -208,83 +229,52 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
} : null;
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
function overflowTile(overflowCount, totalCount) {
|
||||
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
|
||||
const text = _t("and %(count)s others...", { count: overflowCount });
|
||||
return (
|
||||
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
|
||||
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
|
||||
} name={text} presenceState="online" suppressOnHover={true}
|
||||
onClick={() => setTruncateAt(totalCount)} />
|
||||
<EntityTile
|
||||
className="mx_EntityTile_ellipsis"
|
||||
avatarJsx={
|
||||
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
|
||||
}
|
||||
name={text}
|
||||
presenceState="online"
|
||||
suppressOnHover={true}
|
||||
onClick={() => setTruncateAt(totalCount)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let noResults = true;
|
||||
if ((roomsRenderer && rooms.length > 0) ||
|
||||
(dmsRenderer && dms.length > 0) ||
|
||||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
|
||||
) {
|
||||
noResults = false;
|
||||
}
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Filter your rooms and spaces")}
|
||||
placeholder={filterPlaceholder}
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
{ rooms.length > 0 && roomsRenderer ? (
|
||||
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ spaces.length > 0 && spacesRenderer ? (
|
||||
spacesRenderer(spaces, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ dms.length > 0 && dmsRenderer ? (
|
||||
dmsRenderer(dms, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
@ -295,69 +285,166 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
|
||||
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
|
||||
) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
spaceOptionSection = (
|
||||
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ISubspaceSelectorProps {
|
||||
title: string;
|
||||
space: Room;
|
||||
value: Room;
|
||||
onChange(space: Room): void;
|
||||
}
|
||||
|
||||
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
|
||||
const options = useMemo(() => {
|
||||
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
|
||||
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
|
||||
})];
|
||||
}, [space]);
|
||||
|
||||
let body;
|
||||
if (options.length > 1) {
|
||||
body = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
className="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
onChange(options.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
value={value.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
{ options.map((space) => {
|
||||
const classes = classNames({
|
||||
mx_SubspaceSelector_dropdownOptionActive: space === value,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}) }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
body = (
|
||||
<div className="mx_SubspaceSelector_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
return <div className="mx_SubspaceSelector">
|
||||
<RoomAvatar room={value} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
<h1>{ title }</h1>
|
||||
{ body }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing rooms")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={cli}>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onCreateRoomClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for rooms")}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={() => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onAddSubspaceClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Adding spaces has moved.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
)}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
|
|
|
@ -665,8 +665,8 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
|
|||
onChange={this.onQueryChanged}
|
||||
placeholder={this.getPlaceholder()}
|
||||
defaultValue={this.props.value}
|
||||
autoFocus={this.props.focus}>
|
||||
</textarea>,
|
||||
autoFocus={this.props.focus}
|
||||
/>,
|
||||
);
|
||||
|
||||
const filteredSuggestedList = this.getFilteredSuggestions();
|
||||
|
@ -727,8 +727,12 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
|
|||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
|
||||
onFinished={this.props.onFinished} title={this.props.title}>
|
||||
<BaseDialog
|
||||
className="mx_AddressPickerDialog"
|
||||
onKeyDown={this.onKeyDown}
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.props.title}
|
||||
>
|
||||
{ inputLabel }
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
|
||||
|
|
|
@ -118,9 +118,7 @@ export default class BaseDialog extends React.Component {
|
|||
|
||||
let headerImage;
|
||||
if (this.props.headerImage) {
|
||||
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
|
||||
alt=""
|
||||
/>;
|
||||
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue