Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18088
Conflicts: src/components/structures/LoggedInView.tsx src/stores/SpaceStore.tsx
This commit is contained in:
commit
a688e5b8b3
318 changed files with 7386 additions and 4032 deletions
|
@ -86,7 +86,7 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
|
|||
import EventEmitter from 'events';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import { ensureDMExists, findDMForUser } from './createRoom';
|
||||
import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
|
||||
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
||||
|
@ -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) as IPushRule
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
|
|||
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
||||
return [
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
shiftKey: true,
|
||||
key: Key.ENTER,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
key: Key.ENTER,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -52,13 +52,11 @@ export enum MessageComposerAction {
|
|||
|
||||
/** Actions for text editing autocompletion */
|
||||
export enum AutocompleteAction {
|
||||
/**
|
||||
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
|
||||
* selection.
|
||||
*/
|
||||
CompleteOrPrevSelection = 'ApplySelection',
|
||||
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
|
||||
CompleteOrNextSelection = 'CompleteOrNextSelection',
|
||||
/** Accepts chosen autocomplete selection */
|
||||
Complete = 'Complete',
|
||||
/** Accepts chosen autocomplete selection or,
|
||||
* if the autocompletion window is not shown, open the window and select the first selection */
|
||||
ForceComplete = 'ForceComplete',
|
||||
/** Move to the previous autocomplete selection */
|
||||
PrevSelection = 'PrevSelection',
|
||||
/** Move to the next autocomplete selection */
|
||||
|
|
|
@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
|||
opts.pendingEventOrdering = PendingEventOrdering.Detached;
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
|
||||
|
||||
// Connect the matrix client to the dispatcher and setting handlers
|
||||
MatrixActionCreators.start(this.matrixClient);
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
/**
|
||||
|
@ -26,12 +26,18 @@ import { _t } from './languageHandler';
|
|||
* API on the homeserver in question with the new password.
|
||||
*/
|
||||
export default class PasswordReset {
|
||||
private client: MatrixClient;
|
||||
private clientSecret: string;
|
||||
private identityServerDomain: string;
|
||||
private password: string;
|
||||
private sessionId: string;
|
||||
|
||||
/**
|
||||
* Configure the endpoints for password resetting.
|
||||
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
|
||||
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
|
||||
*/
|
||||
constructor(homeserverUrl, identityUrl) {
|
||||
constructor(homeserverUrl: string, identityUrl: string) {
|
||||
this.client = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
idBaseUrl: identityUrl,
|
||||
|
@ -47,7 +53,7 @@ export default class PasswordReset {
|
|||
* @param {string} newPassword The new password for the account.
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
resetPassword(emailAddress, newPassword) {
|
||||
public resetPassword(emailAddress: string, newPassword: string): Promise<IRequestTokenResponse> {
|
||||
this.password = newPassword;
|
||||
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
|
@ -69,7 +75,7 @@ export default class PasswordReset {
|
|||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
|
||||
*/
|
||||
async checkEmailLinkClicked() {
|
||||
public async checkEmailLinkClicked(): Promise<void> {
|
||||
const creds = {
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
|
@ -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>',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -89,9 +89,9 @@ export class PlaybackClock implements IDestroyable {
|
|||
return this.observable;
|
||||
}
|
||||
|
||||
private checkTime = () => {
|
||||
private checkTime = (force = false) => {
|
||||
const now = this.timeSeconds; // calculated dynamically
|
||||
if (this.lastCheck !== now) {
|
||||
if (this.lastCheck !== now || force) {
|
||||
this.observable.update([now, this.durationSeconds]);
|
||||
this.lastCheck = now;
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ export class PlaybackClock implements IDestroyable {
|
|||
public syncTo(contextTime: number, clipTime: number) {
|
||||
this.clipStart = contextTime - clipTime;
|
||||
this.stopped = false; // count as a mid-stream pause (if we were stopped)
|
||||
this.checkTime();
|
||||
this.checkTime(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DEFAULT_WAVEFORM, Playback } from "./Playback";
|
||||
import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
|
||||
import { ManagedPlayback } from "./ManagedPlayback";
|
||||
|
||||
/**
|
||||
|
@ -34,12 +34,14 @@ export class PlaybackManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stops all other playback instances. If no playback is provided, all instances
|
||||
* are stopped.
|
||||
* Pauses all other playback instances. If no playback is provided, all playing
|
||||
* instances are paused.
|
||||
* @param playback Optional. The playback to leave untouched.
|
||||
*/
|
||||
public playOnly(playback?: Playback) {
|
||||
this.instances.filter(p => p !== playback).forEach(p => p.stop());
|
||||
public pauseAllExcept(playback?: Playback) {
|
||||
this.instances
|
||||
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
|
||||
.forEach(p => p.pause());
|
||||
}
|
||||
|
||||
public destroyPlaybackInstance(playback: ManagedPlayback) {
|
||||
|
|
217
src/audio/PlaybackQueue.ts
Normal file
217
src/audio/PlaybackQueue.ts
Normal file
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
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, MatrixEvent, Room } from "matrix-js-sdk";
|
||||
import { Playback, PlaybackState } from "./Playback";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { arrayFastClone } from "../utils/arrays";
|
||||
import { PlaybackManager } from "./PlaybackManager";
|
||||
import { isVoiceMessage } from "../utils/EventUtils";
|
||||
import RoomViewStore from "../stores/RoomViewStore";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
/**
|
||||
* Audio playback queue management for a given room. This keeps track of where the user
|
||||
* was at for each playback, what order the playbacks were played in, and triggers subsequent
|
||||
* playbacks.
|
||||
*
|
||||
* Currently this is only intended to be used by voice messages.
|
||||
*
|
||||
* The primary mechanics are:
|
||||
* * Persisted clock state for each playback instance (tied to Event ID).
|
||||
* * Limited memory of playback order (see code; not persisted).
|
||||
* * Autoplay of next eligible playback instance.
|
||||
*/
|
||||
export class PlaybackQueue {
|
||||
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
|
||||
|
||||
private playbacks = new Map<string, Playback>(); // keyed by event ID
|
||||
private clockStates = new Map<string, number>(); // keyed by event ID
|
||||
private playbackIdOrder: string[] = []; // event IDs, last == current
|
||||
private currentPlaybackId: string; // event ID, broken out from above for ease of use
|
||||
private recentFullPlays = new Set<string>(); // event IDs
|
||||
|
||||
constructor(private client: MatrixClient, private room: Room) {
|
||||
this.loadClocks();
|
||||
|
||||
RoomViewStore.addListener(() => {
|
||||
if (RoomViewStore.getRoomId() === this.room.roomId) {
|
||||
// Reset the state of the playbacks before they start mounting and enqueuing updates.
|
||||
// We reset the entirety of the queue, including order, to ensure the user isn't left
|
||||
// confused with what order the messages are playing in.
|
||||
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static forRoom(roomId: string): PlaybackQueue {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) throw new Error("Unknown room");
|
||||
if (PlaybackQueue.queues.has(room.roomId)) {
|
||||
return PlaybackQueue.queues.get(room.roomId);
|
||||
}
|
||||
const queue = new PlaybackQueue(cli, room);
|
||||
PlaybackQueue.queues.set(room.roomId, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
private persistClocks() {
|
||||
localStorage.setItem(
|
||||
`mx_voice_message_clocks_${this.room.roomId}`,
|
||||
JSON.stringify(Array.from(this.clockStates.entries())),
|
||||
);
|
||||
}
|
||||
|
||||
private loadClocks() {
|
||||
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
|
||||
if (!!val) {
|
||||
this.clockStates = new Map<string, number>(JSON.parse(val));
|
||||
}
|
||||
}
|
||||
|
||||
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
|
||||
// We don't ever detach our listeners: we expect the Playback to clean up for us
|
||||
this.playbacks.set(mxEvent.getId(), playback);
|
||||
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
|
||||
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
|
||||
}
|
||||
|
||||
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
|
||||
// Remember where the user got to in playback
|
||||
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
|
||||
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
playback.skipTo(this.clockStates.get(mxEvent.getId()));
|
||||
} else if (newState === PlaybackState.Stopped) {
|
||||
// Remove the now-useless clock for some space savings
|
||||
this.clockStates.delete(mxEvent.getId());
|
||||
|
||||
if (wasLastPlaying) {
|
||||
this.recentFullPlays.add(this.currentPlaybackId);
|
||||
const orderClone = arrayFastClone(this.playbackIdOrder);
|
||||
const last = orderClone.pop();
|
||||
if (last === this.currentPlaybackId) {
|
||||
const next = orderClone.pop();
|
||||
if (next) {
|
||||
const instance = this.playbacks.get(next);
|
||||
if (!instance) {
|
||||
console.warn(
|
||||
"Voice message queue desync: Missing playback for next message: "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
|
||||
);
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
} else {
|
||||
// else no explicit next event, so find an event we haven't played that comes next. The live
|
||||
// timeline is already most recent last, so we can iterate down that.
|
||||
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
|
||||
let scanForVoiceMessage = false;
|
||||
let nextEv: MatrixEvent;
|
||||
for (const event of timeline) {
|
||||
if (event.getId() === mxEvent.getId()) {
|
||||
scanForVoiceMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (!scanForVoiceMessage) continue;
|
||||
|
||||
if (!isVoiceMessage(event)) {
|
||||
const evType = event.getType();
|
||||
if (evType !== EventType.RoomMessage && evType !== EventType.Sticker) {
|
||||
continue; // Event can be skipped for automatic playback consideration
|
||||
}
|
||||
break; // Stop automatic playback: next useful event is not a voice message
|
||||
}
|
||||
|
||||
const havePlayback = this.playbacks.has(event.getId());
|
||||
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
|
||||
if (havePlayback && !isRecentlyCompleted) {
|
||||
nextEv = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nextEv) {
|
||||
// if we don't have anywhere to go, reset the recent playback queue so the user
|
||||
// can start a new chain of playbacks.
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
|
||||
const instance = this.playbacks.get(nextEv.getId());
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Voice message queue desync: Expected playback stop to be last in order. "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newState === PlaybackState.Playing) {
|
||||
const order = this.playbackIdOrder;
|
||||
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
const lastInstance = this.playbacks.get(this.currentPlaybackId);
|
||||
if (
|
||||
lastInstance.currentState === PlaybackState.Playing
|
||||
|| lastInstance.currentState === PlaybackState.Paused
|
||||
) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlaybackId = mxEvent.getId();
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
|
||||
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
|
||||
// This should get triggered from normal voice message component unmount due to the playback
|
||||
// stopping itself for cleanup.
|
||||
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
|
||||
this.persistClocks();
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
|
||||
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
|
||||
|
||||
if (playback.currentState !== PlaybackState.Stopped) {
|
||||
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
|
||||
}
|
||||
}
|
||||
}
|
|
@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
|
|||
|
||||
process(inputs, outputs, parameters) {
|
||||
const currentSecond = roundTimeToTargetFreq(currentTime);
|
||||
if (currentSecond === this.nextAmplitudeSecond) {
|
||||
// We special case the first ping because there's a fairly good chance that we'll miss the zeroth
|
||||
// update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first
|
||||
// time. Edge and Chrome occasionally lag behind too, but for the most part are on time.
|
||||
//
|
||||
// When this doesn't work properly we end up producing a waveform of nulls and no live preview
|
||||
// of the recorded message.
|
||||
if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) {
|
||||
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||
// samples for the analysis.
|
||||
const monoChan = inputs[0][0];
|
||||
|
|
|
@ -27,11 +27,11 @@ export interface ICommand {
|
|||
};
|
||||
}
|
||||
|
||||
export default class AutocompleteProvider {
|
||||
export default abstract class AutocompleteProvider {
|
||||
commandRegex: RegExp;
|
||||
forcedCommandRegex: RegExp;
|
||||
|
||||
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
if (commandRegex) {
|
||||
if (!commandRegex.global) {
|
||||
throw new Error('commandRegex must have global flag set');
|
||||
|
@ -93,23 +93,16 @@ export default class AutocompleteProvider {
|
|||
};
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
abstract getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
return [];
|
||||
}
|
||||
force: boolean,
|
||||
limit: number,
|
||||
): Promise<ICompletion[]>;
|
||||
|
||||
getName(): string {
|
||||
return 'Default Provider';
|
||||
}
|
||||
abstract getName(): string;
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
|
||||
console.error('stub; should be implemented in subclasses');
|
||||
return null;
|
||||
}
|
||||
abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null;
|
||||
|
||||
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
||||
shouldForceComplete(): boolean {
|
||||
|
|
|
@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
|
|||
|
||||
import CommandProvider from './CommandProvider';
|
||||
import CommunityProvider from './CommunityProvider';
|
||||
import DuckDuckGoProvider from './DuckDuckGoProvider';
|
||||
import RoomProvider from './RoomProvider';
|
||||
import UserProvider from './UserProvider';
|
||||
import EmojiProvider from './EmojiProvider';
|
||||
|
@ -55,7 +54,6 @@ const PROVIDERS = [
|
|||
EmojiProvider,
|
||||
NotifProvider,
|
||||
CommandProvider,
|
||||
DuckDuckGoProvider,
|
||||
];
|
||||
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
|
|
|
@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
// The input looks like a command with arguments, perform exact match
|
||||
const name = command[1].substr(1); // strip leading `/`
|
||||
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
|
||||
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
|
||||
// some commands, namely `me` don't suit having the usage shown whilst typing their arguments
|
||||
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
|
||||
matches = [CommandMap.get(name)];
|
||||
}
|
||||
|
@ -95,8 +95,8 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="presentation"
|
||||
aria-label={_t("Command Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -116,7 +116,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Community Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
|
||||
import { TextualCompletion } from './Components';
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
|
||||
const DDG_REGEX = /\/ddg\s+(.+)$/g;
|
||||
const REFERRER = 'vector';
|
||||
|
||||
export default class DuckDuckGoProvider extends AutocompleteProvider {
|
||||
constructor() {
|
||||
super(DDG_REGEX);
|
||||
}
|
||||
|
||||
static getQueryUri(query: string) {
|
||||
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
|
||||
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (!query || !command) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
|
||||
method: 'GET',
|
||||
});
|
||||
const json = await response.json();
|
||||
const maxLength = limit > -1 ? limit : json.Results.length;
|
||||
const results = json.Results.slice(0, maxLength).map((result) => {
|
||||
return {
|
||||
completion: result.Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={result.Text}
|
||||
description={result.Result} />
|
||||
),
|
||||
range,
|
||||
};
|
||||
});
|
||||
if (json.Answer) {
|
||||
results.unshift({
|
||||
completion: json.Answer,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.Answer}
|
||||
description={json.AnswerType} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
|
||||
results.unshift({
|
||||
completion: json.RelatedTopics[0].Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.RelatedTopics[0].Text} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.AbstractText) {
|
||||
results.unshift({
|
||||
completion: json.AbstractText,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.AbstractText} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return '🔍 ' + _t('Results from DuckDuckGo');
|
||||
}
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
aria-label={_t("DuckDuckGo Results")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -67,7 +67,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
constructor() {
|
||||
super(EMOJI_REGEX);
|
||||
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
|
||||
keys: ['emoji.emoticon'],
|
||||
keys: [],
|
||||
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
|
||||
// For matching against ascii equivalents
|
||||
shouldMatchWordsOnly: false,
|
||||
|
@ -91,7 +91,8 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
|
||||
let completions = [];
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
|
||||
if (command && command[0].length > 2) {
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
|
||||
|
@ -139,7 +140,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Emoji Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -70,7 +70,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Notification Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -134,7 +134,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Room Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -181,7 +181,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("User Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
44
src/components/structures/BackdropPanel.tsx
Normal file
44
src/components/structures/BackdropPanel.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties } from "react";
|
||||
|
||||
interface IProps {
|
||||
backgroundImage?: string;
|
||||
blurMultiplier?: number;
|
||||
}
|
||||
|
||||
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
|
||||
if (!backgroundImage) return null;
|
||||
|
||||
const styles: CSSProperties = {};
|
||||
if (blurMultiplier) {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
|
||||
const pixelsValue = blurValue.replace('px', '');
|
||||
const parsed = parseInt(pixelsValue, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
styles.filter = `blur(${parsed * blurMultiplier}px)`;
|
||||
}
|
||||
}
|
||||
return <div className="mx_BackdropPanel">
|
||||
<img
|
||||
style={styles}
|
||||
className="mx_BackdropPanel--image"
|
||||
src={backgroundImage} />
|
||||
</div>;
|
||||
};
|
||||
export default BackdropPanel;
|
|
@ -25,6 +25,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
|
|||
export enum CallEventGrouperEvent {
|
||||
StateChanged = "state_changed",
|
||||
SilencedChanged = "silenced_changed",
|
||||
LengthChanged = "length_changed",
|
||||
}
|
||||
|
||||
const CONNECTING_STATES = [
|
||||
|
@ -113,6 +114,10 @@ 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();
|
||||
};
|
||||
|
@ -139,6 +144,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 = () => {
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
@ -141,7 +165,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}
|
|
@ -1,300 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
|
||||
// response from initial request. If not supplied, will do a request on
|
||||
// mount.
|
||||
authData: PropTypes.shape({
|
||||
flows: PropTypes.array,
|
||||
params: PropTypes.object,
|
||||
session: PropTypes.string,
|
||||
}),
|
||||
|
||||
// callback
|
||||
makeRequest: PropTypes.func.isRequired,
|
||||
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {bool} status True if the operation requiring
|
||||
// auth was completed sucessfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the identity server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs: PropTypes.object,
|
||||
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken: PropTypes.func,
|
||||
sessionId: PropTypes.string,
|
||||
clientSecret: PropTypes.string,
|
||||
emailSid: PropTypes.string,
|
||||
|
||||
// If true, poll to see if the auth flow has been completed
|
||||
// out-of-band
|
||||
poll: PropTypes.bool,
|
||||
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged: PropTypes.bool,
|
||||
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange: PropTypes.func,
|
||||
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this._unmounted = false;
|
||||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this._requestCallback,
|
||||
busyChanged: this._onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this._authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this._authLogic.getEmailSid(),
|
||||
clientSecret: this._authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
|
||||
if (this._intervalId !== null) {
|
||||
clearInterval(this._intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
_requestEmailToken = async (...args) => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(...args);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tryContinue = () => {
|
||||
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
|
||||
this._stageComponent.current.tryContinue();
|
||||
}
|
||||
};
|
||||
|
||||
_authStateUpdated = (stageType, stageState) => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this._setFocus();
|
||||
} else if (
|
||||
!stageState.error && this._stageComponent.current &&
|
||||
this._stageComponent.current.attemptFailed
|
||||
) {
|
||||
this._stageComponent.current.attemptFailed();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_requestCallback = (auth) => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
_onBusyChanged = (busy) => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
_setFocus() {
|
||||
if (this._stageComponent.current && this._stageComponent.current.focus) {
|
||||
this._stageComponent.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_submitAuthDict = authData => {
|
||||
this._authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
_onPhaseChange = newPhase => {
|
||||
if (this.props.onStagePhaseChange) {
|
||||
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
|
||||
}
|
||||
};
|
||||
|
||||
_onStageCancel = () => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
_renderCurrentStage() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this._stageComponent}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this._authLogic.getSessionId()}
|
||||
clientSecret={this._authLogic.getClientSecret()}
|
||||
stageParams={this._authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this._submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this._onAuthStageFailed}
|
||||
setEmailSid={this._setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this._onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this._onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onAuthStageFailed = e => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
_setEmailSid = sid => {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this._renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
300
src/components/structures/InteractiveAuth.tsx
Normal file
300
src/components/structures/InteractiveAuth.tsx
Normal file
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
IAuthData,
|
||||
IAuthDict,
|
||||
IInputs,
|
||||
InteractiveAuth,
|
||||
IStageStatus,
|
||||
} from "matrix-js-sdk/src/interactive-auth";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import React, { createRef } from 'react';
|
||||
|
||||
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
interface IProps {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: MatrixClient;
|
||||
// response from initial request. If not supplied, will do a request on mount.
|
||||
authData?: IAuthData;
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs?: IInputs;
|
||||
sessionId?: string;
|
||||
clientSecret?: string;
|
||||
emailSid?: string;
|
||||
// If true, poll to see if the auth flow has been completed out-of-band
|
||||
poll?: boolean;
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged?: boolean;
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
// callback
|
||||
makeRequest(auth: IAuthData): Promise<IAuthData>;
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {boolean} status True if the operation requiring
|
||||
// auth was completed successfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
onAuthFinished(
|
||||
status: boolean,
|
||||
result: IAuthData | Error,
|
||||
extra?: { emailSid?: string, clientSecret?: string },
|
||||
): void;
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange?(stage: string, phase: string | number): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
authStage?: AuthType;
|
||||
stageState?: IStageStatus;
|
||||
busy: boolean;
|
||||
errorText?: string;
|
||||
stageErrorText?: string;
|
||||
submitButtonEnabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
|
||||
private readonly authLogic: InteractiveAuth;
|
||||
private readonly intervalId: number = null;
|
||||
private readonly stageComponent = createRef<IStageComponent>();
|
||||
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this.authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this.requestCallback,
|
||||
busyChanged: this.onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this.authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this.requestEmailToken,
|
||||
});
|
||||
|
||||
if (this.props.poll) {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
|
||||
this.authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this.authLogic.getEmailSid(),
|
||||
clientSecret: this.authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmailToken = async (
|
||||
email: string,
|
||||
secret: string,
|
||||
attempt: number,
|
||||
session: string,
|
||||
): Promise<{sid: string}> => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(email, secret, attempt, session);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private tryContinue = (): void => {
|
||||
this.stageComponent.current?.tryContinue?.();
|
||||
};
|
||||
|
||||
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this.setFocus();
|
||||
} else if (!stageState.error) {
|
||||
this.stageComponent.current?.attemptFailed?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
private onBusyChanged = (busy: boolean): void => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
private setFocus(): void {
|
||||
this.stageComponent.current?.focus?.();
|
||||
}
|
||||
|
||||
private submitAuthDict = (authData: IAuthDict): void => {
|
||||
this.authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
private onPhaseChange = (newPhase: number): void => {
|
||||
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
|
||||
};
|
||||
|
||||
private onStageCancel = (): void => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
private renderCurrentStage(): JSX.Element {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this.stageComponent as any}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this.authLogic.getSessionId()}
|
||||
clientSecret={this.authLogic.getClientSecret()}
|
||||
stageParams={this.authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this.submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this.onAuthStageFailed}
|
||||
setEmailSid={this.setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this.onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this.onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private onAuthStageFailed = (e: Error): void => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
private setEmailSid = (sid: string): void => {
|
||||
this.authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this.renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,8 +19,6 @@ import { createRef } from "react";
|
|||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
|
@ -33,15 +31,12 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
|
|||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
|
||||
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
@ -53,7 +48,6 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
|
@ -70,8 +64,6 @@ const cssClasses = [
|
|||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
|
@ -80,22 +72,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
activeSpace: SpaceStore.instance.activeSpace,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
|
||||
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
|
||||
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
|
||||
// Using the passive option to not block the main thread
|
||||
|
@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
|
@ -149,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onBackgroundImageUpdate = () => {
|
||||
// Note: we do this in the LeftPanel as it uses this variable most prominently.
|
||||
const avatarSize = 32; // arbitrary
|
||||
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (settingBgMxc) {
|
||||
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
||||
private handleStickyHeaders(list: HTMLDivElement) {
|
||||
if (this.isDoingStickyHeaders) return;
|
||||
this.isDoingStickyHeaders = true;
|
||||
|
@ -440,16 +406,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
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 +429,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{ leftLeftPanel }
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{ this.renderHeader() }
|
||||
{ this.renderSearchDialExplore() }
|
||||
|
|
|
@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
|
|||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018, 2020 New Vector Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -55,15 +53,22 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
|
|||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import CallHandler from '../../CallHandler';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
|
||||
import { OwnProfileStore } from '../../stores/OwnProfileStore';
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import RoomView from './RoomView';
|
||||
import ToastContainer from './ToastContainer';
|
||||
import MyGroups from "./MyGroups";
|
||||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import BackdropPanel from "./BackdropPanel";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import classNames from 'classnames';
|
||||
import GroupFilterPanel from './GroupFilterPanel';
|
||||
import CustomRoomTagPanel from './CustomRoomTagPanel';
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import LegacyCommunityPreview from "./LegacyCommunityPreview";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
|
@ -128,6 +133,7 @@ interface IState {
|
|||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -143,10 +149,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) {
|
||||
|
@ -157,7 +166,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
|
||||
|
@ -169,11 +178,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();
|
||||
|
||||
|
@ -190,26 +200,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) => {
|
||||
|
@ -248,6 +283,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({
|
||||
|
@ -263,7 +299,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) => {
|
||||
|
@ -530,24 +566,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// Do not capture the context menu key to improve keyboard accessibility
|
||||
if (ev.key === Key.CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
// We explicitly allow alt to be held due to it being a common accent modifier.
|
||||
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
|
||||
// move focus to the composer so the user can re-type the dead key correctly.
|
||||
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// If the user is entering a printable character outside of an input field
|
||||
// redirect it to the composer for them.
|
||||
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -606,10 +642,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 (
|
||||
|
@ -622,18 +662,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 />
|
||||
|
|
|
@ -108,6 +108,7 @@ import SoftLogout from './auth/SoftLogout';
|
|||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
import { PosthogAnalytics } from '../../PosthogAnalytics';
|
||||
import { initSentry } from "../../sentry";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -393,6 +394,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
PosthogAnalytics.instance.updatePlatformSuperProperties();
|
||||
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
|
||||
initSentry(SdkConfig.get()["sentry"]);
|
||||
}
|
||||
|
||||
private async postLoginSetup() {
|
||||
|
@ -1013,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');
|
||||
|
|
|
@ -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.replyEventId
|
||||
&& this.props.hideThreadedMessages
|
||||
&& SettingsStore.getValue("feature_thread")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideEvent(mxEv, this.context);
|
||||
}
|
||||
|
||||
|
|
|
@ -45,17 +45,21 @@ 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -309,6 +313,22 @@ 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} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.ThreadPanel:
|
||||
panel = <ThreadPanel
|
||||
roomId={roomId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
|
||||
break;
|
||||
|
|
|
@ -347,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
|
||||
// If room was shift-clicked, remove it from the room directory
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
|
@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
|
|
@ -1867,7 +1867,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,7 +2042,6 @@ 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}
|
||||
/>);
|
||||
}
|
||||
|
||||
|
@ -2052,7 +2051,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
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)} />
|
||||
: null;
|
||||
|
||||
const timelineClasses = classNames("mx_RoomView_timeline", {
|
||||
|
|
717
src/components/structures/SpaceHierarchy.tsx
Normal file
717
src/components/structures/SpaceHierarchy.tsx
Normal file
|
@ -0,0 +1,717 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
useContext,
|
||||
SetStateAction,
|
||||
Dispatch,
|
||||
} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import InfoTooltip from "../views/elements/InfoTooltip";
|
||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import { getChildOrder } from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: IHierarchyRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
onViewRoomClick(autoJoin: boolean): void;
|
||||
onToggleClick?(): void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<ITileProps> = ({
|
||||
room,
|
||||
suggested,
|
||||
selected,
|
||||
hasPermissions,
|
||||
onToggleClick,
|
||||
onViewRoomClick,
|
||||
numChildRooms,
|
||||
children,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(false);
|
||||
};
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true);
|
||||
};
|
||||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton
|
||||
onClick={onPreviewClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton
|
||||
onClick={onJoinClick}
|
||||
kind="primary"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar;
|
||||
if (joinedRoom) {
|
||||
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
|
||||
} else {
|
||||
avatar = <BaseAvatar
|
||||
name={name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||
width={20}
|
||||
height={20}
|
||||
/>;
|
||||
}
|
||||
|
||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||
}
|
||||
|
||||
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
|
||||
if (topic) {
|
||||
description += " · " + topic;
|
||||
}
|
||||
|
||||
let suggestedSection;
|
||||
if (suggested) {
|
||||
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
|
||||
{ _t("Suggested") }
|
||||
</InfoTooltip>;
|
||||
}
|
||||
|
||||
const content = <React.Fragment>
|
||||
{ avatar }
|
||||
<div className="mx_SpaceHierarchy_roomTile_name">
|
||||
{ name }
|
||||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx_SpaceHierarchy_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceHierarchy_actions">
|
||||
{ button }
|
||||
{ checkbox }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle: JSX.Element;
|
||||
let childSection: JSX.Element;
|
||||
let onKeyDown: KeyboardEventHandler;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
|
||||
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
|
||||
})}
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
|
||||
if (showChildren) {
|
||||
const onChildrenKeyDown = (e) => {
|
||||
if (e.key === Key.ARROW_LEFT) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
childSection = <div
|
||||
className="mx_SpaceHierarchy_subspace_children"
|
||||
onKeyDown={onChildrenKeyDown}
|
||||
role="group"
|
||||
>
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
let handled = false;
|
||||
|
||||
switch (e.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
if (showChildren) {
|
||||
handled = true;
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return <li
|
||||
className="mx_SpaceHierarchy_roomTileWrapper"
|
||||
role="treeitem"
|
||||
aria-expanded={children ? showChildren : undefined}
|
||||
>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceHierarchy_roomTile", {
|
||||
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
onKeyDown={onKeyDown}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</li>;
|
||||
};
|
||||
|
||||
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
|
||||
const room = hierarchy.roomMap.get(roomId);
|
||||
|
||||
// Don't let the user view a room they won't be able to either peek or join:
|
||||
// fail earlier so they don't have to click back to the directory.
|
||||
if (cli.isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
auto_join: autoJoin,
|
||||
should_peek: true,
|
||||
_type: "room_directory", // instrumentation
|
||||
room_alias: roomAlias,
|
||||
room_id: room.room_id,
|
||||
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
|
||||
oob_data: {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t("Unnamed room"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
root: IHierarchyRoom;
|
||||
roomSet: Set<IHierarchyRoom>;
|
||||
hierarchy: RoomHierarchy;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, autoJoin: boolean): void;
|
||||
onToggleClick?(parentId: string, childId: string): void;
|
||||
}
|
||||
|
||||
export const HierarchyLevel = ({
|
||||
root,
|
||||
roomSet,
|
||||
hierarchy,
|
||||
parents,
|
||||
selectedMap,
|
||||
onViewRoomClick,
|
||||
onToggleClick,
|
||||
}: IHierarchyLevelProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const space = cli.getRoom(root.room_id);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
const sortedChildren = sortBy(root.children_state, ev => {
|
||||
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
|
||||
});
|
||||
|
||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
|
||||
const room = hierarchy.roomMap.get(ev.state_key);
|
||||
if (room && roomSet.has(room)) {
|
||||
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
|
||||
}
|
||||
return result;
|
||||
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
|
||||
|
||||
const newParents = new Set(parents).add(root.room_id);
|
||||
return <React.Fragment>
|
||||
{
|
||||
childRooms.map(room => (
|
||||
<Tile
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
|
||||
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(room.room_id, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
|
||||
<Tile
|
||||
key={space.room_id}
|
||||
room={space}
|
||||
numChildRooms={space.children_state.filter(ev => {
|
||||
const room = hierarchy.roomMap.get(ev.state_key);
|
||||
return room && roomSet.has(room) && !room.room_type;
|
||||
}).length}
|
||||
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
|
||||
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
onViewRoomClick(space.room_id, autoJoin);
|
||||
}}
|
||||
hasPermissions={hasPermissions}
|
||||
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
|
||||
>
|
||||
<HierarchyLevel
|
||||
root={space}
|
||||
roomSet={roomSet}
|
||||
hierarchy={hierarchy}
|
||||
parents={newParents}
|
||||
selectedMap={selectedMap}
|
||||
onViewRoomClick={onViewRoomClick}
|
||||
onToggleClick={onToggleClick}
|
||||
/>
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const INITIAL_PAGE_SIZE = 20;
|
||||
|
||||
export const useSpaceSummary = (space: Room): {
|
||||
loading: boolean;
|
||||
rooms: IHierarchyRoom[];
|
||||
hierarchy: RoomHierarchy;
|
||||
loadMore(pageSize?: number): Promise <void>;
|
||||
} => {
|
||||
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||
|
||||
const resetHierarchy = useCallback(() => {
|
||||
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
|
||||
setHierarchy(hierarchy);
|
||||
|
||||
let discard = false;
|
||||
hierarchy.load().then(() => {
|
||||
if (discard) return;
|
||||
setRooms(hierarchy.rooms);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
discard = true;
|
||||
};
|
||||
}, [space]);
|
||||
useEffect(resetHierarchy, [resetHierarchy]);
|
||||
|
||||
useDispatcher(defaultDispatcher, (payload => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setLoading(true);
|
||||
setRooms([]); // TODO
|
||||
resetHierarchy();
|
||||
}
|
||||
}));
|
||||
|
||||
const loadMore = useCallback(async (pageSize?: number) => {
|
||||
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
|
||||
|
||||
setLoading(true);
|
||||
await hierarchy.load(pageSize);
|
||||
setRooms(hierarchy.rooms);
|
||||
setLoading(false);
|
||||
}, [hierarchy]);
|
||||
|
||||
return { loading, rooms, hierarchy, loadMore };
|
||||
};
|
||||
|
||||
const useIntersectionObserver = (callback: () => void) => {
|
||||
const handleObserver = (entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const observerRef = useRef<IntersectionObserver>();
|
||||
return (element: HTMLDivElement) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
} else if (element) {
|
||||
observerRef.current = new IntersectionObserver(handleObserver, {
|
||||
root: element.parentElement,
|
||||
rootMargin: "0px 0px 600px 0px",
|
||||
});
|
||||
}
|
||||
|
||||
if (observerRef.current && element) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface IManageButtonsProps {
|
||||
hierarchy: RoomHierarchy;
|
||||
selected: Map<string, Set<string>>;
|
||||
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [
|
||||
...selected.get(parentId).values(),
|
||||
].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return hierarchy.isSuggested(parentId, childId);
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
|
||||
hierarchy.removeRelation(parentId, childId);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
// mutate the local state to save us having to refetch the world
|
||||
existingContent.suggested = content.suggested;
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
};
|
||||
|
||||
const SpaceHierarchy = ({
|
||||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
additionalButtons,
|
||||
}: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [query, setQuery] = useState(initialText);
|
||||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
|
||||
|
||||
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
|
||||
if (!rooms.length) return new Set();
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
if (!lcQuery) return new Set(rooms);
|
||||
|
||||
const directMatches = rooms.filter(r => {
|
||||
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
|
||||
});
|
||||
|
||||
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
|
||||
const visited = new Set<string>();
|
||||
const queue = [...directMatches.map(r => r.room_id)];
|
||||
while (queue.length) {
|
||||
const roomId = queue.pop();
|
||||
visited.add(roomId);
|
||||
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
|
||||
if (!visited.has(parentId)) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Set(rooms.filter(r => visited.has(r.room_id)));
|
||||
}, [rooms, hierarchy, query]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loaderRef = useIntersectionObserver(loadMore);
|
||||
|
||||
if (!loading && hierarchy.noSupport) {
|
||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||
}
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleClick = (parentId: string, childId: string): void => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
};
|
||||
|
||||
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||
{ ({ onKeyDownHandler }) => {
|
||||
let content: JSX.Element;
|
||||
let loader: JSX.Element;
|
||||
|
||||
if (loading && !rooms.length) {
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
const hasPermissions = space?.getMyMembership() === "join" &&
|
||||
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
let results: JSX.Element;
|
||||
if (filteredRoomSet.size) {
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
root={hierarchy.roomMap.get(space.roomId)}
|
||||
roomSet={filteredRoomSet}
|
||||
hierarchy={hierarchy}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? onToggleClick : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(cli, hierarchy, roomId, autoJoin);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
|
||||
if (hierarchy.canLoadMore) {
|
||||
loader = <div ref={loaderRef}>
|
||||
<Spinner />
|
||||
</div>;
|
||||
}
|
||||
} else {
|
||||
results = <div className="mx_SpaceHierarchy_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceHierarchy_listHeader">
|
||||
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ hasPermissions && (
|
||||
<ManageButtons
|
||||
hierarchy={hierarchy}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
setError={setError}
|
||||
/>
|
||||
) }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceHierarchy_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<ul
|
||||
className="mx_SpaceHierarchy_list"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
role="tree"
|
||||
aria-label={_t("Space")}
|
||||
>
|
||||
{ results }
|
||||
</ul>
|
||||
{ loader }
|
||||
</>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
} }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
export default SpaceHierarchy;
|
|
@ -1,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);
|
||||
}
|
|
@ -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";
|
||||
|
@ -492,6 +492,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
/>;
|
||||
});
|
||||
|
||||
|
@ -501,11 +502,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 => {
|
||||
return createRoom({
|
||||
createOpts: {
|
||||
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
|
||||
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
},
|
||||
spinner: false,
|
||||
|
@ -513,6 +515,8 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
andView: false,
|
||||
inlineErrors: true,
|
||||
parentSpace: space,
|
||||
joinRule: !isPublic ? JoinRule.Restricted : undefined,
|
||||
suggested: true,
|
||||
});
|
||||
}));
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
|
@ -801,6 +805,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) {
|
||||
|
|
93
src/components/structures/ThreadPanel.tsx
Normal file
93
src/components/structures/ThreadPanel.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
|
||||
import { Thread } from 'matrix-js-sdk/src/models/thread';
|
||||
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import EventTile from '../views/rooms/EventTile';
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
threads?: Thread[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ThreadView")
|
||||
export default class ThreadPanel extends React.Component<IProps, IState> {
|
||||
private room: Room;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.room.on("Thread.update", this.onThreadEventReceived);
|
||||
this.room.on("Thread.ready", this.onThreadEventReceived);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.room.removeListener("Thread.update", this.onThreadEventReceived);
|
||||
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
|
||||
}
|
||||
|
||||
private onThreadEventReceived = () => this.updateThreads();
|
||||
|
||||
private updateThreads = (callback?: () => void): void => {
|
||||
this.setState({
|
||||
threads: this.room.getThreads(),
|
||||
}, callback);
|
||||
};
|
||||
|
||||
private renderEventTile(event: MatrixEvent): JSX.Element {
|
||||
return <EventTile
|
||||
key={event.getId()}
|
||||
mxEvent={event}
|
||||
enableFlair={false}
|
||||
showReadReceipts={false}
|
||||
as="div"
|
||||
/>;
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_ThreadPanel"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
{
|
||||
this.state?.threads.map((thread: Thread) => {
|
||||
if (thread.ready) {
|
||||
return this.renderEventTile(thread.rootEvent);
|
||||
}
|
||||
})
|
||||
}
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
}
|
148
src/components/structures/ThreadView.tsx
Normal file
148
src/components/structures/ThreadView.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
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';
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
replyToEvent?: MatrixEvent;
|
||||
thread?: Thread;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ThreadView")
|
||||
export default class ThreadView extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
|
||||
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) => {
|
||||
const thread = mxEv.getThread();
|
||||
if (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 });
|
||||
} else {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_ThreadView"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer={true}
|
||||
>
|
||||
{ this.state.thread && (
|
||||
<TimelinePanel
|
||||
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}
|
||||
compact={true}
|
||||
/>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -126,6 +126,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 +216,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
sendReadReceiptOnLoad: true,
|
||||
hideThreadedMessages: true,
|
||||
};
|
||||
|
||||
private lastRRSentEventId: string = undefined;
|
||||
|
@ -1511,6 +1514,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -15,34 +15,30 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { formatSeconds } from "../../../DateUtils";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export interface IProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simply converts seconds into minutes and seconds. Note that hours will not be
|
||||
* displayed, making it possible to see "82:29".
|
||||
*/
|
||||
@replaceableComponent("views.audio_messages.Clock")
|
||||
export default class Clock extends React.Component<IProps, IState> {
|
||||
export default class Clock extends React.Component<IProps> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
|
||||
const currentFloor = Math.floor(this.props.seconds);
|
||||
const nextFloor = Math.floor(nextProps.seconds);
|
||||
return currentFloor !== nextFloor;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
|
||||
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
|
||||
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -74,33 +75,6 @@ import CaptchaForm from "./CaptchaForm";
|
|||
* focus: set the input focus appropriately in the form.
|
||||
*/
|
||||
|
||||
enum AuthType {
|
||||
Password = "m.login.password",
|
||||
Recaptcha = "m.login.recaptcha",
|
||||
Terms = "m.login.terms",
|
||||
Email = "m.login.email.identity",
|
||||
Msisdn = "m.login.msisdn",
|
||||
Sso = "m.login.sso",
|
||||
SsoUnstable = "org.matrix.login.sso",
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IAuthDict {
|
||||
type?: AuthType;
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user?: string;
|
||||
identifier?: any;
|
||||
password?: string;
|
||||
response?: string;
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds?: any;
|
||||
threepidCreds?: any;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export const DEFAULT_PHASE = 0;
|
||||
|
||||
interface IAuthEntryProps {
|
||||
|
@ -835,7 +809,26 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
|
|||
}
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
|
||||
export interface IStageComponentProps extends IAuthEntryProps {
|
||||
clientSecret?: string;
|
||||
stageParams?: Record<string, any>;
|
||||
inputs?: IInputs;
|
||||
stageState?: IStageStatus;
|
||||
showContinue?: boolean;
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
fail?(e: Error): void;
|
||||
setEmailSid?(sid: string): void;
|
||||
onCancel?(): void;
|
||||
}
|
||||
|
||||
export interface IStageComponent extends React.ComponentClass<React.PropsWithRef<IStageComponentProps>> {
|
||||
tryContinue?(): void;
|
||||
attemptFailed?(): void;
|
||||
focus?(): void;
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
|
||||
switch (loginType) {
|
||||
case AuthType.Password:
|
||||
return PasswordAuthEntry;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import Field from "../elements/Field";
|
||||
|
@ -32,6 +33,8 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.context_menus.DialpadContextMenu")
|
||||
export default class DialpadContextMenu extends React.Component<IProps, IState> {
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -40,9 +43,16 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
};
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.props.call.sendDtmfDigit(digit);
|
||||
this.setState({ value: this.state.value + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onCancelClick = () => {
|
||||
|
@ -68,6 +78,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
</div>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
|
|
|
@ -29,11 +29,13 @@ import BaseDialog from "./BaseDialog";
|
|||
import Field from '../elements/Field';
|
||||
import Spinner from "../elements/Spinner";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import { sendSentryReport } from "../../../sentry";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
initialText?: string;
|
||||
label?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -113,6 +115,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
sendSentryReport(this.state.text, this.state.issueUrl, this.props.error);
|
||||
};
|
||||
|
||||
private onDownload = async (): Promise<void> => {
|
||||
|
@ -200,8 +204,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
|||
{ _t(
|
||||
"Debug logs contain application usage data including your " +
|
||||
"username, the IDs or aliases of the rooms or groups you " +
|
||||
"have visited and the usernames of other users. They do " +
|
||||
"not contain messages.",
|
||||
"have visited, which UI elements you last interacted with, " +
|
||||
"and the usernames of other users. They do not contain messages.",
|
||||
) }
|
||||
</p>
|
||||
<p><b>
|
||||
|
@ -211,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>,
|
||||
|
|
|
@ -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: "",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth';
|
||||
|
||||
import Analytics from '../../../Analytics';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
@ -65,7 +66,7 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
|
|||
this.initAuth(/* shouldErase= */false);
|
||||
}
|
||||
|
||||
private onStagePhaseChange = (stage: string, phase: string): void => {
|
||||
private onStagePhaseChange = (stage: AuthType, phase: string): void => {
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."),
|
||||
|
@ -115,7 +116,10 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
|
|||
this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") });
|
||||
};
|
||||
|
||||
private onUIAuthComplete = (auth: any): void => {
|
||||
private onUIAuthComplete = (auth: IAuthData): void => {
|
||||
// XXX: this should be returning a promise to maintain the state inside the state machine correct
|
||||
// but given that a deactivation is followed by a local logout and all object instances being thrown away
|
||||
// this isn't done.
|
||||
MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => {
|
||||
// Deactivation worked - logout & close this dialog
|
||||
Analytics.trackEvent('Account', 'Deactivate Account');
|
||||
|
@ -180,7 +184,9 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
|
|||
<InteractiveAuth
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
authData={this.state.authData}
|
||||
makeRequest={this.onUIAuthComplete}
|
||||
// XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it
|
||||
// knows the entire app is about to die as a result of the account deactivation.
|
||||
makeRequest={this.onUIAuthComplete as any}
|
||||
onAuthFinished={this.onUIAuthFinished}
|
||||
onStagePhaseChange={this.onStagePhaseChange}
|
||||
continueText={this.state.continueText}
|
||||
|
|
|
@ -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("");
|
||||
|
|
|
@ -55,7 +55,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { getAddressType } from "../../../UserAddress";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { compare } from '../../../utils/strings';
|
||||
import { IInvite3PID } from "matrix-js-sdk/src/@types/requests";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
@ -394,6 +394,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
private closeCopiedTooltip: () => void;
|
||||
private debounceTimer: number = null; // actually number because we're in the browser
|
||||
private editorRef = createRef<HTMLInputElement>();
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -1283,13 +1284,27 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
this.setState({ dialPadValue: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private onDigitPress = digit => {
|
||||
private onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.setState({ dialPadValue: this.state.dialPadValue + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onDeletePress = () => {
|
||||
private onDeletePress = (ev: ButtonEvent) => {
|
||||
if (this.state.dialPadValue.length === 0) return;
|
||||
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onTabChange = (tabId: TabId) => {
|
||||
|
@ -1543,6 +1558,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
let dialPadField;
|
||||
if (this.state.dialPadValue.length !== 0) {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_InviteDialog_dialPadField"
|
||||
id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
|
@ -1552,6 +1568,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
/>;
|
||||
} else {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_InviteDialog_dialPadField"
|
||||
id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -15,11 +15,11 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
// Callback for when the button is pressed
|
||||
onBackspacePress: () => void;
|
||||
onBackspacePress: (ev: ButtonEvent) => void;
|
||||
}
|
||||
|
||||
export default class DialPadBackspaceButton extends React.PureComponent<IProps> {
|
||||
|
|
|
@ -71,12 +71,13 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
|
|||
private onBugReport = (): void => {
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
|
||||
label: 'react-soft-crash',
|
||||
error: this.state.error,
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
|
@ -93,8 +94,9 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
|
|||
"If you've submitted a bug via GitHub, debug logs can help " +
|
||||
"us track down the problem. Debug logs contain application " +
|
||||
"usage data including your username, the IDs or aliases of " +
|
||||
"the rooms or groups you have visited and the usernames of " +
|
||||
"other users. They do not contain messages.",
|
||||
"the rooms or groups you have visited, which UI elements you " +
|
||||
"last interacted with, and the usernames of other users. " +
|
||||
"They do not contain messages.",
|
||||
) }</p>
|
||||
<AccessibleButton onClick={this.onBugReport} kind='primary'>
|
||||
{ _t("Submit debug logs") }
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
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,9 @@ 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;
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -35,33 +38,53 @@ interface IProps {
|
|||
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;
|
||||
|
||||
export default class CallEvent extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.setState({ narrow: wrapperElementEntry.contentRect.width < MAX_NON_NARROW_WIDTH });
|
||||
};
|
||||
|
||||
private onSilencedChanged = (newState) => {
|
||||
this.setState({ silenced: newState });
|
||||
};
|
||||
|
@ -82,21 +105,32 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderSilenceIcon(): JSX.Element {
|
||||
const silenceClass = classNames({
|
||||
"mx_CallEvent_iconButton": true,
|
||||
"mx_CallEvent_unSilence": this.state.silenced,
|
||||
"mx_CallEvent_silence": !this.state.silenced,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.props.callEventGrouper.toggleSilenced}
|
||||
title={this.state.silenced ? _t("Sound on") : _t("Silence call")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private renderContent(state: CallState | CustomCallState): JSX.Element {
|
||||
if (state === CallState.Ringing) {
|
||||
const silenceClass = classNames({
|
||||
"mx_CallEvent_iconButton": true,
|
||||
"mx_CallEvent_unSilence": this.state.silenced,
|
||||
"mx_CallEvent_silence": !this.state.silenced,
|
||||
});
|
||||
let silenceIcon;
|
||||
if (!this.state.narrow) {
|
||||
silenceIcon = this.renderSilenceIcon();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.props.callEventGrouper.toggleSilenced}
|
||||
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
|
||||
/>
|
||||
{ silenceIcon }
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
|
||||
onClick={this.props.callEventGrouper.rejectCall}
|
||||
|
@ -145,7 +179,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Missed call") }
|
||||
{ _t("No answer") }
|
||||
{ this.renderCallBackButton(_t("Call back")) }
|
||||
</div>
|
||||
);
|
||||
|
@ -169,7 +203,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
} else if (hangupReason === CallErrorCode.UserBusy) {
|
||||
reason = _t("The user you called is busy.");
|
||||
} else {
|
||||
reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason });
|
||||
reason = _t('Unknown failure: %(reason)s', { reason: hangupReason });
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -184,10 +218,17 @@ export default class CallEvent extends React.Component<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>
|
||||
);
|
||||
}
|
||||
|
@ -215,35 +256,41 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
|||
const callState = this.state.callState;
|
||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||
const content = this.renderContent(callState);
|
||||
const className = classNames({
|
||||
mx_CallEvent: true,
|
||||
const className = classNames("mx_CallEvent", {
|
||||
mx_CallEvent_voice: isVoice,
|
||||
mx_CallEvent_video: !isVoice,
|
||||
mx_CallEvent_missed: (
|
||||
callState === CustomCallState.Missed ||
|
||||
(callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout)
|
||||
),
|
||||
mx_CallEvent_narrow: this.state.narrow,
|
||||
mx_CallEvent_missed: callState === CustomCallState.Missed,
|
||||
mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
|
||||
mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
|
||||
});
|
||||
let silenceIcon;
|
||||
if (this.state.narrow && this.state.callState === CallState.Ringing) {
|
||||
silenceIcon = this.renderSilenceIcon();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mx_CallEvent_info">
|
||||
<MemberAvatar
|
||||
member={event.sender}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="mx_CallEvent_info_basic">
|
||||
<div className="mx_CallEvent_sender">
|
||||
{ sender }
|
||||
</div>
|
||||
<div className="mx_CallEvent_type">
|
||||
<div className="mx_CallEvent_type_icon" />
|
||||
{ callType }
|
||||
<div className="mx_CallEvent_wrapper" ref={this.wrapperElement}>
|
||||
<div className={className}>
|
||||
{ silenceIcon }
|
||||
<div className="mx_CallEvent_info">
|
||||
<MemberAvatar
|
||||
member={event.sender}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="mx_CallEvent_info_basic">
|
||||
<div className="mx_CallEvent_sender">
|
||||
{ sender }
|
||||
</div>
|
||||
<div className="mx_CallEvent_type">
|
||||
<div className="mx_CallEvent_type_icon" />
|
||||
{ callType }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ content }
|
||||
</div>
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
|
|
@ -178,7 +178,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
|
||||
private onPlaceholderClick = async () => {
|
||||
const mediaHelper = this.props.mediaEventHelper;
|
||||
if (mediaHelper.media.isEncrypted) {
|
||||
if (mediaHelper?.media.isEncrypted) {
|
||||
await this.decryptFile();
|
||||
this.downloadFile(this.fileName, this.linkText);
|
||||
} else {
|
||||
|
@ -192,7 +192,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||
const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted;
|
||||
const contentUrl = this.getContentUrl();
|
||||
const fileSize = this.content.info ? this.content.info.size : null;
|
||||
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -23,6 +23,8 @@ import { 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,6 +36,7 @@ import Resend from "../../../Resend";
|
|||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import DownloadActionButton from "./DownloadActionButton";
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
|
||||
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
@ -170,6 +173,17 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
});
|
||||
};
|
||||
|
||||
onThreadClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.ThreadView,
|
||||
allowClose: false,
|
||||
refireParams: {
|
||||
event: this.props.mxEvent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEditClick = (ev) => {
|
||||
dis.dispatch({
|
||||
action: 'edit_event',
|
||||
|
@ -254,12 +268,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
|
||||
|
|
|
@ -51,6 +51,7 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
|
|||
private onBugReport = (): void => {
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
|
||||
label: 'react-soft-crash-tile',
|
||||
error: this.state.error,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import Autocompleter from '../../../autocomplete/Autocompleter';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
const COMPOSER_SELECTED = 0;
|
||||
const MAX_PROVIDER_MATCHES = 20;
|
||||
|
||||
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
|
||||
|
@ -34,9 +33,9 @@ interface IProps {
|
|||
// the query string for which to show autocomplete suggestions
|
||||
query: string;
|
||||
// method invoked with range and text content when completion is confirmed
|
||||
onConfirm: (ICompletion) => void;
|
||||
onConfirm: (completion: ICompletion) => void;
|
||||
// method invoked when selected (if any) completion changes
|
||||
onSelectionChange?: (ICompletion, number) => void;
|
||||
onSelectionChange?: (partIndex: number) => void;
|
||||
selection: ISelectionRange;
|
||||
// The room in which we're autocompleting
|
||||
room: Room;
|
||||
|
@ -71,7 +70,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
completionList: [],
|
||||
|
||||
// how far down the completion list we are (THIS IS 1-INDEXED!)
|
||||
selectionOffset: COMPOSER_SELECTED,
|
||||
selectionOffset: 1,
|
||||
|
||||
// whether we should show completions if they're available
|
||||
shouldShowCompletions: true,
|
||||
|
@ -86,7 +85,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
this.applyNewProps();
|
||||
}
|
||||
|
||||
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
|
||||
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
|
||||
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
|
||||
this.autocompleter.destroy();
|
||||
this.autocompleter = new Autocompleter(this.props.room);
|
||||
|
@ -104,7 +103,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
this.autocompleter.destroy();
|
||||
}
|
||||
|
||||
complete(query: string, selection: ISelectionRange) {
|
||||
private complete(query: string, selection: ISelectionRange): Promise<void> {
|
||||
this.queryRequested = query;
|
||||
if (this.debounceCompletionsRequest) {
|
||||
clearTimeout(this.debounceCompletionsRequest);
|
||||
|
@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
completions: [],
|
||||
completionList: [],
|
||||
// Reset selected completion
|
||||
selectionOffset: COMPOSER_SELECTED,
|
||||
selectionOffset: 1,
|
||||
// Hide the autocomplete box
|
||||
hide: true,
|
||||
});
|
||||
|
@ -135,7 +134,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
processQuery(query: string, selection: ISelectionRange) {
|
||||
private processQuery(query: string, selection: ISelectionRange): Promise<void> {
|
||||
return this.autocompleter.getCompletions(
|
||||
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
|
||||
).then((completions) => {
|
||||
|
@ -147,30 +146,35 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
processCompletions(completions: IProviderCompletions[]) {
|
||||
private processCompletions(completions: IProviderCompletions[]): void {
|
||||
const completionList = flatMap(completions, (provider) => provider.completions);
|
||||
|
||||
// Reset selection when completion list becomes empty.
|
||||
let selectionOffset = COMPOSER_SELECTED;
|
||||
let selectionOffset = 1;
|
||||
if (completionList.length > 0) {
|
||||
/* If the currently selected completion is still in the completion list,
|
||||
try to find it and jump to it. If not, select composer.
|
||||
*/
|
||||
const currentSelection = this.state.selectionOffset === 0 ? null :
|
||||
const currentSelection = this.state.selectionOffset <= 1 ? null :
|
||||
this.state.completionList[this.state.selectionOffset - 1].completion;
|
||||
selectionOffset = completionList.findIndex(
|
||||
(completion) => completion.completion === currentSelection);
|
||||
if (selectionOffset === -1) {
|
||||
selectionOffset = COMPOSER_SELECTED;
|
||||
selectionOffset = 1;
|
||||
} else {
|
||||
selectionOffset++; // selectionOffset is 1-indexed!
|
||||
}
|
||||
}
|
||||
|
||||
let hide = this.state.hide;
|
||||
let hide = true;
|
||||
// If `completion.command.command` is truthy, then a provider has matched with the query
|
||||
const anyMatches = completions.some((completion) => !!completion.command.command);
|
||||
hide = !anyMatches;
|
||||
if (anyMatches) {
|
||||
hide = false;
|
||||
if (this.props.onSelectionChange) {
|
||||
this.props.onSelectionChange(selectionOffset - 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
completions,
|
||||
|
@ -182,25 +186,25 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
hasSelection(): boolean {
|
||||
public hasSelection(): boolean {
|
||||
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
|
||||
}
|
||||
|
||||
countCompletions(): number {
|
||||
public countCompletions(): number {
|
||||
return this.state.completionList.length;
|
||||
}
|
||||
|
||||
// called from MessageComposerInput
|
||||
moveSelection(delta: number) {
|
||||
public moveSelection(delta: number): void {
|
||||
const completionCount = this.countCompletions();
|
||||
if (completionCount === 0) return; // there are no items to move the selection through
|
||||
|
||||
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
|
||||
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
|
||||
this.setSelection(index);
|
||||
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
|
||||
this.setSelection(1 + index);
|
||||
}
|
||||
|
||||
onEscape(e: KeyboardEvent): boolean {
|
||||
public onEscape(e: KeyboardEvent): boolean {
|
||||
const completionCount = this.countCompletions();
|
||||
if (completionCount === 0) {
|
||||
// autocomplete is already empty, so don't preventDefault
|
||||
|
@ -213,16 +217,16 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
this.hide();
|
||||
}
|
||||
|
||||
hide = () => {
|
||||
private hide = (): void => {
|
||||
this.setState({
|
||||
hide: true,
|
||||
selectionOffset: 0,
|
||||
selectionOffset: 1,
|
||||
completions: [],
|
||||
completionList: [],
|
||||
});
|
||||
};
|
||||
|
||||
forceComplete() {
|
||||
public forceComplete(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
this.setState({
|
||||
forceComplete: true,
|
||||
|
@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
onCompletionClicked = (selectionOffset: number): boolean => {
|
||||
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
|
||||
public onConfirmCompletion = (): void => {
|
||||
this.onCompletionClicked(this.state.selectionOffset);
|
||||
};
|
||||
|
||||
private onCompletionClicked = (selectionOffset: number): boolean => {
|
||||
const count = this.countCompletions();
|
||||
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -246,10 +255,10 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
return true;
|
||||
};
|
||||
|
||||
setSelection(selectionOffset: number) {
|
||||
private setSelection(selectionOffset: number): void {
|
||||
this.setState({ selectionOffset, hide: false });
|
||||
if (this.props.onSelectionChange) {
|
||||
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
|
||||
this.props.onSelectionChange(selectionOffset - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -292,7 +301,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
|
||||
return completions.length > 0 ? (
|
||||
<div key={i} className="mx_Autocomplete_ProviderSection">
|
||||
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
|
||||
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
|
||||
{ completionResult.provider.renderCompletions(completions) }
|
||||
</div>
|
||||
|
@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
}).filter((completion) => !!completion);
|
||||
|
||||
return !this.state.hide && renderedCompletions.length > 0 ? (
|
||||
<div className="mx_Autocomplete" ref={this.containerRef}>
|
||||
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
|
||||
{ renderedCompletions }
|
||||
</div>
|
||||
) : null;
|
||||
|
|
|
@ -133,6 +133,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.state = {
|
||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
||||
showVisualBell: false,
|
||||
};
|
||||
|
||||
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||
|
@ -215,7 +216,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
if (isEmpty) {
|
||||
this.formatBarRef.current.hide();
|
||||
}
|
||||
this.setState({ autoComplete: this.props.model.autoComplete });
|
||||
this.setState({
|
||||
autoComplete: this.props.model.autoComplete,
|
||||
// if a change is happening then clear the showVisualBell
|
||||
showVisualBell: diff ? false : this.state.showVisualBell,
|
||||
});
|
||||
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
|
||||
|
||||
let isTyping = !this.props.model.isEmpty;
|
||||
|
@ -435,7 +440,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
const model = this.props.model;
|
||||
let handled = false;
|
||||
|
||||
if (this.state.surroundWith && document.getSelection().type != "Caret") {
|
||||
if (this.state.surroundWith && document.getSelection().type !== "Caret") {
|
||||
// This surrounds the selected text with a character. This is
|
||||
// intentionally left out of the keybinding manager as the keybinds
|
||||
// here shouldn't be changeable
|
||||
|
@ -456,6 +461,44 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
}
|
||||
}
|
||||
|
||||
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
|
||||
if (model.autoComplete?.hasCompletions()) {
|
||||
const autoComplete = model.autoComplete;
|
||||
switch (autocompleteAction) {
|
||||
case AutocompleteAction.ForceComplete:
|
||||
case AutocompleteAction.Complete:
|
||||
autoComplete.confirmCompletion();
|
||||
handled = true;
|
||||
break;
|
||||
case AutocompleteAction.PrevSelection:
|
||||
autoComplete.selectPreviousSelection();
|
||||
handled = true;
|
||||
break;
|
||||
case AutocompleteAction.NextSelection:
|
||||
autoComplete.selectNextSelection();
|
||||
handled = true;
|
||||
break;
|
||||
case AutocompleteAction.Cancel:
|
||||
autoComplete.onEscape(event);
|
||||
handled = true;
|
||||
break;
|
||||
default:
|
||||
return; // don't preventDefault on anything else
|
||||
}
|
||||
} else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) {
|
||||
// there is no current autocomplete window, try to open it
|
||||
this.tabCompleteName();
|
||||
handled = true;
|
||||
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
||||
this.formatBarRef.current.hide();
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||
switch (action) {
|
||||
case MessageComposerAction.FormatBold:
|
||||
|
@ -507,42 +550,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
|
||||
if (model.autoComplete && model.autoComplete.hasCompletions()) {
|
||||
const autoComplete = model.autoComplete;
|
||||
switch (autocompleteAction) {
|
||||
case AutocompleteAction.CompleteOrPrevSelection:
|
||||
case AutocompleteAction.PrevSelection:
|
||||
autoComplete.selectPreviousSelection();
|
||||
handled = true;
|
||||
break;
|
||||
case AutocompleteAction.CompleteOrNextSelection:
|
||||
case AutocompleteAction.NextSelection:
|
||||
autoComplete.selectNextSelection();
|
||||
handled = true;
|
||||
break;
|
||||
case AutocompleteAction.Cancel:
|
||||
autoComplete.onEscape(event);
|
||||
handled = true;
|
||||
break;
|
||||
default:
|
||||
return; // don't preventDefault on anything else
|
||||
}
|
||||
} else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection
|
||||
|| autocompleteAction === AutocompleteAction.CompleteOrNextSelection) {
|
||||
// there is no current autocomplete window, try to open it
|
||||
this.tabCompleteName();
|
||||
handled = true;
|
||||
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
||||
this.formatBarRef.current.hide();
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
@ -577,6 +584,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.setState({ showVisualBell: true });
|
||||
model.autoComplete.close();
|
||||
}
|
||||
} else {
|
||||
this.setState({ showVisualBell: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
@ -592,9 +601,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.props.model.autoComplete.onComponentConfirm(completion);
|
||||
};
|
||||
|
||||
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
|
||||
private onAutoCompleteSelectionChange = (completionIndex: number): void => {
|
||||
this.modifiedFlag = true;
|
||||
this.props.model.autoComplete.onComponentSelectionChange(completion);
|
||||
this.setState({ completionIndex });
|
||||
};
|
||||
|
||||
|
@ -718,6 +726,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
};
|
||||
|
||||
const { completionIndex } = this.state;
|
||||
const hasAutocomplete = Boolean(this.state.autoComplete);
|
||||
let activeDescendant;
|
||||
if (hasAutocomplete && completionIndex >= 0) {
|
||||
activeDescendant = generateCompletionDomId(completionIndex);
|
||||
}
|
||||
|
||||
return (<div className={wrapperClasses}>
|
||||
{ autoComplete }
|
||||
|
@ -736,10 +749,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
aria-label={this.props.label}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-autocomplete="both"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={Boolean(this.state.autoComplete)}
|
||||
aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined}
|
||||
aria-expanded={hasAutocomplete}
|
||||
aria-owns="mx_Autocomplete"
|
||||
aria-activedescendant={activeDescendant}
|
||||
dir="auto"
|
||||
aria-disabled={this.props.disabled}
|
||||
/>
|
||||
|
|
|
@ -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 = "";
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
@ -856,13 +916,19 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
|
||||
render() {
|
||||
const msgtype = this.props.mxEvent.getContent().msgtype;
|
||||
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
|
||||
const eventType = this.props.mxEvent.getType() as EventType;
|
||||
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
|
||||
if (!tileHandler) {
|
||||
const { mxEvent } = this.props;
|
||||
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
|
||||
console.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
|
||||
return <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
|
||||
<div className="mx_EventTile_line">
|
||||
{ _t('This event could not be displayed') }
|
||||
|
@ -878,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,
|
||||
|
@ -886,7 +953,10 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
mx_EventTile_sending: !isEditing && isSending,
|
||||
mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(),
|
||||
mx_EventTile_selected: this.props.isSelectedEvent,
|
||||
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
|
||||
mx_EventTile_continuation: (
|
||||
(this.props.tileShape ? '' : this.props.continuation) ||
|
||||
eventType === EventType.CallInvite
|
||||
),
|
||||
mx_EventTile_last: this.props.last,
|
||||
mx_EventTile_lastInSection: this.props.lastInSection,
|
||||
mx_EventTile_contextual: this.props.contextual,
|
||||
|
@ -932,8 +1002,11 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
} else if (this.props.layout == Layout.IRC) {
|
||||
avatarSize = 14;
|
||||
needsSenderProfile = true;
|
||||
} else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
|
||||
// no avatar or sender profile for continuation messages
|
||||
} else if (
|
||||
(this.props.continuation && this.props.tileShape !== TileShape.FileGrid) ||
|
||||
eventType === EventType.CallInvite
|
||||
) {
|
||||
// no avatar or sender profile for continuation messages and call tiles
|
||||
avatarSize = 0;
|
||||
needsSenderProfile = false;
|
||||
} else {
|
||||
|
@ -1167,6 +1240,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 }
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -183,7 +183,10 @@ interface IProps {
|
|||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
replyToEvent?: MatrixEvent;
|
||||
replyInThread?: boolean;
|
||||
showReplyPreview?: boolean;
|
||||
e2eStatus?: E2EStatus;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -201,6 +204,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
private messageComposerInput: SendMessageComposer;
|
||||
private voiceRecordingButton: VoiceRecordComposerTile;
|
||||
|
||||
static defaultProps = {
|
||||
replyInThread: false,
|
||||
showReplyPreview: true,
|
||||
compact: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
|
||||
|
@ -362,7 +371,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
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,
|
||||
|
@ -376,6 +385,7 @@ 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}
|
||||
|
@ -450,11 +460,19 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
/>;
|
||||
}
|
||||
|
||||
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}>
|
||||
{ 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 }
|
||||
</div>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
) }
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
|
@ -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")} />;
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
@ -320,11 +319,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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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">
|
|
@ -57,15 +57,16 @@ import { ActionPayload } from "../../../dispatcher/payloads";
|
|||
|
||||
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 +78,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 +103,7 @@ export function createMessageContent(
|
|||
}
|
||||
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
|
||||
addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator);
|
||||
}
|
||||
|
||||
return content;
|
||||
|
@ -129,6 +131,7 @@ interface IProps {
|
|||
room: Room;
|
||||
placeholder?: string;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
replyInThread?: boolean;
|
||||
replyToEvent?: MatrixEvent;
|
||||
disabled?: boolean;
|
||||
onChange?(model: EditorModel): void;
|
||||
|
@ -357,7 +360,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
|||
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);
|
||||
|
@ -400,7 +408,12 @@ 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(
|
||||
this.model,
|
||||
replyToEvent,
|
||||
this.props.replyInThread,
|
||||
this.props.permalinkCreator,
|
||||
);
|
||||
}
|
||||
// don't bother sending an empty message
|
||||
if (!content.body.trim()) return;
|
||||
|
|
|
@ -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
|
|
@ -15,22 +15,25 @@ limitations under the License.
|
|||
*/
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import AppTile from '../elements/AppTile';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import WidgetUtils, { IWidgetEvent } from '../../../utils/WidgetUtils';
|
||||
import PersistedElement from "../elements/PersistedElement";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { ContextMenu } from "../../structures/ContextMenu";
|
||||
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
|
||||
import { WidgetType } from "../../../widgets/WidgetType";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ActionPayload } from '../../../dispatcher/payloads';
|
||||
import ScalarAuthClient from '../../../ScalarAuthClient';
|
||||
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
|
||||
|
||||
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
|
||||
// We sit in a context menu, so this should be given to the context menu.
|
||||
|
@ -39,27 +42,35 @@ const STICKERPICKER_Z_INDEX = 3500;
|
|||
// Key to store the widget's AppTile under in PersistedElement
|
||||
const PERSISTED_ELEMENT_KEY = "stickerPicker";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showStickers: boolean;
|
||||
imError: string;
|
||||
stickerpickerX: number;
|
||||
stickerpickerY: number;
|
||||
stickerpickerChevronOffset?: number;
|
||||
stickerpickerWidget: IWidgetEvent;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.Stickerpicker")
|
||||
export default class Stickerpicker extends React.PureComponent {
|
||||
export default class Stickerpicker extends React.PureComponent<IProps, IState> {
|
||||
static currentWidget;
|
||||
|
||||
constructor(props) {
|
||||
private dispatcherRef: string;
|
||||
|
||||
private prevSentVisibility: boolean;
|
||||
|
||||
private popoverWidth = 300;
|
||||
private popoverHeight = 300;
|
||||
// This is loaded by _acquireScalarClient on an as-needed basis.
|
||||
private scalarClient: ScalarAuthClient = null;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this._onShowStickersClick = this._onShowStickersClick.bind(this);
|
||||
this._onHideStickersClick = this._onHideStickersClick.bind(this);
|
||||
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
|
||||
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
|
||||
this._updateWidget = this._updateWidget.bind(this);
|
||||
this._onWidgetAction = this._onWidgetAction.bind(this);
|
||||
this._onResize = this._onResize.bind(this);
|
||||
this._onFinished = this._onFinished.bind(this);
|
||||
|
||||
this.popoverWidth = 300;
|
||||
this.popoverHeight = 300;
|
||||
|
||||
// This is loaded by _acquireScalarClient on an as-needed basis.
|
||||
this.scalarClient = null;
|
||||
|
||||
this.state = {
|
||||
showStickers: false,
|
||||
imError: null,
|
||||
|
@ -70,7 +81,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
};
|
||||
}
|
||||
|
||||
_acquireScalarClient() {
|
||||
private acquireScalarClient(): Promise<void | ScalarAuthClient> {
|
||||
if (this.scalarClient) return Promise.resolve(this.scalarClient);
|
||||
// TODO: Pick the right manager for the widget
|
||||
if (IntegrationManagers.sharedInstance().hasManager()) {
|
||||
|
@ -79,15 +90,15 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
this.forceUpdate();
|
||||
return this.scalarClient;
|
||||
}).catch((e) => {
|
||||
this._imError(_td("Failed to connect to integration manager"), e);
|
||||
this.imError(_td("Failed to connect to integration manager"), e);
|
||||
});
|
||||
} else {
|
||||
IntegrationManagers.sharedInstance().openNoManagerDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async _removeStickerpickerWidgets() {
|
||||
const scalarClient = await this._acquireScalarClient();
|
||||
private removeStickerpickerWidgets = async (): Promise<void> => {
|
||||
const scalarClient = await this.acquireScalarClient();
|
||||
console.log('Removing Stickerpicker widgets');
|
||||
if (this.state.widgetId) {
|
||||
if (scalarClient) {
|
||||
|
@ -109,36 +120,36 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
}).catch((e) => {
|
||||
console.error('Failed to remove sticker picker widget', e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
// Close the sticker picker when the window resizes
|
||||
window.addEventListener('resize', this._onResize);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
this.dispatcherRef = dis.register(this._onWidgetAction);
|
||||
this.dispatcherRef = dis.register(this.onWidgetAction);
|
||||
|
||||
// Track updates to widget state in account data
|
||||
MatrixClientPeg.get().on('accountData', this._updateWidget);
|
||||
MatrixClientPeg.get().on('accountData', this.updateWidget);
|
||||
|
||||
// Initialise widget state from current account data
|
||||
this._updateWidget();
|
||||
this.updateWidget();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) client.removeListener('accountData', this._updateWidget);
|
||||
if (client) client.removeListener('accountData', this.updateWidget);
|
||||
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
if (this.dispatcherRef) {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
this._sendVisibilityToWidget(this.state.showStickers);
|
||||
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
|
||||
this.sendVisibilityToWidget(this.state.showStickers);
|
||||
}
|
||||
|
||||
_imError(errorMsg, e) {
|
||||
private imError(errorMsg: string, e: Error): void {
|
||||
console.error(errorMsg, e);
|
||||
this.setState({
|
||||
showStickers: false,
|
||||
|
@ -146,7 +157,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
_updateWidget() {
|
||||
private updateWidget = (): void => {
|
||||
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
|
||||
if (!stickerpickerWidget) {
|
||||
Stickerpicker.currentWidget = null;
|
||||
|
@ -175,9 +186,9 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
stickerpickerWidget,
|
||||
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onWidgetAction(payload) {
|
||||
private onWidgetAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case "user_widget_updated":
|
||||
this.forceUpdate();
|
||||
|
@ -191,11 +202,11 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
this.setState({ showStickers: false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_defaultStickerpickerContent() {
|
||||
private defaultStickerpickerContent(): JSX.Element {
|
||||
return (
|
||||
<AccessibleButton onClick={this._launchManageIntegrations}
|
||||
<AccessibleButton onClick={this.launchManageIntegrations}
|
||||
className='mx_Stickers_contentPlaceholder'>
|
||||
<p>{ _t("You don't currently have any stickerpacks enabled") }</p>
|
||||
<p className='mx_Stickers_addLink'>{ _t("Add some now") }</p>
|
||||
|
@ -204,29 +215,29 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
_errorStickerpickerContent() {
|
||||
private errorStickerpickerContent(): JSX.Element {
|
||||
return (
|
||||
<div style={{ "text-align": "center" }} className="error">
|
||||
<div style={{ textAlign: "center" }} className="error">
|
||||
<p> { this.state.imError } </p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_sendVisibilityToWidget(visible) {
|
||||
private sendVisibilityToWidget(visible: boolean): void {
|
||||
if (!this.state.stickerpickerWidget) return;
|
||||
const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
|
||||
if (messaging && visible !== this._prevSentVisibility) {
|
||||
if (messaging && visible !== this.prevSentVisibility) {
|
||||
messaging.updateVisibility(visible).catch(err => {
|
||||
console.error("Error updating widget visibility: ", err);
|
||||
});
|
||||
this._prevSentVisibility = visible;
|
||||
this.prevSentVisibility = visible;
|
||||
}
|
||||
}
|
||||
|
||||
_getStickerpickerContent() {
|
||||
public getStickerpickerContent(): JSX.Element {
|
||||
// Handle integration manager errors
|
||||
if (this.state._imError) {
|
||||
return this._errorStickerpickerContent();
|
||||
if (this.state.imError) {
|
||||
return this.errorStickerpickerContent();
|
||||
}
|
||||
|
||||
// Stickers
|
||||
|
@ -239,12 +250,11 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
|
||||
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
|
||||
// updated.
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
|
||||
// Load stickerpack content
|
||||
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
|
||||
// Set default name
|
||||
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
|
||||
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack");
|
||||
|
||||
// FIXME: could this use the same code as other apps?
|
||||
const stickerApp = {
|
||||
|
@ -275,12 +285,12 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
|
||||
waitForIframeLoad={true}
|
||||
showMenubar={true}
|
||||
onEditClick={this._launchManageIntegrations}
|
||||
onDeleteClick={this._removeStickerpickerWidgets}
|
||||
onEditClick={this.launchManageIntegrations}
|
||||
onDeleteClick={this.removeStickerpickerWidgets}
|
||||
showTitle={false}
|
||||
showCancel={false}
|
||||
showPopout={false}
|
||||
onMinimiseClick={this._onHideStickersClick}
|
||||
onMinimiseClick={this.onHideStickersClick}
|
||||
handleMinimisePointerEvents={true}
|
||||
userWidget={true}
|
||||
/>
|
||||
|
@ -290,7 +300,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
);
|
||||
} else {
|
||||
// Default content to show if stickerpicker widget not added
|
||||
stickersContent = this._defaultStickerpickerContent();
|
||||
stickersContent = this.defaultStickerpickerContent();
|
||||
}
|
||||
return stickersContent;
|
||||
}
|
||||
|
@ -300,7 +310,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
* Show the sticker picker overlay
|
||||
* If no stickerpacks have been added, show a link to the integration manager add sticker packs page.
|
||||
*/
|
||||
_onShowStickersClick(e) {
|
||||
private onShowStickersClick = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!SettingsStore.getValue("integrationProvisioning")) {
|
||||
// Intercept this case and spawn a warning.
|
||||
return IntegrationManagers.sharedInstance().showDisabledDialog();
|
||||
|
@ -308,7 +318,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
|
||||
// XXX: Simplify by using a context menu that is positioned relative to the sticker picker button
|
||||
|
||||
const buttonRect = e.target.getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
// The window X and Y offsets are to adjust position when zoomed in to page
|
||||
let x = buttonRect.right + window.pageXOffset - 41;
|
||||
|
@ -324,50 +334,50 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
// Offset the chevron location, which is relative to the left of the context menu
|
||||
// (10 = offset when context menu would not be displayed off viewport)
|
||||
// (2 = context menu borders)
|
||||
const stickerPickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
|
||||
const stickerpickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
|
||||
|
||||
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
|
||||
|
||||
this.setState({
|
||||
showStickers: true,
|
||||
stickerPickerX: x,
|
||||
stickerPickerY: y,
|
||||
stickerPickerChevronOffset,
|
||||
stickerpickerX: x,
|
||||
stickerpickerY: y,
|
||||
stickerpickerChevronOffset,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger hiding of the sticker picker overlay
|
||||
* @param {Event} ev Event that triggered the function call
|
||||
*/
|
||||
_onHideStickersClick(ev) {
|
||||
private onHideStickersClick = (ev: React.MouseEvent): void => {
|
||||
if (this.state.showStickers) {
|
||||
this.setState({ showStickers: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the window is resized
|
||||
*/
|
||||
_onResize() {
|
||||
private onResize = (): void => {
|
||||
if (this.state.showStickers) {
|
||||
this.setState({ showStickers: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The stickers picker was hidden
|
||||
*/
|
||||
_onFinished() {
|
||||
private onFinished = (): void => {
|
||||
if (this.state.showStickers) {
|
||||
this.setState({ showStickers: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Launch the integration manager on the stickers integration page
|
||||
*/
|
||||
_launchManageIntegrations = () => {
|
||||
private launchManageIntegrations = (): void => {
|
||||
// TODO: Open the right integration manager for the widget
|
||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll(
|
||||
|
@ -384,7 +394,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
let stickerPicker;
|
||||
let stickersButton;
|
||||
const className = classNames(
|
||||
|
@ -400,26 +410,24 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
id='stickersButton'
|
||||
key="controls_hide_stickers"
|
||||
className={className}
|
||||
onClick={this._onHideStickersClick}
|
||||
active={this.state.showStickers.toString()}
|
||||
onClick={this.onHideStickersClick}
|
||||
title={_t("Hide Stickers")}
|
||||
/>;
|
||||
|
||||
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
|
||||
stickerPicker = <ContextMenu
|
||||
chevronOffset={this.state.stickerPickerChevronOffset}
|
||||
chevronFace="bottom"
|
||||
left={this.state.stickerPickerX}
|
||||
top={this.state.stickerPickerY}
|
||||
chevronOffset={this.state.stickerpickerChevronOffset}
|
||||
chevronFace={ChevronFace.Bottom}
|
||||
left={this.state.stickerpickerX}
|
||||
top={this.state.stickerpickerY}
|
||||
menuWidth={this.popoverWidth}
|
||||
menuHeight={this.popoverHeight}
|
||||
onFinished={this._onFinished}
|
||||
onFinished={this.onFinished}
|
||||
menuPaddingTop={0}
|
||||
menuPaddingLeft={0}
|
||||
menuPaddingRight={0}
|
||||
zIndex={STICKERPICKER_Z_INDEX}
|
||||
>
|
||||
<GenericElementContextMenu element={this._getStickerpickerContent()} onResize={this._onFinished} />
|
||||
<GenericElementContextMenu element={this.getStickerpickerContent()} onResize={this.onFinished} />
|
||||
</ContextMenu>;
|
||||
} else {
|
||||
// Show show-stickers button
|
||||
|
@ -428,7 +436,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
id='stickersButton'
|
||||
key="controls_show_stickers"
|
||||
className="mx_MessageComposer_button mx_MessageComposer_stickers"
|
||||
onClick={this._onShowStickersClick}
|
||||
onClick={this.onShowStickersClick}
|
||||
title={_t("Show Stickers")}
|
||||
/>;
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 New Vector 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.
|
||||
|
@ -17,19 +15,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.rooms.TopUnreadMessagesBar")
|
||||
export default class TopUnreadMessagesBar extends React.Component {
|
||||
static propTypes = {
|
||||
onScrollUpClick: PropTypes.func,
|
||||
onCloseClick: PropTypes.func,
|
||||
};
|
||||
interface IProps {
|
||||
onScrollUpClick?: (e: React.MouseEvent) => void;
|
||||
onCloseClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
render() {
|
||||
@replaceableComponent("views.rooms.TopUnreadMessagesBar")
|
||||
export default class TopUnreadMessagesBar extends React.PureComponent<IProps> {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="mx_TopUnreadMessagesBar">
|
||||
<AccessibleButton
|
|
@ -179,7 +179,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
|
||||
try {
|
||||
// stop any noises which might be happening
|
||||
await PlaybackManager.instance.playOnly(null);
|
||||
await PlaybackManager.instance.pauseAllExcept(null);
|
||||
|
||||
const recorder = VoiceRecordingStore.instance.startRecording();
|
||||
await recorder.start();
|
||||
|
|
|
@ -15,12 +15,19 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import classNames from "classnames";
|
||||
|
||||
const AvatarSetting = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => {
|
||||
interface IProps {
|
||||
avatarUrl?: string;
|
||||
avatarName: string; // name of user/room the avatar belongs to
|
||||
uploadAvatar?: (e: React.MouseEvent) => void;
|
||||
removeAvatar?: (e: React.MouseEvent) => void;
|
||||
avatarAltText: string;
|
||||
}
|
||||
|
||||
const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const hoveringProps = {
|
||||
onMouseEnter: () => setIsHovering(true),
|
||||
|
@ -78,12 +85,4 @@ const AvatarSetting = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, rem
|
|||
</div>;
|
||||
};
|
||||
|
||||
AvatarSetting.propTypes = {
|
||||
avatarUrl: PropTypes.string,
|
||||
avatarName: PropTypes.string.isRequired, // name of user/room the avatar belongs to
|
||||
uploadAvatar: PropTypes.func,
|
||||
removeAvatar: PropTypes.func,
|
||||
avatarAltText: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AvatarSetting;
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket 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.
|
||||
|
@ -15,54 +15,65 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Spinner from '../elements/Spinner';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
|
||||
interface IProps {
|
||||
initialAvatarUrl?: string;
|
||||
room?: Room;
|
||||
// if false, you need to call changeAvatar.onFileSelected yourself.
|
||||
showUploadSection?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
avatarUrl?: string;
|
||||
errorText?: string;
|
||||
phase?: Phases;
|
||||
}
|
||||
|
||||
enum Phases {
|
||||
Display = "display",
|
||||
Uploading = "uploading",
|
||||
Error = "error",
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.ChangeAvatar")
|
||||
export default class ChangeAvatar extends React.Component {
|
||||
static propTypes = {
|
||||
initialAvatarUrl: PropTypes.string,
|
||||
room: PropTypes.object,
|
||||
// if false, you need to call changeAvatar.onFileSelected yourself.
|
||||
showUploadSection: PropTypes.bool,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
static Phases = {
|
||||
Display: "display",
|
||||
Uploading: "uploading",
|
||||
Error: "error",
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class ChangeAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
showUploadSection: true,
|
||||
className: "",
|
||||
width: 80,
|
||||
height: 80,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
private avatarSet = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
avatarUrl: this.props.initialAvatarUrl,
|
||||
phase: ChangeAvatar.Phases.Display,
|
||||
phase: Phases.Display,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
// eslint-disable-next-line
|
||||
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
|
||||
if (this.avatarSet) {
|
||||
// don't clobber what the user has just set
|
||||
return;
|
||||
|
@ -72,13 +83,13 @@ export default class ChangeAvatar extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
}
|
||||
|
||||
onRoomStateEvents = (ev) => {
|
||||
private onRoomStateEvents = (ev: MatrixEvent) => {
|
||||
if (!this.props.room) {
|
||||
return;
|
||||
}
|
||||
|
@ -94,18 +105,17 @@ export default class ChangeAvatar extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
setAvatarFromFile(file) {
|
||||
private setAvatarFromFile(file: File): Promise<{}> {
|
||||
let newUrl = null;
|
||||
|
||||
this.setState({
|
||||
phase: ChangeAvatar.Phases.Uploading,
|
||||
phase: Phases.Uploading,
|
||||
});
|
||||
const self = this;
|
||||
const httpPromise = MatrixClientPeg.get().uploadContent(file).then(function(url) {
|
||||
const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => {
|
||||
newUrl = url;
|
||||
if (self.props.room) {
|
||||
if (this.props.room) {
|
||||
return MatrixClientPeg.get().sendStateEvent(
|
||||
self.props.room.roomId,
|
||||
this.props.room.roomId,
|
||||
'm.room.avatar',
|
||||
{ url: url },
|
||||
'',
|
||||
|
@ -115,38 +125,37 @@ export default class ChangeAvatar extends React.Component {
|
|||
}
|
||||
});
|
||||
|
||||
httpPromise.then(function() {
|
||||
self.setState({
|
||||
phase: ChangeAvatar.Phases.Display,
|
||||
httpPromise.then(() => {
|
||||
this.setState({
|
||||
phase: Phases.Display,
|
||||
avatarUrl: mediaFromMxc(newUrl).srcHttp,
|
||||
});
|
||||
}, function(error) {
|
||||
self.setState({
|
||||
phase: ChangeAvatar.Phases.Error,
|
||||
}, () => {
|
||||
this.setState({
|
||||
phase: Phases.Error,
|
||||
});
|
||||
self.onError(error);
|
||||
this.onError();
|
||||
});
|
||||
|
||||
return httpPromise;
|
||||
}
|
||||
|
||||
onFileSelected = (ev) => {
|
||||
private onFileSelected = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.avatarSet = true;
|
||||
return this.setAvatarFromFile(ev.target.files[0]);
|
||||
};
|
||||
|
||||
onError = (error) => {
|
||||
private onError = (): void => {
|
||||
this.setState({
|
||||
errorText: _t("Failed to upload profile picture!"),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
let avatarImg;
|
||||
// Having just set an avatar we just display that since it will take a little
|
||||
// time to propagate through to the RoomAvatar.
|
||||
if (this.props.room && !this.avatarSet) {
|
||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||
avatarImg = <RoomAvatar
|
||||
room={this.props.room}
|
||||
width={this.props.width}
|
||||
|
@ -154,7 +163,6 @@ export default class ChangeAvatar extends React.Component {
|
|||
resizeMethod='crop'
|
||||
/>;
|
||||
} else {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
|
||||
avatarImg = <BaseAvatar
|
||||
width={this.props.width}
|
||||
|
@ -178,8 +186,8 @@ export default class ChangeAvatar extends React.Component {
|
|||
}
|
||||
|
||||
switch (this.state.phase) {
|
||||
case ChangeAvatar.Phases.Display:
|
||||
case ChangeAvatar.Phases.Error:
|
||||
case Phases.Display:
|
||||
case Phases.Error:
|
||||
return (
|
||||
<div>
|
||||
<div className={this.props.className}>
|
||||
|
@ -188,7 +196,7 @@ export default class ChangeAvatar extends React.Component {
|
|||
{ uploadSection }
|
||||
</div>
|
||||
);
|
||||
case ChangeAvatar.Phases.Uploading:
|
||||
case Phases.Uploading:
|
||||
return (
|
||||
<Spinner />
|
||||
);
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,14 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import EditableTextContainer from "../elements/EditableTextContainer";
|
||||
|
||||
@replaceableComponent("views.settings.ChangeDisplayName")
|
||||
export default class ChangeDisplayName extends React.Component {
|
||||
_getDisplayName = async () => {
|
||||
private getDisplayName = async (): Promise<string> => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
try {
|
||||
const res = await cli.getProfileInfo(cli.getUserId());
|
||||
|
@ -34,21 +32,20 @@ export default class ChangeDisplayName extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
_changeDisplayName = (newDisplayname) => {
|
||||
private changeDisplayName = (newDisplayname: string): Promise<{}> => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
return cli.setDisplayName(newDisplayname).catch(function(e) {
|
||||
throw new Error("Failed to set display name", e);
|
||||
return cli.setDisplayName(newDisplayname).catch(function() {
|
||||
throw new Error("Failed to set display name");
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<EditableTextContainer
|
||||
getInitialValue={this._getDisplayName}
|
||||
getInitialValue={this.getDisplayName}
|
||||
placeholder={_t("No display name")}
|
||||
blurToSubmit={true}
|
||||
onSubmit={this._changeDisplayName} />
|
||||
onSubmit={this.changeDisplayName} />
|
||||
);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue