Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into pseudonymous-analytics-id

This commit is contained in:
James Salter 2021-09-15 09:52:15 +01:00
commit 79f03dfec3
330 changed files with 8525 additions and 4456 deletions

View file

@ -93,6 +93,26 @@ declare global {
mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore;
mxOnRecaptchaLoaded?: () => void;
electron?: Electron;
}
interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL: string;
}
interface GetSourcesOptions {
types: Array<string>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
}
interface Electron {
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
}
interface Document {

View file

@ -250,7 +250,15 @@ export default class CallHandler extends EventEmitter {
* @returns {boolean}
*/
private areAnyCallsUnsilenced(): boolean {
return this.calls.size > this.silencedCalls.size;
for (const call of this.calls.values()) {
if (
call.state === CallState.Ringing &&
!this.isCallSilenced(call.callId)
) {
return true;
}
}
return false;
}
private async checkProtocols(maxTries) {
@ -464,85 +472,7 @@ export default class CallHandler extends EventEmitter {
this.removeCallForRoom(mappedRoomId);
});
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
if (newState !== CallState.Ringing) {
this.silencedCalls.delete(call.callId);
}
switch (newState) {
case CallState.Ringing: {
const incomingCallPushRule = (
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall)
);
const pushRuleEnabled = incomingCallPushRule?.enabled;
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
action.set_tweak === TweakName.Sound &&
action.value === "ring"
));
if (pushRuleEnabled && tweakSetToRing) {
this.play(AudioID.Ring);
} else {
this.silenceCall(call.callId);
}
break;
}
case CallState.InviteSent: {
this.play(AudioID.Ringback);
break;
}
case CallState.Ended: {
const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
this.play(AudioID.Busy);
// Don't show a modal when we got rejected/the call was hung up
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
let title;
let description;
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else if (
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
) {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
description: _t("The call was answered on another device."),
});
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
// don't play the end-call sound for calls that never got off the ground
this.play(AudioID.CallEnd);
}
this.logCallStats(call, mappedRoomId);
break;
}
}
this.onCallStateChanged(newState, oldState, call);
});
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
if (!this.matchesCallForThisRoom(call)) return;
@ -555,8 +485,8 @@ export default class CallHandler extends EventEmitter {
this.pause(AudioID.Ringback);
}
this.calls.set(mappedRoomId, newCall);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.removeCallForRoom(mappedRoomId);
this.addCallForRoom(mappedRoomId, newCall);
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
@ -591,13 +521,95 @@ export default class CallHandler extends EventEmitter {
this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId;
console.log("Moving call to room " + mappedRoomId);
this.calls.set(mappedRoomId, call);
this.emit(CallHandlerEvent.CallChangeRoom, call);
this.addCallForRoom(mappedRoomId, call, true);
}
}
});
}
private onCallStateChanged = (newState: CallState, oldState: CallState, call: MatrixCall): void => {
if (!this.matchesCallForThisRoom(call)) return;
const mappedRoomId = this.roomIdForCall(call);
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
if (newState !== CallState.Ringing) {
this.silencedCalls.delete(call.callId);
}
switch (newState) {
case CallState.Ringing: {
const incomingCallPushRule = (
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall)
);
const pushRuleEnabled = incomingCallPushRule?.enabled;
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
action.set_tweak === TweakName.Sound &&
action.value === "ring"
));
if (pushRuleEnabled && tweakSetToRing) {
this.play(AudioID.Ring);
} else {
this.silenceCall(call.callId);
}
break;
}
case CallState.InviteSent: {
this.play(AudioID.Ringback);
break;
}
case CallState.Ended: {
const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
this.play(AudioID.Busy);
// Don't show a modal when we got rejected/the call was hung up
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
let title;
let description;
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else if (
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
) {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
description: _t("The call was answered on another device."),
});
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
// don't play the end-call sound for calls that never got off the ground
this.play(AudioID.CallEnd);
}
this.logCallStats(call, mappedRoomId);
break;
}
}
};
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
const stats = await call.getCurrentCallStats();
logger.debug(
@ -743,9 +755,15 @@ export default class CallHandler extends EventEmitter {
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = MatrixClientPeg.get().createCall(mappedRoomId);
console.log("Adding call for room ", roomId);
this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
try {
this.addCallForRoom(roomId, call);
} catch (e) {
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
title: _t('Already in call'),
description: _t("You're already in a call with this person."),
});
return;
}
if (transferee) {
this.transferees[call.callId] = transferee;
}
@ -797,13 +815,8 @@ export default class CallHandler extends EventEmitter {
return;
}
if (this.getCallForRoom(room.roomId)) {
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
title: _t('Already in call'),
description: _t("You're already in a call with this person."),
});
return;
}
// We leave the check for whether there's already a call in this room until later,
// otherwise it can race.
const members = room.getJoinedMembers();
if (members.length <= 1) {
@ -857,10 +870,11 @@ export default class CallHandler extends EventEmitter {
}
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
console.log("Adding call for room ", mappedRoomId);
this.calls.set(mappedRoomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.addCallForRoom(mappedRoomId, call);
this.setCallListeners(call);
// Explicitly handle first state change
this.onCallStateChanged(call.state, null, call);
// get ready to send encrypted events in the room, so if the user does answer
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
@ -871,6 +885,8 @@ export default class CallHandler extends EventEmitter {
break;
case 'hangup':
case 'reject':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
@ -883,11 +899,15 @@ export default class CallHandler extends EventEmitter {
// the hangup event away)
break;
case 'hangup_all':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
@ -922,6 +942,12 @@ export default class CallHandler extends EventEmitter {
}
};
private stopRingingIfPossible(callId: string): void {
this.silencedCalls.delete(callId);
if (this.areAnyCallsUnsilenced()) return;
this.pause(AudioID.Ring);
}
private async dialNumber(number: string) {
const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) {
@ -1123,4 +1149,21 @@ export default class CallHandler extends EventEmitter {
messaging.transport.send(ElementWidgetActions.HangupCall, {});
});
}
private addCallForRoom(roomId: string, call: MatrixCall, changedRooms = false): void {
if (this.calls.has(roomId)) {
console.log(`Couldn't add call to room ${roomId}: already have a call for this room`);
throw new Error("Already have a call for room " + roomId);
}
console.log("setting call for room " + roomId);
this.calls.set(roomId, call);
// Should we always emit CallsChanged too?
if (changedRooms) {
this.emit(CallHandlerEvent.CallChangeRoom, call);
} else {
this.emit(CallHandlerEvent.CallsChanged, this.calls);
}
}
}

View file

@ -39,6 +39,8 @@ import {
import { IUpload } from "./models/IUpload";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { BlurhashEncoder } from "./BlurhashEncoder";
import SettingsStore from "./settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -539,6 +541,10 @@ export default class ContentMessages {
msgtype: "", // set later
};
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
// if we have a mime type for the file, add it to the message metadata
if (file.type) {
content.info.mimetype = file.type;
@ -614,6 +620,11 @@ export default class ContentMessages {
}).then(function() {
if (upload.canceled) throw new UploadCanceledError();
const prom = matrixClient.sendMessage(roomId, content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => {
sendRoundTripMetric(matrixClient, roomId, resp.event_id);
});
}
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
return prom;
}, function(err) {

View file

@ -136,6 +136,18 @@ export function formatCallTime(delta: Date): string {
return output;
}
export function formatSeconds(inSeconds: number): string {
const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0).padStart(2, '0');
const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0).padStart(2, '0');
const seconds = Math.floor(((inSeconds % (60 * 60)) % 60)).toFixed(0).padStart(2, '0');
let output = "";
if (hours !== "00") output += `${hours}:`;
output += `${minutes}:${seconds}`;
return output;
}
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {

View file

@ -100,6 +100,7 @@ export default class DeviceListener {
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
console.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
for (const d of deviceIds) {
this.dismissed.add(d);
}
@ -285,6 +286,9 @@ export default class DeviceListener {
}
}
console.log("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(','));
console.log("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(','));
// Display or hide the batch toast for old unverified sessions
if (oldUnverifiedDeviceIds.size > 0) {
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);

View file

@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);

View file

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

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createClient } from 'matrix-js-sdk/src/matrix';
import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix';
import { _t } from './languageHandler';
/**
@ -26,12 +26,18 @@ import { _t } from './languageHandler';
* API on the homeserver in question with the new password.
*/
export default class PasswordReset {
private client: MatrixClient;
private clientSecret: string;
private identityServerDomain: string;
private password: string;
private sessionId: string;
/**
* Configure the endpoints for password resetting.
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl, identityUrl) {
constructor(homeserverUrl: string, identityUrl: string) {
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
@ -47,7 +53,7 @@ export default class PasswordReset {
* @param {string} newPassword The new password for the account.
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
resetPassword(emailAddress, newPassword) {
public resetPassword(emailAddress: string, newPassword: string): Promise<IRequestTokenResponse> {
this.password = newPassword;
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid;
@ -69,7 +75,7 @@ export default class PasswordReset {
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
*/
async checkEmailLinkClicked() {
public async checkEmailLinkClicked(): Promise<void> {
const creds = {
sid: this.sessionId,
client_secret: this.clientSecret,

View file

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

View file

@ -50,7 +50,6 @@ import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
import ErrorDialog from './components/views/dialogs/ErrorDialog';
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog";
@ -245,21 +244,6 @@ export const Commands = [
},
category: CommandCategories.messages,
}),
new Command({
command: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function() {
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
},
category: CommandCategories.actions,
hideCompletionAfterSpace: true,
}),
new Command({
command: 'upgraderoom',
args: '<new_version>',

View file

@ -441,6 +441,15 @@ function textForPowerEvent(event: MatrixEvent): () => string | null {
});
}
const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => {
defaultDispatcher.dispatch({
action: 'view_room',
event_id: messageId,
highlighted: true,
room_id: roomId,
});
};
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
@ -452,17 +461,77 @@ const onPinnedMessagesClick = (): void => {
function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null {
if (!SettingsStore.getValue("feature_pinning")) return null;
const senderName = event.sender ? event.sender.name : event.getSender();
const roomId = event.getRoomId();
const pinned = event.getContent().pinned ?? [];
const previouslyPinned = event.getPrevContent().pinned ?? [];
const newlyPinned = pinned.filter(item => previouslyPinned.indexOf(item) < 0);
const newlyUnpinned = previouslyPinned.filter(item => pinned.indexOf(item) < 0);
if (newlyPinned.length === 1 && newlyUnpinned.length === 0) {
// A single message was pinned, include a link to that message.
if (allowJSX) {
const messageId = newlyPinned.pop();
return () => (
<span>
{ _t(
"%(senderName)s pinned <a>a message</a> to this room. See all <b>pinned messages</b>.",
{ senderName },
{
"a": (sub) =>
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
{ sub }
</a>,
"b": (sub) =>
<a onClick={onPinnedMessagesClick}>
{ sub }
</a>,
},
) }
</span>
);
}
return () => _t("%(senderName)s pinned a message to this room. See all pinned messages.", { senderName });
}
if (newlyUnpinned.length === 1 && newlyPinned.length === 0) {
// A single message was unpinned, include a link to that message.
if (allowJSX) {
const messageId = newlyUnpinned.pop();
return () => (
<span>
{ _t(
"%(senderName)s unpinned <a>a message</a> from this room. See all <b>pinned messages</b>.",
{ senderName },
{
"a": (sub) =>
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
{ sub }
</a>,
"b": (sub) =>
<a onClick={onPinnedMessagesClick}>
{ sub }
</a>,
},
) }
</span>
);
}
return () => _t("%(senderName)s unpinned a message from this room. See all pinned messages.", { senderName });
}
if (allowJSX) {
return () => (
<span>
{
_t(
"%(senderName)s changed the <a>pinned messages</a> for the room.",
{ senderName },
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
)
}
{ _t(
"%(senderName)s changed the <a>pinned messages</a> for the room.",
{ senderName },
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
) }
</span>
);
}

View file

@ -26,7 +26,7 @@ export class ManagedPlayback extends Playback {
}
public async play(): Promise<void> {
this.manager.playOnly(this);
this.manager.pauseAllExcept(this);
return super.play();
}

View file

@ -117,6 +117,8 @@ export class Playback extends EventEmitter implements IDestroyable {
}
public destroy() {
// Dev note: It's critical that we call stop() during cleanup to ensure that downstream callers
// are aware of the final clock position before the user triggered an unload.
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
this.stop();
this.removeAllListeners();
@ -177,9 +179,12 @@ export class Playback extends EventEmitter implements IDestroyable {
this.waveformObservable.update(this.resampledWaveform);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
// when the downstream callers try to use it.
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
}
private onPlaybackEnd = async () => {

View file

@ -89,9 +89,9 @@ export class PlaybackClock implements IDestroyable {
return this.observable;
}
private checkTime = () => {
private checkTime = (force = false) => {
const now = this.timeSeconds; // calculated dynamically
if (this.lastCheck !== now) {
if (this.lastCheck !== now || force) {
this.observable.update([now, this.durationSeconds]);
this.lastCheck = now;
}
@ -141,7 +141,7 @@ export class PlaybackClock implements IDestroyable {
public syncTo(contextTime: number, clipTime: number) {
this.clipStart = contextTime - clipTime;
this.stopped = false; // count as a mid-stream pause (if we were stopped)
this.checkTime();
this.checkTime(true);
}
public destroy() {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DEFAULT_WAVEFORM, Playback } from "./Playback";
import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
import { ManagedPlayback } from "./ManagedPlayback";
/**
@ -34,12 +34,14 @@ export class PlaybackManager {
}
/**
* Stops all other playback instances. If no playback is provided, all instances
* are stopped.
* Pauses all other playback instances. If no playback is provided, all playing
* instances are paused.
* @param playback Optional. The playback to leave untouched.
*/
public playOnly(playback?: Playback) {
this.instances.filter(p => p !== playback).forEach(p => p.stop());
public pauseAllExcept(playback?: Playback) {
this.instances
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
.forEach(p => p.pause());
}
public destroyPlaybackInstance(playback: ManagedPlayback) {

219
src/audio/PlaybackQueue.ts Normal file
View file

@ -0,0 +1,219 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { Playback, PlaybackState } from "./Playback";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { arrayFastClone } from "../utils/arrays";
import { PlaybackManager } from "./PlaybackManager";
import { isVoiceMessage } from "../utils/EventUtils";
import RoomViewStore from "../stores/RoomViewStore";
import { EventType } from "matrix-js-sdk/src/@types/event";
/**
* Audio playback queue management for a given room. This keeps track of where the user
* was at for each playback, what order the playbacks were played in, and triggers subsequent
* playbacks.
*
* Currently this is only intended to be used by voice messages.
*
* The primary mechanics are:
* * Persisted clock state for each playback instance (tied to Event ID).
* * Limited memory of playback order (see code; not persisted).
* * Autoplay of next eligible playback instance.
*/
export class PlaybackQueue {
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
private playbacks = new Map<string, Playback>(); // keyed by event ID
private clockStates = new Map<string, number>(); // keyed by event ID
private playbackIdOrder: string[] = []; // event IDs, last == current
private currentPlaybackId: string; // event ID, broken out from above for ease of use
private recentFullPlays = new Set<string>(); // event IDs
constructor(private client: MatrixClient, private room: Room) {
this.loadClocks();
RoomViewStore.addListener(() => {
if (RoomViewStore.getRoomId() === this.room.roomId) {
// Reset the state of the playbacks before they start mounting and enqueuing updates.
// We reset the entirety of the queue, including order, to ensure the user isn't left
// confused with what order the messages are playing in.
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
}
});
}
public static forRoom(roomId: string): PlaybackQueue {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) throw new Error("Unknown room");
if (PlaybackQueue.queues.has(room.roomId)) {
return PlaybackQueue.queues.get(room.roomId);
}
const queue = new PlaybackQueue(cli, room);
PlaybackQueue.queues.set(room.roomId, queue);
return queue;
}
private persistClocks() {
localStorage.setItem(
`mx_voice_message_clocks_${this.room.roomId}`,
JSON.stringify(Array.from(this.clockStates.entries())),
);
}
private loadClocks() {
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
if (!!val) {
this.clockStates = new Map<string, number>(JSON.parse(val));
}
}
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
// We don't ever detach our listeners: we expect the Playback to clean up for us
this.playbacks.set(mxEvent.getId(), playback);
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
}
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
// Remember where the user got to in playback
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
// noinspection JSIgnoredPromiseFromCall
playback.skipTo(this.clockStates.get(mxEvent.getId()));
} else if (newState === PlaybackState.Stopped) {
// Remove the now-useless clock for some space savings
this.clockStates.delete(mxEvent.getId());
if (wasLastPlaying) {
this.recentFullPlays.add(this.currentPlaybackId);
const orderClone = arrayFastClone(this.playbackIdOrder);
const last = orderClone.pop();
if (last === this.currentPlaybackId) {
const next = orderClone.pop();
if (next) {
const instance = this.playbacks.get(next);
if (!instance) {
console.warn(
"Voice message queue desync: Missing playback for next message: "
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
);
} else {
this.playbackIdOrder = orderClone;
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
}
} else {
// else no explicit next event, so find an event we haven't played that comes next. The live
// timeline is already most recent last, so we can iterate down that.
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
let scanForVoiceMessage = false;
let nextEv: MatrixEvent;
for (const event of timeline) {
if (event.getId() === mxEvent.getId()) {
scanForVoiceMessage = true;
continue;
}
if (!scanForVoiceMessage) continue;
if (!isVoiceMessage(event)) {
const evType = event.getType();
if (evType !== EventType.RoomMessage && evType !== EventType.Sticker) {
continue; // Event can be skipped for automatic playback consideration
}
break; // Stop automatic playback: next useful event is not a voice message
}
const havePlayback = this.playbacks.has(event.getId());
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
if (havePlayback && !isRecentlyCompleted) {
nextEv = event;
break;
}
}
if (!nextEv) {
// if we don't have anywhere to go, reset the recent playback queue so the user
// can start a new chain of playbacks.
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
} else {
this.playbackIdOrder = orderClone;
const instance = this.playbacks.get(nextEv.getId());
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
}
}
} else {
console.warn(
"Voice message queue desync: Expected playback stop to be last in order. "
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
);
}
}
}
if (newState === PlaybackState.Playing) {
const order = this.playbackIdOrder;
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
const lastInstance = this.playbacks.get(this.currentPlaybackId);
if (
lastInstance.currentState === PlaybackState.Playing
|| lastInstance.currentState === PlaybackState.Paused
) {
order.push(this.currentPlaybackId);
}
}
}
this.currentPlaybackId = mxEvent.getId();
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
order.push(this.currentPlaybackId);
}
}
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
// This should get triggered from normal voice message component unmount due to the playback
// stopping itself for cleanup.
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
this.persistClocks();
}
}
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
if (playback.currentState !== PlaybackState.Stopped) {
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
}
}
}

View file

@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const currentSecond = roundTimeToTargetFreq(currentTime);
if (currentSecond === this.nextAmplitudeSecond) {
// We special case the first ping because there's a fairly good chance that we'll miss the zeroth
// update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first
// time. Edge and Chrome occasionally lag behind too, but for the most part are on time.
//
// When this doesn't work properly we end up producing a waveform of nulls and no live preview
// of the recorded message.
if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) {
// We're expecting exactly one mono input source, so just grab the very first frame of
// samples for the analysis.
const monoChan = inputs[0][0];

View file

@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
@ -55,7 +54,6 @@ const PROVIDERS = [
EmojiProvider,
NotifProvider,
CommandProvider,
DuckDuckGoProvider,
];
if (SpaceStore.spacesEnabled) {

View file

@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
// some commands, namely `me` don't suit having the usage shown whilst typing their arguments
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
matches = [CommandMap.get(name)];
}
@ -95,7 +95,7 @@ export default class CommandProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"
className="mx_Autocomplete_Completion_container_pill"
role="presentation"
aria-label={_t("Command Autocomplete")}
>

View file

@ -1,115 +0,0 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import { TextualCompletion } from './Components';
import { ICompletion, ISelectionRange } from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() {
super(DDG_REGEX);
}
static getQueryUri(query: string) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const { command, range } = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
}
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET',
});
const json = await response.json();
const maxLength = limit > -1 ? limit : json.Results.length;
const results = json.Results.slice(0, maxLength).map((result) => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
}
getName() {
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="presentation"
aria-label={_t("DuckDuckGo Results")}
>
{ completions }
</div>
);
}
}

View file

@ -67,7 +67,7 @@ export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
keys: ['emoji.emoticon'],
keys: [],
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
@ -91,7 +91,8 @@ export default class EmojiProvider extends AutocompleteProvider {
let completions = [];
const { command, range } = this.getCurrentCommand(query, selection);
if (command) {
if (command && command[0].length > 2) {
const matchedString = command[0];
completions = this.matcher.match(matchedString, limit);

View file

@ -0,0 +1,44 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { CSSProperties } from "react";
interface IProps {
backgroundImage?: string;
blurMultiplier?: number;
}
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
if (!backgroundImage) return null;
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
const pixelsValue = blurValue.replace('px', '');
const parsed = parseInt(pixelsValue, 10);
if (!isNaN(parsed)) {
styles.filter = `blur(${parsed * blurMultiplier}px)`;
}
}
return <div className="mx_BackdropPanel">
<img
style={styles}
className="mx_BackdropPanel--image"
src={backgroundImage} />
</div>;
};
export default BackdropPanel;

View file

@ -25,6 +25,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
}
const CONNECTING_STATES = [
@ -104,8 +105,12 @@ export default class CallEventGrouper extends EventEmitter {
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
}
private get callId(): string {
return [...this.events][0].getContent().call_id;
private get callId(): string | undefined {
return [...this.events][0]?.getContent()?.call_id;
}
private get roomId(): string | undefined {
return [...this.events][0]?.getRoomId();
}
private onSilencedCallsChanged = () => {
@ -113,19 +118,29 @@ export default class CallEventGrouper extends EventEmitter {
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
};
public answerCall = () => {
this.call?.answer();
defaultDispatcher.dispatch({
action: 'answer',
room_id: this.roomId,
});
};
public rejectCall = () => {
this.call?.reject();
defaultDispatcher.dispatch({
action: 'reject',
room_id: this.roomId,
});
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: [...this.events][0]?.getRoomId(),
room_id: this.roomId,
});
};
@ -139,6 +154,7 @@ export default class CallEventGrouper extends EventEmitter {
private setCallListeners() {
if (!this.call) return;
this.call.addListener(CallEvent.State, this.setState);
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
}
private setState = () => {

View file

@ -322,10 +322,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
/**
* In some cases we may get the number of 0, which still means that we're supposed to properly
* add the specific position class, but as it was falsy things didn't work as intended.
* In addition, defensively check for counter cases where we may get more than one value,
* even if we shouldn't.
*/
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
@ -404,17 +410,27 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
}
export type ToRightOf = {
left: number;
top: number;
chevronOffset: number;
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { left, top, chevronOffset };
};
export type AboveLeftOf = IPosition & {
chevronFace: ChevronFace;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0): AboveLeftOf => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;

View file

@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { EventSubscription } from "fbemitter";
import React from 'react';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
@ -30,22 +31,43 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
interface IGroupFilterPanelProps {
}
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type OrderedTagsTemporaryType = Array<{}>;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type SelectedTagsTemporaryType = Array<{}>;
interface IGroupFilterPanelState {
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
orderedTags: OrderedTagsTemporaryType;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
selectedTags: SelectedTagsTemporaryType;
}
@replaceableComponent("structures.GroupFilterPanel")
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFilterPanelState> {
public static contextType = MatrixClientContext;
state = {
public state = {
orderedTags: [],
selectedTags: [],
};
componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
private ref = React.createRef<HTMLDivElement>();
private unmounted = false;
private groupFilterOrderStoreToken?: EventSubscription;
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
public componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this.onGroupMyMembership);
this.context.on("sync", this.onClientSync);
this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
@ -56,23 +78,25 @@ class GroupFilterPanel extends React.Component {
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current);
}
componentWillUnmount() {
public componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._groupFilterOrderStoreToken) {
this._groupFilterOrderStoreToken.remove();
this.context.removeListener("Group.myMembership", this.onGroupMyMembership);
this.context.removeListener("sync", this.onClientSync);
if (this.groupFilterOrderStoreToken) {
this.groupFilterOrderStoreToken.remove();
}
UIStore.instance.stopTrackingElementDimensions("GroupPanel");
}
_onGroupMyMembership = () => {
private onGroupMyMembership = () => {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
};
_onClientSync = (syncState, prevState) => {
private onClientSync = (syncState, prevState) => {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component {
}
};
onClick = e => {
private onClick = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({ action: 'deselect_tags' });
}
};
onClearFilterClick = ev => {
private onClearFilterClick = ev => {
dis.dispatch({ action: 'deselect_tags' });
};
renderGlobalIcon() {
private renderGlobalIcon() {
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
return (
@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component {
);
}
render() {
public render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const ActionButton = sdk.getComponent('elements.ActionButton');
@ -147,7 +171,7 @@ class GroupFilterPanel extends React.Component {
);
}
return <div className={classes} onClick={this.onClearFilterClick}>
return <div className={classes} onClick={this.onClearFilterClick} ref={this.ref}>
<AutoHideScrollbar
className="mx_GroupFilterPanel_scroller"
onClick={this.onClick}

View file

@ -19,8 +19,6 @@ import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
@ -33,15 +31,12 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { mediaFromMxc } from "../../customisations/Media";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
@ -53,7 +48,6 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showGroupFilterPanel: boolean;
activeSpace?: Room;
}
@ -70,8 +64,6 @@ const cssClasses = [
export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string;
private focusedElement = null;
private isDoingStickyHeaders = false;
@ -80,22 +72,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
activeSpace: SpaceStore.instance.activeSpace,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
});
}
public componentDidMount() {
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
@ -149,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private onBackgroundImageUpdate = () => {
// Note: we do this in the LeftPanel as it uses this variable most prominently.
const avatarSize = 32; // arbitrary
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) {
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
if (!avatarUrl) {
document.body.style.removeProperty("--avatar-url");
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp);
}
};
private handleStickyHeaders(list: HTMLDivElement) {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
@ -433,23 +399,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={_t("Explore rooms")}
title={this.state.activeSpace
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
: _t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
let leftLeftPanel;
if (this.state.showGroupFilterPanel) {
leftLeftPanel = (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
</div>
);
}
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
@ -473,7 +431,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses} ref={this.ref}>
{ leftLeftPanel }
<aside className="mx_LeftPanel_roomListContainer">
{ this.renderHeader() }
{ this.renderSearchDialExplore() }

View file

@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
setExpanded(!expanded);
}}
>
<span className={classNames({

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018, 2020 New Vector Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -55,15 +53,22 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import { replaceableComponent } from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import CallHandler from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
import ToastContainer from './ToastContainer';
import MyGroups from "./MyGroups";
import UserView from "./UserView";
import GroupView from "./GroupView";
import BackdropPanel from "./BackdropPanel";
import SpaceStore from "../../stores/SpaceStore";
import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
@ -127,6 +132,7 @@ interface IState {
usageLimitEventTs?: number;
useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
backgroundImage?: string;
}
/**
@ -142,10 +148,13 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
private dispatcherRef: string;
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
protected compactLayoutWatcherRef: string;
protected backgroundImageWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
@ -156,7 +165,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: [],
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -168,11 +177,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
this.resizeHandler = React.createRef();
}
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this.dispatcherRef = dis.register(this.onAction);
this.updateServerNoticeEvents();
@ -189,26 +199,51 @@ class LoggedInView extends React.Component<IProps, IState> {
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
);
this.resizer = this.createResizer();
this.resizer.attach();
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
this.loadResizerPreferences();
this.refreshBackgroundImage();
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
dis.unregister(this.dispatcherRef);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.resizer.detach();
}
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
private refreshBackgroundImage = async (): Promise<void> => {
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
if (backgroundImage) {
// convert to http before going much further
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
} else {
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
}
this.setState({ backgroundImage });
};
private onAction = (payload): void => {
switch (payload.action) {
case 'call_state': {
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
if (activeCalls !== this.state.activeCalls) {
this.setState({ activeCalls });
}
break;
}
}
};
public canResetTimelineInRoom = (roomId: string) => {
@ -247,6 +282,7 @@ class LoggedInView extends React.Component<IProps, IState> {
isItemCollapsed: domNode => {
return domNode.classList.contains("mx_LeftPanel_minimized");
},
handler: this.resizeHandler.current,
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
@ -262,7 +298,7 @@ class LoggedInView extends React.Component<IProps, IState> {
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
}
private onAccountData = (event: MatrixEvent) => {
@ -601,10 +637,14 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
}
let bodyClasses = 'mx_MatrixChat';
if (this.state.useCompactLayout) {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const wrapperClasses = classNames({
'mx_MatrixChat_wrapper': true,
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
});
const bodyClasses = classNames({
'mx_MatrixChat': true,
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
});
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
@ -617,18 +657,47 @@ class LoggedInView extends React.Component<IProps, IState> {
<div
onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown}
className='mx_MatrixChat_wrapper'
className={wrapperClasses}
aria-hidden={this.props.hideToSRUsers}
>
<ToastContainer />
<div ref={this._resizeContainer} className={bodyClasses}>
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
<div className={bodyClasses}>
<div className='mx_LeftPanel_wrapper'>
{ SettingsStore.getValue('TagPanel.enableTagPanel') &&
(<div className="mx_GroupFilterPanelContainer">
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<GroupFilterPanel />
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
</div>)
}
{ SpaceStore.spacesEnabled ? <>
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<SpacePanel />
</> : null }
<BackdropPanel
backgroundImage={this.state.backgroundImage}
/>
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
data-collapsed={this.props.collapseLhs ? true : undefined}
>
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
</div>
</div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper">
{ pageElement }
</div>
</div>
</div>
<CallContainer />

View file

@ -143,7 +143,7 @@ export enum Views {
SOFT_LOGOUT,
}
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
// Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
@ -1016,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
currentRoomId: null,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
@ -1896,15 +1897,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
dis.dispatch({ action: 'message_send_failed' });
return;
}
if (!cli) return;
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({ action: 'message_send_failed' });
});
}

View file

@ -173,6 +173,8 @@ interface IProps {
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
}
interface IState {
@ -265,6 +267,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
componentDidMount() {
this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
if (SettingsStore.getValue("feature_thread")) {
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
}
this.isMounted = true;
}
@ -443,6 +448,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
if (mxEv.replyInThread
&& this.props.hideThreadedMessages
&& SettingsStore.getValue("feature_thread")) {
return false;
}
return !shouldHideEvent(mxEv, this.context);
}
@ -694,9 +705,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
let willWantDateSeparator = false;
let lastInSection = true;
if (nextEvent) {
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender();
if (nextEventWithTile) {
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date());
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender();
}
// is this a continuation of the previous message?

View file

@ -45,17 +45,23 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface IState {
@ -265,7 +271,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
case RightPanelPhases.EncryptionPanel:
panel = <UserInfo
user={this.state.member}
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
room={this.context.getRoom(this.state.member.roomId) ?? this.props.room}
key={roomId || this.state.member.userId}
onClose={this.onClose}
phase={this.state.phase}
@ -309,6 +315,23 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;
case RightPanelPhases.ThreadView:
panel = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} />;
break;
case RightPanelPhases.ThreadPanel:
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
break;
case RightPanelPhases.RoomSummary:
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
break;

View file

@ -347,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
});
}
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

View file

@ -1848,6 +1848,19 @@ export default class RoomView extends React.Component<IProps, IState> {
/>;
}
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
// if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen
// show statusBarArea only if statusBar is present
const statusBarArea = statusBar && <div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line" />
{ statusBar }
</div>
</div>;
const roomVersionRecommendation = this.state.upgradeRecommendation;
const showRoomUpgradeBar = (
roomVersionRecommendation &&
@ -1867,7 +1880,7 @@ export default class RoomView extends React.Component<IProps, IState> {
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
/>;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
aux = <RoomUpgradeWarningBar room={this.state.room} />;
} else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it.
@ -2042,17 +2055,16 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>);
}
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
? <RightPanel
room={this.state.room}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
e2eStatus={this.state.e2eStatus} />
: null;
const timelineClasses = classNames("mx_RoomView_timeline", {
@ -2095,12 +2107,7 @@ export default class RoomView extends React.Component<IProps, IState> {
{ messagePanel }
{ searchResultsPanel }
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line" />
{ statusBar }
</div>
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</div>

View file

@ -0,0 +1,719 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { sortBy } from "lodash";
import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
interface IProps {
space: Room;
initialText?: string;
additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
}
interface ITileProps {
room: IHierarchyRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const cli = useContext(MatrixClientContext);
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
};
let button;
if (joinedRoom) {
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
{ avatar }
<div className="mx_SpaceHierarchy_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div
className="mx_SpaceHierarchy_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceHierarchy_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceHierarchy_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</li>;
};
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
const room = hierarchy.roomMap.get(roomId);
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (cli.isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({ action: "require_registration" });
return;
}
}
const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({
action: "view_room",
auto_join: autoJoin,
should_peek: true,
_type: "room_directory", // instrumentation
room_alias: roomAlias,
room_id: room.room_id,
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
oob_data: {
avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"),
},
});
};
interface IHierarchyLevelProps {
root: IHierarchyRoom;
roomSet: Set<IHierarchyRoom>;
hierarchy: RoomHierarchy;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
root,
roomSet,
hierarchy,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = useContext(MatrixClientContext);
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy(root.children_state, ev => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
childRooms.map(room => (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(room.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))
}
{
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
<Tile
key={space.room_id}
room={space}
numChildRooms={space.children_state.filter(ev => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(space.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
};
const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): {
loading: boolean;
rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>;
} => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => {
if (discard) return;
setRooms(hierarchy.rooms);
setLoading(false);
});
return () => {
discard = true;
};
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO
resetHierarchy();
}
}));
const loadMore = useCallback(async (pageSize?: number) => {
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
await hierarchy.load(pageSize);
setRooms(hierarchy.rooms);
setLoading(false);
}, [hierarchy]);
return { loading, rooms, hierarchy, loadMore };
};
const useIntersectionObserver = (callback: () => void) => {
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting) {
callback();
}
};
const observerRef = useRef<IntersectionObserver>();
return (element: HTMLDivElement) => {
if (observerRef.current) {
observerRef.current.disconnect();
} else if (element) {
observerRef.current = new IntersectionObserver(handleObserver, {
root: element.parentElement,
rootMargin: "0px 0px 600px 0px",
});
}
if (observerRef.current && element) {
observerRef.current.observe(element);
}
};
};
interface IManageButtonsProps {
hierarchy: RoomHierarchy;
selected: Map<string, Set<string>>;
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
setError: Dispatch<SetStateAction<string>>;
}
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
const cli = useContext(MatrixClientContext);
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
return <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
};
const SpaceHierarchy = ({
space,
initialText = "",
showRoom,
additionalButtons,
}: IProps) => {
const cli = useContext(MatrixClientContext);
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set();
const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms);
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
return new Set(rooms.filter(r => visited.has(r.room_id)));
}, [rooms, hierarchy, query]);
const [error, setError] = useState("");
const loaderRef = useIntersectionObserver(loadMore);
if (!loading && hierarchy.noSupport) {
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus();
}
};
const onToggleClick = (parentId: string, childId: string): void => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
};
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
const hasPermissions = space?.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
let results: JSX.Element;
if (filteredRoomSet.size) {
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(cli, hierarchy, roomId, autoJoin);
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ hasPermissions && (
<ManageButtons
hierarchy={hierarchy}
selected={selected}
setSelected={setSelected}
setError={setError}
/>
) }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
<ul
className="mx_SpaceHierarchy_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
</ul>
{ loader }
</>;
}
return <>
<SearchBox
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
export default SpaceHierarchy;

View file

@ -1,732 +0,0 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames";
import { sortBy } from "lodash";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import { EnhancedMap } from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
interface IHierarchyProps {
space: Room;
initialText?: string;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
interface ITileProps {
room: ISpaceSummaryRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const cli = MatrixClientPeg.get();
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
};
let button;
if (joinedRoom) {
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
{ avatar }
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div
className="mx_SpaceRoomDirectory_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceRoomDirectory_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <li
className="mx_SpaceRoomDirectory_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</li>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({ action: "require_registration" });
return;
}
}
const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({
action: "view_room",
auto_join: autoJoin,
should_peek: true,
_type: "room_directory", // instrumentation
room_alias: roomAlias,
room_id: room.room_id,
via_servers: viaServers,
oob_data: {
avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"),
},
});
};
interface IHierarchyLevelProps {
spaceId: string;
rooms: Map<string, ISpaceSummaryRoom>;
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
relations,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getChildOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
return result;
}, [[], []]) || [[], []];
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<Tile
key={roomId}
room={rooms.get(roomId)}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<Tile
key={roomId}
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
>
<HierarchyLevel
spaceId={roomId}
rooms={rooms}
relations={relations}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
};
export const useSpaceSummary = (space: Room): [
null,
ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>?,
Map<string, Set<string>>?,
] | [Error] => {
// crude temporary refresh token approach until we have pagination and rework the data flow here
const [refreshToken, setRefreshToken] = useState(0);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setRefreshToken(t => t + 1);
}
}));
// TODO pagination
return useAsyncMemo(async () => {
try {
const data = await space.client.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,
additionalButtons,
children,
}) => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
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>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
};
// TODO loading state/error state
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let manageButtons;
if (space.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar
className="mx_SpaceRoomDirectory_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
return <>
<SearchBox
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
interface IProps {
space: Room;
initialText?: string;
onFinished(): void;
}
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
const onCreateRoomClick = () => {
dis.dispatch({
action: 'view_create_room',
public: true,
});
onFinished();
};
const title = <React.Fragment>
<RoomAvatar room={space} height={32} width={32} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
null,
{ a: sub => {
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{ sub }</AccessibleButton>;
} },
) }
<SpaceHierarchy
space={space}
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
showRoom(room, viaServers, autoJoin);
onFinished();
}}
initialText={initialText}
>
<AccessibleButton
onClick={onCreateRoomClick}
kind="primary"
className="mx_SpaceRoomDirectory_createRoom"
>
{ _t("Create room") }
</AccessibleButton>
</SpaceHierarchy>
</div>
</BaseDialog>
);
};
export default SpaceRoomDirectory;
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter";
@ -54,7 +54,7 @@ import {
showCreateNewSubspace,
showSpaceSettings,
} from "../../utils/space";
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
@ -78,6 +78,7 @@ import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFro
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher";
interface IProps {
space: Room;
@ -89,7 +90,7 @@ interface IProps {
interface IState {
phase: Phase;
createdRooms?: boolean; // internal state for the creation wizard
firstRoomId?: string; // internal state for the creation wizard
showRightPanel: boolean;
myMembership: string;
}
@ -191,6 +192,11 @@ interface ISpacePreviewProps {
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false);
@ -496,6 +502,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
autoComplete="off"
/>;
});
@ -505,11 +512,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setError("");
setBusy(true);
try {
const isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => {
const roomIds = await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
@ -517,9 +525,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
});
}));
onFinished(filteredRoomNames.length > 0);
onFinished(roomIds[0]);
} catch (e) {
console.error("Failed to create initial space rooms", e);
setError(_t("Failed to create initial space rooms"));
@ -529,7 +539,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let onClick = (ev) => {
ev.preventDefault();
onFinished(false);
onFinished();
};
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
@ -584,7 +594,11 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
</div>;
};
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
onFinished(): void;
}
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
@ -597,7 +611,7 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
</div>;
@ -805,6 +819,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.room_id === this.props.space.roomId) {
this.setState({ phase: Phase.Landing });
return;
}
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
if (payload.action === Action.ViewUser && payload.member) {
@ -835,35 +854,10 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private goToFirstRoom = async () => {
// TODO actually go to the first room
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
if (childRooms.length) {
const room = childRooms[0];
if (this.state.firstRoomId) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.roomId,
});
return;
}
let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
}
if (suggestedRooms.length) {
const room = suggestedRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
room_alias: room.canonical_alias || room.aliases?.[0],
via_servers: room.viaServers,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
},
room_id: this.state.firstRoomId,
});
return;
}
@ -893,14 +887,14 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
createdRooms={this.state.createdRooms}
firstRoomId={this.state.firstRoomId}
/>;
case Phase.PrivateScope:
@ -922,7 +916,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms

View file

@ -0,0 +1,93 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile from '../views/rooms/EventTile';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
}
interface IState {
threads?: Thread[];
}
@replaceableComponent("structures.ThreadView")
export default class ThreadPanel extends React.Component<IProps, IState> {
private room: Room;
constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
}
public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived);
}
public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
}
private onThreadEventReceived = () => this.updateThreads();
private updateThreads = (callback?: () => void): void => {
this.setState({
threads: this.room.getThreads(),
}, callback);
};
private renderEventTile(event: MatrixEvent): JSX.Element {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
</BaseCard>
);
}
}

View file

@ -0,0 +1,160 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { Action } from '../../dispatcher/actions';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils';
interface IProps {
room: Room;
onClose: () => void;
resizeNotifier: ResizeNotifier;
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface IState {
replyToEvent?: MatrixEvent;
thread?: Thread;
}
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
constructor(props: IProps) {
super(props);
this.state = {};
}
public componentDidMount(): void {
this.setupThread(this.props.mxEvent);
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
this.teardownThread();
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (prevProps.mxEvent !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(this.props.mxEvent);
}
if (prevProps.room !== this.props.room) {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
}
}
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
}
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = mxEv.getThread();
if (!thread) {
const client = MatrixClientPeg.get();
thread = new Thread([mxEv], this.props.room, client);
mxEv.setThread(thread);
}
thread.on("Thread.update", this.updateThread);
thread.once("Thread.ready", this.updateThread);
this.updateThread(thread);
};
private teardownThread = () => {
if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread);
}
};
private updateThread = (thread?: Thread) => {
if (thread) {
this.setState({
thread,
replyToEvent: thread.replyToEvent,
});
}
this.timelinePanelRef.current?.refreshTimeline();
};
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={false}
tileShape={TileShape.Notif}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
</BaseCard>
);
}
}

View file

@ -47,11 +47,14 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import { debounce } from 'lodash';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const READ_RECEIPT_INTERVAL_MS = 500;
const READ_MARKER_DEBOUNCE_MS = 100;
const DEBUG = false;
let debuglog = function(...s: any[]) {};
@ -126,6 +129,8 @@ interface IProps {
// callback which is called when we wish to paginate the timeline window.
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
}
interface IState {
@ -214,6 +219,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
};
private lastRRSentEventId: string = undefined;
@ -472,22 +478,35 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
if (this.props.manageReadMarkers) {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
this.doManageReadMarkers();
}
};
/*
* Debounced function to manage read markers because we don't need to
* do this on every tiny scroll update. It also sets state which causes
* a component update, which can in turn reset the scroll position, so
* it's important we allow the browser to scroll a bit before running this
* (hence trailing edge only and debounce rather than throttle because
* we really only need to update this once the user has finished scrolling,
* not periodically while they scroll).
*/
private doManageReadMarkers = debounce(() => {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "ignore_state_changed":
@ -1176,6 +1195,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState(this.getEvents());
}
// Force refresh the timeline before threads support pending events
public refreshTimeline(): void {
this.loadTimeline();
this.reloadEvents();
}
// get the list of events from the timeline window and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
const events: MatrixEvent[] = this.timelineWindow.getEvents();
@ -1511,6 +1536,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
/>
);
}

View file

@ -31,6 +31,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import { IValidationResult } from "../../views/elements/Validation";
import InlineSpinner from '../../views/elements/InlineSpinner';
enum Phase {
// Show the forgot password inputs
@ -66,13 +67,14 @@ interface IState {
serverDeadError: string;
passwordFieldValid: boolean;
currentHttpRequest?: Promise<any>;
}
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component<IProps, IState> {
private reset: PasswordReset;
state = {
state: IState = {
phase: Phase.Forgot,
email: "",
password: "",
@ -148,8 +150,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
console.error("onVerify called before submitPasswordReset!");
return;
}
if (this.state.currentHttpRequest) return;
try {
await this.reset.checkEmailLinkClicked();
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
this.setState({ phase: Phase.Done });
} catch (err) {
this.showErrorDialog(err.message);
@ -158,9 +162,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (this.state.currentHttpRequest) return;
// refresh the server errors, just in case the server came back online
await this.checkServerLiveliness(this.props.serverConfig);
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
await this['password_field'].validate({ allowEmpty: false });
@ -221,6 +226,17 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
});
return request.finally(() => {
this.setState({
currentHttpRequest: undefined,
});
});
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -320,6 +336,9 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
type="button"
onClick={this.onVerify}
value={_t('I have verified my email address')} />
{ this.state.currentHttpRequest && (
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
}
</div>;
}
@ -357,6 +376,8 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
}
return (

View file

@ -15,34 +15,30 @@ limitations under the License.
*/
import React from "react";
import { formatSeconds } from "../../../DateUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export interface IProps {
seconds: number;
}
interface IState {
}
/**
* Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.audio_messages.Clock")
export default class Clock extends React.Component<IProps, IState> {
export default class Clock extends React.Component<IProps> {
public constructor(props) {
super(props);
}
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
}
}

View file

@ -36,6 +36,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean;
title?: string;
style?: any;
}
interface IState {

View file

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

View file

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

View file

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

View file

@ -168,7 +168,7 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: space },
refireParams: { space },
});
onFinished();
};

View file

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

View file

@ -215,7 +215,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
{
a: (sub) => <a
target="_blank"
href="https://github.com/vector-im/element-web/issues/new"
href="https://github.com/vector-im/element-web/issues/new/choose"
>
{ sub }
</a>,

View file

@ -39,11 +39,13 @@ interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
defaultEncrypted?: boolean;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
joinRule: JoinRule;
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
@ -74,8 +76,9 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const config = SdkConfig.get();
this.state = {
isPublic: this.props.defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(),
joinRule,
isEncrypted: privateShouldBeEncrypted(),
name: this.props.defaultName || "",
topic: "",
alias: "",

View file

@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
try {
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule });
onFinished(true);
} catch (e) {

View file

@ -28,7 +28,7 @@ import StyledRadioGroup from "../elements/StyledRadioGroup";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
export default (props) => {
const [rating, setRating] = useState("");

View file

@ -80,7 +80,7 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
@ -97,11 +97,11 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
onChange={setState}
definitions={[
{
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),

View file

@ -79,7 +79,10 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
ROOM_SECURITY_TAB,
_td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon",
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
<SecurityRoomSettingsTab
roomId={this.props.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>,
));
tabs.push(new Tab(
ROOM_ROLES_TAB,

View file

@ -158,7 +158,7 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
if (this.state.linkSpecificEvent) {
matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId());
} else {
matrixToUrl = this.props.permalinkCreator.forRoom();
matrixToUrl = this.props.permalinkCreator.forShareableRoom();
}
}
return matrixToUrl;

View file

@ -29,10 +29,12 @@ import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Roles = "SPACE_ROLES_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
@ -60,7 +62,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
SpaceSettingsTab.Visibility,
_td("Visibility"),
"mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} closeSettingsFn={onFinished} />,
),
new Tab(
SpaceSettingsTab.Roles,
_td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={space.roomId} />,
),
SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab(

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,6 +20,7 @@ import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
isTimelineCapability,
Widget,
WidgetEventCapability,
WidgetKind,
@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({ approved });
this.props.onFinished({ approved, remember: this.state.rememberSelection });
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
// We specifically order the timeline capabilities down to the bottom. The capability text
// generation cares strongly about this.
const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
const isTimelineA = isTimelineCapability(capA);
const isTimelineB = isTimelineCapability(capB);
if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
if (isTimelineA && !isTimelineB) return 1;
if (!isTimelineA && isTimelineB) return -1;
if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
return 0;
});
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 Travis Ralston
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.
@ -15,42 +16,46 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from "../../../languageHandler";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { Widget } from "matrix-widget-api";
import { Widget, WidgetKind } from "matrix-widget-api";
import { OIDCState, WidgetPermissionStore } from "../../../stores/widgets/WidgetPermissionStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
widget: Widget;
widgetKind: WidgetKind;
inRoomId?: string;
}
interface IState {
rememberSelection: boolean;
}
@replaceableComponent("views.dialogs.WidgetOpenIDPermissionsDialog")
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
widget: PropTypes.objectOf(Widget).isRequired,
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
inRoomId: PropTypes.string,
};
constructor() {
super();
export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
rememberSelection: false,
};
}
_onAllow = () => {
this._onPermissionSelection(true);
private onAllow = () => {
this.onPermissionSelection(true);
};
_onDeny = () => {
this._onPermissionSelection(false);
private onDeny = () => {
this.onPermissionSelection(false);
};
_onPermissionSelection(allowed) {
private onPermissionSelection(allowed: boolean) {
if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
console.log(`Remembering ${this.props.widget.id} as allowed=${allowed} for OpenID`);
WidgetPermissionStore.instance.setOIDCState(
this.props.widget, this.props.widgetKind, this.props.inRoomId,
@ -61,14 +66,11 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
this.props.onFinished(allowed);
}
_onRememberSelectionChange = (newVal) => {
private onRememberSelectionChange = (newVal: boolean) => {
this.setState({ rememberSelection: newVal });
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
public render() {
return (
<BaseDialog
className='mx_WidgetOpenIDPermissionsDialog'
@ -87,13 +89,13 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
</div>
<DialogButtons
primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onAllow}
onCancel={this._onDeny}
onPrimaryButtonClick={this.onAllow}
onCancel={this.onDeny}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this._onRememberSelectionChange}
onChange={this.onRememberSelectionChange}
label={_t("Remember this")} />}
/>
</BaseDialog>

View file

@ -19,7 +19,7 @@ import React, { ReactHTML } from 'react';
import { Key } from '../../../Keyboard';
import classnames from 'classnames';
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element>;
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element> | React.FormEvent<Element>;
/**
* children: React's magic prop. Represents all children given to the element.
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number;
disabled?: boolean;
className?: string;
onClick(e?: ButtonEvent): void;
onClick(e?: ButtonEvent): void | Promise<void>;
}
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {

View file

@ -25,6 +25,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
title: string;
tooltip?: React.ReactNode;
label?: React.ReactNode;
tooltipClassName?: string;
forceHide?: boolean;
yOffset?: number;
@ -84,7 +85,8 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
aria-label={title}
>
{ children }
{ tip }
{ this.props.label }
{ (tooltip || title) && tip }
</AccessibleButton>
);
}

View file

@ -218,6 +218,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true });
}
@ -307,7 +308,6 @@ export default class AppTile extends React.Component {
if (this.iframe) {
// Reload iframe
this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
}
@ -333,7 +333,7 @@ export default class AppTile extends React.Component {
// this would only be for content hosted on the same origin as the element client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox " +
"allow-same-origin allow-scripts allow-presentation";
// Additional iframe feature pemissions
@ -443,25 +443,25 @@ export default class AppTile extends React.Component {
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
{ appTileBody }
</div>

View file

@ -20,14 +20,21 @@ import BaseDialog from "..//dialogs/BaseDialog";
import DialogButtons from "./DialogButtons";
import classNames from 'classnames';
import AccessibleButton from './AccessibleButton';
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
export interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL;
export function getDesktopCapturerSources(): Promise<Array<DesktopCapturerSource>> {
const options: GetSourcesOptions = {
thumbnailSize: {
height: 176,
width: 312,
},
types: [
"screen",
"window",
],
};
return window.electron.getDesktopCapturerSources(options);
}
export enum Tabs {
@ -78,7 +85,7 @@ export interface PickerIState {
selectedSource: DesktopCapturerSource | null;
}
export interface PickerIProps {
onFinished(source: DesktopCapturerSource): void;
onFinished(sourceId: string): void;
}
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
@ -123,7 +130,7 @@ export default class DesktopCapturerSourcePicker extends React.Component<
};
private onShare = (): void => {
this.props.onFinished(this.state.selectedSource);
this.props.onFinished(this.state.selectedSource.id);
};
private onTabChange = (): void => {

View file

@ -77,7 +77,7 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
render() {
if (this.state.error) {
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
let bugReportSection;
if (SdkConfig.get().bug_report_endpoint_url) {

View file

@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
interface IProps {
/**
@ -45,7 +46,7 @@ interface IProps {
/**
* The ID of the displayed user
*/
userId: string;
userId?: string;
/**
* The display name of the displayed user
@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
}
public render() {
const event = this.fakeEvent(this.state);
const className = classnames(this.props.className, {
"mx_IRCLayout": this.props.layout == Layout.IRC,
"mx_GroupLayout": this.props.layout == Layout.Group,
"mx_EventTilePreview_loader": !this.props.userId,
});
if (!this.props.userId) return <div className={className}><Spinner /></div>;
const event = this.fakeEvent(this.state);
return <div className={className}>
<EventTile
mxEvent={event}

View file

@ -419,6 +419,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const avatar = (
<MemberAvatar
member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
width={32}
height={32}
viewUserOnClick={true}

View file

@ -16,11 +16,13 @@ limitations under the License.
import classNames from "classnames";
import React from "react";
import { sanitizedHtmlNode } from "../../../HtmlUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason: string;
htmlReason?: string;
}
interface IState {
@ -51,7 +53,7 @@ export default class InviteReason extends React.PureComponent<IProps, IState> {
});
return <div className={classes}>
<div className="mx_InviteReason_reason">{ this.props.reason }</div>
<div className="mx_InviteReason_reason">{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }</div>
<div className="mx_InviteReason_view"
onClick={this.onViewClick}
>

View file

@ -19,6 +19,7 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { UNSTABLE_ELEMENT_REPLY_IN_THREAD } from "matrix-js-sdk/src/@types/event";
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
@ -206,15 +207,28 @@ export default class ReplyThread extends React.Component<IProps, IState> {
return { body, html };
}
public static makeReplyMixIn(ev: MatrixEvent) {
public static makeReplyMixIn(ev: MatrixEvent, replyInThread: boolean) {
if (!ev) return {};
return {
const replyMixin = {
'm.relates_to': {
'm.in_reply_to': {
'event_id': ev.getId(),
},
},
};
/**
* @experimental
* Rendering hint for threads, only attached if true to make
* sure that Element does not start sending that property for all events
*/
if (replyInThread) {
const inReplyTo = replyMixin['m.relates_to']['m.in_reply_to'];
inReplyTo[UNSTABLE_ELEMENT_REPLY_IN_THREAD.name] = replyInThread;
}
return replyMixin;
}
public static makeThread(

View file

@ -1,27 +1,27 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
//see src/resizer for the actual resizing code, this is just the DOM for the resize handle
const ResizeHandle = (props) => {
interface IResizeHandleProps {
vertical?: boolean;
reverse?: boolean;
id?: string;
passRef?: React.RefObject<HTMLDivElement>;
}
const ResizeHandle: React.FC<IResizeHandleProps> = ({ vertical, reverse, id, passRef }) => {
const classNames = ['mx_ResizeHandle'];
if (props.vertical) {
if (vertical) {
classNames.push('mx_ResizeHandle_vertical');
} else {
classNames.push('mx_ResizeHandle_horizontal');
}
if (props.reverse) {
if (reverse) {
classNames.push('mx_ResizeHandle_reverse');
}
return (
<div className={classNames.join(' ')} data-id={props.id}><div /></div>
<div ref={passRef} className={classNames.join(' ')} data-id={id}><div /></div>
);
};
ResizeHandle.propTypes = {
vertical: PropTypes.bool,
reverse: PropTypes.bool,
id: PropTypes.string,
};
export default ResizeHandle;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { createRef, KeyboardEventHandler } from "react";
import { _t } from '../../../languageHandler';
import withValidation from './Validation';
@ -28,6 +28,7 @@ interface IProps {
label?: string;
placeholder?: string;
disabled?: boolean;
onKeyDown?: KeyboardEventHandler;
onChange?(value: string): void;
}
@ -70,6 +71,8 @@ export default class RoomAliasField extends React.PureComponent<IProps, IState>
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
disabled={this.props.disabled}
autoComplete="off"
onKeyDown={this.props.onKeyDown}
/>
);
}

View file

@ -173,16 +173,16 @@ class EmojiPicker extends React.Component<IProps, IState> {
};
private onChangeFilter = (filter: string) => {
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive
for (const cat of this.categories) {
let emojis;
// If the new filter string includes the old filter string, we don't have to re-filter the whole dataset.
if (filter.includes(this.state.filter)) {
if (lcFilter.includes(this.state.filter)) {
emojis = this.memoizedDataByCategory[cat.id];
} else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
}
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter));
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, lcFilter));
this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
@ -194,9 +194,12 @@ class EmojiPicker extends React.Component<IProps, IState> {
setTimeout(this.updateVisibility, 0);
};
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean =>
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)]
.some(x => x?.includes(filter));
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => {
return emoji.annotation.toLowerCase().includes(filter) ||
emoji.emoticon?.toLowerCase().includes(filter) ||
emoji.shortcodes.some(x => x.toLowerCase().includes(filter)) ||
emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter);
};
private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, { createRef } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t, _td } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
import AccessibleButton from '../elements/AccessibleButton';
@ -26,6 +26,7 @@ import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import { formatCallTime } from "../../../DateUtils";
import Clock from "../audio_messages/Clock";
const MAX_NON_NARROW_WIDTH = 450 / 70 * 100;
@ -38,13 +39,9 @@ interface IState {
callState: CallState | CustomCallState;
silenced: boolean;
narrow: boolean;
length: number;
}
const TEXTUAL_STATES: Map<CallState | CustomCallState, string> = new Map([
[CallState.Connected, _td("Connected")],
[CallState.Connecting, _td("Connecting")],
]);
export default class CallEvent extends React.PureComponent<IProps, IState> {
private wrapperElement = createRef<HTMLDivElement>();
private resizeObserver: ResizeObserver;
@ -56,12 +53,14 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
callState: this.props.callEventGrouper.state,
silenced: false,
narrow: false,
length: 0,
};
}
componentDidMount() {
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.resizeObserver.observe(this.wrapperElement.current);
@ -70,10 +69,15 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
componentWillUnmount() {
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver.disconnect();
}
private onLengthChanged = (length: number): void => {
this.setState({ length });
};
private resizeObserverCallback = (entries: ResizeObserverEntry[]): void => {
const wrapperElementEntry = entries.find((entry) => entry.target === this.wrapperElement.current);
if (!wrapperElementEntry) return;
@ -214,10 +218,17 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
</div>
);
}
if (Array.from(TEXTUAL_STATES.keys()).includes(state)) {
if (state === CallState.Connected) {
return (
<div className="mx_CallEvent_content">
{ TEXTUAL_STATES.get(state) }
<Clock seconds={this.state.length} />
</div>
);
}
if (state === CallState.Connecting) {
return (
<div className="mx_CallEvent_content">
{ _t("Connecting") }
</div>
);
}

View file

@ -16,26 +16,38 @@ limitations under the License.
import React, { forwardRef, useContext } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IRoomEncryption } from "matrix-js-sdk/src/crypto/RoomList";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import { objectHasDiff } from "../../../utils/objects";
interface IProps {
mxEvent: MatrixEvent;
}
const ALGORITHM = "m.megolm.v1.aes-sha2";
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent }, ref) => {
const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId();
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
const prevContent = mxEvent.getPrevContent() as IRoomEncryption;
const content = mxEvent.getContent<IRoomEncryption>();
// if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level.
if (!objectHasDiff(prevContent, content)) return null; // nop
if (content.algorithm === ALGORITHM && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (dmPartner) {
if (prevContent.algorithm === ALGORITHM) {
subtitle = _t("Some encryption parameters have been changed.");
} else if (dmPartner) {
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
subtitle = _t("Messages here are end-to-end encrypted. " +
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
@ -49,7 +61,9 @@ const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent }, ref) =>
title={_t("Encryption enabled")}
subtitle={subtitle}
/>;
} else if (isRoomEncrypted) {
}
if (isRoomEncrypted) {
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("Encryption enabled")}

View file

@ -24,6 +24,8 @@ import { IMediaEventContent } from "../../../customisations/models/IMediaEventCo
import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
interface IState {
error?: Error;
@ -67,6 +69,10 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback });
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()).unsortedEnqueue(this.props.mxEvent, playback);
}
// Note: the components later on will handle preparing the Playback class for us.
}

View file

@ -47,6 +47,7 @@ interface IState {
};
hover: boolean;
showImage: boolean;
placeholder: 'no-image' | 'blurhash';
}
@replaceableComponent("views.messages.MImageBody")
@ -54,6 +55,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
private timeout?: number;
constructor(props: IBodyProps) {
super(props);
@ -68,6 +70,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
loadedImageDimensions: null,
hover: false,
showImage: SettingsStore.getValue("showImages"),
placeholder: 'no-image',
};
}
@ -126,7 +129,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return;
}
const imgElement = e.currentTarget;
@ -136,7 +139,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return;
}
const imgElement = e.currentTarget;
@ -144,12 +147,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
};
private onImageError = (): void => {
this.clearBlurhashTimeout();
this.setState({
imgError: true,
});
};
private onImageLoad = (): void => {
this.clearBlurhashTimeout();
this.props.onHeightChanged();
let loadedImageDimensions;
@ -265,6 +270,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
}
private clearBlurhashTimeout() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
@ -277,11 +289,24 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
this.downloadImage();
this.setState({ showImage: true });
} // else don't download anything because we don't want to display anything.
// Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
this.clearBlurhashTimeout();
this.timeout = setTimeout(() => {
if (!this.state.imgLoaded || !this.state.imgError) {
this.setState({
placeholder: 'blurhash',
});
}
}, 150);
}
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('sync', this.onClientSync);
this.clearBlurhashTimeout();
}
protected messageContent(
@ -374,13 +399,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
}
if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
if (this.isGif() && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
}
const classes = classNames({
'mx_MImageBody_thumbnail': true,
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info[BLURHASH_FIELD],
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
});
// This has incredibly broken types.
@ -433,8 +458,15 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
if (blurhash) {
if (this.state.placeholder === 'no-image') {
return <div className="mx_no-image-placeholder" style={{ width: width, height: height }} />;
} else if (this.state.placeholder === 'blurhash') {
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
}
}
return (
<InlineSpinner w={32} h={32} />
);
@ -467,7 +499,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const contentUrl = this.getContentUrl();
let thumbUrl;
if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
if (this.isGif() && SettingsStore.getValue("autoplayGifs")) {
thumbUrl = contentUrl;
} else {
thumbUrl = this.getThumbUrl();

View file

@ -43,7 +43,7 @@ export default class MStickerBody extends MImageBody {
// Placeholder to show in place of the sticker image if
// img onLoad hasn't fired yet.
protected getPlaceholder(width: number, height: number): JSX.Element {
if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
}

View file

@ -145,7 +145,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
this.loadBlurhash();
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
@ -209,7 +209,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
render() {
const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
const autoplay = SettingsStore.getValue("autoplayVideo");
if (this.state.error !== null) {
return (

View file

@ -19,14 +19,12 @@ import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MVoiceMessageBody from "./MVoiceMessageBody";
import { IBodyProps } from "./IBodyProps";
import { isVoiceMessage } from "../../../utils/EventUtils";
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
if (isVoiceMessage) {
if (isVoiceMessage(this.props.mxEvent)) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;

View file

@ -17,12 +17,13 @@ limitations under the License.
*/
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
@ -34,48 +35,66 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
import SettingsStore from '../../../settings/SettingsStore';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ReplyThread from '../elements/ReplyThread';
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here
getReplyThread: () => ReplyThread;
permalinkCreator: RoomPermalinkCreator;
onFocusChange: (menuDisplayed: boolean) => void;
}
let contextMenu;
if (menuDisplayed) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const OptionsButton: React.FC<IOptionsButtonProps> =
({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
let contextMenu;
if (menuDisplayed) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyThread={replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined}
onFinished={closeMenu}
/>;
}
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyThread={replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined}
onFinished={closeMenu}
/>;
}
{ contextMenu }
</React.Fragment>;
};
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
const ReactButton = ({ mxEvent, reactions, onFocusChange }) => {
{ contextMenu }
</React.Fragment>;
};
interface IReactButtonProps {
mxEvent: MatrixEvent;
reactions: any; // TODO: types
onFocusChange: (menuDisplayed: boolean) => void;
}
const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
@ -106,21 +125,21 @@ const ReactButton = ({ mxEvent, reactions, onFocusChange }) => {
</React.Fragment>;
};
interface IMessageActionBarProps {
mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: any; // TODO: types
permalinkCreator?: RoomPermalinkCreator;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here
getReplyThread?: () => ReplyThread;
onFocusChange?: (menuDisplayed: boolean) => void;
}
@replaceableComponent("views.messages.MessageActionBar")
export default class MessageActionBar extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
permalinkCreator: PropTypes.object,
getTile: PropTypes.func,
getReplyThread: PropTypes.func,
onFocusChange: PropTypes.func,
};
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext;
static contextType = RoomContext;
componentDidMount() {
public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent);
}
@ -134,43 +153,54 @@ export default class MessageActionBar extends React.PureComponent {
this.props.mxEvent.on("Event.beforeRedaction", this.onBeforeRedaction);
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.props.mxEvent.off("Event.status", this.onSent);
this.props.mxEvent.off("Event.decrypted", this.onDecrypted);
this.props.mxEvent.off("Event.beforeRedaction", this.onBeforeRedaction);
}
onDecrypted = () => {
private onDecrypted = (): void => {
// When an event decrypts, it is likely to change the set of available
// actions, so we force an update to check again.
this.forceUpdate();
};
onBeforeRedaction = () => {
private onBeforeRedaction = (): void => {
// When an event is redacted, we can't edit it so update the available actions.
this.forceUpdate();
};
onSent = () => {
private onSent = (): void => {
// When an event is sent and echoed the possible actions change.
this.forceUpdate();
};
onFocusChange = (focused) => {
private onFocusChange = (focused: boolean): void => {
if (!this.props.onFocusChange) {
return;
}
this.props.onFocusChange(focused);
};
onReplyClick = (ev) => {
private onReplyClick = (ev: React.MouseEvent): void => {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
});
};
onEditClick = (ev) => {
private onThreadClick = (): void => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
allowClose: false,
refireParams: {
event: this.props.mxEvent,
},
});
};
private onEditClick = (ev: React.MouseEvent): void => {
dis.dispatch({
action: 'edit_event',
event: this.props.mxEvent,
@ -186,7 +216,7 @@ export default class MessageActionBar extends React.PureComponent {
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
runActionOnFailedEv(fn, checkFn) {
private runActionOnFailedEv(fn: (ev: MatrixEvent) => void, checkFn?: (ev: MatrixEvent) => boolean): void {
if (!checkFn) checkFn = () => true;
const mxEvent = this.props.mxEvent;
@ -201,18 +231,18 @@ export default class MessageActionBar extends React.PureComponent {
}
}
onResendClick = (ev) => {
private onResendClick = (ev: React.MouseEvent): void => {
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
};
onCancelClick = (ev) => {
private onCancelClick = (ev: React.MouseEvent): void => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(tarEv),
(testEv) => canCancel(testEv.status),
);
};
render() {
public render(): JSX.Element {
const toolbarOpts = [];
if (canEditContent(this.props.mxEvent)) {
toolbarOpts.push(<RovingAccessibleTooltipButton
@ -235,7 +265,7 @@ export default class MessageActionBar extends React.PureComponent {
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
const isFailed = [mxEvent.status, editStatus, redactStatus].includes(EventStatus.NOT_SENT);
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
@ -254,12 +284,22 @@ export default class MessageActionBar extends React.PureComponent {
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>
{ SettingsStore.getValue("feature_thread") && (
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>
) }
</>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton

View file

@ -57,7 +57,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
// state to show a spinner immediately after clicking "start verification",
// before we have a request
const [isRequesting, setRequesting] = useState(false);
const [phase, setPhase] = useState(request && request.phase);
const [phase, setPhase] = useState(request?.phase);
useEffect(() => {
setRequest(verificationRequest);
if (verificationRequest) {

View file

@ -220,6 +220,13 @@ const onRoomFilesClick = () => {
});
};
const onRoomThreadsClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
};
const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") }
</Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") }
</Button>
) }
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
{ _t("Share room") }
</Button>

View file

@ -428,7 +428,7 @@ const UserOptionsSection: React.FC<{
let directMessageButton;
if (!isMe) {
directMessageButton = (
<AccessibleButton onClick={() => openDMForUser(cli, member.userId)} className="mx_UserInfo_field">
<AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') }
</AccessibleButton>
);
@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (canAffectUser && me.powerLevel >= editPowerLevel) {
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
@ -1278,7 +1278,9 @@ const BasicUserInfo: React.FC<{
// hide the Roles section for DMs as it doesn't make sense there
if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) {
memberDetails = <div className="mx_UserInfo_container">
<h3>{ _t("Role") }</h3>
<h3>{ _t("Role in <RoomName/>", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }</h3>
<PowerLevelSection
powerLevels={powerLevels}
user={member as RoomMember}
@ -1573,11 +1575,12 @@ const UserInfo: React.FC<IProps> = ({
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room && phase === RightPanelPhases.EncryptionPanel) {
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = { member: member };
refireParams = { member };
} else if (room?.isSpaceRoom() && SpaceStore.spacesEnabled) {
previousPhase = previousPhase = RightPanelPhases.SpaceMemberList;
refireParams = { space: room };
} else if (room) {
previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList;
previousPhase = RightPanelPhases.RoomMemberList;
}
const onEncryptionPanelClose = () => {

View file

@ -29,43 +29,27 @@ import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import E2EIcon from "../rooms/E2EIcon";
import {
PHASE_READY,
PHASE_DONE,
PHASE_STARTED,
PHASE_CANCELLED,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { Phase } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import Spinner from "../elements/Spinner";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "../elements/AccessibleButton";
import VerificationShowSas from "../verification/VerificationShowSas";
// XXX: Should be defined in matrix-js-sdk
enum VerificationPhase {
PHASE_UNSENT,
PHASE_REQUESTED,
PHASE_READY,
PHASE_DONE,
PHASE_STARTED,
PHASE_CANCELLED,
}
interface IProps {
layout: string;
request: VerificationRequest;
member: RoomMember | User;
phase: VerificationPhase;
phase: Phase;
onClose: () => void;
isRoomEncrypted: boolean;
inDialog: boolean;
key: number;
}
interface IState {
sasEvent?: SAS;
sasEvent?: SAS["sasEvent"];
emojiButtonClicked?: boolean;
reciprocateButtonClicked?: boolean;
reciprocateQREvent?: ReciprocateQRCode;
reciprocateQREvent?: ReciprocateQRCode["reciprocateQREvent"];
}
@replaceableComponent("views.right_panel.VerificationPanel")
@ -321,9 +305,9 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
const displayName = (member as User).displayName || (member as RoomMember).name || member.userId;
switch (phase) {
case PHASE_READY:
case Phase.Ready:
return this.renderQRPhase();
case PHASE_STARTED:
case Phase.Started:
switch (request.chosenMethod) {
case verificationMethods.RECIPROCATE_QR_CODE:
return this.renderQRReciprocatePhase();
@ -346,9 +330,9 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
default:
return null;
}
case PHASE_DONE:
case Phase.Done:
return this.renderVerifiedPhase();
case PHASE_CANCELLED:
case Phase.Cancelled:
return this.renderCancelledPhase();
}
console.error("VerificationPanel unhandled phase:", phase);
@ -375,7 +359,8 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
private updateVerifierState = () => {
const { request } = this.props;
const { sasEvent, reciprocateQREvent } = request.verifier;
const sasEvent = (request.verifier as SAS).sasEvent;
const reciprocateQREvent = (request.verifier as ReciprocateQRCode).reciprocateQREvent;
request.verifier.off('show_sas', this.updateVerifierState);
request.verifier.off('show_reciprocate_qr', this.updateVerifierState);
this.setState({ sasEvent, reciprocateQREvent });
@ -402,7 +387,8 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
const { request } = this.props;
request.on("change", this.onRequestChange);
if (request.verifier) {
const { sasEvent, reciprocateQREvent } = request.verifier;
const sasEvent = (request.verifier as SAS).sasEvent;
const reciprocateQREvent = (request.verifier as ReciprocateQRCode).reciprocateQREvent;
this.setState({ sasEvent, reciprocateQREvent });
}
this.onRequestChange();

View file

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

View file

@ -43,11 +43,6 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton';
function eventIsReply(mxEvent: MatrixEvent): boolean {
const relatesTo = mxEvent.getContent()["m.relates_to"];
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
if (!html) {
@ -72,7 +67,7 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
if (isEmote) {
model = stripEmoteCommand(model);
}
const isReply = eventIsReply(editedEvent);
const isReply = !!editedEvent.replyEventId;
let plainPrefix = "";
let htmlPrefix = "";

View file

@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -240,6 +243,7 @@ interface IProps {
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room
// first loads
// TODO: Proper typing for RR info
readReceiptMap?: any;
// A function which is used to check if the parent panel is being
@ -299,6 +303,9 @@ interface IProps {
// whether or not to display the sender
hideSender?: boolean;
// whether or not to display thread info
showThreadInfo?: boolean;
}
interface IState {
@ -315,6 +322,8 @@ interface IState {
reactions: Relations;
hover: boolean;
thread?: Thread;
}
@replaceableComponent("views.rooms.EventTile")
@ -351,6 +360,8 @@ export default class EventTile extends React.Component<IProps, IState> {
reactions: this.getReactions(),
hover: false,
thread: this.props.mxEvent?.getThread(),
};
// don't do RR animations until we are mounted
@ -451,8 +462,20 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
}
}
private updateThread = (thread) => {
this.setState({
thread,
});
this.forceUpdate();
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
@ -463,7 +486,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
shouldComponentUpdate(nextProps, nextState) {
shouldComponentUpdate(nextProps, nextState, nextContext) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -491,6 +514,43 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
private renderThreadInfo(): React.ReactNode {
if (!SettingsStore.getValue("feature_thread")) {
return null;
}
const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) {
return null;
}
const avatars = Array.from(thread.participants).map((mxId: string) => {
const member = room.getMember(mxId);
return <MemberAvatar key={member.userId} member={member} width={14} height={14} />;
});
return (
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
}}
>
<span className="mx_EventListSummary_avatars">
{ avatars }
</span>
{ thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' }
</div>
);
}
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
@ -857,7 +917,12 @@ export default class EventTile extends React.Component<IProps, IState> {
render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const {
tileHandler,
isBubbleMessage,
isInfoMessage,
isLeftAlignedBubbleMessage,
} = getEventDisplayInfo(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
@ -879,6 +944,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const isEditing = !!this.props.editState;
const classes = classNames({
mx_EventTile_bubbleContainer: isBubbleMessage,
mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage,
mx_EventTile: true,
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
@ -1126,14 +1192,19 @@ export default class EventTile extends React.Component<IProps, IState> {
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
let thread;
// When the "showHiddenEventsInTimeline" lab is enabled,
// avoid showing replies for hidden events (events without tiles)
if (haveTileForEvent(this.props.mxEvent)) {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
@ -1174,6 +1245,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo }
{ actionBar }
{ this.props.layout === Layout.IRC && (reactionsRow) }
{ this.renderThreadInfo() }
</div>
{ this.props.layout !== Layout.IRC && (reactionsRow) }
{ msgOption }

View file

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default (props) => {
interface IProps {
numUnreadMessages: number;
highlight: boolean;
onScrollToBottomClick: (e: React.MouseEvent) => void;
}
const JumpToBottomButton: React.FC<IProps> = (props) => {
const className = classNames({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
@ -36,3 +43,5 @@ export default (props) => {
{ badge }
</div>);
};
export default JumpToBottomButton;

View file

@ -45,6 +45,8 @@ import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`;
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
@ -171,20 +173,27 @@ export default class MemberList extends React.Component<IProps, IState> {
}
private getMembersState(members: Array<RoomMember>): IState {
let searchQuery;
try {
searchQuery = window.localStorage.getItem(getSearchQueryLSKey(this.props.roomId));
} catch (error) {
console.warn("Failed to get last the MemberList search query", error);
}
// set the state after determining showPresence to make sure it's
// taken into account while rendering
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
searchQuery: "",
searchQuery: searchQuery ?? "",
};
}
@ -414,6 +423,12 @@ export default class MemberList extends React.Component<IProps, IState> {
};
private onSearchQueryChanged = (searchQuery: string): void => {
try {
window.localStorage.setItem(getSearchQueryLSKey(this.props.roomId), searchQuery);
} catch (error) {
console.warn("Failed to set the last MemberList search query", error);
}
this.setState({
searchQuery,
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
@ -554,7 +569,9 @@ export default class MemberList extends React.Component<IProps, IState> {
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t('Filter room members')}
onSearch={this.onSearchQueryChanged} />
onSearch={this.onSearchQueryChanged}
initialValue={this.state.searchQuery}
/>
);
let previousPhase = RightPanelPhases.RoomSummary;

View file

@ -13,7 +13,7 @@ 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 React, { createRef } from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -27,7 +27,13 @@ import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalin
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import {
aboveLeftOf,
ContextMenu,
useContextMenu,
MenuItem,
AboveLeftOf,
} from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import { UIFeature } from "../../../settings/UIFeature";
@ -45,9 +51,13 @@ import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
import EmojiPicker from '../emojipicker/EmojiPicker';
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps {
me: object;
me: RoomMember;
}
function ComposerAvatar(props: IComposerAvatarProps) {
@ -71,13 +81,19 @@ function SendButton(props: ISendButtonProps) {
);
}
const EmojiButton = ({ addEmoji }) => {
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: any; // TODO: Types
narrowMode: boolean;
}
const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition, narrowMode }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
contextMenu = <ContextMenu {...position} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
@ -93,12 +109,11 @@ const EmojiButton = ({ addEmoji }) => {
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <React.Fragment>
<ContextMenuTooltipButton
<AccessibleTooltipButton
className={className}
onClick={openMenu}
isExpanded={menuDisplayed}
title={_t('Emoji picker')}
inputRef={button}
title={!narrowMode && _t('Emoji picker')}
label={narrowMode && _t("Add emoji")}
/>
{ contextMenu }
@ -183,7 +198,10 @@ interface IProps {
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
replyInThread?: boolean;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus;
compact?: boolean;
}
interface IState {
@ -193,6 +211,9 @@ interface IState {
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
narrowMode?: boolean;
isMenuOpen: boolean;
showStickers: boolean;
}
@replaceableComponent("views.rooms.MessageComposer")
@ -200,6 +221,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
static defaultProps = {
replyInThread: false,
showReplyPreview: true,
compact: false,
};
constructor(props) {
super(props);
@ -211,15 +240,32 @@ export default class MessageComposer extends React.Component<IProps, IState> {
isComposerEmpty: true,
haveRecording: false,
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
isMenuOpen: false,
showStickers: false,
};
this.instanceId = instanceCount++;
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
this.waitForOwnMember();
UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current);
UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize);
}
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => {
if (type === UI_EVENTS.Resize) {
const narrowMode = entry.contentRect.width <= NARROW_MODE_BREAKPOINT;
this.setState({
narrowMode,
isMenuOpen: !narrowMode ? false : this.state.isMenuOpen,
showStickers: false,
});
}
};
private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so
@ -254,6 +300,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef);
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
}
private onRoomStateEvents = (ev, state) => {
@ -303,7 +351,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private renderPlaceholderText = () => {
if (this.props.replyToEvent) {
if (this.props.e2eStatus) {
if (this.props.replyInThread && this.props.e2eStatus) {
return _t('Reply to encrypted thread…');
} else if (this.props.replyInThread) {
return _t('Reply to thread…');
} else if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply…');
@ -317,11 +369,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
};
private addEmoji(emoji: string) {
private addEmoji(emoji: string): boolean {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
text: emoji,
});
return true;
}
private sendMessage = async () => {
@ -360,14 +413,111 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
};
private shouldShowStickerPicker = (): boolean => {
return SettingsStore.getValue(UIFeature.Widgets)
&& SettingsStore.getValue("MessageComposerInput.showStickersButton")
&& !this.state.haveRecording;
};
private showStickers = (showStickers: boolean) => {
this.setState({ showStickers });
};
private toggleButtonMenu = (): void => {
this.setState({
isMenuOpen: !this.state.isMenuOpen,
});
};
private renderButtons(menuPosition): JSX.Element | JSX.Element[] {
const buttons: JSX.Element[] = [];
if (!this.state.haveRecording) {
buttons.push(
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
);
buttons.push(
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} menuPosition={menuPosition} narrowMode={this.state.narrowMode} />,
);
}
if (this.shouldShowStickerPicker()) {
let title;
if (!this.state.narrowMode) {
title = this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers");
}
buttons.push(
<AccessibleTooltipButton
id='stickersButton'
key="controls_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={() => this.showStickers(!this.state.showStickers)}
title={title}
label={this.state.narrowMode && _t("Send a sticker")}
/>,
);
}
if (!this.state.haveRecording && !this.state.narrowMode) {
buttons.push(
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()}
title={_t("Send voice message")}
/>,
);
}
if (!this.state.narrowMode) {
return buttons;
} else {
const classnames = classNames({
mx_MessageComposer_button: true,
mx_MessageComposer_buttonMenu: true,
mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen,
});
return <>
{ buttons[0] }
<AccessibleTooltipButton
className={classnames}
onClick={this.toggleButtonMenu}
title={_t("More options")}
tooltip={false}
/>
{ this.state.isMenuOpen && (
<ContextMenu
onFinished={this.toggleButtonMenu}
{...menuPosition}
menuPaddingRight={10}
menuPaddingTop={5}
menuPaddingBottom={5}
menuWidth={150}
wrapperClassName="mx_MessageComposer_Menu"
>
{ buttons.slice(1).map((button, index) => (
<MenuItem className="mx_CallContextMenu_item" key={index} onClick={this.toggleButtonMenu}>
{ button }
</MenuItem>
)) }
</ContextMenu>
) }
</>;
}
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
];
let menuPosition: AboveLeftOf | undefined;
if (this.ref.current) {
const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect);
}
if (!this.state.tombstone && this.state.canSendMessages) {
controls.push(
<SendMessageComposer
@ -376,39 +526,17 @@ export default class MessageComposer extends React.Component<IProps, IState> {
room={this.props.room}
placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator}
replyInThread={this.props.replyInThread}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
disabled={this.state.haveRecording}
/>,
);
if (!this.state.haveRecording) {
controls.push(
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
);
}
if (SettingsStore.getValue(UIFeature.Widgets) &&
SettingsStore.getValue("MessageComposerInput.showStickersButton") &&
!this.state.haveRecording) {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
}
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
if (!this.state.isComposerEmpty || this.state.haveRecording) {
controls.push(
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>,
);
}
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -449,14 +577,39 @@ export default class MessageComposer extends React.Component<IProps, IState> {
yOffset={-50}
/>;
}
controls.push(
<Stickerpicker
room={this.props.room}
showStickers={this.state.showStickers}
setShowStickers={this.showStickers}
menuPosition={menuPosition} />,
);
const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording;
const classes = classNames({
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
});
return (
<div className="mx_MessageComposer mx_GroupLayout">
<div className={classes} ref={this.ref}>
{ recordingTooltip }
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
{ this.props.showReplyPreview && (
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
) }
<div className="mx_MessageComposer_row">
{ controls }
{ this.renderButtons(menuPosition) }
{ showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>
) }
</div>
</div>
</div>

View file

@ -36,6 +36,7 @@ import { showSpaceInvite } from "../../../utils/space";
import { privateShouldBeEncrypted } from "../../../createRoom";
import EventTileBubble from "../messages/EventTileBubble";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@ -191,11 +192,21 @@ const NewRoomIntro = () => {
});
}
const sub2 = _t(
const subText = _t(
"Your private messages are normally encrypted, but this room isn't. "+
"Usually this is due to an unsupported device or method being used, " +
"like email invites. <a>Enable encryption in settings.</a>", {},
{ a: sub => <a onClick={openRoomSettings} href="#">{ sub }</a> },
"like email invites.",
);
let subButton;
if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) {
subButton = (
<a onClick={openRoomSettings} href="#"> { _t("Enable encryption in settings.") }</a>
);
}
const subtitle = (
<span> { subText } { subButton } </span>
);
return <div className="mx_NewRoomIntro">
@ -204,7 +215,7 @@ const NewRoomIntro = () => {
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
title={_t("End-to-end encryption isn't enabled")}
subtitle={sub2}
subtitle={subtitle}
/>
) }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { MouseEvent } from "react";
import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps {
notification: NotificationState;
@ -39,6 +42,7 @@ interface IProps {
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/**
* If specified will return an AccessibleButton instead of a div.
*/
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
showTooltip: boolean;
}
@replaceableComponent("views.rooms.NotificationBadge")
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
showTooltip: false,
};
this.countWatcherRef = SettingsStore.watchSetting(
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.forceUpdate(); // notification state changed - update
};
private onMouseOver = (e: MouseEvent) => {
e.stopPropagation();
this.setState({
showTooltip: true,
});
};
private onMouseLeave = () => {
this.setState({
showTooltip: false,
});
};
public render(): React.ReactElement {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, forceCount, roomId, onClick, ...props } = this.props;
const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
// Don't show a badge if we don't need to
if (notification.isIdle) return null;
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
});
if (onClick) {
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<AccessibleButton
aria-label={label}
{...props}
className={classes}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span>
{ tooltip }
</AccessibleButton>
);
}

View file

@ -15,62 +15,75 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import React, { createRef, RefObject } from 'react';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils';
import NodeAnimator from "../../../NodeAnimator";
import * as sdk from "../../../index";
import { toPx } from "../../../utils/units";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MemberAvatar from '../avatars/MemberAvatar';
interface IProps {
// the RoomMember to show the RR for
member?: RoomMember;
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: string;
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
leftOffset?: number;
// true to hide the avatar (it will still be animated)
hidden?: boolean;
// don't animate this RR into position
suppressAnimation?: boolean;
// an opaque object for storing information about this user's RR in
// this room
// TODO: proper typing for RR info
readReceiptInfo: any;
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting?: () => boolean;
// callback for clicks on this RR
onClick?: (e: React.MouseEvent) => void;
// Timestamp when the receipt was read
timestamp?: number;
// True to show twelve hour format, false otherwise
showTwelveHour?: boolean;
}
interface IState {
suppressDisplay: boolean;
startStyles?: IReadReceiptMarkerStyle[];
}
interface IReadReceiptMarkerStyle {
top: number;
left: number;
}
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
// the RoomMember to show the RR for
member: PropTypes.object,
// userId to fallback the avatar to
// if the member hasn't been loaded yet
fallbackUserId: PropTypes.string.isRequired,
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
leftOffset: PropTypes.number,
// true to hide the avatar (it will still be animated)
hidden: PropTypes.bool,
// don't animate this RR into position
suppressAnimation: PropTypes.bool,
// an opaque object for storing information about this user's RR in
// this room
readReceiptInfo: PropTypes.object,
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting: PropTypes.func,
// callback for clicks on this RR
onClick: PropTypes.func,
// Timestamp when the receipt was read
timestamp: PropTypes.number,
// True to show twelve hour format, false otherwise
showTwelveHour: PropTypes.bool,
};
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef();
static defaultProps = {
leftOffset: 0,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this._avatar = createRef();
this.state = {
// if we are going to animate the RR, we don't show it on first render,
// and instead just add a placeholder to the DOM; once we've been
@ -80,7 +93,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
};
}
componentWillUnmount() {
public componentWillUnmount(): void {
// before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place.
const rrInfo = this.props.readReceiptInfo;
@ -95,29 +108,29 @@ export default class ReadReceiptMarker extends React.PureComponent {
return;
}
const avatarNode = this._avatar.current;
const avatarNode = this.avatar.current;
rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft;
rrInfo.parent = avatarNode.offsetParent;
}
componentDidMount() {
public componentDidMount(): void {
if (!this.state.suppressDisplay) {
// we've already done our display - nothing more to do.
return;
}
this._animateMarker();
this.animateMarker();
}
componentDidUpdate(prevProps) {
public componentDidUpdate(prevProps: IProps): void {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset;
const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) {
this._animateMarker();
this.animateMarker();
}
}
_animateMarker() {
private animateMarker(): void {
// treat new RRs as though they were off the top of the screen
let oldTop = -15;
@ -126,7 +139,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top;
}
const newElement = this._avatar.current;
const newElement = this.avatar.current;
let startTopOffset;
if (!newElement.offsetParent) {
// this seems to happen sometimes for reasons I don't understand
@ -156,10 +169,9 @@ export default class ReadReceiptMarker extends React.PureComponent {
});
}
render() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
public render(): JSX.Element {
if (this.state.suppressDisplay) {
return <div ref={this._avatar} />;
return <div ref={this.avatar as RefObject<HTMLDivElement>} />;
}
const style = {
@ -198,7 +210,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
style={style}
title={title}
onClick={this.props.onClick}
inputRef={this._avatar}
inputRef={this.avatar as RefObject<HTMLImageElement>}
/>
</NodeAnimator>
);

View file

@ -25,8 +25,9 @@ import MImageReplyBody from "../messages/MImageReplyBody";
import * as sdk from '../../../index';
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { getEventDisplayInfo, isVoiceMessage } from '../../../utils/EventUtils';
import MFileBody from "../messages/MFileBody";
import MVoiceMessageBody from "../messages/MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
@ -95,7 +96,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType;
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const { tileHandler, isInfoMessage } = getEventDisplayInfo(mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
@ -109,14 +110,14 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const EventTileType = sdk.getComponent(tileHandler);
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
mx_ReplyTile_info: isInfoMessage && !mxEvent.isRedacted(),
mx_ReplyTile_audio: msgType === MsgType.Audio,
mx_ReplyTile_video: msgType === MsgType.Video,
});
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
permalink = this.props.permalinkCreator.forEvent(mxEvent.getId());
}
let sender;
@ -129,7 +130,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
if (needsSenderProfile) {
sender = <SenderProfile
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
enableFlair={false}
/>;
}
@ -137,7 +138,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgtypeOverrides = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: MFileBody,
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides = {
@ -151,14 +152,14 @@ export default class ReplyTile extends React.PureComponent<IProps> {
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
replacingEventId={mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>

View file

@ -14,41 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import React from 'react';
import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import { Room } from 'matrix-js-sdk/src';
import classNames from 'classnames';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { roomShape } from './RoomDetailRow';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomDetailRow from "./RoomDetailRow";
interface IProps {
rooms?: Room[];
className?: string;
}
@replaceableComponent("views.rooms.RoomDetailList")
export default class RoomDetailList extends React.Component {
static propTypes = {
rooms: PropTypes.arrayOf(roomShape),
className: PropTypes.string,
};
getRows() {
export default class RoomDetailList extends React.Component<IProps> {
private getRows(): JSX.Element[] {
if (!this.props.rooms) return [];
const RoomDetailRow = sdk.getComponent('rooms.RoomDetailRow');
return this.props.rooms.map((room, index) => {
return <RoomDetailRow key={index} room={room} onClick={this.onDetailsClick} />;
});
}
onDetailsClick = (ev, room) => {
private onDetailsClick = (ev: React.MouseEvent, room: Room): void => {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
room_alias: room.canonicalAlias || (room.aliases || [])[0],
room_alias: room.getCanonicalAlias() || (room.getAltAliases() || [])[0],
});
};
render() {
public render(): JSX.Element {
const rows = this.getRows();
let rooms;
if (rows.length === 0) {

View file

@ -195,7 +195,7 @@ export default class RoomHeader extends React.Component<IProps> {
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => ev.shiftKey ?
onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />;
}

View file

@ -38,7 +38,6 @@ import { StaticNotificationState } from "../../../stores/notifications/StaticNot
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SettingsStore from "../../../settings/SettingsStore";
import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
@ -49,6 +48,7 @@ import SpaceStore, { ISuggestedRoom, SUGGESTED_ROOMS } from "../../../stores/Spa
import { showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -320,11 +320,6 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log("new lists", newLists);
}
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists).filter(t => {
if (!isCustomTag(t)) return true; // always include non-custom tags
@ -528,20 +523,23 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
} else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
const spaceName = this.props.activeSpace.name;
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Quick actions") }</div>
{ this.props.activeSpace.canInvite(userId) && <AccessibleButton
{ this.props.activeSpace.canInvite(userId) && <AccessibleTooltipButton
className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick}
title={_t("Invite to %(spaceName)s", { spaceName })}
>
{ _t("Invite people") }
</AccessibleButton> }
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
</AccessibleTooltipButton> }
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleTooltipButton
className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore}
title={_t("Explore %(spaceName)s", { spaceName })}
>
{ _t("Explore rooms") }
</AccessibleButton> }
</AccessibleTooltipButton> }
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists;

View file

@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component {
}
const myUserId = MatrixClientPeg.get().getUserId();
const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason;
if (reason) {
reasonElement = <InviteReason reason={reason} />;
const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent();
if (memberEventContent.reason) {
reasonElement = <InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>;
}
primaryActionHandler = this.props.onJoinClick;

View file

@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
showUnsentTooltip={true}
/>
);

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,8 +50,6 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
interface IProps {
room: Room;
@ -68,7 +65,6 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
hasUnsentEvents: boolean;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
@ -106,11 +101,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
private onRoomNameUpdate = (room: Room) => {
this.forceUpdate();
};
@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room?.roomId !== this.props.room.roomId) return;
this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 });
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
@ -178,12 +164,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate);
this.props.room?.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
public componentWillUnmount() {
@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
private onAction = (payload: ActionPayload) => {
@ -587,30 +571,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>;
let badge: React.ReactNode;
if (!this.props.isMinimized) {
if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
let messagePreview = null;

View file

@ -1,5 +1,5 @@
/*
Copyright 2018-2020 New Vector Ltd
Copyright 2018-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,41 +15,43 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomUpgradeDialog from '../dialogs/RoomUpgradeDialog';
import AccessibleButton from '../elements/AccessibleButton';
interface IProps {
room: Room;
}
interface IState {
upgraded?: boolean;
}
@replaceableComponent("views.rooms.RoomUpgradeWarningBar")
export default class RoomUpgradeWarningBar extends React.PureComponent {
static propTypes = {
room: PropTypes.object.isRequired,
recommendation: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, IState> {
public componentDidMount(): void {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
MatrixClientPeg.get().on("RoomState.events", this.onStateEvents);
}
componentWillUnmount() {
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onStateEvents);
cli.removeListener("RoomState.events", this.onStateEvents);
}
}
_onStateEvents = (event, state) => {
private onStateEvents = (event: MatrixEvent, state: RoomState): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
@ -60,14 +62,11 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
};
onUpgradeClick = () => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
private onUpgradeClick = (): void => {
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room: this.props.room });
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
public render(): JSX.Element {
let doUpgradeWarnings = (
<div>
<div className="mx_RoomUpgradeWarningBar_body">

View file

@ -31,8 +31,8 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
@ -54,18 +54,20 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
function addReplyToMessageContent(
content: IContent,
repliedToEvent: MatrixEvent,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
const replyContent = ReplyThread.makeReplyMixIn(replyToEvent, replyInThread);
Object.assign(content, replyContent);
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator);
const nestedReply = ReplyThread.getNestedReplyText(replyToEvent, permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -77,8 +79,9 @@ function addReplyToMessageContent(
// exported for tests
export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
@ -101,7 +104,7 @@ export function createMessageContent(
}
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator);
}
return content;
@ -129,6 +132,7 @@ interface IProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
replyInThread?: boolean;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
@ -343,21 +347,35 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
public async sendMessage(): Promise<void> {
if (this.model.isEmpty) {
const model = this.model;
if (model.isEmpty) {
return;
}
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent;
let shouldSend = true;
let content;
if (!containsEmote(this.model) && this.isSlashCommand()) {
if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) {
if (cmd.category === CommandCategories.messages) {
content = await this.runSlashCommand(cmd, args);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
addReplyToMessageContent(
content,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
}
} else {
this.runSlashCommand(cmd, args);
@ -391,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
}
if (isQuickReaction(this.model)) {
if (isQuickReaction(model)) {
shouldSend = false;
this.sendQuickReaction();
}
@ -400,11 +418,20 @@ export default class SendMessageComposer extends React.Component<IProps> {
const startTime = CountlyAnalytics.getTimestamp();
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
content = createMessageContent(
model,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
const prom = this.context.sendMessage(roomId, content);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
@ -420,12 +447,17 @@ export default class SendMessageComposer extends React.Component<IProps> {
dis.dispatch({ action: `effects.${effect.command}` });
}
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => {
sendRoundTripMetric(this.context, roomId, resp.event_id);
});
}
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}
this.sendHistoryManager.save(this.model, replyToEvent);
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
this.model.reset([]);
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();

View file

@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2016-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,23 +15,21 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
title?: string;
// `src` to an image. Optional.
icon?: string;
}
/*
* A stripped-down room header used for things like the user settings
* and room directory.
*/
@replaceableComponent("views.rooms.SimpleRoomHeader")
export default class SimpleRoomHeader extends React.Component {
static propTypes = {
title: PropTypes.string,
// `src` to an image. Optional.
icon: PropTypes.string,
};
render() {
export default class SimpleRoomHeader extends React.PureComponent<IProps> {
public render(): JSX.Element {
let icon;
if (this.props.icon) {
icon = <img

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