Merge branch 'develop' into reorganize-preferences

This commit is contained in:
Šimon Brandner 2021-04-30 10:55:29 +02:00
commit e2bee396bb
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
645 changed files with 23824 additions and 7486 deletions

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-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.
@ -39,6 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
declare global {
interface Window {
@ -70,11 +73,16 @@ declare global {
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
}
interface Document {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
hasStorageAccess?: () => Promise<boolean>;
// https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess
requestStorageAccess?: () => Promise<undefined>;
// Safari & IE11 only have this prefixed: we used prefixed versions
// previously so let's continue to support them for now
@ -127,4 +135,30 @@ declare global {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
columnNumber?: number;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>
): boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
const AudioWorkletProcessor: {
prototype: AudioWorkletProcessor;
new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
};
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor(
name: string,
processorCtor: (new (
options?: AudioWorkletNodeOptions
) => AudioWorkletProcessor) & {
parameterDescriptors?: AudioParamDescriptor[];
}
);
}

View file

@ -14,28 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {User} from "matrix-js-sdk/src/models/user";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import {mediaFromMxc} from "./customisations/Media";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url: string;
if (member && member.getAvatarUrl) {
url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
false,
false,
);
if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
if (!url) {
// member can be null here currently since on invites, the JS SDK
@ -47,16 +39,8 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
}
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
const url = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
if (!url || url.length === 0) {
return null;
}
return url;
if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
function isValidHexColor(color: string): boolean {
@ -154,15 +138,8 @@ export function getInitialLetter(name: string): string {
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
if (room.getMxcAvatarUrl()) {
return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
// space rooms cannot be DMs so skip the rest
@ -177,14 +154,8 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (otherMember?.getMxcAvatarUrl()) {
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
return null;
}

View file

@ -212,6 +212,18 @@ export default abstract class BasePlatform {
throw new Error("Unimplemented");
}
supportsWarnBeforeExit(): boolean {
return false;
}
async shouldWarnBeforeExit(): Promise<boolean> {
return false;
}
async setWarnBeforeExit(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}
@ -246,7 +258,7 @@ export default abstract class BasePlatform {
return null;
}
setLanguage(preferredLangs: string[]) {}
async setLanguage(preferredLangs: string[]) {}
setSpellCheckLanguages(preferredLangs: string[]) {}

View file

@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
@ -86,6 +85,8 @@ import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom';
export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
@ -154,6 +155,9 @@ function getRemoteAudioElement(): HTMLAudioElement {
export default class CallHandler {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
@ -164,6 +168,11 @@ export default class CallHandler {
private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false;
// Map of the asserted identity users after we've looked them up using the API.
// We need to be be able to determine the mapped room synchronously, so we
// do the async lookup when we get new information and then store these mappings here
private assertedIdentityNativeUsers = new Map<string, string>();
static sharedInstance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler()
@ -176,8 +185,19 @@ export default class CallHandler {
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config)
*/
public static roomIdForCall(call: MatrixCall): string {
public roomIdForCall(call: MatrixCall): string {
if (!call) return null;
const voipConfig = SdkConfig.get()['voip'];
if (voipConfig && voipConfig.obeyAssertedIdentity) {
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
if (nativeUser) {
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (room) return room.roomId
}
}
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
}
@ -325,6 +345,10 @@ export default class CallHandler {
return callsNotInThatRoom;
}
getTransfereeForCallId(callId: string): MatrixCall {
return this.transferees[callId];
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
@ -372,14 +396,14 @@ export default class CallHandler {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = this.roomIdForCall(call);
const callForThisRoom = this.getCallForRoom(mappedRoomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
const mappedRoomId = CallHandler.roomIdForCall(call);
let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return;
@ -493,6 +517,42 @@ export default class CallHandler {
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
call.on(CallEvent.AssertedIdentityChanged, async () => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid;
}
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
if (newNativeAssertedIdentity) {
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
// If we don't already have a room with this user, make one. This will be slightly odd
// if they called us because we'll be inviting them, but there's not much we can do about
// this if we want the actual, native room to exist (which we do). This is why it's
// important to only obey asserted identity in trusted environments, since anyone you're
// on a call with can cause you to send a room invite to someone.
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
const newMappedRoomId = this.roomIdForCall(call);
console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
if (newMappedRoomId !== mappedRoomId) {
this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call);
dis.dispatch({
action: Action.CallChangeRoom,
call,
});
}
}
});
}
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
@ -544,7 +604,7 @@ export default class CallHandler {
}
private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
console.log(
`Call state in ${mappedRoomId} changed to ${status}`,
@ -622,6 +682,7 @@ export default class CallHandler {
private async placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -630,10 +691,13 @@ export default class CallHandler {
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds");
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call);
if (transferee) {
this.transferees[call.callId] = transferee;
}
this.setCallListeners(call);
this.setCallAudioElement(call);
@ -662,7 +726,7 @@ export default class CallHandler {
call.placeScreenSharingCall(
remoteElement,
localElement,
async () : Promise<DesktopCapturerSource> => {
async (): Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
@ -723,7 +787,10 @@ export default class CallHandler {
} else if (members.length === 2) {
console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
this.placeCall(
payload.room_id, payload.type, payload.local_element, payload.remote_element,
payload.transferee,
);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
@ -758,7 +825,7 @@ export default class CallHandler {
const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room
return;
@ -788,6 +855,11 @@ export default class CallHandler {
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
break;
case 'hangup_all':
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer

View file

@ -14,9 +14,9 @@
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default {
hasAnyLabeledDevices: async function() {
@ -54,24 +54,24 @@ export default {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
Matrix.setMatrixCallAudioOutput(audioOutDeviceId);
Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallVideoInput(videoDeviceId);
setMatrixCallAudioOutput(audioOutDeviceId);
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioOutput(deviceId);
setMatrixCallAudioOutput(deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);
setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {

View file

@ -32,6 +32,14 @@ import Spinner from "./components/views/elements/Spinner";
import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics";
import {
UploadCanceledPayload,
UploadErrorPayload,
UploadFinishedPayload,
UploadProgressPayload,
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import {IUpload} from "./models/IUpload";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -44,15 +52,6 @@ export class UploadCanceledError extends Error {}
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
interface IUpload {
fileName: string;
roomId: string;
total: number;
loaded: number;
promise: Promise<any>;
canceled?: boolean;
}
interface IMediaConfig {
"m.upload.size"?: number;
}
@ -478,7 +477,7 @@ export default class ContentMessages {
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
dis.dispatch<UploadCanceledPayload>({action: Action.UploadCanceled, upload});
}
}
@ -539,7 +538,7 @@ export default class ContentMessages {
promise: prom,
};
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
dis.dispatch<UploadStartedPayload>({action: Action.UploadStarted, upload});
// Focus the composer view
dis.fire(Action.FocusComposer);
@ -547,7 +546,7 @@ export default class ContentMessages {
function onProgress(ev) {
upload.total = ev.total;
upload.loaded = ev.loaded;
dis.dispatch({action: 'upload_progress', upload: upload});
dis.dispatch<UploadProgressPayload>({action: Action.UploadProgress, upload});
}
let error;
@ -601,9 +600,9 @@ export default class ContentMessages {
if (error && error.http_status === 413) {
this.mediaConfig = null;
}
dis.dispatch({action: 'upload_failed', upload, error});
dis.dispatch<UploadErrorPayload>({action: Action.UploadFailed, upload, error});
} else {
dis.dispatch({action: 'upload_finished', upload});
dis.dispatch<UploadFinishedPayload>({action: Action.UploadFinished, upload});
dis.dispatch({action: 'message_sent'});
}
});

View file

@ -97,7 +97,7 @@ export function formatFullDateNoTime(date: Date): string {
});
}
export function formatFullDate(date: Date, showTwelveHour = false): string {
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
@ -105,7 +105,7 @@ export function formatFullDate(date: Date, showTwelveHour = false): string {
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
time: formatFullTime(date, showTwelveHour),
time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour),
});
}

View file

@ -32,10 +32,10 @@ import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio';
import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
import {mediaFromMxc} from "./customisations/Media";
linkifyMatrix(linkify);
@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
const contentDiv = document.createElement("div");
contentDiv.innerHTML = saneHtml;
return contentDiv.innerText;
export function getHtmlText(insaneHtml: string) {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
})
}
/**
@ -181,11 +184,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {}};
}
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
attribs.src,
attribs.width || 800,
attribs.height || 600,
);
const width = Number(attribs.width) || 800;
const height = Number(attribs.height) || 600;
attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height);
return { tagName, attribs };
},
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
@ -239,6 +240,7 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'details', 'summary',
],
allowedAttributes: {
// custom ones first:

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createClient, SERVICE_TYPES } from 'matrix-js-sdk';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { createClient } from 'matrix-js-sdk/src/matrix';
import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal';

407
src/KeyBindingsDefaults.ts Normal file
View file

@ -0,0 +1,407 @@
/*
Copyright 2021 Clemens Zeidler
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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction,
RoomListAction } from "./KeyBindingsManager";
import { isMac, Key } from "./Keyboard";
import SettingsStore from "./settings/SettingsStore";
const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
const bindings: KeyBinding<MessageComposerAction>[] = [
{
action: MessageComposerAction.SelectPrevSendHistory,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.SelectNextSendHistory,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.EditPrevMessage,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: MessageComposerAction.EditNextMessage,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: MessageComposerAction.CancelEditing,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: MessageComposerAction.FormatBold,
keyCombo: {
key: Key.B,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatItalics,
keyCombo: {
key: Key.I,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatQuote,
keyCombo: {
key: Key.GREATER_THAN,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: MessageComposerAction.EditUndo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToStart,
keyCombo: {
key: Key.HOME,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToEnd,
keyCombo: {
key: Key.END,
ctrlOrCmd: true,
},
},
];
if (isMac) {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
shiftKey: true,
},
});
} else {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Y,
ctrlOrCmd: true,
},
});
}
if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
ctrlOrCmd: true,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
},
});
} else {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
shiftKey: true,
},
});
if (isMac) {
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
altKey: true,
},
});
}
}
return bindings;
}
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
return [
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
},
},
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
shiftKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
shiftKey: true,
},
},
{
action: AutocompleteAction.Cancel,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: AutocompleteAction.PrevSelection,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: AutocompleteAction.NextSelection,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
];
}
const roomListBindings = (): KeyBinding<RoomListAction>[] => {
return [
{
action: RoomListAction.ClearSearch,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomListAction.PrevRoom,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: RoomListAction.NextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: RoomListAction.SelectRoom,
keyCombo: {
key: Key.ENTER,
},
},
{
action: RoomListAction.CollapseSection,
keyCombo: {
key: Key.ARROW_LEFT,
},
},
{
action: RoomListAction.ExpandSection,
keyCombo: {
key: Key.ARROW_RIGHT,
},
},
];
}
const roomBindings = (): KeyBinding<RoomAction>[] => {
const bindings: KeyBinding<RoomAction>[] = [
{
action: RoomAction.ScrollUp,
keyCombo: {
key: Key.PAGE_UP,
},
},
{
action: RoomAction.RoomScrollDown,
keyCombo: {
key: Key.PAGE_DOWN,
},
},
{
action: RoomAction.DismissReadMarker,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomAction.JumpToOldestUnread,
keyCombo: {
key: Key.PAGE_UP,
shiftKey: true,
},
},
{
action: RoomAction.UploadFile,
keyCombo: {
key: Key.U,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: RoomAction.JumpToFirstMessage,
keyCombo: {
key: Key.HOME,
ctrlKey: true,
},
},
{
action: RoomAction.JumpToLatestMessage,
keyCombo: {
key: Key.END,
ctrlKey: true,
},
},
];
if (SettingsStore.getValue('ctrlFForSearch')) {
bindings.push({
action: RoomAction.FocusSearch,
keyCombo: {
key: Key.F,
ctrlOrCmd: true,
},
});
}
return bindings;
}
const navigationBindings = (): KeyBinding<NavigationAction>[] => {
return [
{
action: NavigationAction.FocusRoomSearch,
keyCombo: {
key: Key.K,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleRoomSidePanel,
keyCombo: {
key: Key.PERIOD,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleUserMenu,
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
keyCombo: {
key: Key.BACKTICK,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: NavigationAction.GoToHome,
keyCombo: {
key: Key.H,
ctrlKey: true,
altKey: !isMac,
shiftKey: isMac,
},
},
{
action: NavigationAction.SelectPrevRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
},
},
{
action: NavigationAction.SelectNextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
},
},
{
action: NavigationAction.SelectPrevUnreadRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
shiftKey: true,
},
},
{
action: NavigationAction.SelectNextUnreadRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
shiftKey: true,
},
},
];
}
export const defaultBindingsProvider: IKeyBindingsProvider = {
getMessageComposerBindings: messageComposerBindings,
getAutocompleteBindings: autocompleteBindings,
getRoomListBindings: roomListBindings,
getRoomBindings: roomBindings,
getNavigationBindings: navigationBindings,
}

273
src/KeyBindingsManager.ts Normal file
View file

@ -0,0 +1,273 @@
/*
Copyright 2021 Clemens Zeidler
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 { defaultBindingsProvider } from './KeyBindingsDefaults';
import { isMac } from './Keyboard';
/** Actions for the chat message composer component */
export enum MessageComposerAction {
/** Send a message */
Send = 'Send',
/** Go backwards through the send history and use the message in composer view */
SelectPrevSendHistory = 'SelectPrevSendHistory',
/** Go forwards through the send history */
SelectNextSendHistory = 'SelectNextSendHistory',
/** Start editing the user's last sent message */
EditPrevMessage = 'EditPrevMessage',
/** Start editing the user's next sent message */
EditNextMessage = 'EditNextMessage',
/** Cancel editing a message or cancel replying to a message */
CancelEditing = 'CancelEditing',
/** Set bold format the current selection */
FormatBold = 'FormatBold',
/** Set italics format the current selection */
FormatItalics = 'FormatItalics',
/** Format the current selection as quote */
FormatQuote = 'FormatQuote',
/** Undo the last editing */
EditUndo = 'EditUndo',
/** Redo editing */
EditRedo = 'EditRedo',
/** Insert new line */
NewLine = 'NewLine',
/** Move the cursor to the start of the message */
MoveCursorToStart = 'MoveCursorToStart',
/** Move the cursor to the end of the message */
MoveCursorToEnd = 'MoveCursorToEnd',
}
/** 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',
/** Move to the previous autocomplete selection */
PrevSelection = 'PrevSelection',
/** Move to the next autocomplete selection */
NextSelection = 'NextSelection',
/** Close the autocompletion window */
Cancel = 'Cancel',
}
/** Actions for the room list sidebar */
export enum RoomListAction {
/** Clear room list filter field */
ClearSearch = 'ClearSearch',
/** Navigate up/down in the room list */
PrevRoom = 'PrevRoom',
/** Navigate down in the room list */
NextRoom = 'NextRoom',
/** Select room from the room list */
SelectRoom = 'SelectRoom',
/** Collapse room list section */
CollapseSection = 'CollapseSection',
/** Expand room list section, if already expanded, jump to first room in the selection */
ExpandSection = 'ExpandSection',
}
/** Actions for the current room view */
export enum RoomAction {
/** Scroll up in the timeline */
ScrollUp = 'ScrollUp',
/** Scroll down in the timeline */
RoomScrollDown = 'RoomScrollDown',
/** Dismiss read marker and jump to bottom */
DismissReadMarker = 'DismissReadMarker',
/** Jump to oldest unread message */
JumpToOldestUnread = 'JumpToOldestUnread',
/** Upload a file */
UploadFile = 'UploadFile',
/** Focus search message in a room (must be enabled) */
FocusSearch = 'FocusSearch',
/** Jump to the first (downloaded) message in the room */
JumpToFirstMessage = 'JumpToFirstMessage',
/** Jump to the latest message in the room */
JumpToLatestMessage = 'JumpToLatestMessage',
}
/** Actions for navigating do various menus, dialogs or screens */
export enum NavigationAction {
/** Jump to room search (search for a room) */
FocusRoomSearch = 'FocusRoomSearch',
/** Toggle the room side panel */
ToggleRoomSidePanel = 'ToggleRoomSidePanel',
/** Toggle the user menu */
ToggleUserMenu = 'ToggleUserMenu',
/** Toggle the short cut help dialog */
ToggleShortCutDialog = 'ToggleShortCutDialog',
/** Got to the Element home screen */
GoToHome = 'GoToHome',
/** Select prev room */
SelectPrevRoom = 'SelectPrevRoom',
/** Select next room */
SelectNextRoom = 'SelectNextRoom',
/** Select prev room with unread messages */
SelectPrevUnreadRoom = 'SelectPrevUnreadRoom',
/** Select next room with unread messages */
SelectNextUnreadRoom = 'SelectNextUnreadRoom',
}
/**
* Represent a key combination.
*
* The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo.
*/
export type KeyCombo = {
key?: string;
/** On PC: ctrl is pressed; on Mac: meta is pressed */
ctrlOrCmd?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
}
export type KeyBinding<T extends string> = {
action: T;
keyCombo: KeyCombo;
}
/**
* Helper method to check if a KeyboardEvent matches a KeyCombo
*
* Note, this method is only exported for testing.
*/
export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean {
if (combo.key !== undefined) {
// When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison.
// This works for letter combos such as shift + U as well for none letter combos such as shift + Escape.
// If shift is not pressed, the toLowerCase conversion can be avoided.
if (ev.shiftKey) {
if (ev.key.toLowerCase() !== combo.key.toLowerCase()) {
return false;
}
} else if (ev.key !== combo.key) {
return false;
}
}
const comboCtrl = combo.ctrlKey ?? false;
const comboAlt = combo.altKey ?? false;
const comboShift = combo.shiftKey ?? false;
const comboMeta = combo.metaKey ?? false;
// Tests mock events may keep the modifiers undefined; convert them to booleans
const evCtrl = ev.ctrlKey ?? false;
const evAlt = ev.altKey ?? false;
const evShift = ev.shiftKey ?? false;
const evMeta = ev.metaKey ?? false;
// When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac
if (combo.ctrlOrCmd) {
if (onMac) {
if (!evMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
} else {
if (!evCtrl
|| evMeta !== comboMeta
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
}
return true;
}
if (evMeta !== comboMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
return true;
}
export type KeyBindingGetter<T extends string> = () => KeyBinding<T>[];
export interface IKeyBindingsProvider {
getMessageComposerBindings: KeyBindingGetter<MessageComposerAction>;
getAutocompleteBindings: KeyBindingGetter<AutocompleteAction>;
getRoomListBindings: KeyBindingGetter<RoomListAction>;
getRoomBindings: KeyBindingGetter<RoomAction>;
getNavigationBindings: KeyBindingGetter<NavigationAction>;
}
export class KeyBindingsManager {
/**
* List of key bindings providers.
*
* Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers.
*
* To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for
* customized key bindings.
*/
bindingsProviders: IKeyBindingsProvider[] = [
defaultBindingsProvider,
];
/**
* Finds a matching KeyAction for a given KeyboardEvent
*/
private getAction<T extends string>(
getters: KeyBindingGetter<T>[],
ev: KeyboardEvent | React.KeyboardEvent,
): T | undefined {
for (const getter of getters) {
const bindings = getter();
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac));
if (binding) {
return binding.action;
}
}
return undefined;
}
getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev);
}
getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev);
}
getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev);
}
getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev);
}
getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev);
}
}
const manager = new KeyBindingsManager();
export function getKeyBindingsManager(): KeyBindingsManager {
return manager;
}

View file

@ -17,8 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes";
@ -219,7 +218,7 @@ export function attemptTokenLogin(
button: _t("Try again"),
onFinished: tryAgain => {
if (tryAgain) {
const cli = Matrix.createClient({
const cli = createClient({
baseUrl: homeserver,
idBaseUrl: identityServer,
});
@ -276,7 +275,7 @@ function registerAsGuest(
console.log(`Doing guest login on ${hsUrl}`);
// create a temporary MatrixClient to do the login
const client = Matrix.createClient({
const client = createClient({
baseUrl: hsUrl,
});

55
src/Livestream.ts Normal file
View file

@ -0,0 +1,55 @@
/*
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 { ClientWidgetApi } from "matrix-widget-api";
import { MatrixClientPeg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
export function getConfigLivestreamUrl() {
return SdkConfig.get()["audioStreamUrl"];
}
// Dummy rtmp URL used to signal that we want a special audio-only stream
const AUDIOSTREAM_DUMMY_URL = 'rtmp://audiostream.dummy/';
async function createLiveStream(roomId: string) {
const openIdToken = await MatrixClientPeg.get().getOpenIdToken();
const url = getConfigLivestreamUrl() + "/createStream";
const response = await window.fetch(url, {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room_id: roomId,
openid_token: openIdToken,
}),
});
const respBody = await response.json();
return respBody['stream_id'];
}
export async function startJitsiAudioLivestream(widgetMessaging: ClientWidgetApi, roomId: string) {
const streamId = await createLiveStream(roomId);
await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, {
rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId,
});
}

View file

@ -1,9 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,7 +16,7 @@ limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import Matrix from "matrix-js-sdk";
import {createClient} from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
@ -59,7 +56,7 @@ export type LoginFlow = ISSOFlow | IPasswordFlow;
// TODO: Move this to JS SDK
/* eslint-disable camelcase */
interface ILoginParams {
identifier?: string;
identifier?: object;
password?: string;
token?: string;
device_id?: string;
@ -115,7 +112,7 @@ export default class Login {
*/
public createTemporaryClient(): MatrixClient {
if (this.tempClient) return this.tempClient; // use memoization
return this.tempClient = Matrix.createClient({
return this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
@ -210,7 +207,7 @@ export async function sendLoginRequest(
loginType: string,
loginParams: ILoginParams,
): Promise<IMatrixClientCreds> {
const client = Matrix.createClient({
const client = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});

View file

@ -261,7 +261,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
public getHomeserverName(): string {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
@ -296,10 +296,11 @@ class _MatrixClientPeg implements IMatrixClientPeg {
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
const customisedCallbacks = {
getDehydrationKey: SecurityCustomisations.getDehydrationKey,
};
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks);
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
if (SecurityCustomisations.getDehydrationKey) {
opts.cryptoCallbacks.getDehydrationKey =
SecurityCustomisations.getDehydrationKey;
}
this.matrixClient = createMatrixClient(opts);

View file

@ -36,6 +36,7 @@ export interface IModal<T extends any[]> {
onBeforeClose?(reason?: string): Promise<boolean>;
onFinished(...args: T): void;
close(...args: T): void;
hidden?: boolean;
}
export interface IHandle<T extends any[]> {
@ -93,6 +94,12 @@ export class ModalManager {
return container;
}
public toggleCurrentDialogVisibility() {
const modal = this.getCurrentModal();
if (!modal) return;
modal.hidden = !modal.hidden;
}
public hasDialogs() {
return this.priorityModal || this.staticModal || this.modals.length > 0;
}
@ -364,7 +371,7 @@ export class ModalManager {
}
const modal = this.getCurrentModal();
if (modal !== this.staticModal) {
if (modal !== this.staticModal && !modal.hidden) {
const classes = classNames("mx_Dialog_wrapper", modal.className, {
mx_Dialog_wrapperWithStaticUnder: this.staticModal,
});

View file

@ -1,16 +1,15 @@
import React from "react";
import ReactDom from "react-dom";
import Velocity from "velocity-animate";
import PropTypes from 'prop-types';
/**
* The Velociraptor contains components and animates transitions with velocity.
* The NodeAnimator contains components and animates transitions.
* It will only pick up direct changes to properties ('left', currently), and so
* will not work for animating positional changes where the position is implicit
* from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries.
*/
export default class Velociraptor extends React.Component {
export default class NodeAnimator extends React.Component {
static propTypes = {
// either a list of child nodes, or a single child.
children: PropTypes.any,
@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component {
// a list of state objects to apply to each child node in turn
startStyles: PropTypes.array,
// a list of transition options from the corresponding startStyle
enterTransitionOpts: PropTypes.array,
};
static defaultProps = {
startStyles: [],
enterTransitionOpts: [],
};
constructor(props) {
@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component {
this._updateChildren(this.props.children);
}
/**
*
* @param {HTMLElement} node element to apply styles to
* @param {object} styles a key/value pair of CSS properties
* @returns {void}
*/
_applyStyles(node, styles) {
Object.entries(styles).forEach(([property, value]) => {
node.style[property] = value;
});
}
_updateChildren(newChildren) {
const oldChildren = this.children || {};
this.children = {};
@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component {
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
if (oldNode && oldNode.style.left !== c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => {
// special case visibility because it's nonsensical to animate an invisible element
// so we always hidden->visible pre-transition and visible->hidden after
if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') {
oldNode.style.visibility = c.props.style.visibility;
}
});
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') {
oldNode.style.visibility = c.props.style.visibility;
this._applyStyles(oldNode, { left: c.props.style.left });
// console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
// clone the old element with the props (and children) of the new element
// so prop updates are still received by the children.
@ -94,33 +92,22 @@ export default class Velociraptor extends React.Component {
this.props.startStyles.length > 0
) {
const startStyles = this.props.startStyles;
const transitionOpts = this.props.enterTransitionOpts;
const domNode = ReactDom.findDOMNode(node);
// start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) {
Velocity(domNode, startStyles[i], transitionOpts[i-1]);
/*
console.log("start:",
JSON.stringify(transitionOpts[i-1]),
"->",
JSON.stringify(startStyles[i]),
);
*/
for (let i = 1; i < startStyles.length; ++i) {
this._applyStyles(domNode, startStyles[i]);
// console.log("start:"
// JSON.stringify(startStyles[i]),
// );
}
// and then we animate to the resting state
Velocity(domNode, restingStyle,
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = restingStyle.visibility;
});
setTimeout(() => {
this._applyStyles(domNode, restingStyle);
}, 0);
// console.log("enter:",
// JSON.stringify(transitionOpts[i-1]),
// "->",
// JSON.stringify(restingStyle));
}
this.nodes[k] = node;
@ -128,9 +115,7 @@ export default class Velociraptor extends React.Component {
render() {
return (
<span>
{ Object.values(this.children) }
</span>
<>{ Object.values(this.children) }</>
);
}
}

View file

@ -36,6 +36,7 @@ import {SettingLevel} from "./settings/SettingLevel";
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
import RoomViewStore from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
import {mediaFromMxc} from "./customisations/Media";
/*
* Dispatches:
@ -150,7 +151,7 @@ export const Notifier = {
// Ideally in here we could use MSC1310 to detect the type of file, and reject it.
return {
url: MatrixClientPeg.get().mxcUrlToHttp(content.url),
url: mediaFromMxc(content.url).srcHttp,
name: content.name,
type: content.type,
size: content.size,
@ -382,6 +383,10 @@ export const Notifier = {
// don't bother notifying as user was recently active in this room
return;
}
if (SettingsStore.getValue("doNotDisturb")) {
// Don't bother the user if they didn't ask to be bothered
return;
}
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { _t } from './languageHandler';
/**
@ -32,7 +32,7 @@ export default class PasswordReset {
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
});

View file

@ -99,9 +99,9 @@ class Presence {
try {
await MatrixClientPeg.get().setPresence(this.state);
console.info("Presence: %s", newState);
console.info("Presence:", newState);
} catch (err) {
console.error("Failed to set presence: %s", err);
console.error("Failed to set presence:", err);
this.state = oldState;
}
}

View file

@ -17,15 +17,15 @@ limitations under the License.
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import { EventStatus } from 'matrix-js-sdk';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
export default class Resend {
static resendUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
return Promise.all(room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
Resend.resend(event);
});
}).map(function(event) {
return Resend.resend(event);
}));
}
static cancelUnsentEvents(room) {
@ -38,7 +38,7 @@ export default class Resend {
static resend(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({
action: 'message_sent',
event: event,

View file

@ -22,7 +22,7 @@ import MultiInviter from './utils/MultiInviter';
import Modal from './Modal';
import * as sdk from './';
import { _t } from './languageHandler';
import InviteDialog, {KIND_DM, KIND_INVITE, KIND_SPACE_INVITE} from "./components/views/dialogs/InviteDialog";
import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
@ -49,12 +49,12 @@ export function showStartChatInviteDialog(initialText) {
);
}
export function showRoomInviteDialog(roomId) {
const isSpace = MatrixClientPeg.get()?.getRoom(roomId)?.isSpaceRoom();
export function showRoomInviteDialog(roomId, initialText = "") {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog(
"Invite Users", isSpace ? "Space" : "Room", InviteDialog, {
kind: isSpace ? KIND_SPACE_INVITE : KIND_INVITE,
"Invite Users", "", InviteDialog, {
kind: KIND_INVITE,
initialText,
roomId,
},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016, 2019, 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.
@ -17,13 +16,14 @@ limitations under the License.
import url from 'url';
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
import {MatrixClientPeg} from "./MatrixClientPeg";
import request from "browser-request";
import * as Matrix from 'matrix-js-sdk';
import SdkConfig from "./SdkConfig";
import {WidgetType} from "./widgets/WidgetType";
import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types";
import { Room } from "matrix-js-sdk/src/models/room";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
@ -31,9 +31,11 @@ const imApiVersion = "1.1";
// TODO: Generify the name of this class and all components within - it's not just for Scalar.
export default class ScalarAuthClient {
constructor(apiUrl, uiUrl) {
this.apiUrl = apiUrl;
this.uiUrl = uiUrl;
private scalarToken: string;
private termsInteractionCallback: TermsInteractionCallback;
private isDefaultManager: boolean;
constructor(private apiUrl: string, private uiUrl: string) {
this.scalarToken = null;
// `undefined` to allow `startTermsFlow` to fallback to a default
// callback if this is unset.
@ -46,7 +48,7 @@ export default class ScalarAuthClient {
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
}
_writeTokenToStore() {
private writeTokenToStore() {
window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken);
if (this.isDefaultManager) {
// We remove the old token from storage to migrate upwards. This is safe
@ -56,7 +58,7 @@ export default class ScalarAuthClient {
}
}
_readTokenFromStore() {
private readTokenFromStore(): string {
let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl);
if (!token && this.isDefaultManager) {
token = window.localStorage.getItem("mx_scalar_token");
@ -64,33 +66,33 @@ export default class ScalarAuthClient {
return token;
}
_readToken() {
private readToken(): string {
if (this.scalarToken) return this.scalarToken;
return this._readTokenFromStore();
return this.readTokenFromStore();
}
setTermsInteractionCallback(callback) {
this.termsInteractionCallback = callback;
}
connect() {
connect(): Promise<void> {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
});
}
hasCredentials() {
hasCredentials(): boolean {
return this.scalarToken != null; // undef or null
}
// Returns a promise that resolves to a scalar_token string
getScalarToken() {
const token = this._readToken();
getScalarToken(): Promise<string> {
const token = this.readToken();
if (!token) {
return this.registerForToken();
} else {
return this._checkToken(token).catch((e) => {
return this.checkToken(token).catch((e) => {
if (e instanceof TermsNotSignedError) {
// retrying won't help this
throw e;
@ -100,7 +102,7 @@ export default class ScalarAuthClient {
}
}
_getAccountName(token) {
private getAccountName(token: string): Promise<string> {
const url = this.apiUrl + "/account";
return new Promise(function(resolve, reject) {
@ -125,8 +127,8 @@ export default class ScalarAuthClient {
});
}
_checkToken(token) {
return this._getAccountName(token).then(userId => {
private checkToken(token: string): Promise<string> {
return this.getAccountName(token).then(userId => {
const me = MatrixClientPeg.get().getUserId();
if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me);
@ -153,8 +155,8 @@ export default class ScalarAuthClient {
parsedImRestUrl.path = '';
parsedImRestUrl.pathname = '';
return startTermsFlow([new Service(
Matrix.SERVICE_TYPES.IM,
parsedImRestUrl.format(),
SERVICE_TYPES.IM,
url.format(parsedImRestUrl),
token,
)], this.termsInteractionCallback).then(() => {
return token;
@ -165,22 +167,22 @@ export default class ScalarAuthClient {
});
}
registerForToken() {
registerForToken(): Promise<string> {
// Get openid bearer token from the HS as the first part of our dance
return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => {
// Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(tokenObject);
}).then((token) => {
// Validate it (this mostly checks to see if the IM needs us to agree to some terms)
return this._checkToken(token);
return this.checkToken(token);
}).then((token) => {
this.scalarToken = token;
this._writeTokenToStore();
this.writeTokenToStore();
return token;
});
}
exchangeForScalarToken(openidTokenObject) {
exchangeForScalarToken(openidTokenObject: any): Promise<string> {
const scalarRestUrl = this.apiUrl;
return new Promise(function(resolve, reject) {
@ -194,7 +196,7 @@ export default class ScalarAuthClient {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body || !body.scalar_token) {
reject(new Error("Missing scalar_token in response"));
} else {
@ -204,7 +206,7 @@ export default class ScalarAuthClient {
});
}
getScalarPageTitle(url) {
getScalarPageTitle(url: string): Promise<string> {
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
@ -218,7 +220,7 @@ export default class ScalarAuthClient {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Missing page title in response"));
} else {
@ -240,10 +242,10 @@ export default class ScalarAuthClient {
* @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion
*/
disableWidgetAssets(widgetType: WidgetType, widgetId) {
disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
let url = this.apiUrl + '/widgets/set_assets_state';
url = this.getStarterLink(url);
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
request({
method: 'GET', // XXX: Actions shouldn't be GET requests
uri: url,
@ -257,7 +259,7 @@ export default class ScalarAuthClient {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Failed to set widget assets state"));
} else {
@ -267,7 +269,7 @@ export default class ScalarAuthClient {
});
}
getScalarInterfaceUrlForRoom(room, screen, id) {
getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string {
const roomId = room.roomId;
const roomName = room.name;
let url = this.uiUrl;
@ -284,7 +286,7 @@ export default class ScalarAuthClient {
return url;
}
getStarterLink(starterLinkUrl) {
getStarterLink(starterLinkUrl: string): string {
return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken);
}
}

View file

@ -237,7 +237,7 @@ Example:
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';

View file

@ -395,6 +395,8 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
} catch (e) {
SecurityCustomisations.catchAccessSecretStorageError?.(e);
console.error(e);
// Re-throw so that higher level logic can abort as needed
throw e;
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;

View file

@ -23,7 +23,7 @@ class Skinner {
if (!name) throw new Error(`Invalid component name: ${name}`);
if (this.components === null) {
throw new Error(
"Attempted to get a component before a skin has been loaded."+
`Attempted to get a component (${name}) before a skin has been loaded.`+
" This is probably because either:"+
" a) Your app has not called sdk.loadSkin(), or"+
" b) A component has called getComponent at the root level",

View file

@ -20,6 +20,7 @@ limitations under the License.
import * as React from 'react';
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
@ -126,10 +127,10 @@ export class Command {
return this.getCommand() + " " + this.args;
}
run(roomId: string, args: string, cmd: string) {
run(roomId: string, args: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return reject(_t("Command error"));
return this.runFn.bind(this)(roomId, args, cmd);
return this.runFn.bind(this)(roomId, args);
}
getUsage() {
@ -154,6 +155,18 @@ function success(promise?: Promise<any>) {
*/
export const Commands = [
new Command({
command: 'spoiler',
args: '<message>',
description: _td('Sends the given message as a spoiler'),
runFn: function(roomId, message) {
return success(ContentHelpers.makeHtmlMessage(
message,
`<span data-mx-spoiler>${message}</span>`,
));
},
category: CommandCategories.messages,
}),
new Command({
command: 'shrug',
args: '<message>',
@ -163,7 +176,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -176,7 +189,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -189,7 +202,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -202,7 +215,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -211,7 +224,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendTextMessage(roomId, messages));
return success(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
@ -220,7 +233,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages));
return success(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
@ -441,15 +454,14 @@ export const Commands = [
}),
new Command({
command: 'invite',
args: '<user-id>',
args: '<user-id> [<reason>]',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const [address, reason] = args.split(/\s+(.+)/);
if (address) {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
const address = matches[1];
// If we need an identity server but don't have one, things
// get a bit more complex here, but we try to show something
// meaningful.
@ -490,7 +502,7 @@ export const Commands = [
}
const inviter = new MultiInviter(roomId);
return success(prom.then(() => {
return inviter.invite([address]);
return inviter.invite([address], reason);
}).then(() => {
if (inviter.getCompletionState(address) !== "invited") {
throw new Error(inviter.getErrorText(address));
@ -966,7 +978,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -976,7 +988,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -1201,10 +1213,14 @@ export function parseCommandString(input: string) {
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId: string, input: string) {
export function getCommand(input: string) {
const {cmd, args} = parseCommandString(input);
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
return () => CommandMap.get(cmd).run(roomId, args, cmd);
return {
cmd: CommandMap.get(cmd),
args,
};
}
return {};
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './';
import * as sdk from '.';
import Modal from './Modal';
export class TermsNotSignedError extends Error {}
@ -32,13 +32,30 @@ export class Service {
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
* @param {string} accessToken The user's access token for the service
*/
constructor(serviceType, baseUrl, accessToken) {
this.serviceType = serviceType;
this.baseUrl = baseUrl;
this.accessToken = accessToken;
constructor(public serviceType: string, public baseUrl: string, public accessToken: string) {
}
}
interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: {
url: string;
};
}
type Policies = {
[policy: string]: Policy,
};
export type TermsInteractionCallback = (
policiesAndServicePairs: {
service: Service,
policies: Policies,
}[],
agreedUrls: string[],
extraClassNames?: string,
) => Promise<string[]>;
/**
* Start a flow where the user is presented with terms & conditions for some services
*
@ -51,8 +68,8 @@ export class Service {
* if they cancel.
*/
export async function startTermsFlow(
services,
interactionCallback = dialogTermsInteractionCallback,
services: Service[],
interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback,
) {
const termsPromises = services.map(
(s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl),
@ -77,7 +94,7 @@ export async function startTermsFlow(
* }
*/
const terms = await Promise.all(termsPromises);
const terms: { policies: Policies }[] = await Promise.all(termsPromises);
const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; });
// fetch the set of agreed policy URLs from account data
@ -158,10 +175,13 @@ export async function startTermsFlow(
}
export function dialogTermsInteractionCallback(
policiesAndServicePairs,
agreedUrls,
extraClassNames,
) {
policiesAndServicePairs: {
service: Service,
policies: { [policy: string]: Policy },
}[],
agreedUrls: string[],
extraClassNames?: string,
): Promise<string[]> {
return new Promise((resolve, reject) => {
console.log("Terms that need agreement", policiesAndServicePairs);
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");

View file

@ -95,9 +95,10 @@ function textForMemberEvent(ev) {
senderName,
targetName,
}) + ' ' + reason;
} else {
// sender is not target and made the target leave, if not from invite/ban then this is a kick
} else if (prevContent.membership === "join") {
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
} else {
return "";
}
}
}

View file

@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) {
}
export function doesRoomHaveUnreadMessages(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),

View file

@ -1,17 +0,0 @@
import Velocity from "velocity-animate";
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)
function bounce( p ) {
let pow2;
let bounce = 4;
while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {
// just sets pow2
}
return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 );
}
Velocity.Easings.easeOutBounce = function(p) {
return 1 - bounce(1 - p);
};

View file

@ -37,7 +37,7 @@ export default class VoipUserMapper {
return results[0].userid;
}
public async getOrCreateVirtualRoomForRoom(roomId: string):Promise<string> {
public async getOrCreateVirtualRoomForRoom(roomId: string): Promise<string> {
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (!userId) return null;
@ -52,15 +52,19 @@ export default class VoipUserMapper {
return virtualRoomId;
}
public nativeRoomForVirtualRoom(roomId: string):string {
public nativeRoomForVirtualRoom(roomId: string): string {
const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
return virtualRoomEvent.getContent()['native_room'] || null;
const nativeRoomID = virtualRoomEvent.getContent()['native_room'];
const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null;
return nativeRoomID;
}
public isVirtualRoom(room: Room):boolean {
public isVirtualRoom(room: Room): boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualRoomIdCache.has(room.roomId)) return true;
@ -79,6 +83,8 @@ export default class VoipUserMapper {
}
public async onNewInvitedRoom(invitedRoom: Room) {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);

View file

@ -265,7 +265,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
description: _td("Toggle this dialog"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.ALT],
modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT],
key: Key.H,
}],
description: _td("Go to Home View"),

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-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.
@ -16,7 +16,6 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import SdkConfig from '../../../../SdkConfig';
import SettingsStore from "../../../../settings/SettingsStore";
@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
import {SettingLevel} from "../../../../settings/SettingLevel";
interface IProps {
onFinished: (confirmed: boolean) => void;
}
interface IState {
eventIndexSize: number;
eventCount: number;
crawlingRoomsCount: number;
roomCount: number;
currentRoom: string;
crawlerSleepTime: number;
}
/*
* Allows the user to introspect the event index state and disable it.
*/
export default class ManageEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
export default class ManageEventIndexDialog extends React.Component<IProps, IState> {
constructor(props) {
super(props);
@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component {
}
}
async componentDidMount(): void {
async componentDidMount(): Promise<void> {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component {
});
}
_onDisable = async () => {
private onDisable = async () => {
Modal.createTrackedDialogAsync("Disable message search", "Disable message search",
import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
};
_onCrawlerSleepTimeChange = (e) => {
private onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
};
@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component {
crawlerState = _t("Not currently indexing messages for any room.");
} else {
crawlerState = (
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
_t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom })
);
}
@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component {
label={_t('Message downloading sleep time(ms)')}
type='number'
value={this.state.crawlerSleepTime}
onChange={this._onCrawlerSleepTimeChange} />
onChange={this.onCrawlerSleepTimeChange} />
</div>
</div>
);
@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component {
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("Disable")}
onCancel={this._onDisable}
onCancel={this.onDisable}
cancelButtonClass="danger"
/>
</BaseDialog>

View file

@ -19,7 +19,7 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';
import { _t } from '../../../../languageHandler';

View file

@ -27,6 +27,7 @@ import {sortBy} from "lodash";
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
import {mediaFromMxc} from "../customisations/Media";
const COMMUNITY_REGEX = /\B\+\S*/g;
@ -95,7 +96,7 @@ export default class CommunityProvider extends AutocompleteProvider {
name={name || groupId}
width={24}
height={24}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} />
</PillCompletion>
),
range,

View file

@ -23,7 +23,6 @@ interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
// whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true
fuzzy?: boolean;
}
@ -56,12 +55,6 @@ export default class QueryMatcher<T extends Object> {
if (this._options.shouldMatchWordsOnly === undefined) {
this._options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this._options.shouldMatchPrefix === undefined) {
this._options.shouldMatchPrefix = false;
}
}
setObjects(objects: T[]) {
@ -112,7 +105,7 @@ export default class QueryMatcher<T extends Object> {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
if (index !== -1) {
matches.push(
...candidates.map((candidate) => ({index, ...candidate})),
);

View file

@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new QueryMatcher([], {
keys: ['name'],
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchPrefix: true,
shouldMatchWordsOnly: false,
});

View file

@ -22,6 +22,7 @@ import classNames from "classnames";
import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -91,6 +92,7 @@ interface IState {
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
@ -467,6 +469,7 @@ export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<
return [isOpen, button, open, close, setIsOpen];
};
@replaceableComponent("structures.LegacyContextMenu")
export default class LegacyContextMenu extends ContextMenu {
render() {
return this.renderMenu(false);

View file

@ -21,7 +21,9 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.CustomRoomTagPanel")
class CustomRoomTagPanel extends React.Component {
constructor(props) {
super(props);

View file

@ -16,8 +16,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import request from 'browser-request';

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {Filter} from 'matrix-js-sdk';
import {Filter} from 'matrix-js-sdk/src/filter';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
@ -26,10 +26,12 @@ import { _t } from '../../languageHandler';
import BaseCard from "../views/right_panel/BaseCard";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice";
import {replaceableComponent} from "../../utils/replaceableComponent";
/*
* Component which shows the filtered file using a TimelinePanel
*/
@replaceableComponent("structures.FilePanel")
class FilePanel extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,

View file

@ -16,7 +16,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.GenericErrorPage")
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
title: PropTypes.object.isRequired, // jsx for title

View file

@ -30,7 +30,9 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.GroupFilterPanel")
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;

View file

@ -35,10 +35,12 @@ import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk";
import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -367,8 +369,7 @@ class FeaturedUser extends React.Component {
const permalink = makeUserPermalink(this.props.summaryInfo.user_id);
const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>;
const httpUrl = MatrixClientPeg.get()
.mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64);
const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64);
const deleteButton = this.props.editing ?
<img
@ -391,6 +392,7 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
groupId: PropTypes.string.isRequired,
@ -979,10 +981,9 @@ export default class GroupView extends React.Component {
<Spinner />
</div>;
}
const httpInviterAvatar = this.state.inviterProfile ?
this._matrixClient.mxcUrlToHttp(
this.state.inviterProfile.avatarUrl, 36, 36,
) : null;
const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl
? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36)
: null;
const inviter = group.inviter || {};
let inviterName = inviter.userId;

View file

@ -22,11 +22,13 @@ import {
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {}
interface IState {}
@replaceableComponent("structures.HostSignupAction")
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => {
await HostSignupStore.instance.setHostSignupActive(true);

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator

View file

@ -15,16 +15,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {InteractiveAuth} from "matrix-js-sdk";
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

View file

@ -16,9 +16,11 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
@ -32,14 +34,15 @@ 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 {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
interface IProps {
isMinimized: boolean;
@ -49,6 +52,7 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showGroupFilterPanel: boolean;
activeSpace?: Room;
}
// List of CSS classes which should be included in keyboard navigation within the room list
@ -60,6 +64,7 @@ const cssClasses = [
"mx_RoomSublist_showNButton",
];
@replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string;
@ -73,11 +78,13 @@ 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, () => {
@ -95,9 +102,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
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);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
}
private updateActiveSpace = (activeSpace: Room) => {
this.setState({ activeSpace });
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
@ -119,7 +131,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) {
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
@ -142,7 +154,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private doStickyHeaders(list: HTMLDivElement) {
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
@ -285,17 +297,18 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
ev.stopPropagation();
ev.preventDefault();
this.onMoveFocus(ev.key === Key.ARROW_UP);
this.onMoveFocus(action === RoomListAction.PrevRoom);
break;
}
};
private onEnter = () => {
private selectRoom = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
@ -376,11 +389,13 @@ export default class LeftPanel extends React.Component<IProps, IState> {
>
<RoomSearch
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter}
onKeyDown={this.onKeyDown}
onSelectRoom={this.selectRoom}
/>
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={_t("Explore rooms")}
/>
@ -390,11 +405,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
public render(): React.ReactNode {
let leftLeftPanel;
// Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now
// ignore it and force the rendering of SpacePanel if that Labs flag is enabled.
if (SettingsStore.getValue("feature_spaces")) {
leftLeftPanel = <SpacePanel />;
} else if (this.state.showGroupFilterPanel) {
if (this.state.showGroupFilterPanel) {
leftLeftPanel = (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
@ -410,6 +421,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
activeSpace={this.state.activeSpace}
/>;
const containerClasses = classNames({

View file

@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
@ -55,7 +55,10 @@ import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
// 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.
@ -72,7 +75,6 @@ function canElementReceiveInput(el) {
interface IProps {
matrixClient: MatrixClient;
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
// eslint-disable-next-line camelcase
@ -96,8 +98,10 @@ interface IProps {
}
interface IUsageLimit {
// "hs_disabled" is NOT a specced string, but is used in Synapse
// This is tracked over at https://github.com/matrix-org/synapse/issues/9237
// eslint-disable-next-line camelcase
limit_type: "monthly_active_user" | string;
limit_type: "monthly_active_user" | "hs_disabled" | string;
// eslint-disable-next-line camelcase
admin_contact?: string;
}
@ -105,6 +109,8 @@ interface IUsageLimit {
interface IState {
syncErrorData?: {
error: {
// This is not specced, but used in Synapse. See
// https://github.com/matrix-org/synapse/issues/9237#issuecomment-768238922
data: IUsageLimit;
errcode: string;
};
@ -124,6 +130,7 @@ interface IState {
*
* Components mounted below us can access the matrix client via the react context.
*/
@replaceableComponent("structures.LoggedInView")
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
@ -136,9 +143,6 @@ class LoggedInView extends React.Component<IProps, IState> {
// transitioned to PWLU)
onRegistered: PropTypes.func,
// Used by the RoomView to handle joining rooms
viaServers: PropTypes.arrayOf(PropTypes.string),
// and lots and lots of other stuff.
};
@ -223,21 +227,15 @@ class LoggedInView extends React.Component<IProps, IState> {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
// TODO: the space panel currently does not have a fixed width,
// just the headers at each level have a max-width of 150px
// Taking 222px for the space panel for now,
// so this will look slightly off for now,
// depending on the depth of your space tree.
// To fix this, we'll need to turn toggleSize
// into a callback so it can be measured when starting the resize operation
toggleSize: 222 + 68,
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
toggleSize: 206 - 50,
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
dis.dispatch({action: "hide_left_panel"}, true);
dis.dispatch({action: "hide_left_panel"});
window.localStorage.setItem("mx_lhs_size", '0');
} else {
dis.dispatch({action: "show_left_panel"}, true);
dis.dispatch({action: "show_left_panel"});
}
},
onResized: (_size) => {
@ -439,86 +437,55 @@ class LoggedInView extends React.Component<IProps, IState> {
_onKeyDown = (ev) => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
switch (ev.key) {
case Key.PAGE_UP:
case Key.PAGE_DOWN:
if (!hasModifier && !isModifier) {
this._onScrollKeyPressed(ev);
handled = true;
}
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case RoomAction.ScrollUp:
case RoomAction.RoomScrollDown:
case RoomAction.JumpToFirstMessage:
case RoomAction.JumpToLatestMessage:
// pass the event down to the scroll panel
this._onScrollKeyPressed(ev);
handled = true;
break;
case RoomAction.FocusSearch:
dis.dispatch({
action: 'focus_search',
});
handled = true;
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
return;
}
case Key.HOME:
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
const navAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navAction) {
case NavigationAction.FocusRoomSearch:
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
break;
case Key.K:
if (ctrlCmdOnly) {
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
}
case NavigationAction.ToggleUserMenu:
dis.fire(Action.ToggleUserMenu);
handled = true;
break;
case Key.F:
if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
dis.dispatch({
action: 'focus_search',
});
handled = true;
}
case NavigationAction.ToggleShortCutDialog:
KeyboardShortcuts.toggleDialog();
handled = true;
break;
case Key.BACKTICK:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
dis.fire(Action.ToggleUserMenu);
handled = true;
}
case NavigationAction.GoToHome:
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
break;
case Key.SLASH:
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
KeyboardShortcuts.toggleDialog();
handled = true;
}
break;
case Key.H:
if (ev.altKey && modKey) {
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
handled = true;
}
break;
case Key.PERIOD:
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
case NavigationAction.ToggleRoomSidePanel:
if (this.props.page_type === "room_view" || this.props.page_type === "group_view") {
dis.dispatch<ToggleRightPanelPayload>({
action: Action.ToggleRightPanel,
type: this.props.page_type === "room_view" ? "room" : "group",
@ -526,16 +493,48 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
}
break;
case NavigationAction.SelectPrevRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
handled = true;
break;
case NavigationAction.SelectNextRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
handled = true;
break;
case NavigationAction.SelectPrevUnreadRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: -1,
unread: true,
});
break;
case NavigationAction.SelectNextUnreadRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: 1,
unread: true,
});
break;
default:
// if we do not have a handler for it, pass it to the platform which might
handled = PlatformPeg.get().onKeyDown(ev);
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
} else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
return;
}
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) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
@ -624,11 +623,9 @@ class LoggedInView extends React.Component<IProps, IState> {
case PageTypes.RoomView:
pageElement = <RoomView
ref={this._roomView}
autoJoin={this.props.autoJoin}
onRegistered={this.props.onRegistered}
threepidInvite={this.props.threepidInvite}
oobData={this.props.roomOobData}
viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'}
resizeNotifier={this.props.resizeNotifier}
justCreatedOpts={this.props.roomJustCreatedOpts}
@ -664,13 +661,6 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const leftPanel = (
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -682,7 +672,11 @@ class LoggedInView extends React.Component<IProps, IState> {
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
{ leftPanel }
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from 'react';
import { Resizable } from 're-resizable';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.MainSplit")
export default class MainSplit extends React.Component {
_onResizeStart = () => {
this.props.resizeNotifier.startResizing();

View file

@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017-2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
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.
@ -18,8 +15,7 @@ limitations under the License.
*/
import React, { createRef } from 'react';
// @ts-ignore - XXX: no idea why this import fails
import * as Matrix from "matrix-js-sdk";
import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -82,8 +78,13 @@ import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import { shouldUseLoginForWelcome } from "../../utils/pages";
import SpaceStore from "../../stores/SpaceStore";
import SpaceRoomDirectory from "./SpaceRoomDirectory";
import {replaceableComponent} from "../../utils/replaceableComponent";
import RoomListStore from "../../stores/room-list/RoomListStore";
import {RoomUpdateCause} from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
/** constants for MatrixChat.state.view */
export enum Views {
@ -202,12 +203,12 @@ interface IState {
ready: boolean;
threepidInvite?: IThreepidInvite,
roomOobData?: object;
viaServers?: string[];
pendingInitialSync?: boolean;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
}
@replaceableComponent("structures.MatrixChat")
export default class MatrixChat extends React.PureComponent<IProps, IState> {
static displayName = "MatrixChat";
@ -395,7 +396,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) {
this.onLoggedIn();
} else {
this.setStateForNewView({view: Views.COMPLETE_SECURITY});
}
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
@ -580,6 +585,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
dis.dispatch({action: "hangup_all"});
Lifecycle.logout();
break;
case 'require_registration':
@ -604,12 +610,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (payload.screenAfterLogin) {
this.screenAfterLogin = payload.screenAfterLogin;
}
this.setStateForNewView({
view: Views.LOGIN,
});
this.notifyNewScreen('login');
ThemeController.isLogin = true;
this.themeWatcher.recheck();
this.viewLogin();
break;
case 'start_password_recovery':
this.setStateForNewView({
@ -694,10 +695,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewRoomDirectory: {
if (SpaceStore.instance.activeSpace) {
Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, {
space: SpaceStore.instance.activeSpace,
initialText: payload.initialText,
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
defaultDispatcher.dispatch({
action: "view_room",
room_id: SpaceStore.instance.activeSpace.roomId,
});
} else {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
@ -932,7 +933,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
page_type: PageTypes.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
viaServers: roomInfo.via_servers,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
@ -973,6 +973,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private viewWelcome() {
if (shouldUseLoginForWelcome(SdkConfig.get())) {
return this.viewLogin();
}
this.setStateForNewView({
view: Views.WELCOME,
});
@ -981,6 +984,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.themeWatcher.recheck();
}
private viewLogin(otherState?: any) {
this.setStateForNewView({
view: Views.LOGIN,
...otherState,
});
this.notifyNewScreen('login');
ThemeController.isLogin = true;
this.themeWatcher.recheck();
}
private viewHome(justRegistered = false) {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
@ -1083,8 +1096,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications.
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
const warnings = [];
const memberCount = roomToLeave.currentState.getJoinedMemberCount();
if (memberCount === 1) {
warnings.push((
<span className="warning" key="only_member_warning">
{' '/* Whitespace, otherwise the sentences get smashed together */ }
{ _t("You are the only person here. " +
"If you leave, no one will be able to join in the future, including you.") }
</span>
));
return warnings;
}
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
const rule = joinRules.getContent().join_rule;
if (rule !== "public") {
@ -1137,11 +1164,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private forgetRoom(roomId: string) {
const room = MatrixClientPeg.get().getRoom(roomId);
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to home page if we're currently viewing the forgotten room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_home_page" });
}
// We have to manually update the room list because the forgotten room will not
// be notified to us, therefore the room list will have no other way of knowing
// the room is forgotten.
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
@ -1296,17 +1329,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Called when the session is logged out
*/
private onLoggedOut() {
this.notifyNewScreen('login');
this.setStateForNewView({
view: Views.LOGIN,
this.viewLogin({
ready: false,
collapseLhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
this.setPageSubtitle();
ThemeController.isLogin = true;
this.themeWatcher.recheck();
}
/**
@ -1544,7 +1573,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
title: request.isSelfVerification ? _t("Self-verification request") : _t("Verification Request"),
title: _t("Verification requested"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
@ -1646,7 +1675,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let cli = MatrixClientPeg.get();
if (!cli) {
const {hsUrl, isUrl} = this.props.serverConfig;
cli = Matrix.createClient({
cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});

View file

@ -23,7 +23,6 @@ import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index';
import dis from "../../dispatcher/dispatcher";
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
@ -34,6 +33,7 @@ import {textForEvent} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import {replaceableComponent} from "../../utils/replaceableComponent";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -46,6 +46,9 @@ function shouldFormContinuation(prevEvent, mxEvent) {
// check if within the max continuation period
if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false;
// As we summarise redactions, do not continue a redacted event onto a non-redacted one and vice-versa
if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false;
// Some events should appear as continuations from previous events of different types.
if (mxEvent.getType() !== prevEvent.getType() &&
(!continuedTypes.includes(mxEvent.getType()) ||
@ -66,6 +69,7 @@ const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType()
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
@replaceableComponent("structures.MessagePanel")
export default class MessagePanel extends React.Component {
static propTypes = {
// true to give the component a 'display: none' style.
@ -208,13 +212,11 @@ export default class MessagePanel extends React.Component {
componentDidMount() {
this._isMounted = true;
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
this._isMounted = false;
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
dis.unregister(this.dispatcherRef);
}
componentDidUpdate(prevProps, prevState) {
@ -227,14 +229,6 @@ export default class MessagePanel extends React.Component {
}
}
onAction = (payload) => {
switch (payload.action) {
case "scroll_to_bottom":
this.scrollToBottom();
break;
}
}
onShowTypingNotificationsChange = () => {
this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -461,6 +455,20 @@ export default class MessagePanel extends React.Component {
});
};
_getNextEventInfo(arr, i) {
const nextEvent = i < arr.length - 1
? arr[i + 1]
: null;
// The next event with tile is used to to determine the 'last successful' flag
// when rendering the tile. The shouldShowEvent function is pretty quick at what
// it does, so this should have no significant cost even when a room is used for
// not-chat purposes.
const nextTile = arr.slice(i + 1).find(e => this._shouldShowEvent(e));
return {nextEvent, nextTile};
}
_getEventTiles() {
this.eventNodes = {};
@ -498,6 +506,9 @@ export default class MessagePanel extends React.Component {
let prevEvent = null; // the last event we showed
// Note: the EventTile might still render a "sent/sending receipt" independent of
// this information. When not providing read receipt information, the tile is likely
// to assume that sent receipts are to be shown more often.
this._readReceiptsByEvent = {};
if (this.props.showReadReceipts) {
this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
@ -509,6 +520,7 @@ export default class MessagePanel extends React.Component {
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
const {nextEvent, nextTile} = this._getNextEventInfo(this.props.events, i);
if (grouper) {
if (grouper.shouldGroup(mxEv)) {
@ -525,19 +537,16 @@ export default class MessagePanel extends React.Component {
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent);
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
}
}
if (!grouper) {
const wantTile = this._shouldShowEvent(mxEv);
if (wantTile) {
const nextEvent = i < this.props.events.length - 1
? this.props.events[i + 1]
: null;
// make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up
// replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent));
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile));
prevEvent = mxEv;
}
@ -553,7 +562,7 @@ export default class MessagePanel extends React.Component {
return ret;
}
_getTilesForEvent(prevEvent, mxEv, last, nextEvent) {
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) {
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
@ -595,6 +604,30 @@ export default class MessagePanel extends React.Component {
const readReceipts = this._readReceiptsByEvent[eventId];
let isLastSuccessful = false;
const isSentState = s => !s || s === 'sent';
const isSent = isSentState(mxEv.getAssociatedStatus());
const hasNextEvent = nextEvent && this._shouldShowEvent(nextEvent);
if (!hasNextEvent && isSent) {
isLastSuccessful = true;
} else if (hasNextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) {
isLastSuccessful = true;
}
// This is a bit nuanced, but if our next event is hidden but a future event is not
// hidden then we're not the last successful.
if (
nextEventWithTile &&
nextEventWithTile !== nextEvent &&
isSentState(nextEventWithTile.getAssociatedStatus())
) {
isLastSuccessful = false;
}
// We only want to consider "last successful" if the event is sent by us, otherwise of course
// it's successful: we received it.
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
// use txnId as key if available so that we don't remount during sending
ret.push(
<li
@ -620,11 +653,13 @@ export default class MessagePanel extends React.Component {
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/>
</TileErrorBoundary>
</li>,
@ -1001,6 +1036,103 @@ class CreationGrouper {
}
}
class RedactionGrouper {
static canStartGroup = function(panel, ev) {
return panel._shouldShowEvent(ev) && ev.isRedacted();
}
constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) {
this.panel = panel;
this.readMarker = panel._readMarkerForEvent(
ev.getId(),
ev === lastShownEvent,
);
this.events = [ev];
this.prevEvent = prevEvent;
this.lastShownEvent = lastShownEvent;
this.nextEvent = nextEvent;
this.nextEventTile = nextEventTile;
}
shouldGroup(ev) {
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped
if (!this.panel._shouldShowEvent(ev)) {
return true;
}
if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return ev.isRedacted();
}
add(ev) {
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
ev.getId(),
ev === this.lastShownEvent,
);
if (!this.panel._shouldShowEvent(ev)) {
return;
}
this.events.push(ev);
}
getTiles() {
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const panel = this.panel;
const ret = [];
const lastShownEvent = this.lastShownEvent;
if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
const ts = this.events[0].getTs();
ret.push(
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
);
}
const key = "redactioneventlistsummary-" + (
this.prevEvent ? this.events[0].getId() : "initial"
);
const senders = new Set();
let eventTiles = this.events.map((e, i) => {
senders.add(e.sender);
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
eventTiles = null;
}
ret.push(
<EventListSummary
key={key}
threshold={2}
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={Array.from(senders)}
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
>
{ eventTiles }
</EventListSummary>,
);
if (this.readMarker) {
ret.push(this.readMarker);
}
return ret;
}
getNewPrevEvent() {
return this.events[this.events.length - 1];
}
}
// Wrap consecutive member events in a ListSummary, ignore if redacted
class MemberGrouper {
static canStartGroup = function(panel, ev) {
@ -1111,4 +1243,4 @@ class MemberGrouper {
}
// all the grouper classes that we use
const groupers = [CreationGrouper, MemberGrouper];
const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper];

View file

@ -24,7 +24,9 @@ import dis from '../../dispatcher/dispatcher';
import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component {
static contextType = MatrixClientContext;

View file

@ -18,6 +18,7 @@ import * as React from "react";
import { ComponentClass } from "../../@types/common";
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {
}
@ -26,6 +27,7 @@ interface IState {
toasts: ComponentClass[],
}
@replaceableComponent("structures.NonUrgentToastContainer")
export default class NonUrgentToastContainer extends React.PureComponent<IProps, IState> {
public constructor(props, context) {
super(props, context);

View file

@ -23,10 +23,12 @@ import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import BaseCard from "../views/right_panel/BaseCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
/*
* Component which shows the global notification list using a TimelinePanel
*/
@replaceableComponent("structures.NotificationPanel")
class NotificationPanel extends React.Component {
static propTypes = {
onClose: PropTypes.func.isRequired,

View file

@ -34,7 +34,9 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component {
static get propTypes() {
return {

View file

@ -27,13 +27,14 @@ import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
@ -42,6 +43,7 @@ function track(action) {
Analytics.trackEvent('RoomDirectory', action);
}
@replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
@ -519,10 +521,9 @@ export default class RoomDirectory extends React.Component {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyAndSanitizeHtml(topic);
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
room.avatar_url, 32, 32, "crop",
);
let avatarUrl = null;
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 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.
@ -20,16 +20,21 @@ import classNames from "classnames";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
interface IProps {
isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent): void;
onEnter(ev: React.KeyboardEvent): boolean;
onKeyDown(ev: React.KeyboardEvent): void;
/**
* @returns true if a room has been selected and the search field should be cleared
*/
onSelectRoom(): boolean;
}
interface IState {
@ -37,6 +42,7 @@ interface IState {
focused: boolean;
}
@replaceableComponent("structures.RoomSearch")
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
@ -51,6 +57,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -70,6 +78,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
}
private onAction = (payload: ActionPayload) => {
@ -106,18 +115,26 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ESCAPE) {
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} else if (ev.key === Key.ENTER) {
const shouldClear = this.props.onEnter(ev);
if (shouldClear) {
// wrap in set immediate to delay it so that we don't clear the filter & then change room
setImmediate(() => {
this.clearInput();
});
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case RoomListAction.ClearSearch:
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
break;
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
// we don't handle these actions here put pass the event on to the interested party (LeftPanel)
this.props.onKeyDown(ev);
break;
case RoomListAction.SelectRoom: {
const shouldClear = this.props.onSelectRoom();
if (shouldClear) {
// wrap in set immediate to delay it so that we don't clear the filter & then change room
setImmediate(() => {
this.clearInput();
});
}
break;
}
}
};

View file

@ -1,5 +1,5 @@
/*
Copyright 2015-2020 The Matrix.org Foundation C.I.C.
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.
@ -16,25 +16,31 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {messageForResourceLimitError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {EventStatus} from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge";
import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
export function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
return ev.status === EventStatus.NOT_SENT;
});
}
@replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.Component {
static propTypes = {
// the room this statusbar is representing.
@ -74,6 +80,7 @@ export default class RoomStatusBar extends React.Component {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room),
isResending: false,
};
componentDidMount() {
@ -107,7 +114,10 @@ export default class RoomStatusBar extends React.Component {
};
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({isResending: false});
});
this.setState({isResending: true});
dis.fire(Action.FocusComposer);
};
@ -118,9 +128,10 @@ export default class RoomStatusBar extends React.Component {
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room);
this.setState({
unsentMessages: getUnsentMessages(this.props.room),
unsentMessages: messages,
isResending: messages.length > 0 && this.state.isResending,
});
};
@ -139,7 +150,7 @@ export default class RoomStatusBar extends React.Component {
_getSize() {
if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
@ -160,7 +171,6 @@ export default class RoomStatusBar extends React.Component {
_getUnsentMessageContent() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
@ -195,80 +205,85 @@ export default class RoomStatusBar extends React.Component {
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'hs_disabled': _td(
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
});
} else if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
} else {
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
title = _t('Some of your messages have not been sent');
}
const content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " +
"now. You can also select individual messages to resend or cancel.",
{ count: unsentMessages.length },
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{_t("Delete all")}
</AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{_t("Retry all")}
</AccessibleButton>
</>;
if (this.state.isResending) {
buttonRow = <>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("Sending")}</span>
</>;
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
return <>
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ title }
</div>
<div className="mx_RoomStatusBar_unsentDescription">
{ _t("You can select all or individual messages to retry or delete") }
</div>
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{buttonRow}
</div>
</div>
</div>
</div>;
</>;
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
render() {
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<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.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<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="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{_t('Connectivity to the server has been lost.')}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{_t('Sent messages will be stored until your connection has returned.')}
</div>
</div>
</div>
</div>
</div>
);
}
if (this.state.unsentMessages.length > 0) {
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent();
}
return null;
}
render() {
const content = this._getContent();
return (
<div className="mx_RoomStatusBar">
<div role="alert">
{ content }
</div>
</div>
);
}
}

View file

@ -40,7 +40,6 @@ import Tinter from '../../Tinter';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
@ -79,9 +78,11 @@ import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -111,10 +112,6 @@ interface IProps {
inviterName?: string;
};
// Servers the RoomView can use to try and assist joins
viaServers?: string[];
autoJoin?: boolean;
resizeNotifier: ResizeNotifier;
justCreatedOpts?: IOpts;
@ -195,6 +192,7 @@ export interface IState {
dragCounter: number;
}
@replaceableComponent("structures.RoomView")
export default class RoomView extends React.Component<IProps, IState> {
private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription;
@ -448,9 +446,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// now not joined because the js-sdk peeking API will clobber our historical room,
// making it impossible to indicate a newly joined room.
if (!joining && roomId) {
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (!room && shouldPeek) {
if (!room && shouldPeek) {
console.info("Attempting to peek into room %s", roomId);
this.setState({
peekLoading: true,
@ -666,26 +662,20 @@ export default class RoomView extends React.Component<IProps, IState> {
private onReactKeyDown = ev => {
let handled = false;
switch (ev.key) {
case Key.ESCAPE:
if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) {
this.messagePanel.forgetReadMarker();
this.jumpToLiveTimeline();
handled = true;
}
const action = getKeyBindingsManager().getRoomAction(ev);
switch (action) {
case RoomAction.DismissReadMarker:
this.messagePanel.forgetReadMarker();
this.jumpToLiveTimeline();
handled = true;
break;
case Key.PAGE_UP:
if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) {
this.jumpToReadMarker();
handled = true;
}
case RoomAction.JumpToOldestUnread:
this.jumpToReadMarker();
handled = true;
break;
case Key.U: // Mac returns lowercase
case Key.U.toUpperCase():
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) {
dis.dispatch({ action: "upload_file" }, true);
handled = true;
}
case RoomAction.UploadFile:
dis.dispatch({ action: "upload_file" }, true);
handled = true;
break;
}
@ -711,9 +701,9 @@ export default class RoomView extends React.Component<IProps, IState> {
[payload.file], this.state.room.roomId, this.context);
break;
case 'notifier_enabled':
case 'upload_started':
case 'upload_finished':
case 'upload_canceled':
case Action.UploadStarted:
case Action.UploadFinished:
case Action.UploadCanceled:
this.forceUpdate();
break;
case 'call_state': {
@ -1121,7 +1111,7 @@ export default class RoomView extends React.Component<IProps, IState> {
const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({
action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
opts: { inviteSignUrl: signUrl },
_type: "unknown", // TODO: instrumentation
});
return Promise.resolve();
@ -1147,10 +1137,16 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter + 1,
draggingFile: true,
});
// We always increment the counter no matter the types, because dragging is
// still happening. If we didn't, the drag counter would get out of sync.
this.setState({dragCounter: this.state.dragCounter + 1});
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({draggingFile: true});
}
};
private onDragLeave = ev => {
@ -1174,6 +1170,9 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.dataTransfer.dropEffect = 'none';
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
ev.dataTransfer.dropEffect = 'copy';
}
@ -1911,7 +1910,7 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
if (this.state.room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) {
return <SpaceRoomView
space={this.state.room}
justCreatedOpts={this.props.justCreatedOpts}

View file

@ -16,9 +16,10 @@ limitations under the License.
import React, {createRef} from "react";
import PropTypes from 'prop-types';
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager";
const DEBUG_SCROLL = false;
@ -83,6 +84,7 @@ if (DEBUG_SCROLL) {
* offset as normal.
*/
@replaceableComponent("structures.ScrollPanel")
export default class ScrollPanel extends React.Component {
static propTypes = {
/* stickyBottom: if set to true, then once the user hits the bottom of
@ -523,7 +525,7 @@ export default class ScrollPanel extends React.Component {
*/
scrollRelative = mult => {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
};
@ -533,29 +535,19 @@ export default class ScrollPanel extends React.Component {
* @param {object} ev the keyboard event
*/
handleScrollKey = ev => {
switch (ev.key) {
case Key.PAGE_UP:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(-1);
}
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case RoomAction.ScrollUp:
this.scrollRelative(-1);
break;
case Key.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(1);
}
case RoomAction.RoomScrollDown:
this.scrollRelative(1);
break;
case Key.HOME:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToTop();
}
case RoomAction.JumpToFirstMessage:
this.scrollToTop();
break;
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToBottom();
}
case RoomAction.JumpToLatestMessage:
this.scrollToBottom();
break;
}
};

View file

@ -22,7 +22,9 @@ import dis from '../../dispatcher/dispatcher';
import {throttle} from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames';
import {replaceableComponent} from "../../utils/replaceableComponent";
@replaceableComponent("structures.SearchBox")
export default class SearchBox extends React.Component {
static propTypes = {
onSearch: PropTypes.func,
@ -30,6 +32,8 @@ export default class SearchBox extends React.Component {
onKeyDown: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
autoFocus: PropTypes.bool,
initialValue: PropTypes.string,
// If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take
@ -47,7 +51,7 @@ export default class SearchBox extends React.Component {
this._search = createRef();
this.state = {
searchTerm: "",
searchTerm: this.props.initialValue || "",
blurred: true,
};
}
@ -156,6 +160,7 @@ export default class SearchBox extends React.Component {
onBlur={this._onBlur}
placeholder={ placeholder }
autoComplete="off"
autoFocus={this.props.autoFocus}
/>
{ clearButton }
</div>

View file

@ -14,31 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useRef, useState} from "react";
import Room from "matrix-js-sdk/src/models/room";
import MatrixEvent from "matrix-js-sdk/src/models/event";
import React, {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 classNames from "classnames";
import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import FormButton from "../views/elements/FormButton";
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 {shouldShowSpaceSettings} from "../../utils/space";
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";
interface IProps {
interface IHierarchyProps {
space: Room;
initialText?: string;
onFinished(): void;
refreshToken?: any;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
/* eslint-disable camelcase */
@ -64,243 +70,138 @@ export interface ISpaceSummaryEvent {
state_key: string;
content: {
order?: string;
suggested?: boolean;
auto_join?: boolean;
via?: string;
};
}
/* eslint-enable camelcase */
interface ISubspaceProps {
space: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick?(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
interface ITileProps {
room: ISpaceSummaryRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const SubSpace: React.FC<ISubspaceProps> = ({
space,
editing,
event,
queueAction,
onJoinClick,
onPreviewClick,
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
const name = room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const evContent = event?.getContent();
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
const [removed, _setRemoved] = useState(!evContent?.via);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(space.room_id);
const myMembership = cliRoom?.getMyMembership();
// TODO DRY code
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setAutoJoin = () => {
_setAutoJoin(v => {
queueAction({
event,
removed,
autoJoin: !v,
});
return !v;
});
};
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
autoJoin,
});
return !v;
});
};
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
</React.Fragment>;
}
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this space")}
</span>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
}
}
let url: string;
if (space.avatar_url) {
url = MatrixClientPeg.get().mxcUrlToHttp(
space.avatar_url,
Math.floor(24 * window.devicePixelRatio),
Math.floor(24 * window.devicePixelRatio),
"crop",
);
}
return <div className="mx_SpaceRoomDirectory_subspace">
<div className="mx_SpaceRoomDirectory_subspace_info">
<BaseAvatar name={name} idName={space.room_id} url={url} width={24} height={24} />
{ name }
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
</div>
</div>
<div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>
</div>
};
interface IAction {
event: MatrixEvent;
removed: boolean;
autoJoin: boolean;
}
interface IRoomTileProps {
room: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
}
const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
const evContent = event?.getContent();
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
const [removed, _setRemoved] = useState(!evContent?.via);
const [showChildren, toggleShowChildren] = useStateToggle(true);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setAutoJoin = () => {
_setAutoJoin(v => {
queueAction({
event,
removed,
autoJoin: !v,
});
return !v;
});
};
const onPreviewClick = () => onViewRoomClick(false);
const onJoinClick = () => onViewRoomClick(true);
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
autoJoin,
});
return !v;
});
};
let button;
if (myMembership === "join") {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
{ _t("Join") }
</AccessibleButton>;
}
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
</React.Fragment>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this room")}
</span>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation() }}
>
<StyledCheckbox disabled={true} />
</TextWithTooltip>;
}
}
let url: string;
if (room.avatar_url) {
url = cli.mxcUrlToHttp(
room.avatar_url,
Math.floor(32 * window.devicePixelRatio),
Math.floor(32 * window.devicePixelRatio),
"crop",
);
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
if (room.topic) {
description += " · " + room.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>
<BaseAvatar name={name} idName={room.room_id} url={url} width={32} height={32} />
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_info">
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_topic">
{ room.topic }
</div>
{ description }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_memberCount">
{ room.num_joined_members }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
{ button }
{ checkbox }
</div>
</React.Fragment>;
if (editing) {
return <div className="mx_SpaceRoomDirectory_roomTile">
{ content }
</div>
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="mx_SpaceRoomDirectory_roomTile" onClick={onPreviewClick}>
{ content }
</AccessibleButton>;
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) => {
@ -333,99 +234,339 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi
interface IHierarchyLevelProps {
spaceId: string;
rooms: Map<string, ISpaceSummaryRoom>;
editing?: boolean;
relations: EnhancedMap<string, string[]>;
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
parents: Set<string>;
queueAction?(action: IAction): void;
onPreviewClick(roomId: string): void;
onRemoveFromSpaceClick?(roomId: string): void;
onJoinClick?(roomId: string): void;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
editing,
relations,
parents,
onPreviewClick,
onJoinClick,
queueAction,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
const space = cli.getRoom(spaceId);
// TODO respect order
const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
if (!rooms.has(roomId)) return result; // TODO wat
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null);
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;
}, [[], []]) || [[], []];
// Don't render this subspace if it has no rooms we can show
// TODO this is broken - as a space may have subspaces we still need to show
// if (!childRooms.length) return null;
const userId = cli.getUserId();
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<RoomTile
<Tile
key={roomId}
room={rooms.get(roomId)}
event={space?.currentState.maySendStateEvent(EventType.SpaceChild, userId)
? space?.currentState.getStateEvents(EventType.SpaceChild, roomId)
: undefined}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
onJoinClick={onJoinClick ? () => {
onJoinClick(roomId);
} : undefined}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<SubSpace
<Tile
key={roomId}
space={rooms.get(roomId)}
event={space?.currentState.getStateEvents(EventType.SpaceChild, roomId)}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
}}
onJoinClick={() => {
onJoinClick(roomId);
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).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}
editing={editing}
relations={relations}
parents={newParents}
onPreviewClick={onPreviewClick}
onJoinClick={onJoinClick}
queueAction={queueAction}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</SubSpace>
</Tile>
))
}
</React.Fragment>
};
const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinished }) => {
// 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
const cli = MatrixClientPeg.get();
const [query, setQuery] = useState(initialText);
const [isEditing, setIsEditing] = useState(false);
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,
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 !== RoomType.Space).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 editSection;
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][];
});
let buttons;
if (selectedRelations.length) {
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = removing || saving;
buttons = <>
<AccessibleButton
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(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") }
</AccessibleButton>
<AccessibleButton
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);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</AccessibleButton>
</>;
}
editSection = <span>
{ buttons }
</span>;
}
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 }
{ editSection }
</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 description") }
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',
@ -434,134 +575,40 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
onFinished();
};
// stored within a ref as we don't need to re-render when it changes
const pendingActions = useRef(new Map<string, IAction>());
let adminButton;
if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
const onManageButtonClicked = () => {
setIsEditing(true);
};
const onSaveButtonClicked = () => {
// TODO setBusy
pendingActions.current.forEach(({event, autoJoin, removed}) => {
const content = {
...event.getContent(),
auto_join: autoJoin,
};
if (removed) {
delete content["via"];
}
cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
});
setIsEditing(false);
};
if (isEditing) {
adminButton = <React.Fragment>
<FormButton label={_t("Save changes")} onClick={onSaveButtonClicked} />
<span>{ _t("All users join by default") }</span>
</React.Fragment>;
} else {
adminButton = <FormButton label={_t("Manage rooms")} onClick={onManageButtonClicked} />;
}
}
const [rooms, relations, viaMap] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, string[]>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
if (Array.isArray(ev.content["via"])) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
}
});
return [data.rooms, parentChildRelations, viaMap];
} catch (e) {
console.error(e); // TODO
}
return [];
}, [space], []);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase();
const filteredRooms = rooms.filter(r => {
return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
|| r.name?.toLowerCase().includes(lcQuery)
|| r.topic?.toLowerCase().includes(lcQuery);
});
return new Map<string, ISpaceSummaryRoom>(filteredRooms.map(r => [r.room_id, r]));
// const root = rooms.get(space.roomId);
}, [rooms, query]);
const title = <React.Fragment>
<RoomAvatar room={space} height={40} width={40} />
<RoomAvatar room={space} height={32} width={32} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
const explanation =
_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>;
}},
);
let content;
if (roomsMap) {
content = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={isEditing}
relations={relations}
parents={new Set()}
queueAction={action => {
pendingActions.current.set(action.event.room_id, action);
}}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
onFinished();
}}
onJoinClick={(roomId) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
onFinished();
}}
/>
</AutoHideScrollbar>;
}
// TODO loading state/error state
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ explanation }
{ _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>;
}},
) }
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Find a room...") }
onSearch={setQuery}
/>
<div className="mx_SpaceRoomDirectory_listHeader">
{ adminButton }
</div>
{ content }
<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>
);

View file

@ -15,8 +15,9 @@ limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {EventSubscription} from "fbemitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomAvatar from "../views/avatars/RoomAvatar";
@ -25,13 +26,11 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import FormButton from "../views/elements/FormButton";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom";
import Field from "../views/elements/Field";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import StyledRadioGroup from "../views/elements/StyledRadioGroup";
import withValidation from "../views/elements/Validation";
import * as Email from "../../email";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -42,18 +41,19 @@ import ErrorBoundary from "../views/elements/ErrorBoundary";
import {ActionPayload} from "../../dispatcher/payloads";
import RightPanel from "./RightPanel";
import RightPanelStore from "../../stores/RightPanelStore";
import {EventSubscription} from "fbemitter";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import {useStateArray} from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory";
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 {allSettled} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
interface IProps {
space: Room;
@ -66,6 +66,7 @@ interface IProps {
interface IState {
phase: Phase;
showRightPanel: boolean;
myMembership: string;
}
enum Phase {
@ -94,40 +95,151 @@ const useMyRoomMembership = (room: Room) => {
return membership;
};
const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const SpaceInfo = ({ space }) => {
const joinRule = space.getJoinRule();
let visibilitySection;
if (joinRule === "public") {
visibilitySection = <span className="mx_SpaceRoomView_info_public">
{ _t("Public space") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_info_private">
{ _t("Private space") }
</span>;
}
return <div className="mx_SpaceRoomView_info">
{ visibilitySection }
{ joinRule === "public" && <RoomMemberCount room={space}>
{(count) => count > 0 ? (
<AccessibleButton
kind="link"
onClick={() => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
</RoomMemberCount> }
</div>
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const joinRule = space.getJoinRule();
const userId = cli.getUserId();
const [busy, setBusy] = useState(false);
let inviterSection;
let joinButtons;
if (myMembership === "invite") {
joinButtons = <div className="mx_SpaceRoomView_landing_joinButtons">
<FormButton label={_t("Accept Invite")} onClick={onJoinButtonClicked} />
<AccessibleButton kind="link" onClick={onRejectButtonClicked}>
{_t("Decline")}
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender);
if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} width={32} height={32} />
<div>
<div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
{ inviteSender }
</div> : null }
</div>
</div>;
}
joinButtons = <>
<AccessibleButton
kind="secondary"
onClick={() => {
setBusy(true);
onRejectButtonClicked();
}}
>
{ _t("Reject") }
</AccessibleButton>
</div>;
} else if (myMembership !== "join" && joinRule === "public") {
joinButtons = <div className="mx_SpaceRoomView_landing_joinButtons">
<FormButton label={_t("Join")} onClick={onJoinButtonClicked} />
</div>;
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Accept") }
</AccessibleButton>
</>;
} else {
joinButtons = (
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Join") }
</AccessibleButton>
)
}
if (busy) {
joinButtons = <InlineSpinner />;
}
return <div className="mx_SpaceRoomView_preview">
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
<RoomName room={space} />
</h1>
<SpaceInfo space={space} />
<RoomTopic room={space}>
{(topic, ref) =>
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
{ topic }
</div>
}
</RoomTopic>
{ space.getJoinRule() === "public" && <FacePile room={space} /> }
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
</div>;
};
const SpaceLanding = ({ space }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const userId = cli.getUserId();
let inviteButton;
if (myMembership === "join" && space.canInvite(userId)) {
inviteButton = (
<AccessibleButton className="mx_SpaceRoomView_landing_inviteButton" onClick={() => {
showRoomInviteDialog(space.roomId);
}}>
{ _t("Invite people") }
<AccessibleButton
kind="primary"
className="mx_SpaceRoomView_landing_inviteButton"
onClick={() => {
showRoomInviteDialog(space.roomId);
}}
>
{ _t("Invite") }
</AccessibleButton>
);
}
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [_, forceUpdate] = useStateToggle(false); // TODO
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons;
if (canAddRooms) {
@ -157,50 +269,13 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</AccessibleButton>;
}
const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join");
const parentChildRelations = new EnhancedMap<string, string[]>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
});
const roomsMap = new Map<string, ISpaceSummaryRoom>(data.rooms.map(r => [r.room_id, r]));
const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length;
return [false, roomsMap, parentChildRelations, numRooms];
} catch (e) {
console.error(e); // TODO
}
return [false];
}, [space, _], [true]);
let previewRooms;
if (roomsMap) {
previewRooms = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<div className="mx_SpaceRoomDirectory_roomCount">
<h3>{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}</h3>
<span>{ numRooms }</span>
</div>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={false}
relations={relations}
parents={new Set()}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), [], false); // TODO
}}
/>
</AutoHideScrollbar>;
} else if (loading) {
previewRooms = <InlineSpinner />;
} else {
previewRooms = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
const onMembersClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
};
return <div className="mx_SpaceRoomView_landing">
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
@ -209,65 +284,26 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
{(name) => {
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
<h1>{ name }</h1>
<RoomMemberCount room={space}>
{(count) => count > 0 ? (
<AccessibleButton
className="mx_SpaceRoomView_landing_memberCount"
kind="link"
onClick={() => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
</RoomMemberCount>
</div> };
if (myMembership === "invite") {
const inviteSender = space.getMember(userId)?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender);
if (inviteSender) {
return _t("<inviter/> invited you to <name/>", {}, {
name: tags.name,
inviter: () => inviter
? <span className="mx_SpaceRoomView_landing_inviter">
<MemberAvatar member={inviter} width={26} height={26} viewUserOnClick={true} />
{ inviter.name }
</span>
: <span className="mx_SpaceRoomView_landing_inviter">
{ inviteSender }
</span>,
}) as JSX.Element;
} else {
return _t("You have been invited to <name/>", {}, tags) as JSX.Element;
}
} else if (shouldShowSpaceSettings(cli, space)) {
if (space.getJoinRule() === "public") {
return _t("Your public space <name/>", {}, tags) as JSX.Element;
} else {
return _t("Your private space <name/>", {}, tags) as JSX.Element;
}
}
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
}}
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_info">
<SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
</div>
<div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} />
</div>
{ joinButtons }
<hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ inviteButton }
{ addRoomButtons }
{ settingsButton }
</div>
{ previewRooms }
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
</div>;
};
@ -288,6 +324,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
placeholder={placeholders[i]}
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
/>;
});
@ -320,7 +357,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Next")
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
}
return <div>
@ -331,61 +368,114 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
{ fields }
<div className="mx_SpaceRoomView_buttons">
<FormButton
label={buttonLabel}
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
/>
>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
const SpaceAddExistingRooms = ({ space, onFinished }) => {
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) {
onClick = async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
};
buttonLabel = busy ? _t("Adding...") : _t("Add");
}
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share your public space") }</h1>
<div className="mx_SpacePublicShare_description">{ _t("At the moment only you can see it.") }</div>
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") }
</div>
<SpacePublicShare space={space} onFinished={onFinished} />
<SpacePublicShare space={space} />
<div className="mx_SpaceRoomView_buttons">
<FormButton label={_t("Finish")} onClick={onFinished} />
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Go to my first room") }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPrivateScope = ({ onFinished }) => {
const [option, setOption] = useState<string>(null);
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
<StyledRadioGroup
name="privateSpaceScope"
value={option}
onChange={setOption}
definitions={[
{
value: "justMe",
className: "mx_SpaceRoomView_privateScope_justMeButton",
label: <React.Fragment>
<h3>{ _t("Just Me") }</h3>
<div>{ _t("A private space just for you") }</div>
</React.Fragment>,
}, {
value: "meAndMyTeammates",
className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton",
label: <React.Fragment>
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</React.Fragment>,
},
]}
/>
<div className="mx_SpaceRoomView_buttons">
<FormButton label={_t("Next")} disabled={!option} onClick={() => onFinished(option !== "justMe")} />
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
</div>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_justMeButton"
onClick={() => { onFinished(false) }}
>
<h3>{ _t("Just me") }</h3>
<div>{ _t("A private space to organise your rooms") }</div>
</AccessibleButton>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
onClick={() => { onFinished(true) }}
>
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton>
</div>;
};
@ -415,6 +505,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
onChange={ev => setEmailAddress(i, ev.target.value)}
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
/>;
});
@ -452,9 +543,18 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Inviting...") : _t("Continue")
}
return <div className="mx_SpaceRoomView_inviteTeammates">
<h1>{ _t("Invite your teammates") }</h1>
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access. You can invite more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
@ -469,8 +569,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton onClick={onFinished} kind="link">{_t("Skip for now")}</AccessibleButton>
<FormButton label={busy ? _t("Inviting...") : _t("Next")} disabled={busy} onClick={onNextClick} />
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
@ -498,17 +599,26 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
this.state = {
phase,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
myMembership: this.props.space.getMyMembership(),
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
this.context.on("Room.myMembership", this.onMyMembership);
}
componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
this.rightPanelStoreToken.remove();
this.context.off("Room.myMembership", this.onMyMembership);
}
private onMyMembership = (room: Room, myMembership: string) => {
if (room.roomId === this.props.space.roomId) {
this.setState({ myMembership });
}
};
private onRightPanelStoreUpdate = () => {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
@ -545,32 +655,73 @@ 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];
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)).rooms;
}
if (suggestedRooms.length) {
const room = suggestedRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
},
});
return;
}
this.setState({ phase: Phase.Landing });
};
private renderBody() {
switch (this.state.phase) {
case Phase.Landing:
return <SpaceLanding
space={this.props.space}
onJoinButtonClicked={this.props.onJoinButtonClicked}
onRejectButtonClicked={this.props.onRejectButtonClicked}
/>;
if (this.state.myMembership === "join") {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
space={this.props.space}
onJoinButtonClicked={this.props.onJoinButtonClicked}
onRejectButtonClicked={this.props.onRejectButtonClicked}
/>;
}
case Phase.PublicCreateRooms:
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What discussions do you want to have?")}
description={_t("We'll create rooms for each topic.")}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.space.name,
})}
description={
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={() => this.setState({ phase: Phase.PublicShare })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
case Phase.PrivateScope:
return <SpaceSetupPrivateScope
space={this.props.space}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}}
/>;
case Phase.PrivateInvite:
@ -582,7 +733,13 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. You can add existing rooms after setup.")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
}

View file

@ -20,6 +20,7 @@ import * as React from "react";
import {_t} from '../../languageHandler';
import * as sdk from "../../index";
import AutoHideScrollbar from './AutoHideScrollbar';
import {replaceableComponent} from "../../utils/replaceableComponent";
/**
* Represents a tab for the TabbedView.
@ -45,6 +46,7 @@ interface IState {
activeTabIndex: number;
}
@replaceableComponent("structures.TabbedView")
export default class TabbedView extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);

View file

@ -22,8 +22,8 @@ import {LayoutPropType} from "../../settings/Layout";
import React, {createRef} from 'react';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
import {EventTimeline} from "matrix-js-sdk";
import * as Matrix from "matrix-js-sdk";
import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline";
import {TimelineWindow} from "matrix-js-sdk/src/timeline-window";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import UserActivity from "../../UserActivity";
@ -37,6 +37,7 @@ import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent";
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
@ -55,6 +56,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
@replaceableComponent("structures.TimelinePanel")
class TimelinePanel extends React.Component {
static propTypes = {
// The js-sdk EventTimelineSet object for the timeline sequence we are
@ -461,6 +463,9 @@ class TimelinePanel extends React.Component {
}
});
}
if (payload.action === "scroll_to_bottom") {
this.jumpToLiveTimeline();
}
};
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
@ -1005,7 +1010,7 @@ class TimelinePanel extends React.Component {
* returns a promise which will resolve when the load completes.
*/
_loadTimeline(eventId, pixelOffset, offsetBase) {
this._timelineWindow = new Matrix.TimelineWindow(
this._timelineWindow = new TimelineWindow(
MatrixClientPeg.get(), this.props.timelineSet,
{windowLimit: this.props.timelineCap});

View file

@ -17,12 +17,14 @@ limitations under the License.
import * as React from "react";
import ToastStore, {IToast} from "../../stores/ToastStore";
import classNames from "classnames";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IState {
toasts: IToast<any>[];
countSeen: number;
}
@replaceableComponent("structures.ToastContainer")
export default class ToastContainer extends React.Component<{}, IState> {
constructor(props, context) {
super(props, context);

View file

@ -1,109 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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 PropTypes from 'prop-types';
import ContentMessages from '../../ContentMessages';
import dis from "../../dispatcher/dispatcher";
import filesize from "filesize";
import { _t } from '../../languageHandler';
export default class UploadBar extends React.Component {
static propTypes = {
room: PropTypes.object,
};
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
}
onAction = payload => {
switch (payload.action) {
case 'upload_progress':
case 'upload_finished':
case 'upload_canceled':
case 'upload_failed':
if (this.mounted) this.forceUpdate();
break;
}
};
render() {
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
// check in RoomView
//
// uploads = [{
// roomId: this.props.room.roomId,
// loaded: 123493,
// total: 347534,
// fileName: "testing_fooble.jpg",
// }];
if (uploads.length == 0) {
return <div />;
}
let upload;
for (let i = 0; i < uploads.length; ++i) {
if (uploads[i].roomId == this.props.room.roomId) {
upload = uploads[i];
break;
}
}
if (!upload) {
return <div />;
}
const innerProgressStyle = {
width: ((upload.loaded / (upload.total || 1)) * 100) + '%',
};
let uploadedSize = filesize(upload.loaded);
const totalSize = filesize(upload.total);
if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) {
uploadedSize = uploadedSize.replace(/ .*/, '');
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)},
);
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_uploadProgressOuter">
<div className="mx_UploadBar_uploadProgressInner" style={innerProgressStyle}></div>
</div>
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src={require("../../../res/img/fileicon.png")} width="17" height="22" />
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src={require("../../../res/img/cancel.svg")} width="18" height="18"
onClick={function() { ContentMessages.sharedInstance().cancelUpload(upload.promise); }}
/>
<div className="mx_UploadBar_uploadBytes">
{ uploadedSize } / { totalSize }
</div>
<div className="mx_UploadBar_uploadFilename">{ uploadText }</div>
</div>
);
}
}

View file

@ -0,0 +1,110 @@
/*
Copyright 2015, 2016, 2019, 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 { Room } from "matrix-js-sdk/src/models/room";
import ContentMessages from '../../ContentMessages';
import dis from "../../dispatcher/dispatcher";
import filesize from "filesize";
import { _t } from '../../languageHandler';
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import ProgressBar from "../views/elements/ProgressBar";
import AccessibleButton from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {
room: Room;
}
interface IState {
currentUpload?: IUpload;
uploadsHere: IUpload[];
}
@replaceableComponent("structures.UploadBar")
export default class UploadBar extends React.Component<IProps, IState> {
private dispatcherRef: string;
private mounted: boolean;
constructor(props) {
super(props);
// Set initial state to any available upload in this room - we might be mounting
// earlier than the first progress event, so should show something relevant.
const uploadsHere = this.getUploadsInRoom();
this.state = {currentUpload: uploadsHere[0], uploadsHere};
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
}
private getUploadsInRoom(): IUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
return uploads.filter(u => u.roomId === this.props.room.roomId);
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case Action.UploadStarted:
case Action.UploadProgress:
case Action.UploadFinished:
case Action.UploadCanceled:
case Action.UploadFailed: {
if (!this.mounted) return;
const uploadsHere = this.getUploadsInRoom();
this.setState({currentUpload: uploadsHere[0], uploadsHere});
break;
}
}
};
private onCancelClick = (ev) => {
ev.preventDefault();
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise);
};
render() {
if (!this.state.currentUpload) {
return null;
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {
filename: this.state.currentUpload.fileName,
count: this.state.uploadsHere.length - 1,
},
);
const uploadSize = filesize(this.state.currentUpload.total);
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_filename">{uploadText} ({uploadSize})</div>
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
<ProgressBar value={this.state.currentUpload.loaded} max={this.state.currentUpload.total} />
</div>
);
}
}

View file

@ -56,6 +56,7 @@ import HostSignupAction from "./HostSignupAction";
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {
isMinimized: boolean;
@ -69,9 +70,11 @@ interface IState {
selectedSpace?: Room;
}
@replaceableComponent("structures.UserMenu")
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private dndWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription;
@ -87,6 +90,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
// Force update is the easiest way to trigger the UI update (we don't store state for this)
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
}
private get hasHomePage(): boolean {
@ -101,6 +107,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
this.tagStoreRef.remove();
@ -286,6 +293,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({contextMenuPosition: null}); // also close the menu
};
private onDndToggle = (ev) => {
ev.stopPropagation();
const current = SettingsStore.getValue("doNotDisturb");
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
};
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
@ -532,6 +545,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
{/* masked image in CSS */}
</span>
);
let dnd;
if (this.state.selectedSpace) {
name = (
<div className="mx_UserMenu_doubleName">
@ -558,6 +572,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
</div>
);
isPrototype = true;
} else if (SettingsStore.getValue("feature_dnd")) {
const isDnd = SettingsStore.getValue("doNotDisturb");
dnd = <AccessibleButton
onClick={this.onDndToggle}
className={classNames({
"mx_UserMenu_dnd": true,
"mx_UserMenu_dnd_noisy": !isDnd,
"mx_UserMenu_dnd_muted": isDnd,
})}
/>;
}
if (this.props.isMinimized) {
name = null;
@ -593,6 +617,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</span>
{name}
{dnd}
{buttons}
</div>
</ContextMenuButton>

View file

@ -17,13 +17,16 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import Matrix from "matrix-js-sdk";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import HomePage from "./HomePage";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
@replaceableComponent("structures.UserView")
export default class UserView extends React.Component {
static get propTypes() {
return {
@ -66,8 +69,8 @@ export default class UserView extends React.Component {
this.setState({loading: false});
return;
}
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new Matrix.RoomMember(null, this.props.userId);
const fakeEvent = new MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
}

View file

@ -16,34 +16,176 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
import {_t} from "../../languageHandler";
import React from "react";
import PropTypes from "prop-types";
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
import { _t } from "../../languageHandler";
import * as sdk from "../../index";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
import { canEditContent } from "../../utils/EventUtils";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.ViewSource")
export default class ViewSource extends React.Component {
static propTypes = {
content: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
roomId: PropTypes.string.isRequired,
eventId: PropTypes.string.isRequired,
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
<div className="mx_ViewSource_label_left">Room ID: { this.props.roomId }</div>
<div className="mx_ViewSource_label_right">Event ID: { this.props.eventId }</div>
<div className="mx_ViewSource_label_bottom" />
constructor(props) {
super(props);
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
this.state = {
isEditing: false,
};
}
onBack() {
// TODO: refresh the "Event ID:" modal header
this.setState({ isEditing: false });
}
onEdit() {
this.setState({ isEditing: true });
}
// returns the dialog body for viewing the event source
viewSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted();
const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private
const originalEventSource = mxEvent.event;
if (isEncrypted) {
return (
<>
<details open className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">{_t("Decrypted event source")}</span>
</summary>
<SyntaxHighlight className="json">{JSON.stringify(decryptedEventSource, null, 2)}</SyntaxHighlight>
</details>
<details className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">{_t("Original event source")}</span>
</summary>
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
</details>
</>
);
} else {
return (
<>
<div className="mx_ViewSource_heading">{_t("Original event source")}</div>
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
</>
);
}
}
// returns the id of the initial message, not the id of the previous edit
getBaseEventId() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted();
const baseMxEvent = this.props.mxEvent;
if (isEncrypted) {
// `relates_to` field is inside the encrypted event
return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId();
} else {
return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId();
}
}
// returns the SendCustomEvent component prefilled with the correct details
editSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isStateEvent = mxEvent.isState();
const roomId = mxEvent.getRoomId();
const originalContent = mxEvent.getContent();
if (isStateEvent) {
return (
<MatrixClientContext.Consumer>
{(cli) => (
<SendCustomEvent
room={cli.getRoom(roomId)}
forceStateEvent={true}
onBack={() => this.onBack()}
inputs={{
eventType: mxEvent.getType(),
evContent: JSON.stringify(originalContent, null, "\t"),
stateKey: mxEvent.getStateKey(),
}}
/>
)}
</MatrixClientContext.Consumer>
);
} else {
// prefill an edit-message event
// keep only the `body` and `msgtype` fields of originalContent
const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there
const newContent = {
"body": ` * ${bodyToStartFrom}`,
"msgtype": originalContent.msgtype,
"m.new_content": {
body: bodyToStartFrom,
msgtype: originalContent.msgtype,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: this.getBaseEventId(),
},
};
return (
<MatrixClientContext.Consumer>
{(cli) => (
<SendCustomEvent
room={cli.getRoom(roomId)}
forceStateEvent={false}
forceGeneralEvent={true}
onBack={() => this.onBack()}
inputs={{
eventType: mxEvent.getType(),
evContent: JSON.stringify(newContent, null, "\t"),
}}
/>
)}
</MatrixClientContext.Consumer>
);
}
}
canSendStateEvent(mxEvent) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
}
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEditing = this.state.isEditing;
const roomId = mxEvent.getRoomId();
const eventId = mxEvent.getId();
const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent);
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
<div>
<div>Room ID: {roomId}</div>
<div>Event ID: {eventId}</div>
<div className="mx_ViewSource_separator" />
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
</div>
{!isEditing && canEdit && (
<div className="mx_Dialog_buttons">
<button onClick={() => this.onEdit()}>{_t("Edit")}</button>
</div>
)}
</BaseDialog>
);
}

View file

@ -20,13 +20,16 @@ import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
} from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("structures.auth.CompleteSecurity")
export default class CompleteSecurity extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
@ -58,7 +61,9 @@ export default class CompleteSecurity extends React.Component {
let icon;
let title;
if (phase === PHASE_INTRO) {
if (phase === PHASE_LOADING) {
return null;
} else if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else if (phase === PHASE_DONE) {

View file

@ -19,7 +19,9 @@ import PropTypes from 'prop-types';
import AuthPage from '../../views/auth/AuthPage';
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("structures.auth.E2eSetup")
export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
@ -27,6 +27,9 @@ import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
import PassphraseField from '../../views/auth/PassphraseField';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
// Phases
// Show the forgot password inputs
@ -38,6 +41,7 @@ const PHASE_EMAIL_SENT = 3;
// User has clicked the link in email and completed reset
const PHASE_DONE = 4;
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component {
static propTypes = {
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
@ -135,10 +139,14 @@ export default class ForgotPassword extends React.Component {
// refresh the server errors, just in case the server came back online
await this._checkServerLiveliness(this.props.serverConfig);
await this['password_field'].validate({ allowEmpty: false });
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (!this.state.passwordFieldValid) {
this.showErrorDialog(_t('Please choose a strong password'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
@ -184,6 +192,12 @@ export default class ForgotPassword extends React.Component {
});
}
onPasswordValidate(result) {
this.setState({
passwordFieldValid: result.valid,
});
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -228,12 +242,15 @@ export default class ForgotPassword extends React.Component {
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
<PassphraseField
name="reset_password"
type="password"
label={_t('New Password')}
label={_td('New Password')}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
onChange={this.onInputChanged.bind(this, "password")}
fieldRef={field => this['password_field'] = field}
onValidate={(result) => this.onPasswordValidate(result)}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
autoComplete="new-password"

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C.
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.
@ -35,6 +35,7 @@ import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import {replaceableComponent} from "../../../utils/replaceableComponent";
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
@ -93,12 +94,13 @@ interface IState {
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
serverDeadError?: ReactNode;
}
/*
* A wire component which glues together login UI components and Login logic
*/
@replaceableComponent("structures.auth.LoginComponent")
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
@ -218,6 +220,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'hs_blocked': _td(
"This homeserver has been blocked by it's administrator.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
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.
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import {createClient} from 'matrix-js-sdk/src/matrix';
import React, {ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -30,6 +30,7 @@ import Login, {ISSOFlow} from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
serverConfig: ValidatedServerConfig;
@ -94,7 +95,7 @@ interface IState {
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
serverDeadError?: ReactNode;
// Our matrix client - part of state because we can't render the UI auth
// component without it.
@ -109,6 +110,7 @@ interface IState {
ssoFlow?: ISSOFlow;
}
@replaceableComponent("structures.auth.Registration")
export default class Registration extends React.Component<IProps, IState> {
loginLogic: Login;
@ -179,7 +181,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
const {hsUrl, isUrl} = serverConfig;
const cli = Matrix.createClient({
const cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
@ -276,6 +278,7 @@ export default class Registration extends React.Component<IProps, IState> {
response.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'hs_blocked': _td("This homeserver has been blocked by it's administrator."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
);
@ -433,6 +436,8 @@ export default class Registration extends React.Component<IProps, IState> {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
return sessionLoaded;
};
private renderRegisterComponent() {
@ -554,7 +559,12 @@ export default class Registration extends React.Component<IProps, IState> {
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
<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>;

View file

@ -17,17 +17,20 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function keyHasPassphrase(keyInfo) {
return (
@ -37,6 +40,7 @@ function keyHasPassphrase(keyInfo) {
);
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
@ -81,6 +85,22 @@ export default class SetupEncryptionBody extends React.Component {
store.usePassPhrase();
}
_onVerifyClick = () => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
this.props.onFinished(true);
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
const request = await requestPromise;
request.cancel();
},
});
}
onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
@ -132,32 +152,21 @@ export default class SetupEncryptionBody extends React.Component {
</AccessibleButton>;
}
const brand = SdkConfig.get().brand;
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this._onVerifyClick}>
{ _t("Use another login") }
</AccessibleButton>;
}
return (
<div>
<p>{_t(
"Confirm your identity by verifying this login from one of your other sessions, " +
"granting it access to encrypted messages.",
"Verify your identity to access encrypted messages and prove your identity to others.",
)}</p>
<p>{_t(
"This requires the latest %(brand)s on your other devices:",
{ brand },
)}</p>
<div className="mx_CompleteSecurity_clients">
<div className="mx_CompleteSecurity_clients_desktop">
<div>{_t("%(brand)s Web", { brand })}</div>
<div>{_t("%(brand)s Desktop", { brand })}</div>
</div>
<div className="mx_CompleteSecurity_clients_mobile">
<div>{_t("%(brand)s iOS", { brand })}</div>
<div>{_t("%(brand)s Android", { brand })}</div>
</div>
<p>{_t("or another cross-signing capable Matrix client")}</p>
</div>
<div className="mx_CompleteSecurity_actionRow">
{verifyButton}
{useRecoveryKeyButton}
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
{_t("Skip")}
@ -195,8 +204,8 @@ export default class SetupEncryptionBody extends React.Component {
return (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
"Without verifying, you wont have access to all your messages " +
"and may appear as untrusted to others.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
@ -215,7 +224,7 @@ export default class SetupEncryptionBody extends React.Component {
</div>
</div>
);
} else if (phase === PHASE_BUSY) {
} else if (phase === PHASE_BUSY || phase === PHASE_LOADING) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />;
} else {

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019-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.
@ -15,17 +15,17 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const LOGIN_VIEW = {
LOADING: 1,
@ -41,25 +41,38 @@ const FLOWS_TO_VIEWS = {
"m.login.sso": LOGIN_VIEW.SSO,
};
export default class SoftLogout extends React.Component {
static propTypes = {
// Query parameters from MatrixChat
realQueryParams: PropTypes.object, // {loginToken}
// Called when the SSO login completes
onTokenLoginCompleted: PropTypes.func,
interface IProps {
// Query parameters from MatrixChat
realQueryParams: {
loginToken?: string;
};
fragmentAfterLogin?: string;
constructor() {
super();
// Called when the SSO login completes
onTokenLoginCompleted: () => void,
}
interface IState {
loginView: number;
keyBackupNeeded: boolean;
busy: boolean;
password: string;
errorText: string;
flows: LoginFlow[];
}
@replaceableComponent("structures.auth.SoftLogout")
export default class SoftLogout extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
loginView: LOGIN_VIEW.LOADING,
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
busy: false,
password: "",
errorText: "",
flows: [],
};
}
@ -70,7 +83,7 @@ export default class SoftLogout extends React.Component {
return;
}
this._initLogin();
this.initLogin();
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
@ -92,7 +105,7 @@ export default class SoftLogout extends React.Component {
});
};
async _initLogin() {
private async initLogin() {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams && queryParams['loginToken'];
if (hasAllParams) {
@ -187,7 +200,7 @@ export default class SoftLogout extends React.Component {
});
}
_renderSignInSection() {
private renderSignInSection() {
if (this.state.loginView === LOGIN_VIEW.LOADING) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
@ -245,7 +258,7 @@ export default class SoftLogout extends React.Component {
} // else we already have a message and should use it (key backup warning)
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
return (
<div>
@ -287,7 +300,7 @@ export default class SoftLogout extends React.Component {
<h3>{_t("Sign in")}</h3>
<div>
{this._renderSignInSection()}
{this.renderSignInSection()}
</div>
<h3>{_t("Clear personal data")}</h3>

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthBody")
export default class AuthBody extends React.PureComponent {
render() {
return <div className="mx_AuthBody">

View file

@ -18,7 +18,9 @@ limitations under the License.
import { _t } from '../../../languageHandler';
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthFooter")
export default class AuthFooter extends React.Component {
render() {
return (

View file

@ -18,7 +18,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthHeader")
export default class AuthHeader extends React.Component {
static propTypes = {
disableLanguageSelector: PropTypes.bool,

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthHeaderLogo")
export default class AuthHeaderLogo extends React.PureComponent {
render() {
return <div className="mx_AuthHeaderLogo">

View file

@ -18,12 +18,14 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const DIV_ID = 'mx_recaptcha';
/**
* A pure UI component which displays a captcha form.
*/
@replaceableComponent("views.auth.CaptchaForm")
export default class CaptchaForm extends React.Component {
static propTypes = {
sitePublicKey: PropTypes.string,

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.CompleteSecurityBody")
export default class CompleteSecurityBody extends React.PureComponent {
render() {
return <div className="mx_CompleteSecurityBody">

View file

@ -22,6 +22,7 @@ import * as sdk from '../../../index';
import {COUNTRIES, getEmojiFlag} from '../../../phonenumber';
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
@ -40,6 +41,7 @@ function countryMatchesSearchQuery(query, country) {
return false;
}
@replaceableComponent("views.auth.CountryDropdown")
export default class CountryDropdown extends React.Component {
constructor(props) {
super(props);

View file

@ -26,6 +26,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -75,6 +76,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics";
export const DEFAULT_PHASE = 0;
@replaceableComponent("views.auth.PasswordAuthEntry")
export class PasswordAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.password";
@ -173,6 +175,7 @@ export class PasswordAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.recaptcha";
@ -235,6 +238,7 @@ export class RecaptchaAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.terms";
@ -385,6 +389,7 @@ export class TermsAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.email.identity";
@ -432,6 +437,7 @@ export class EmailIdentityAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.MsisdnAuthEntry")
export class MsisdnAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.msisdn";
@ -578,6 +584,7 @@ export class MsisdnAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
@ -708,6 +715,7 @@ export class SSOAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.FallbackAuthEntry")
export class FallbackAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,

View file

@ -22,6 +22,7 @@ import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field, {IInputProps} from "../elements/Field";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean;
@ -40,6 +41,7 @@ interface IProps extends Omit<IInputProps, "onValidate"> {
onValidate(result: IValidationResult);
}
@replaceableComponent("views.auth.PassphraseField")
class PassphraseField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Password"),

View file

@ -26,6 +26,7 @@ import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import {replaceableComponent} from "../../../utils/replaceableComponent";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -66,6 +67,7 @@ enum LoginField {
* A pure UI component which displays a username/password form.
* The email/username/phone fields are fully-controlled, the password field is not.
*/
@replaceableComponent("views.auth.PasswordLogin")
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onUsernameChanged: function() {},

View file

@ -30,6 +30,7 @@ import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
import {replaceableComponent} from "../../../utils/replaceableComponent";
enum RegistrationField {
Email = "field_email",
@ -39,7 +40,7 @@ enum RegistrationField {
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
interface IProps {
// Values pre-filled in the input boxes when the component loads
@ -80,6 +81,7 @@ interface IState {
/*
* A pure UI component which displays a registration form.
*/
@replaceableComponent("views.auth.RegistrationForm")
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
static defaultProps = {
onValidationChange: console.error,

View file

@ -24,10 +24,12 @@ import {_td} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
// translatable strings for Welcome pages
_td("Sign in with SSO");
@replaceableComponent("views.auth.Welcome")
export default class Welcome extends React.PureComponent {
constructor(props) {
super(props);

View file

@ -25,6 +25,8 @@ import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { _t } from '../../../languageHandler';
interface IProps {
name: string; // The name (first initial used as default)
@ -35,7 +37,7 @@ interface IProps {
width?: number;
height?: number;
// XXX: resizeMethod not actually used.
resizeMethod?: string;
resizeMethod?: ResizeMethod;
defaultToInitialLetter?: boolean; // true to add default url
onClick?: React.MouseEventHandler;
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
@ -139,6 +141,7 @@ const BaseAvatar = (props: IProps) => {
if (onClick) {
return (
<AccessibleButton
aria-label={_t("Avatar")}
{...otherProps}
element="span"
className={classNames("mx_BaseAvatar", className)}

View file

@ -30,6 +30,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {_t} from "../../../languageHandler";
import TextWithTooltip from "../elements/TextWithTooltip";
import DMRoomMap from "../../../utils/DMRoomMap";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
room: Room;
@ -68,6 +69,7 @@ function tooltipText(variant: Icon) {
}
}
@replaceableComponent("views.avatars.DecoratedRoomAvatar")
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
private _dmUser: User;
private isUnmounted = false;

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
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.
@ -15,8 +15,10 @@ limitations under the License.
*/
import React from 'react';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import BaseAvatar from './BaseAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
export interface IProps {
groupId?: string;
@ -24,10 +26,11 @@ export interface IProps {
groupAvatarUrl?: string;
width?: number;
height?: number;
resizeMethod?: string;
resizeMethod?: ResizeMethod;
onClick?: React.MouseEventHandler;
}
@replaceableComponent("views.avatars.GroupAvatar")
export default class GroupAvatar extends React.Component<IProps> {
public static defaultProps = {
width: 36,
@ -36,8 +39,8 @@ export default class GroupAvatar extends React.Component<IProps> {
};
getGroupAvatarUrl() {
return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl,
if (!this.props.groupAvatarUrl) return null;
return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp(
this.props.width,
this.props.height,
this.props.resizeMethod,

View file

@ -20,15 +20,17 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember;
fallbackUserId?: string;
width: number;
height: number;
resizeMethod?: string;
resizeMethod?: ResizeMethod;
// The onClick to give the avatar
onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
@ -42,6 +44,7 @@ interface IState {
imageUrl?: string;
}
@replaceableComponent("views.avatars.MemberAvatar")
export default class MemberAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 40,
@ -61,18 +64,19 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
}
private static getState(props: IProps): IState {
if (props.member && props.member.name) {
if (props.member?.name) {
let imageUrl = null;
if (props.member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
props.width,
props.height,
props.resizeMethod,
);
}
return {
name: props.member.name,
title: props.title || props.member.userId,
imageUrl: props.member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
false,
),
imageUrl: imageUrl,
};
} else if (props.fallbackUserId) {
return {

View file

@ -23,7 +23,9 @@ import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component {
static propTypes = {
member: PropTypes.object.isRequired,

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import React, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
@ -23,6 +22,8 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,
@ -42,6 +43,7 @@ interface IState {
urls: string[];
}
@replaceableComponent("views.avatars.RoomAvatar")
export default class RoomAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 36,
@ -88,16 +90,16 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
private static getImageUrls(props: IProps): string[] {
return [
getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
// Default props don't play nicely with getDerivedStateFromProps
//props.oobData !== undefined ? props.oobData.avatarUrl : {},
props.oobData.avatarUrl,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
let oobAvatar = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
props.width,
props.height,
props.resizeMethod,
), // highest priority
);
}
return [
oobAvatar, // highest priority
RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) {
return (url !== null && url !== "");
@ -107,12 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
}
private onRoomAvatarClick = () => {
@ -127,7 +124,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
name: this.props.room.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
};
public render() {

View file

@ -14,21 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps, useContext} from 'react';
import React, {ComponentProps} from 'react';
import classNames from 'classnames';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {IApp} from "../../../stores/WidgetStore";
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp;
}
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
const cli = useContext(MatrixClientContext);
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
// heuristics for some better icons until Widgets support their own icons
if (app.type.includes("jitsi")) {
@ -47,7 +44,7 @@ const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 2
name={app.id}
className={classNames("mx_WidgetAvatar", className)}
// MSC2765
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
url={app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : undefined}
urls={iconUrls}
width={width}
height={height}

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