Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/12740
Conflicts: src/components/views/rooms/EditMessageComposer.js src/components/views/rooms/SendMessageComposer.js
This commit is contained in:
commit
d8acc0612a
441 changed files with 17399 additions and 5943 deletions
|
@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
|
@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
|
||||
if (handled) {
|
||||
// consume all other keys in context menu
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -50,6 +50,9 @@ class FilePanel extends React.Component {
|
|||
if (room?.roomId !== this.props?.roomId) return;
|
||||
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.decryptEventIfNeeded(ev);
|
||||
|
||||
if (ev.isBeingDecrypted()) {
|
||||
this.decryptingEvents.add(ev.getId());
|
||||
} else {
|
||||
|
@ -200,10 +203,10 @@ class FilePanel extends React.Component {
|
|||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<div className="mx_RoomView_empty">
|
||||
{ _t("You must <a>register</a> to use this functionality",
|
||||
{},
|
||||
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
|
||||
}
|
||||
{ _t("You must <a>register</a> to use this functionality",
|
||||
{},
|
||||
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
|
||||
}
|
||||
</div>
|
||||
</BaseCard>;
|
||||
} else if (this.noRoom) {
|
||||
|
|
|
@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component {
|
|||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
let betaDot;
|
||||
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
|
||||
betaDot = <div className="mx_BetaDot" />;
|
||||
}
|
||||
|
||||
let createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus" />
|
||||
className="mx_TagTile mx_TagTile_plus">
|
||||
{ betaDot }
|
||||
</ActionButton>
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
|
@ -153,17 +160,17 @@ class GroupFilterPanel extends React.Component {
|
|||
type="draggable-TagTile"
|
||||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div
|
||||
className="mx_GroupFilterPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ this.renderGlobalIcon() }
|
||||
{ tags }
|
||||
<div>
|
||||
{createButton}
|
||||
</div>
|
||||
{ provided.placeholder }
|
||||
<div
|
||||
className="mx_GroupFilterPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ this.renderGlobalIcon() }
|
||||
{ tags }
|
||||
<div>
|
||||
{createButton}
|
||||
</div>
|
||||
{ provided.placeholder }
|
||||
</div>
|
||||
) }
|
||||
</Droppable>
|
||||
</AutoHideScrollbar>
|
||||
|
|
|
@ -43,7 +43,7 @@ import {mediaFromMxc} from "../../customisations/Media";
|
|||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
`<h1>HTML for your community's page</h1>
|
||||
<p>
|
||||
Use the long description to introduce new members to the community, or distribute
|
||||
some important <a href="foo">links</a>
|
||||
|
@ -110,14 +110,16 @@ class CategoryRoomList extends React.Component {
|
|||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to add the following room to the group summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following rooms to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
});
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following rooms to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
|
@ -146,8 +148,8 @@ class CategoryRoomList extends React.Component {
|
|||
let catHeader = <div />;
|
||||
if (this.props.category && this.props.category.profile) {
|
||||
catHeader = <div className="mx_GroupView_featuredThings_category">
|
||||
{ this.props.category.profile.name }
|
||||
</div>;
|
||||
{ this.props.category.profile.name }
|
||||
</div>;
|
||||
}
|
||||
return <div className="mx_GroupView_featuredThings_container">
|
||||
{ catHeader }
|
||||
|
@ -190,13 +192,14 @@ class FeaturedRoom extends React.Component {
|
|||
Modal.createTrackedDialog(
|
||||
'Failed to remove room from group summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove the room from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
|
||||
});
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove the room from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -283,13 +286,14 @@ class RoleUserList extends React.Component {
|
|||
Modal.createTrackedDialog(
|
||||
'Failed to add the following users to the community summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following users to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
});
|
||||
{
|
||||
title: _t(
|
||||
"Failed to add the following users to the summary of %(groupId)s:",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
|
@ -299,11 +303,11 @@ class RoleUserList extends React.Component {
|
|||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const addButton = this.props.editing ?
|
||||
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
|
||||
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<div className="mx_GroupView_featuredThings_addButton_label">
|
||||
{ _t('Add a User') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
|
||||
<div className="mx_GroupView_featuredThings_addButton_label">
|
||||
{ _t('Add a User') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
const userNodes = this.props.users.map((u) => {
|
||||
return <FeaturedUser
|
||||
key={u.user_id}
|
||||
|
@ -352,14 +356,16 @@ class FeaturedUser extends React.Component {
|
|||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to remove user from community summary',
|
||||
'', ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove a user from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
|
||||
});
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t(
|
||||
"Failed to remove a user from the summary of %(groupId)s",
|
||||
{groupId: this.props.groupId},
|
||||
),
|
||||
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -767,8 +773,8 @@ export default class GroupView extends React.Component {
|
|||
title: _t("Leave Community"),
|
||||
description: (
|
||||
<span>
|
||||
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
|
||||
{ warnings }
|
||||
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
button: _t("Leave"),
|
||||
|
@ -1055,10 +1061,11 @@ export default class GroupView extends React.Component {
|
|||
return null;
|
||||
}
|
||||
|
||||
const membershipButtonClasses = classnames([
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
const membershipButtonClasses = classnames(
|
||||
[
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
membershipButtonExtraClasses,
|
||||
);
|
||||
|
||||
|
|
|
@ -154,7 +154,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
private doStickyHeaders(list: HTMLDivElement) {
|
||||
const topEdge = list.scrollTop;
|
||||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
|
||||
|
||||
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = list.clientWidth - headerRightMargin;
|
||||
|
@ -347,7 +347,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
if (element) {
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && !cssClasses.some(c => classes.contains(c)));
|
||||
} while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
|
@ -416,7 +416,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={null}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
|
|
|
@ -27,7 +27,7 @@ import CallMediaHandler from '../../CallMediaHandler';
|
|||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import { IMatrixClientCreds } from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
|
@ -59,6 +59,9 @@ 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 { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -119,6 +122,7 @@ interface IState {
|
|||
usageLimitEventContent?: IUsageLimit;
|
||||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: [],
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
|
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
|
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
|
@ -206,15 +213,11 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this.resizer.detach();
|
||||
}
|
||||
|
||||
// Child components assume that the client peg will not be null, so give them some
|
||||
// sort of assurance here by only allowing a re-render if the client is truthy.
|
||||
//
|
||||
// This is required because `LoggedInView` maintains its own state and if this state
|
||||
// updates after the client peg has been made null (during logout), then it will
|
||||
// attempt to re-render and the children will throw errors.
|
||||
shouldComponentUpdate() {
|
||||
return Boolean(MatrixClientPeg.get());
|
||||
}
|
||||
private onCallsChanged = () => {
|
||||
this.setState({
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
});
|
||||
};
|
||||
|
||||
canResetTimelineInRoom = (roomId) => {
|
||||
if (!this._roomView.current) {
|
||||
|
@ -661,6 +664,12 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
|
||||
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
|
||||
return (
|
||||
<AudioFeedArrayForCall call={call} key={call.callId} />
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
|
@ -685,6 +694,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -86,6 +86,8 @@ import {RoomUpdateCause} from "../../stores/room-list/models";
|
|||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import SecurityCustomisations from "../../customisations/Security";
|
||||
|
||||
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
// a special initial state which is only used at startup, while we are
|
||||
|
@ -484,42 +486,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
startPageChangeTimer() {
|
||||
// Tor doesn't support performance
|
||||
if (!performance || !performance.mark) return null;
|
||||
|
||||
// This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate
|
||||
// are used.
|
||||
if (this.pageChanging) {
|
||||
console.warn('MatrixChat.startPageChangeTimer: timer already started');
|
||||
return;
|
||||
}
|
||||
this.pageChanging = true;
|
||||
performance.mark('element_MatrixChat_page_change_start');
|
||||
PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE);
|
||||
}
|
||||
|
||||
stopPageChangeTimer() {
|
||||
// Tor doesn't support performance
|
||||
if (!performance || !performance.mark) return null;
|
||||
const perfMonitor = PerformanceMonitor.instance;
|
||||
|
||||
if (!this.pageChanging) {
|
||||
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
|
||||
return;
|
||||
}
|
||||
this.pageChanging = false;
|
||||
performance.mark('element_MatrixChat_page_change_stop');
|
||||
performance.measure(
|
||||
'element_MatrixChat_page_change_delta',
|
||||
'element_MatrixChat_page_change_start',
|
||||
'element_MatrixChat_page_change_stop',
|
||||
);
|
||||
performance.clearMarks('element_MatrixChat_page_change_start');
|
||||
performance.clearMarks('element_MatrixChat_page_change_stop');
|
||||
const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop();
|
||||
perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE);
|
||||
|
||||
// In practice, sometimes the entries list is empty, so we get no measurement
|
||||
if (!measurement) return null;
|
||||
const entries = perfMonitor.getEntries({
|
||||
name: PerformanceEntryNames.PAGE_CHANGE,
|
||||
});
|
||||
const measurement = entries.pop();
|
||||
|
||||
return measurement.duration;
|
||||
return measurement
|
||||
? measurement.duration
|
||||
: null;
|
||||
}
|
||||
|
||||
shouldTrackPageChange(prevState: IState, state: IState) {
|
||||
|
@ -683,7 +665,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
break;
|
||||
}
|
||||
case 'view_create_room':
|
||||
this.createRoom(payload.public);
|
||||
this.createRoom(payload.public, payload.defaultName);
|
||||
break;
|
||||
case 'view_create_group': {
|
||||
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
|
||||
|
@ -740,6 +722,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.showScreenAfterLogin();
|
||||
break;
|
||||
case 'toggle_my_groups':
|
||||
// persist that the user has interacted with this, use it to dismiss the beta dot
|
||||
localStorage.setItem("mx_seenSpacesBeta", "1");
|
||||
// We just dispatch the page change rather than have to worry about
|
||||
// what the logic is for each of these branches.
|
||||
if (this.state.page_type === PageTypes.MyGroups) {
|
||||
|
@ -906,6 +890,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
let presentedId = roomInfo.room_alias || roomInfo.room_id;
|
||||
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
|
||||
if (room) {
|
||||
// Not all timeline events are decrypted ahead of time anymore
|
||||
// Only the critical ones for a typical UI are
|
||||
// This will start the decryption process for all events when a
|
||||
// user views a room
|
||||
room.decryptAllEvents();
|
||||
const theAlias = Rooms.getDisplayAliasForRoom(room);
|
||||
if (theAlias) {
|
||||
presentedId = theAlias;
|
||||
|
@ -1022,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private async createRoom(defaultPublic = false) {
|
||||
private async createRoom(defaultPublic = false, defaultName?: string) {
|
||||
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
if (communityId) {
|
||||
// double check the user will have permission to associate this room with the community
|
||||
|
@ -1036,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
|
||||
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
|
||||
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
|
||||
defaultPublic,
|
||||
defaultName,
|
||||
});
|
||||
|
||||
const [shouldCreate, opts] = await modal.finished;
|
||||
if (shouldCreate) {
|
||||
|
@ -1094,10 +1086,24 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private leaveRoomWarnings(roomId: string) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const isSpace = roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
// Show a warning if there are additional complications.
|
||||
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const warnings = [];
|
||||
|
||||
const memberCount = roomToLeave.currentState.getJoinedMemberCount();
|
||||
if (memberCount === 1) {
|
||||
warnings.push((
|
||||
<span className="warning" key="only_member_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ _t("You are the only person here. " +
|
||||
"If you leave, no one will be able to join in the future, including you.") }
|
||||
</span>
|
||||
));
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
|
||||
if (joinRules) {
|
||||
const rule = joinRules.getContent().join_rule;
|
||||
if (rule !== "public") {
|
||||
|
@ -1119,7 +1125,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
const isSpace = roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
|
||||
title: isSpace ? _t("Leave space") : _t("Leave room"),
|
||||
description: (
|
||||
|
@ -1611,11 +1617,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'start_registration',
|
||||
params: params,
|
||||
});
|
||||
PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER);
|
||||
} else if (screen === 'login') {
|
||||
dis.dispatch({
|
||||
action: 'start_login',
|
||||
params: params,
|
||||
});
|
||||
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
|
||||
} else if (screen === 'forgot_password') {
|
||||
dis.dispatch({
|
||||
action: 'start_password_recovery',
|
||||
|
@ -1670,6 +1678,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
});
|
||||
|
@ -1753,6 +1765,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = screen.substring(6);
|
||||
|
||||
// TODO: Check valid group ID
|
||||
|
@ -1935,6 +1952,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// Create and start the client
|
||||
await Lifecycle.setLoggedIn(credentials);
|
||||
await this.postLoginSetup();
|
||||
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
|
||||
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
|
||||
};
|
||||
|
||||
// complete security / e2e setup has finished
|
||||
|
|
|
@ -34,6 +34,7 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz
|
|||
import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import defaultDispatcher from '../../dispatcher/dispatcher';
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
@ -427,8 +428,10 @@ export default class MessagePanel extends React.Component {
|
|||
// we get a new DOM node (restarting the animation) when the ghost
|
||||
// moves to a different event.
|
||||
return (
|
||||
<li key={"_readuptoghost_"+eventId}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
<li
|
||||
key={"_readuptoghost_"+eventId}
|
||||
className="mx_RoomView_myReadMarker_container"
|
||||
>
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
|
@ -469,6 +472,10 @@ export default class MessagePanel extends React.Component {
|
|||
return {nextEvent, nextTile};
|
||||
}
|
||||
|
||||
get _roomHasPendingEdit() {
|
||||
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
|
||||
}
|
||||
|
||||
_getEventTiles() {
|
||||
this.eventNodes = {};
|
||||
|
||||
|
@ -542,11 +549,13 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
if (!grouper) {
|
||||
const wantTile = this._shouldShowEvent(mxEv);
|
||||
const isGrouped = false;
|
||||
if (wantTile) {
|
||||
// make sure we unpack the array returned by _getTilesForEvent,
|
||||
// otherwise react will auto-generate keys and we will end up
|
||||
// replacing all of the DOM elements every time we paginate.
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile));
|
||||
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
|
||||
nextEvent, nextTile));
|
||||
prevEvent = mxEv;
|
||||
}
|
||||
|
||||
|
@ -555,6 +564,13 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this.props.editState && this._roomHasPendingEdit) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "edit_event",
|
||||
event: this.props.room.findEventById(this._roomHasPendingEdit),
|
||||
});
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
ret.push(...grouper.getTiles());
|
||||
}
|
||||
|
@ -562,7 +578,7 @@ export default class MessagePanel extends React.Component {
|
|||
return ret;
|
||||
}
|
||||
|
||||
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) {
|
||||
_getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
|
||||
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
|
@ -570,7 +586,6 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
const isEditing = this.props.editState &&
|
||||
this.props.editState.getEvent().getId() === mxEv.getId();
|
||||
|
||||
// local echoes have a fake date, which could even be yesterday. Treat them
|
||||
// as 'today' for the date separators.
|
||||
let ts1 = mxEv.getTs();
|
||||
|
@ -582,7 +597,7 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
// do we need a date separator since the last event?
|
||||
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
|
||||
if (wantsDateSeparator) {
|
||||
if (wantsDateSeparator && !isGrouped) {
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
}
|
||||
|
@ -659,6 +674,7 @@ export default class MessagePanel extends React.Component {
|
|||
showReactions={this.props.showReactions}
|
||||
layout={this.props.layout}
|
||||
enableFlair={this.props.enableFlair}
|
||||
showReadReceipts={this.props.showReadReceipts}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
</li>,
|
||||
|
@ -965,9 +981,9 @@ class CreationGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
|
||||
|
||||
const panel = this.panel;
|
||||
const ret = [];
|
||||
const isGrouped = true;
|
||||
const createEvent = this.createEvent;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
|
||||
|
@ -981,12 +997,12 @@ class CreationGrouper {
|
|||
// If this m.room.create event should be shown (room upgrade) then show it before the summary
|
||||
if (panel._shouldShowEvent(createEvent)) {
|
||||
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
|
||||
ret.push(...panel._getTilesForEvent(createEvent, createEvent, false));
|
||||
ret.push(...panel._getTilesForEvent(createEvent, createEvent));
|
||||
}
|
||||
|
||||
for (const ejected of this.ejectedEvents) {
|
||||
ret.push(...panel._getTilesForEvent(
|
||||
createEvent, ejected, createEvent === lastShownEvent,
|
||||
createEvent, ejected, createEvent === lastShownEvent, isGrouped,
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -995,7 +1011,7 @@ class CreationGrouper {
|
|||
// of EventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.events[this.events.length - 1];
|
||||
|
@ -1013,13 +1029,13 @@ class CreationGrouper {
|
|||
|
||||
ret.push(
|
||||
<EventListSummary
|
||||
key="roomcreationsummary"
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
key="roomcreationsummary"
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
>
|
||||
{ eventTiles }
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
);
|
||||
|
||||
|
@ -1080,7 +1096,7 @@ class RedactionGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
|
||||
|
||||
const isGrouped = true;
|
||||
const panel = this.panel;
|
||||
const ret = [];
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
|
@ -1100,7 +1116,8 @@ class RedactionGrouper {
|
|||
let eventTiles = this.events.map((e, i) => {
|
||||
senders.add(e.sender);
|
||||
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
|
||||
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
|
||||
return panel._getTilesForEvent(
|
||||
prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
|
@ -1179,7 +1196,7 @@ class MemberGrouper {
|
|||
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
|
||||
|
||||
const isGrouped = true;
|
||||
const panel = this.panel;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
const ret = [];
|
||||
|
@ -1212,7 +1229,7 @@ class MemberGrouper {
|
|||
// of MemberEventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent);
|
||||
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
|
||||
}).reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
|
@ -1221,11 +1238,11 @@ class MemberGrouper {
|
|||
|
||||
ret.push(
|
||||
<MemberEventListSummary key={key}
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
>
|
||||
{ eventTiles }
|
||||
{ eventTiles }
|
||||
</MemberEventListSummary>,
|
||||
);
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
|||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import BetaCard from "../views/beta/BetaCard";
|
||||
|
||||
@replaceableComponent("structures.MyGroups")
|
||||
export default class MyGroups extends React.Component {
|
||||
|
@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
|
|||
</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
|
||||
<div className="mx_MyGroups_content">
|
||||
{ contentHeader }
|
||||
{ content }
|
||||
|
|
|
@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions";
|
|||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
@replaceableComponent("structures.RightPanel")
|
||||
export default class RightPanel extends React.Component {
|
||||
|
@ -85,7 +86,9 @@ export default class RightPanel extends React.Component {
|
|||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) {
|
||||
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
|
||||
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
|
||||
) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
} else if (userForPanel) {
|
||||
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,39 +15,90 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as sdk from "../../index";
|
||||
import React from "react";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
|
||||
import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import {mediaFromMxc} from "../../customisations/Media";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { IDialogProps } from "../views/dialogs/IDialogProps";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
|
||||
import NetworkDropdown from "../views/directory/NetworkDropdown";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
function track(action) {
|
||||
function track(action: string) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
}
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
publicRooms: IRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
instanceId: string | symbol;
|
||||
roomServer: string;
|
||||
filterString: string;
|
||||
selectedCommunityId?: string;
|
||||
communityName?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoom {
|
||||
room_id: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
topic?: string;
|
||||
canonical_alias?: string;
|
||||
aliases?: string[];
|
||||
world_readable: boolean;
|
||||
guest_can_join: boolean;
|
||||
num_joined_members: number;
|
||||
}
|
||||
|
||||
interface IPublicRoomsRequest {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
server?: string;
|
||||
filter?: object;
|
||||
include_all_networks?: boolean;
|
||||
third_party_instance_id?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
@replaceableComponent("structures.RoomDirectory")
|
||||
export default class RoomDirectory extends React.Component {
|
||||
static propTypes = {
|
||||
initialText: PropTypes.string,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private readonly startTime: number;
|
||||
private unmounted = false
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: NodeJS.Timeout;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component {
|
|||
CountlyAnalytics.instance.trackRoomDirectoryBegin();
|
||||
this.startTime = CountlyAnalytics.getTimestamp();
|
||||
|
||||
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? selectedCommunityId
|
||||
: null,
|
||||
communityName: null,
|
||||
};
|
||||
const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? GroupFilterOrderStore.getSelectedTags()[0]
|
||||
: null;
|
||||
|
||||
this._unmounted = false;
|
||||
this.nextBatch = null;
|
||||
this.filterTimeout = null;
|
||||
this.scrollPanel = null;
|
||||
this.protocols = null;
|
||||
|
||||
this.state.protocolsLoading = true;
|
||||
let protocolsLoading = true;
|
||||
if (!MatrixClientPeg.get()) {
|
||||
// We may not have a client yet when invoked from welcome page
|
||||
this.state.protocolsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.selectedCommunityId) {
|
||||
protocolsLoading = false;
|
||||
} else if (!selectedCommunityId) {
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
this.setState({ protocolsLoading: false });
|
||||
}, (err) => {
|
||||
console.warn(`error loading third party protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
this.setState({ protocolsLoading: false });
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
// ignore this as otherwise this error is literally the
|
||||
|
@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component {
|
|||
error: _t(
|
||||
'%(brand)s failed to get the protocol list from the homeserver. ' +
|
||||
'The homeserver may be too old to support third party networks.',
|
||||
{brand},
|
||||
{ brand },
|
||||
),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// We don't use the protocols in the communities v2 prototype experience
|
||||
this.state.protocolsLoading = false;
|
||||
protocolsLoading = false;
|
||||
|
||||
// Grab the profile info async
|
||||
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
|
||||
this.setState({communityName: profile.name});
|
||||
this.setState({ communityName: profile.name });
|
||||
});
|
||||
}
|
||||
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId,
|
||||
communityName: null,
|
||||
protocolsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component {
|
|||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this._unmounted = true;
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
refreshRoomList = () => {
|
||||
private refreshRoomList = () => {
|
||||
if (this.state.selectedCommunityId) {
|
||||
this.setState({
|
||||
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
|
||||
|
@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component {
|
|||
this.getMoreRooms();
|
||||
};
|
||||
|
||||
getMoreRooms() {
|
||||
private getMoreRooms() {
|
||||
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve();
|
||||
|
||||
|
@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component {
|
|||
loading: true,
|
||||
});
|
||||
|
||||
const my_filter_string = this.state.filterString;
|
||||
const my_server = this.state.roomServer;
|
||||
const filterString = this.state.filterString;
|
||||
const roomServer = this.state.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const my_next_batch = this.nextBatch;
|
||||
const opts = {limit: 20};
|
||||
if (my_server != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = my_server;
|
||||
const nextBatch = this.nextBatch;
|
||||
const opts: IPublicRoomsRequest = { limit: 20 };
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId;
|
||||
opts.third_party_instance_id = this.state.instanceId as string;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
|
||||
if (filterString) opts.filter = { generic_search_term: filterString };
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch) {
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
// since we must still have a request in flight)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
if (this.unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => {
|
||||
s.publicRooms.push(...(data.chunk || []));
|
||||
s.loading = false;
|
||||
return s;
|
||||
});
|
||||
this.setState((s) => ({
|
||||
...s,
|
||||
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
|
||||
loading: false,
|
||||
}));
|
||||
return Boolean(data.next_batch);
|
||||
}, (err) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch) {
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// as above: we don't care about errors for old
|
||||
// requests either
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
if (this.unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component {
|
|||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
removeFromDirectory(room) {
|
||||
const alias = get_display_alias_for_room(room);
|
||||
private removeFromDirectory(room: IRoom) {
|
||||
const alias = getDisplayAliasForRoom(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
let desc;
|
||||
if (alias) {
|
||||
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
|
||||
|
@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component {
|
|||
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
|
||||
title: _t('Remove from Directory'),
|
||||
description: desc,
|
||||
onFinished: (should_delete) => {
|
||||
if (!should_delete) return;
|
||||
onFinished: (shouldDelete: boolean) => {
|
||||
if (!shouldDelete) return;
|
||||
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const modal = Modal.createDialog(Loader);
|
||||
const modal = Modal.createDialog(Spinner);
|
||||
let step = _t('remove %(name)s from the directory.', {name: name});
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
|
@ -289,14 +327,16 @@ export default class RoomDirectory extends React.Component {
|
|||
console.error("Failed to " + step + ": " + err);
|
||||
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
|
||||
description: (err && err.message)
|
||||
? err.message
|
||||
: _t('The server may be unavailable or overloaded'),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onRoomClicked = (room, ev) => {
|
||||
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
this.removeFromDirectory(room);
|
||||
|
@ -305,7 +345,7 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onOptionChange = (server, instanceId) => {
|
||||
private onOptionChange = (server: string, instanceId?: string | symbol) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
|
@ -325,13 +365,13 @@ export default class RoomDirectory extends React.Component {
|
|||
// Easiest to just blow away the state & re-fetch.
|
||||
};
|
||||
|
||||
onFillRequest = (backwards) => {
|
||||
private onFillRequest = (backwards: boolean) => {
|
||||
if (backwards || !this.nextBatch) return Promise.resolve(false);
|
||||
|
||||
return this.getMoreRooms();
|
||||
};
|
||||
|
||||
onFilterChange = (alias) => {
|
||||
private onFilterChange = (alias: string) => {
|
||||
this.setState({
|
||||
filterString: alias || null,
|
||||
});
|
||||
|
@ -349,7 +389,7 @@ export default class RoomDirectory extends React.Component {
|
|||
}, 700);
|
||||
};
|
||||
|
||||
onFilterClear = () => {
|
||||
private onFilterClear = () => {
|
||||
// update immediately
|
||||
this.setState({
|
||||
filterString: null,
|
||||
|
@ -360,7 +400,7 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onJoinFromSearchClick = (alias) => {
|
||||
private onJoinFromSearchClick = (alias: string) => {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
|
@ -373,9 +413,10 @@ export default class RoomDirectory extends React.Component {
|
|||
// This is a 3rd party protocol. Let's see if we can join it
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
|
||||
const fields = protocolName
|
||||
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
|
||||
: null;
|
||||
if (!fields) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const brand = SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
|
||||
title: _t('Unable to join network'),
|
||||
|
@ -387,14 +428,12 @@ export default class RoomDirectory extends React.Component {
|
|||
if (resp.length > 0 && resp[0].alias) {
|
||||
this.showRoomAlias(resp[0].alias, true);
|
||||
} else {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
title: _t('Room not found'),
|
||||
description: _t('Couldn\'t find a matching Matrix room'),
|
||||
});
|
||||
}
|
||||
}, (e) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
|
||||
title: _t('Fetching third party location failed'),
|
||||
description: _t('Unable to look up room ID from server'),
|
||||
|
@ -403,36 +442,37 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onPreviewClick = (ev, room) => {
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
onViewClick = (ev, room) => {
|
||||
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
onJoinClick = (ev, room) => {
|
||||
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
onCreateRoomClick = room => {
|
||||
private onCreateRoomClick = () => {
|
||||
this.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
defaultName: this.state.filterString.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
showRoomAlias(alias, autoJoin=false) {
|
||||
private showRoomAlias(alias: string, autoJoin = false) {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
}
|
||||
|
||||
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
|
||||
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
this.onFinished();
|
||||
const payload = {
|
||||
const payload: ActionPayload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
should_peek: shouldPeek,
|
||||
|
@ -449,15 +489,15 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (!room_alias) {
|
||||
room_alias = get_display_alias_for_room(room);
|
||||
if (!roomAlias) {
|
||||
roomAlias = getDisplayAliasForRoom(room);
|
||||
}
|
||||
|
||||
payload.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 || room_alias || _t('Unnamed room'),
|
||||
name: room.name || roomAlias || _t('Unnamed room'),
|
||||
};
|
||||
|
||||
if (this.state.roomServer) {
|
||||
|
@ -471,21 +511,19 @@ export default class RoomDirectory extends React.Component {
|
|||
// which servers to start querying. However, there's no other way to join rooms in
|
||||
// this list without aliases at present, so if roomAlias isn't set here we have no
|
||||
// choice but to supply the ID.
|
||||
if (room_alias) {
|
||||
payload.room_alias = room_alias;
|
||||
if (roomAlias) {
|
||||
payload.room_alias = roomAlias;
|
||||
} else {
|
||||
payload.room_id = room.room_id;
|
||||
}
|
||||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
createRoomCells(room) {
|
||||
private createRoomCells(room: IRoom) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
const isGuest = client.isGuest();
|
||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
let previewButton;
|
||||
let joinOrViewButton;
|
||||
|
||||
|
@ -495,20 +533,26 @@ export default class RoomDirectory extends React.Component {
|
|||
// it is readable, the preview appears as normal.
|
||||
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
|
||||
previewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
|
||||
{ _t("Preview") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
if (hasJoinedRoom) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (!isGuest) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
|
||||
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
@ -531,9 +575,13 @@ export default class RoomDirectory extends React.Component {
|
|||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_roomAvatar"
|
||||
>
|
||||
<BaseAvatar width={32} height={32} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ avatarUrl }
|
||||
<BaseAvatar
|
||||
width={32}
|
||||
height={32}
|
||||
resizeMethod='crop'
|
||||
name={name}
|
||||
idName={name}
|
||||
url={avatarUrl}
|
||||
/>
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_description` }
|
||||
|
@ -547,7 +595,7 @@ export default class RoomDirectory extends React.Component {
|
|||
onClick={ (ev) => { ev.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }}
|
||||
/>
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
|
||||
<div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div>
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_memberCount` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
|
@ -576,20 +624,16 @@ export default class RoomDirectory extends React.Component {
|
|||
];
|
||||
}
|
||||
|
||||
collectScrollPanel = (element) => {
|
||||
this.scrollPanel = element;
|
||||
};
|
||||
|
||||
_stringLooksLikeId(s, field_type) {
|
||||
private stringLooksLikeId(s: string, fieldType: IFieldType) {
|
||||
let pat = /^#[^\s]+:[^\s]/;
|
||||
if (field_type && field_type.regexp) {
|
||||
pat = new RegExp(field_type.regexp);
|
||||
if (fieldType && fieldType.regexp) {
|
||||
pat = new RegExp(fieldType.regexp);
|
||||
}
|
||||
|
||||
return pat.test(s);
|
||||
}
|
||||
|
||||
_getFieldsForThirdPartyLocation(userInput, protocol, instance) {
|
||||
private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
|
||||
// make an object with the fields specified by that protocol. We
|
||||
// require that the values of all but the last field come from the
|
||||
// instance. The last is the user input.
|
||||
|
@ -605,71 +649,73 @@ export default class RoomDirectory extends React.Component {
|
|||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
handleScrollKey = ev => {
|
||||
if (this.scrollPanel) {
|
||||
this.scrollPanel.handleScrollKey(ev);
|
||||
}
|
||||
};
|
||||
|
||||
onFinished = () => {
|
||||
private onFinished = () => {
|
||||
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
|
||||
this.props.onFinished();
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let content;
|
||||
if (this.state.error) {
|
||||
content = this.state.error;
|
||||
} else if (this.state.protocolsLoading) {
|
||||
content = <Loader />;
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
const cells = (this.state.publicRooms || [])
|
||||
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
|
||||
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
|
||||
let spinner;
|
||||
if (this.state.loading) {
|
||||
spinner = <Loader />;
|
||||
spinner = <Spinner />;
|
||||
}
|
||||
|
||||
let scrollpanel_content;
|
||||
const createNewButton = <>
|
||||
<hr />
|
||||
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
|
||||
{ _t("Create new room") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
|
||||
let scrollPanelContent;
|
||||
let footer;
|
||||
if (cells.length === 0 && !this.state.loading) {
|
||||
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
|
||||
footer = <>
|
||||
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
|
||||
<p>
|
||||
{ _t("Try different words or check for typos. " +
|
||||
"Some results may not be visible as they're private and you need an invite to join them.") }
|
||||
</p>
|
||||
{ createNewButton }
|
||||
</>;
|
||||
} else {
|
||||
scrollpanel_content = <div className="mx_RoomDirectory_table">
|
||||
scrollPanelContent = <div className="mx_RoomDirectory_table">
|
||||
{ cells }
|
||||
</div>;
|
||||
if (!this.state.loading && !this.nextBatch) {
|
||||
footer = createNewButton;
|
||||
}
|
||||
}
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
content = <ScrollPanel ref={this.collectScrollPanel}
|
||||
content = <ScrollPanel
|
||||
className="mx_RoomDirectory_tableWrapper"
|
||||
onFillRequest={ this.onFillRequest }
|
||||
onFillRequest={this.onFillRequest}
|
||||
stickyBottom={false}
|
||||
startAtBottom={false}
|
||||
>
|
||||
{ scrollpanel_content }
|
||||
{ scrollPanelContent }
|
||||
{ spinner }
|
||||
{ footer && <div className="mx_RoomDirectory_footer">
|
||||
{ footer }
|
||||
</div> }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
|
||||
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
|
||||
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instance_expected_field_type;
|
||||
let instanceExpectedFieldType;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
|
@ -677,21 +723,27 @@ export default class RoomDirectory extends React.Component {
|
|||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
|
||||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
|
||||
exampleRoom: "#example:" + this.state.roomServer,
|
||||
});
|
||||
} else if (instanceExpectedFieldType) {
|
||||
placeholder = instanceExpectedFieldType.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
|
||||
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
|
||||
if (this.getFieldsForThirdPartyLocation(
|
||||
this.state.filterString,
|
||||
this.protocols[protocolName],
|
||||
instance,
|
||||
) === null) {
|
||||
showJoinButton = false;
|
||||
}
|
||||
}
|
||||
|
@ -723,12 +775,11 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
|
||||
{a: sub => {
|
||||
return (<AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={this.onCreateRoomClick}
|
||||
>{sub}</AccessibleButton>);
|
||||
}},
|
||||
{a: sub => (
|
||||
<AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
|
||||
{ sub }
|
||||
</AccessibleButton>
|
||||
)},
|
||||
);
|
||||
|
||||
const title = this.state.selectedCommunityId
|
||||
|
@ -756,6 +807,6 @@ export default class RoomDirectory extends React.Component {
|
|||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function get_display_alias_for_room(room) {
|
||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
||||
function getDisplayAliasForRoom(room: IRoom) {
|
||||
return room.canonical_alias || room.aliases?.[0] || "";
|
||||
}
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
|
@ -25,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
|
|||
import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -40,6 +42,7 @@ interface IProps {
|
|||
interface IState {
|
||||
query: string;
|
||||
focused: boolean;
|
||||
inSpaces: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomSearch")
|
||||
|
@ -54,11 +57,13 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
this.state = {
|
||||
query: "",
|
||||
focused: false,
|
||||
inSpaces: false,
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
|
||||
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
|
||||
|
@ -79,8 +84,15 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
public componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
|
||||
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
|
||||
}
|
||||
|
||||
private onSpaces = (spaces: Room[]) => {
|
||||
this.setState({
|
||||
inSpaces: spaces.length > 0,
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'view_room' && payload.clear_search) {
|
||||
this.clearInput();
|
||||
|
@ -152,6 +164,11 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
|
||||
});
|
||||
|
||||
let placeholder = _t("Filter");
|
||||
if (this.state.inSpaces) {
|
||||
placeholder = _t("Filter all spaces");
|
||||
}
|
||||
|
||||
let icon = (
|
||||
<div className='mx_RoomSearch_icon' />
|
||||
);
|
||||
|
@ -165,7 +182,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Filter")}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015-2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -20,16 +20,20 @@ import { _t, _td } from '../../languageHandler';
|
|||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {messageForResourceLimitError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import {EventStatus} from "matrix-js-sdk/src/models/event";
|
||||
import NotificationBadge from "../views/rooms/NotificationBadge";
|
||||
import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
const STATUS_BAR_EXPANDED_LARGE = 2;
|
||||
|
||||
function getUnsentMessages(room) {
|
||||
export function getUnsentMessages(room) {
|
||||
if (!room) { return []; }
|
||||
return room.getPendingEvents().filter(function(ev) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
|
@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component {
|
|||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -109,7 +114,10 @@ export default class RoomStatusBar extends React.Component {
|
|||
};
|
||||
|
||||
_onResendAllClick = () => {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
Resend.resendUnsentEvents(this.props.room).then(() => {
|
||||
this.setState({isResending: false});
|
||||
});
|
||||
this.setState({isResending: true});
|
||||
dis.fire(Action.FocusComposer);
|
||||
};
|
||||
|
||||
|
@ -120,9 +128,10 @@ export default class RoomStatusBar extends React.Component {
|
|||
|
||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
|
||||
const messages = getUnsentMessages(this.props.room);
|
||||
this.setState({
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
unsentMessages: messages,
|
||||
isResending: messages.length > 0 && this.state.isResending,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -141,7 +150,7 @@ export default class RoomStatusBar extends React.Component {
|
|||
_getSize() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0) {
|
||||
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
}
|
||||
return STATUS_BAR_HIDDEN;
|
||||
|
@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
|
||||
_getUnsentMessageContent() {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
if (!unsentMessages.length) return null;
|
||||
|
||||
let title;
|
||||
|
||||
|
@ -192,89 +200,92 @@ export default class RoomStatusBar extends React.Component {
|
|||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'hs_disabled': _td(
|
||||
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'': _td(
|
||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
});
|
||||
} else if (
|
||||
unsentMessages.length === 1 &&
|
||||
unsentMessages[0].error &&
|
||||
unsentMessages[0].error.data &&
|
||||
unsentMessages[0].error.data.error
|
||||
) {
|
||||
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
|
||||
resourceLimitError.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td(
|
||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'hs_disabled': _td(
|
||||
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
'': _td(
|
||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
|
||||
"Please <a>contact your service administrator</a> to continue using the service.",
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
|
||||
title = _t('Some of your messages have not been sent');
|
||||
}
|
||||
|
||||
const content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " +
|
||||
"now. You can also select individual messages to resend or cancel.",
|
||||
{ count: unsentMessages.length },
|
||||
{
|
||||
'resendText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
|
||||
'cancelText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
|
||||
},
|
||||
);
|
||||
let buttonRow = <>
|
||||
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("Delete all")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
|
||||
{_t("Retry all")}
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
if (this.state.isResending) {
|
||||
buttonRow = <>
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{/* span for css */}
|
||||
<span>{_t("Sending")}</span>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ content }
|
||||
return <>
|
||||
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_unsentBadge">
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.RED_EXCLAMATION}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_unsentTitle">
|
||||
{ title }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentDescription">
|
||||
{ _t("You can select all or individual messages to retry or delete") }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentButtonBar">
|
||||
{buttonRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
</>;
|
||||
}
|
||||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent() {
|
||||
render() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{ _t('Connectivity to the server has been lost.') }
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{ _t('Sent messages will be stored until your connection has returned.') }
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
|
||||
height="24" title="/!\ " alt="/!\ " />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{_t('Connectivity to the server has been lost.')}
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{_t('Sent messages will be stored until your connection has returned.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.unsentMessages.length > 0) {
|
||||
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return this._getUnsentMessageContent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this._getContent();
|
||||
|
||||
return (
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -190,6 +190,9 @@ export interface IState {
|
|||
rejectError?: Error;
|
||||
hasPinnedWidgets?: boolean;
|
||||
dragCounter: number;
|
||||
// whether or not a spaces context switch brought us here,
|
||||
// if it did we don't want the room to be marked as read as soon as it is loaded.
|
||||
wasContextSwitch?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomView")
|
||||
|
@ -326,6 +329,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
|
||||
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
|
||||
wasContextSwitch: RoomViewStore.getWasContextSwitch(),
|
||||
};
|
||||
|
||||
if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
|
||||
|
@ -807,7 +811,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onEvent = (ev) => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
|
@ -1594,33 +1598,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
|
||||
};
|
||||
|
||||
private onFullscreenClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'video_fullscreen',
|
||||
fullscreen: true,
|
||||
}, true);
|
||||
};
|
||||
|
||||
private onMuteAudioClick = () => {
|
||||
const call = this.getCallForRoom();
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const newState = !call.isMicrophoneMuted();
|
||||
call.setMicrophoneMuted(newState);
|
||||
this.forceUpdate(); // TODO: just update the voip buttons
|
||||
};
|
||||
|
||||
private onMuteVideoClick = () => {
|
||||
const call = this.getCallForRoom();
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const newState = !call.isLocalVideoMuted();
|
||||
call.setLocalVideoMuted(newState);
|
||||
this.forceUpdate(); // TODO: just update the voip buttons
|
||||
};
|
||||
|
||||
private onStatusBarVisible = () => {
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
|
@ -1636,24 +1613,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
private handleScrollKey = ev => {
|
||||
let panel;
|
||||
if (this.searchResultsPanel.current) {
|
||||
panel = this.searchResultsPanel.current;
|
||||
} else if (this.messagePanel) {
|
||||
panel = this.messagePanel;
|
||||
}
|
||||
|
||||
if (panel) {
|
||||
panel.handleScrollKey(ev);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get any current call for this room
|
||||
*/
|
||||
|
@ -1746,7 +1705,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const myMembership = this.state.room.getMyMembership();
|
||||
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself
|
||||
if (myMembership === "invite"
|
||||
// SpaceRoomView handles invites itself
|
||||
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
|
||||
) {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
@ -1888,7 +1850,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
|
||||
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
|
@ -1910,7 +1872,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) {
|
||||
if (this.state.room?.isSpaceRoom()) {
|
||||
return <SpaceRoomView
|
||||
space={this.state.room}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
|
@ -2014,6 +1976,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
|
||||
manageReadMarkers={!this.state.isPeeking}
|
||||
hidden={hideMessagePanel}
|
||||
highlightedEventId={highlightedEventId}
|
||||
|
|
|
@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component {
|
|||
*/
|
||||
scrollRelative = mult => {
|
||||
const scrollNode = this._getScrollNode();
|
||||
const delta = mult * scrollNode.clientHeight * 0.5;
|
||||
const delta = mult * scrollNode.clientHeight * 0.9;
|
||||
scrollNode.scrollBy(0, delta);
|
||||
this._saveScrollState();
|
||||
};
|
||||
|
@ -884,16 +884,20 @@ export default class ScrollPanel extends React.Component {
|
|||
|
||||
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
|
||||
// list-style-type: none; is no longer a list
|
||||
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
|
||||
return (
|
||||
<AutoHideScrollbar
|
||||
wrappedRef={this._collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
|
||||
{ this.props.fixedChildren }
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
className={`mx_ScrollPanel ${this.props.className}`}
|
||||
style={this.props.style}
|
||||
>
|
||||
{ this.props.fixedChildren }
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useMemo, useState} from "react";
|
||||
import React, {ReactNode, useMemo, useState} from "react";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
|
||||
|
@ -24,7 +24,7 @@ import {sortBy} from "lodash";
|
|||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {_t} from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
|
@ -39,11 +39,15 @@ import {mediaFromMxc} from "../../customisations/Media";
|
|||
import InfoTooltip from "../views/elements/InfoTooltip";
|
||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||
import {useStateToggle} from "../../hooks/useStateToggle";
|
||||
import {getOrder} from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import {linkifyElement} from "../../HtmlUtils";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
|
@ -72,7 +76,7 @@ export interface ISpaceSummaryEvent {
|
|||
order?: string;
|
||||
suggested?: boolean;
|
||||
auto_join?: boolean;
|
||||
via?: string;
|
||||
via?: string[];
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
@ -106,8 +110,16 @@ const Tile: React.FC<ITileProps> = ({
|
|||
const cliRoom = cli.getRoom(room.room_id);
|
||||
const myMembership = cliRoom?.getMyMembership();
|
||||
|
||||
const onPreviewClick = () => onViewRoomClick(false);
|
||||
const onJoinClick = () => onViewRoomClick(true);
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(false);
|
||||
}
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true);
|
||||
}
|
||||
|
||||
let button;
|
||||
if (myMembership === "join") {
|
||||
|
@ -136,11 +148,11 @@ const Tile: React.FC<ITileProps> = ({
|
|||
|
||||
let url: string;
|
||||
if (room.avatar_url) {
|
||||
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio));
|
||||
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
|
||||
}
|
||||
|
||||
let description = _t("%(count)s members", { count: room.num_joined_members });
|
||||
if (numChildRooms) {
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
|
||||
}
|
||||
if (room.topic) {
|
||||
|
@ -161,7 +173,16 @@ const Tile: React.FC<ITileProps> = ({
|
|||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_info">
|
||||
<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">
|
||||
|
@ -254,7 +275,11 @@ export const HierarchyLevel = ({
|
|||
const space = cli.getRoom(spaceId);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null);
|
||||
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 getOrder(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;
|
||||
|
@ -312,11 +337,12 @@ export const HierarchyLevel = ({
|
|||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>,
|
||||
Map<string, Set<string>>,
|
||||
Map<string, Set<string>>,
|
||||
] | [] => {
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
|
@ -330,19 +356,18 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
|
|||
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"])) {
|
||||
if (Array.isArray(ev.content.via)) {
|
||||
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
||||
ev.content["via"].forEach(via => set.add(via));
|
||||
ev.content.via.forEach(via => set.add(via));
|
||||
}
|
||||
});
|
||||
|
||||
return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
||||
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
return [e];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [space, refreshToken], []);
|
||||
}, [space, refreshToken], [undefined]);
|
||||
};
|
||||
|
||||
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
||||
|
@ -350,6 +375,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
@ -358,7 +384,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
|
@ -397,6 +423,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
const [removing, setRemoving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
|
||||
|
@ -411,78 +441,87 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
}
|
||||
|
||||
let editSection;
|
||||
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][];
|
||||
});
|
||||
|
||||
let buttons;
|
||||
if (selectedRelations.length) {
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = removing || saving;
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
buttons = <>
|
||||
<AccessibleButton
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).get(childId).content = {};
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
editSection = <span>
|
||||
{ buttons }
|
||||
</span>;
|
||||
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);
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
|
@ -528,7 +567,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
{ editSection }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
|
@ -538,17 +580,15 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else if (!rooms) {
|
||||
content = <Spinner />;
|
||||
} else {
|
||||
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Search names and description") }
|
||||
placeholder={ _t("Search names and descriptions") }
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
|
|
|
@ -51,6 +51,20 @@ import MemberAvatar from "../views/avatars/MemberAvatar";
|
|||
import {useStateToggle} from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import {BetaPill} from "../views/beta/BetaCard";
|
||||
import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -62,6 +76,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
createdRooms?: boolean; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
|
@ -76,6 +91,26 @@ enum Phase {
|
|||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
@ -127,15 +162,39 @@ const SpaceInfo = ({ space }) => {
|
|||
</div>
|
||||
};
|
||||
|
||||
const onBetaClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
|
||||
let inviterSection;
|
||||
let joinButtons;
|
||||
if (myMembership === "invite") {
|
||||
if (myMembership === "join") {
|
||||
// XXX remove this when spaces leaves Beta
|
||||
joinButtons = (
|
||||
<AccessibleButton
|
||||
kind="danger_outline"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("Leave") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (myMembership === "invite") {
|
||||
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
|
||||
const inviter = inviteSender && space.getMember(inviteSender);
|
||||
|
||||
|
@ -171,6 +230,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
|
@ -183,10 +243,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
setBusy(true);
|
||||
onJoinButtonClicked();
|
||||
}}
|
||||
disabled={!spacesEnabled}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
|
@ -194,6 +255,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
|
@ -211,9 +273,84 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
<div className="mx_SpaceRoomView_preview_joinButtons">
|
||||
{ joinButtons }
|
||||
</div>
|
||||
{ !spacesEnabled && <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ myMembership === "join"
|
||||
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
|
||||
spaceName: space.name,
|
||||
}, {
|
||||
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
}
|
||||
</div> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current.getBoundingClientRect();
|
||||
contextMenu = <IconizedContextMenu
|
||||
left={rect.left + window.pageXOffset + 0}
|
||||
top={rect.bottom + window.pageYOffset + 8}
|
||||
chevronFace={ChevronFace.None}
|
||||
onFinished={closeMenu}
|
||||
className="mx_RoomTile_contextMenu"
|
||||
compact
|
||||
>
|
||||
<IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Create new room")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(cli, space)) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<ContextMenuButton
|
||||
kind="primary"
|
||||
inputRef={handle}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t("Add")}
|
||||
>
|
||||
{ _t("Add") }
|
||||
</ContextMenuButton>
|
||||
{ contextMenu }
|
||||
</>;
|
||||
};
|
||||
|
||||
const SpaceLanding = ({ space }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
@ -238,32 +375,20 @@ const SpaceLanding = ({ space }) => {
|
|||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButtons;
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButtons = <React.Fragment>
|
||||
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
forceUpdate();
|
||||
}
|
||||
}}>
|
||||
{ _t("Add existing rooms & spaces") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
|
||||
showCreateNewRoom(cli, space);
|
||||
}}>
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
}}>
|
||||
{ _t("Settings") }
|
||||
</AccessibleButton>;
|
||||
settingsButton = <AccessibleTooltipButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
}}
|
||||
title={_t("Settings")}
|
||||
/>;
|
||||
}
|
||||
|
||||
const onMembersClick = () => {
|
||||
|
@ -290,17 +415,20 @@ const SpaceLanding = ({ space }) => {
|
|||
<SpaceInfo space={space} />
|
||||
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
|
||||
{ inviteButton }
|
||||
{ settingsButton }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_landing_topic">
|
||||
<RoomTopic room={space} />
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
<div className="mx_SpaceRoomView_landing_adminButtons">
|
||||
{ addRoomButtons }
|
||||
{ settingsButton }
|
||||
</div>
|
||||
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -322,14 +450,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
value={roomNames[i]}
|
||||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => {
|
||||
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,
|
||||
|
@ -342,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
parentSpace: space,
|
||||
});
|
||||
}));
|
||||
onFinished();
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
|
@ -350,11 +482,14 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished(false);
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue")
|
||||
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
|
||||
}
|
||||
|
||||
return <div>
|
||||
|
@ -362,23 +497,55 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
|||
<div className="mx_SpaceRoomView_description">{ description }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ buttonLabel }
|
||||
</AccessibleButton>
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupFirstRooms"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
||||
const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
||||
return <div>
|
||||
<h1>{ _t("What do you want to organise?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
|
||||
"no one will be informed. You can add more later.") }
|
||||
</div>
|
||||
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
emptySelectionButton={
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
onFinished={onFinished}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
|
||||
return <div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
|
||||
<h1>{ _t("Share %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("It's just you at the moment, it will be even better with others.") }
|
||||
</div>
|
||||
|
@ -387,17 +554,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
|||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ _t("Go to my first room") }
|
||||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
|
||||
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
||||
return <div className="mx_SpaceRoomView_privateScope">
|
||||
<h1>{ _t("Who are you working with?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
|
||||
{ _t("Make sure the right people have access to %(name)s", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
|
@ -414,6 +584,7 @@ const SpaceSetupPrivateScope = ({ space, onFinished }) => {
|
|||
<h3>{ _t("Me and my teammates") }</h3>
|
||||
<div>{ _t("A private space for you and your teammates") }</div>
|
||||
</AccessibleButton>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -444,10 +615,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
ref={fieldRefs[i]}
|
||||
onValidate={validateEmailRules}
|
||||
autoFocus={i === 0}
|
||||
disabled={busy}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
const onNextClick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
for (let i = 0; i < fieldRefs.length; i++) {
|
||||
const fieldRef = fieldRefs[i];
|
||||
|
@ -481,7 +655,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (emailAddresses.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
|
@ -494,8 +671,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
{ _t("Make sure the right people have access. You can invite more later.") }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ _t("<b>This is an experimental feature.</b> For now, " +
|
||||
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
|
||||
b: sub => <b>{ sub }</b>,
|
||||
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
|
||||
app.element.io
|
||||
</a>,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
|
||||
{ fields }
|
||||
</form>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
|
||||
<AccessibleButton
|
||||
|
@ -507,10 +697,17 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
|||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
|
||||
{ buttonLabel }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupPrivateInvite"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -609,7 +806,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
let suggestedRooms = SpaceStore.instance.suggestedRooms;
|
||||
if (SpaceStore.instance.activeSpace !== this.props.space) {
|
||||
// the space store has the suggested rooms loaded for a different space, fetch the right ones
|
||||
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms;
|
||||
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
|
||||
}
|
||||
|
||||
if (suggestedRooms.length) {
|
||||
|
@ -617,9 +814,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.room_id,
|
||||
room_alias: room.canonical_alias || room.aliases?.[0],
|
||||
via_servers: room.viaServers,
|
||||
oobData: {
|
||||
avatarUrl: room.avatar_url,
|
||||
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
|
||||
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
@ -631,7 +830,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join") {
|
||||
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
|
@ -644,22 +843,28 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What are some things you want to discuss in %(spaceName)s?", {
|
||||
spaceName: this.props.space.name,
|
||||
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
|
||||
})}
|
||||
description={
|
||||
_t("Let's create a room for each of them.") + "\n" +
|
||||
_t("You can add more later too, including already existing ones.")
|
||||
}
|
||||
onFinished={() => this.setState({ phase: Phase.PublicShare })}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
|
||||
return <SpaceSetupPublicShare
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
space={this.props.space}
|
||||
onFinished={this.goToFirstRoom}
|
||||
createdRooms={this.state.createdRooms}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
return <SpaceSetupPrivateScope
|
||||
space={this.props.space}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
onFinished={(invite: boolean) => {
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
|
||||
}}
|
||||
/>;
|
||||
case Phase.PrivateInvite:
|
||||
|
@ -673,6 +878,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. " +
|
||||
"You can add more later too, including already existing ones.")}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
|
||||
/>;
|
||||
case Phase.PrivateExistingRooms:
|
||||
return <SpaceAddExistingRooms
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
/>;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
|
|||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import {objectHasDiff} from "../../utils/objects";
|
||||
import {replaceableComponent} from "../../utils/replaceableComponent";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
|
@ -68,6 +69,7 @@ class TimelinePanel extends React.Component {
|
|||
showReadReceipts: PropTypes.bool,
|
||||
// Enable managing RRs and RMs. These require the timelineSet to have a room.
|
||||
manageReadReceipts: PropTypes.bool,
|
||||
sendReadReceiptOnLoad: PropTypes.bool,
|
||||
manageReadMarkers: PropTypes.bool,
|
||||
|
||||
// true to give the component a 'display: none' style.
|
||||
|
@ -126,6 +128,7 @@ class TimelinePanel extends React.Component {
|
|||
// event tile heights. (See _unpaginateEvents)
|
||||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
sendReadReceiptOnLoad: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -806,8 +809,10 @@ class TimelinePanel extends React.Component {
|
|||
return;
|
||||
}
|
||||
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
|
||||
this._setReadMarker(lastDisplayedEvent.getId(),
|
||||
lastDisplayedEvent.getTs());
|
||||
this._setReadMarker(
|
||||
lastDisplayedEvent.getId(),
|
||||
lastDisplayedEvent.getTs(),
|
||||
);
|
||||
|
||||
// the read-marker should become invisible, so that if the user scrolls
|
||||
// down, they don't see it.
|
||||
|
@ -893,7 +898,7 @@ class TimelinePanel extends React.Component {
|
|||
// The messagepanel knows where the RM is, so we must have loaded
|
||||
// the relevant event.
|
||||
this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
|
||||
0, 1/3);
|
||||
0, 1/3);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1065,12 +1070,14 @@ class TimelinePanel extends React.Component {
|
|||
}
|
||||
if (eventId) {
|
||||
this._messagePanel.current.scrollToEvent(eventId, pixelOffset,
|
||||
offsetBase);
|
||||
offsetBase);
|
||||
} else {
|
||||
this._messagePanel.current.scrollToBottom();
|
||||
}
|
||||
|
||||
this.sendReadReceipt();
|
||||
if (this.props.sendReadReceiptOnLoad) {
|
||||
this.sendReadReceipt();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -1156,6 +1163,17 @@ class TimelinePanel extends React.Component {
|
|||
// get the list of events from the timeline window and the pending event list
|
||||
_getEvents() {
|
||||
const events = this._timelineWindow.getEvents();
|
||||
|
||||
// `arrayFastClone` performs a shallow copy of the array
|
||||
// we want the last event to be decrypted first but displayed last
|
||||
// `reverse` is destructive and unfortunately mutates the "events" array
|
||||
arrayFastClone(events)
|
||||
.reverse()
|
||||
.forEach(event => {
|
||||
const client = MatrixClientPeg.get();
|
||||
client.decryptEventIfNeeded(event);
|
||||
});
|
||||
|
||||
const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
|
||||
|
||||
// Hold onto the live events separately. The read receipt and read marker
|
||||
|
@ -1439,8 +1457,8 @@ class TimelinePanel extends React.Component {
|
|||
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
|
||||
);
|
||||
const events = this.state.firstVisibleEventIndex
|
||||
? this.state.events.slice(this.state.firstVisibleEventIndex)
|
||||
: this.state.events;
|
||||
? this.state.events.slice(this.state.firstVisibleEventIndex)
|
||||
: this.state.events;
|
||||
return (
|
||||
<MessagePanel
|
||||
ref={this._messagePanel}
|
||||
|
|
|
@ -74,6 +74,7 @@ interface IState {
|
|||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private dndWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
private tagStoreRef: fbEmitter.EventSubscription;
|
||||
|
||||
|
@ -89,6 +90,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
// Force update is the easiest way to trigger the UI update (we don't store state for this)
|
||||
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
|
||||
}
|
||||
|
||||
private get hasHomePage(): boolean {
|
||||
|
@ -103,6 +107,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
public componentWillUnmount() {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
|
@ -288,6 +293,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onDndToggle = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const current = SettingsStore.getValue("doNotDisturb");
|
||||
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
|
||||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
|
@ -534,6 +545,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{/* masked image in CSS */}
|
||||
</span>
|
||||
);
|
||||
let dnd;
|
||||
if (this.state.selectedSpace) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
|
@ -560,6 +572,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
);
|
||||
isPrototype = true;
|
||||
} else if (SettingsStore.getValue("feature_dnd")) {
|
||||
const isDnd = SettingsStore.getValue("doNotDisturb");
|
||||
dnd = <AccessibleButton
|
||||
onClick={this.onDndToggle}
|
||||
className={classNames({
|
||||
"mx_UserMenu_dnd": true,
|
||||
"mx_UserMenu_dnd_noisy": !isDnd,
|
||||
"mx_UserMenu_dnd_muted": isDnd,
|
||||
})}
|
||||
/>;
|
||||
}
|
||||
if (this.props.isMinimized) {
|
||||
name = null;
|
||||
|
@ -595,6 +617,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
/>
|
||||
</span>
|
||||
{name}
|
||||
{dnd}
|
||||
{buttons}
|
||||
</div>
|
||||
</ContextMenuButton>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -94,7 +94,7 @@ interface IState {
|
|||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
serverDeadError?: ReactNode;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -95,7 +95,7 @@ interface IState {
|
|||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
serverDeadError?: ReactNode;
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
|
@ -436,6 +436,8 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
|
||||
return sessionLoaded;
|
||||
};
|
||||
|
||||
private renderRegisterComponent() {
|
||||
|
@ -557,7 +559,12 @@ export default class Registration extends React.Component<IProps, IState> {
|
|||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({action: "view_welcome_page"});
|
||||
}
|
||||
}}>
|
||||
{_t("Continue with previous account")}
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,14 +15,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
|
@ -42,26 +41,38 @@ const FLOWS_TO_VIEWS = {
|
|||
"m.login.sso": LOGIN_VIEW.SSO,
|
||||
};
|
||||
|
||||
@replaceableComponent("structures.auth.SoftLogout")
|
||||
export default class SoftLogout extends React.Component {
|
||||
static propTypes = {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: PropTypes.object, // {loginToken}
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: PropTypes.func,
|
||||
interface IProps {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: {
|
||||
loginToken?: string;
|
||||
};
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: () => void,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loginView: number;
|
||||
keyBackupNeeded: boolean;
|
||||
busy: boolean;
|
||||
password: string;
|
||||
errorText: string;
|
||||
flows: LoginFlow[];
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.SoftLogout")
|
||||
export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loginView: LOGIN_VIEW.LOADING,
|
||||
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
|
||||
|
||||
busy: false,
|
||||
password: "",
|
||||
errorText: "",
|
||||
flows: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -72,7 +83,7 @@ export default class SoftLogout extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
this._initLogin();
|
||||
this.initLogin();
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isCryptoEnabled()) {
|
||||
|
@ -94,7 +105,7 @@ export default class SoftLogout extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
async _initLogin() {
|
||||
private async initLogin() {
|
||||
const queryParams = this.props.realQueryParams;
|
||||
const hasAllParams = queryParams && queryParams['loginToken'];
|
||||
if (hasAllParams) {
|
||||
|
@ -189,7 +200,7 @@ export default class SoftLogout extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_renderSignInSection() {
|
||||
private renderSignInSection() {
|
||||
if (this.state.loginView === LOGIN_VIEW.LOADING) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
|
@ -247,7 +258,7 @@ export default class SoftLogout extends React.Component {
|
|||
} // else we already have a message and should use it (key backup warning)
|
||||
|
||||
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -289,7 +300,7 @@ export default class SoftLogout extends React.Component {
|
|||
|
||||
<h3>{_t("Sign in")}</h3>
|
||||
<div>
|
||||
{this._renderSignInSection()}
|
||||
{this.renderSignInSection()}
|
||||
</div>
|
||||
|
||||
<h3>{_t("Clear personal data")}</h3>
|
|
@ -169,7 +169,7 @@ export class PasswordAuthEntry extends React.Component {
|
|||
{ submitButtonOrSpinner }
|
||||
</div>
|
||||
</form>
|
||||
{ errorSection }
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -375,7 +375,7 @@ export class TermsAuthEntry extends React.Component {
|
|||
if (this.props.showContinue !== false) {
|
||||
// XXX: button classes
|
||||
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
|
||||
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
|
||||
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -179,7 +179,7 @@ const BaseAvatar = (props: IProps) => {
|
|||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
title={title} alt={_t("Avatar")}
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
|
|
|
@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import { TagID } from '../../../stores/room-list/models';
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import NotificationBadge from '../rooms/NotificationBadge';
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
|
|||
interface IProps {
|
||||
room: Room;
|
||||
avatarSize: number;
|
||||
tag: TagID;
|
||||
displayBadge?: boolean;
|
||||
forceCount?: boolean;
|
||||
oobData?: object;
|
||||
|
|
|
@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
|
|||
let imageUrl = null;
|
||||
if (props.member.getMxcAvatarUrl()) {
|
||||
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
let oobAvatar = null;
|
||||
if (props.oobData.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.width,
|
||||
props.height,
|
||||
props.resizeMethod,
|
||||
);
|
||||
}
|
||||
|
@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
private static getRoomAvatarUrl(props: IProps): string {
|
||||
if (!props.room) return null;
|
||||
|
||||
return Avatar.avatarUrlForRoom(
|
||||
props.room,
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
Math.floor(props.height * window.devicePixelRatio),
|
||||
props.resizeMethod,
|
||||
);
|
||||
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
|
||||
}
|
||||
|
||||
private onRoomAvatarClick = () => {
|
||||
|
@ -129,7 +124,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
name: this.props.room.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
|
108
src/components/views/beta/BetaCard.tsx
Normal file
108
src/components/views/beta/BetaCard.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
|
||||
import {_t} from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import Modal from "../../../Modal";
|
||||
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
export const BetaPill = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (onClick) {
|
||||
return <TextWithTooltip
|
||||
class={classNames("mx_BetaCard_betaPill", {
|
||||
mx_BetaCard_betaPill_clickable: !!onClick,
|
||||
})}
|
||||
tooltip={<div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("Spaces is a beta feature") }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ _t("Tap for more info") }
|
||||
</div>
|
||||
</div>}
|
||||
onClick={onClick}
|
||||
tooltipProps={{ yOffset: -10 }}
|
||||
>
|
||||
{ _t("Beta") }
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
|
||||
return <span
|
||||
className={classNames("mx_BetaCard_betaPill", {
|
||||
mx_BetaCard_betaPill_clickable: !!onClick,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ _t("Beta") }
|
||||
</span>;
|
||||
};
|
||||
|
||||
const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
if (!info) return null; // Beta is invalid/disabled
|
||||
|
||||
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info;
|
||||
const value = SettingsStore.getValue(featureId);
|
||||
|
||||
let feedbackButton;
|
||||
if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) {
|
||||
feedbackButton = <AccessibleButton
|
||||
onClick={() => {
|
||||
Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId });
|
||||
}}
|
||||
kind="primary"
|
||||
>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className="mx_BetaCard">
|
||||
<div>
|
||||
<h3 className="mx_BetaCard_title">
|
||||
{ titleOverride || _t(title) }
|
||||
<BetaPill />
|
||||
</h3>
|
||||
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
|
||||
<div>
|
||||
{ feedbackButton }
|
||||
<AccessibleButton
|
||||
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
|
||||
kind={feedbackButton ? "primary_outline" : "primary"}
|
||||
>
|
||||
{ value ? _t("Leave the beta") : _t("Join the beta") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
{ disclaimer && <div className="mx_BetaCard_disclaimer">
|
||||
{ disclaimer(value) }
|
||||
</div> }
|
||||
</div>
|
||||
<img src={image} alt="" />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default BetaCard;
|
|
@ -1,8 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu";
|
|||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
function canCancel(eventStatus) {
|
||||
export function canCancel(eventStatus) {
|
||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||
}
|
||||
|
||||
|
@ -52,6 +50,9 @@ export default class MessageContextMenu extends React.Component {
|
|||
|
||||
/* callback called when the menu is dismissed */
|
||||
onFinished: PropTypes.func,
|
||||
|
||||
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
|
||||
onCloseDialog: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -77,8 +78,10 @@ export default class MessageContextMenu extends React.Component {
|
|||
|
||||
// We explicitly decline to show the redact option on ACL events as it has a potential
|
||||
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
|
||||
// Similarly for encryption events, since redacting them "breaks everything"
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
|
||||
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
|
||||
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
|
||||
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
|
||||
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
|
||||
|
||||
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
|
||||
|
@ -95,21 +98,6 @@ export default class MessageContextMenu extends React.Component {
|
|||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
onResendClick = () => {
|
||||
Resend.resend(this.props.mxEvent);
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onResendEditClick = () => {
|
||||
Resend.resend(this.props.mxEvent.replacingEvent());
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onResendRedactionClick = () => {
|
||||
Resend.resend(this.props.mxEvent.localRedactionEvent());
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onResendReactionsClick = () => {
|
||||
for (const reaction of this._getUnsentReactions()) {
|
||||
Resend.resend(reaction);
|
||||
|
@ -141,6 +129,7 @@ export default class MessageContextMenu extends React.Component {
|
|||
|
||||
const cli = MatrixClientPeg.get();
|
||||
try {
|
||||
if (this.props.onCloseDialog) this.props.onCloseDialog();
|
||||
await cli.redactEvent(
|
||||
this.props.mxEvent.getRoomId(),
|
||||
this.props.mxEvent.getId(),
|
||||
|
@ -166,30 +155,8 @@ export default class MessageContextMenu extends React.Component {
|
|||
this.closeMenu();
|
||||
};
|
||||
|
||||
onCancelSendClick = () => {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editEvent = mxEvent.replacingEvent();
|
||||
const redactEvent = mxEvent.localRedactionEvent();
|
||||
const pendingReactions = this._getPendingReactions();
|
||||
|
||||
if (editEvent && canCancel(editEvent.status)) {
|
||||
Resend.removeFromQueue(editEvent);
|
||||
}
|
||||
if (redactEvent && canCancel(redactEvent.status)) {
|
||||
Resend.removeFromQueue(redactEvent);
|
||||
}
|
||||
if (pendingReactions.length) {
|
||||
for (const reaction of pendingReactions) {
|
||||
Resend.removeFromQueue(reaction);
|
||||
}
|
||||
}
|
||||
if (canCancel(mxEvent.status)) {
|
||||
Resend.removeFromQueue(this.props.mxEvent);
|
||||
}
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onForwardClick = () => {
|
||||
if (this.props.onCloseDialog) this.props.onCloseDialog();
|
||||
dis.dispatch({
|
||||
action: 'forward_event',
|
||||
event: this.props.mxEvent,
|
||||
|
@ -280,20 +247,9 @@ export default class MessageContextMenu extends React.Component {
|
|||
const me = cli.getUserId();
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const eventStatus = mxEvent.status;
|
||||
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
|
||||
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
|
||||
const unsentReactionsCount = this._getUnsentReactions().length;
|
||||
const pendingReactionsCount = this._getPendingReactions().length;
|
||||
const allowCancel = canCancel(mxEvent.status) ||
|
||||
canCancel(editStatus) ||
|
||||
canCancel(redactStatus) ||
|
||||
pendingReactionsCount !== 0;
|
||||
let resendButton;
|
||||
let resendEditButton;
|
||||
let resendReactionsButton;
|
||||
let resendRedactionButton;
|
||||
let redactButton;
|
||||
let cancelButton;
|
||||
let forwardButton;
|
||||
let pinButton;
|
||||
let unhidePreviewButton;
|
||||
|
@ -304,22 +260,6 @@ export default class MessageContextMenu extends React.Component {
|
|||
// status is SENT before remote-echo, null after
|
||||
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
|
||||
if (!mxEvent.isRedacted()) {
|
||||
if (eventStatus === EventStatus.NOT_SENT) {
|
||||
resendButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
|
||||
{ _t('Resend') }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (editStatus === EventStatus.NOT_SENT) {
|
||||
resendEditButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
|
||||
{ _t('Resend edit') }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (unsentReactionsCount !== 0) {
|
||||
resendReactionsButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
|
||||
|
@ -329,14 +269,6 @@ export default class MessageContextMenu extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (redactStatus === EventStatus.NOT_SENT) {
|
||||
resendRedactionButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
|
||||
{ _t('Resend removal') }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSent && this.state.canRedact) {
|
||||
redactButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
|
||||
|
@ -345,14 +277,6 @@ export default class MessageContextMenu extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
if (allowCancel) {
|
||||
cancelButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
|
||||
{ _t('Cancel Sending') }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (isContentActionable(mxEvent)) {
|
||||
forwardButton = (
|
||||
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
|
||||
|
@ -428,7 +352,7 @@ export default class MessageContextMenu extends React.Component {
|
|||
>
|
||||
{ _t('Source URL') }
|
||||
</MenuItem>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.collapseReplyThread) {
|
||||
|
@ -450,12 +374,8 @@ export default class MessageContextMenu extends React.Component {
|
|||
|
||||
return (
|
||||
<div className="mx_MessageContextMenu">
|
||||
{ resendButton }
|
||||
{ resendEditButton }
|
||||
{ resendReactionsButton }
|
||||
{ resendRedactionButton }
|
||||
{ redactButton }
|
||||
{ cancelButton }
|
||||
{ forwardButton }
|
||||
{ pinButton }
|
||||
{ viewSourceButton }
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useState} from "react";
|
||||
import React, {ReactNode, useContext, useMemo, useState} from "react";
|
||||
import classNames from "classnames";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
@ -29,10 +29,16 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
|||
import {getDisplayAliasForRoom} from "../../../Rooms";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import {allSettled} from "../../../utils/promise";
|
||||
import {sleep} from "../../../utils/promise";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import ProgressBar from "../elements/ProgressBar";
|
||||
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
|
@ -41,43 +47,241 @@ interface IProps extends IDialogProps {
|
|||
}
|
||||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
return <div className="mx_AddExistingToSpaceDialog_entry">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
|
||||
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
|
||||
return <label className="mx_AddExistingToSpace_entry">
|
||||
{ room?.isSpaceRoom()
|
||||
? <RoomAvatar room={room} height={32} width={32} />
|
||||
: <DecoratedRoomAvatar room={room} avatarSize={32} />
|
||||
}
|
||||
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
|
||||
<StyledCheckbox
|
||||
onChange={onChange ? (e) => onChange(e.target.checked) : null}
|
||||
checked={checked}
|
||||
disabled={!onChange}
|
||||
/>
|
||||
</label>;
|
||||
};
|
||||
|
||||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
footerPrompt?: ReactNode;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
}
|
||||
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
emptySelectionButton,
|
||||
onFinished,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]);
|
||||
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
const [progress, setProgress] = useState<number>(null);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
|
||||
const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]);
|
||||
const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]);
|
||||
|
||||
const [spaces, rooms, dms] = useMemo(() => {
|
||||
let rooms = visibleRooms;
|
||||
|
||||
if (lcQuery) {
|
||||
const matcher = new QueryMatcher<Room>(visibleRooms, {
|
||||
keys: ["name"],
|
||||
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
|
||||
shouldMatchWordsOnly: false,
|
||||
});
|
||||
|
||||
rooms = matcher.match(lcQuery);
|
||||
}
|
||||
|
||||
const joinRule = space.getJoinRule();
|
||||
return sortRooms(rooms).reduce((arr, room) => {
|
||||
if (room.isSpaceRoom()) {
|
||||
if (room !== space && !existingSubspacesSet.has(room)) {
|
||||
arr[0].push(room);
|
||||
}
|
||||
} else if (!existingRoomsSet.has(room)) {
|
||||
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
arr[1].push(room);
|
||||
} else if (joinRule !== "public") {
|
||||
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
|
||||
arr[2].push(room);
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
}, [[], [], []]);
|
||||
}, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]);
|
||||
|
||||
const addRooms = async () => {
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
|
||||
let error;
|
||||
|
||||
for (const room of selectedToAdd) {
|
||||
const via = calculateRoomVia(room);
|
||||
try {
|
||||
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
|
||||
if (e.errcode === "M_LIMIT_EXCEEDED") {
|
||||
await sleep(e.data.retry_after_ms);
|
||||
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
|
||||
}
|
||||
|
||||
throw e;
|
||||
});
|
||||
setProgress(i => i + 1);
|
||||
} catch (e) {
|
||||
console.error("Failed to add rooms to space", e);
|
||||
setError(error = e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
onFinished(true);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = progress !== null;
|
||||
|
||||
let footer;
|
||||
if (error) {
|
||||
footer = <>
|
||||
<img
|
||||
src={require("../../../../res/img/element-icons/warning-badge.svg")}
|
||||
height="24"
|
||||
width="24"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<span className="mx_AddExistingToSpaceDialog_error">
|
||||
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
|
||||
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
|
||||
</span>
|
||||
|
||||
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
|
||||
{ _t("Retry") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
} else if (busy) {
|
||||
footer = <span>
|
||||
<ProgressBar value={progress} max={selectedToAdd.size} />
|
||||
<div className="mx_AddExistingToSpaceDialog_progressText">
|
||||
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
|
||||
count: selectedToAdd.size,
|
||||
progress,
|
||||
}) }
|
||||
</div>
|
||||
</span>;
|
||||
} else {
|
||||
let button = emptySelectionButton;
|
||||
if (!button || selectedToAdd.size > 0) {
|
||||
button = <AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
|
||||
{ _t("Add") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
footer = <>
|
||||
<span>
|
||||
{ footerPrompt }
|
||||
</span>
|
||||
|
||||
{ button }
|
||||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
} : null;
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
{ rooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
||||
<div className="mx_AddExistingToSpace_footer">
|
||||
{ footer }
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase();
|
||||
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
const existingSubspacesSet = new Set(existingSubspaces);
|
||||
const spaces = SpaceStore.instance.getSpaces().filter(s => {
|
||||
return !existingSubspacesSet.has(s) // not already in space
|
||||
&& space !== s // not the top-level space
|
||||
&& selectedSpace !== s // not the selected space
|
||||
&& s.name.toLowerCase().includes(lcQuery); // contains query
|
||||
});
|
||||
|
||||
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
|
||||
const existingRoomsSet = new Set(existingRooms);
|
||||
const rooms = cli.getVisibleRooms().filter(room => {
|
||||
return !existingRoomsSet.has(room) // not already in space
|
||||
&& !room.isSpaceRoom() // not a space itself
|
||||
&& room.name.toLowerCase().includes(lcQuery) // contains query
|
||||
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspacesSet.size > 0) {
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
|
@ -117,93 +321,24 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
|
|||
return <BaseDialog
|
||||
title={title}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
|
||||
<MatrixClientContext.Provider value={cli}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpaceDialog_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
{ rooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
}}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(space);
|
||||
} else {
|
||||
selectedToAdd.delete(space);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
}}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
||||
<div className="mx_AddExistingToSpaceDialog_footer">
|
||||
<span>
|
||||
<div>{ _t("Don't want to add an existing room?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</span>
|
||||
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy || selectedToAdd.size < 1}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await allSettled(Array.from(selectedToAdd).map((room) =>
|
||||
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
|
||||
onFinished(true);
|
||||
} catch (e) {
|
||||
console.error("Failed to add rooms to space", e);
|
||||
setError(_t("Failed to add rooms to space"));
|
||||
}
|
||||
setBusy(false);
|
||||
}}
|
||||
>
|
||||
{ busy ? _t("Adding...") : _t("Add") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
|
|
106
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal file
106
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useState} from "react";
|
||||
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {IDialogProps} from "./IDialogProps";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {submitFeedback} from "../../../rageshake/submit-rageshake";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {USER_LABS_TAB} from "./UserSettingsDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
|
||||
const [comment, setComment] = useState("");
|
||||
const [canContact, setCanContact] = useState(false);
|
||||
|
||||
const sendFeedback = async (ok: boolean) => {
|
||||
if (!ok) return onFinished(false);
|
||||
|
||||
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact);
|
||||
onFinished(true);
|
||||
|
||||
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
|
||||
title: _t("Beta feedback"),
|
||||
description: _t("Thank you for your feedback, we really appreciate it."),
|
||||
button: _t("Done"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_BetaFeedbackDialog"
|
||||
hasCancelButton={true}
|
||||
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
|
||||
description={<React.Fragment>
|
||||
<div className="mx_BetaFeedbackDialog_subheading">
|
||||
{ _t(info.feedbackSubheading) }
|
||||
|
||||
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.")}
|
||||
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: USER_LABS_TAB,
|
||||
});
|
||||
}}>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
|
||||
>
|
||||
{ _t("You may contact me if you have any follow up questions") }
|
||||
</StyledCheckbox>
|
||||
</React.Fragment>}
|
||||
button={_t("Send feedback")}
|
||||
buttonDisabled={!comment}
|
||||
onFinished={sendFeedback}
|
||||
/>);
|
||||
};
|
||||
|
||||
export default BetaFeedbackDialog;
|
|
@ -184,7 +184,7 @@ export default class BugReportDialog extends React.Component {
|
|||
|
||||
return (
|
||||
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
|
||||
title={_t('Submit debug logs')}
|
||||
title={_t('Submit debug logs')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
|
|
|
@ -95,7 +95,7 @@ export default class ChangelogDialog extends React.Component {
|
|||
description={content}
|
||||
button={_t("Update")}
|
||||
onFinished={this.props.onFinished}
|
||||
/>
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,9 +39,12 @@ export default class ConfirmWipeDeviceDialog extends React.Component {
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Clear all data in this session?")}>
|
||||
<BaseDialog
|
||||
className='mx_ConfirmWipeDeviceDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Clear all data in this session?")}
|
||||
>
|
||||
<div className='mx_ConfirmWipeDeviceDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
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.
|
||||
|
@ -15,27 +15,46 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import withValidation from '../elements/Validation';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import withValidation, {IFieldState} from '../elements/Validation';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {privateShouldBeEncrypted} from "../../../createRoom";
|
||||
import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom";
|
||||
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
|
||||
interface IProps {
|
||||
defaultPublic?: boolean;
|
||||
defaultName?: string;
|
||||
parentSpace?: Room;
|
||||
onFinished(proceed: boolean, opts?: IOpts): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
isPublic: boolean;
|
||||
isEncrypted: boolean;
|
||||
name: string;
|
||||
topic: string;
|
||||
alias: string;
|
||||
detailsOpen: boolean;
|
||||
noFederate: boolean;
|
||||
nameIsValid: boolean;
|
||||
canChangeEncryption: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.CreateRoomDialog")
|
||||
export default class CreateRoomDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
defaultPublic: PropTypes.bool,
|
||||
parentSpace: PropTypes.instanceOf(Room),
|
||||
};
|
||||
export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
private nameField = createRef<Field>();
|
||||
private aliasField = createRef<RoomAliasField>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -44,7 +63,7 @@ export default class CreateRoomDialog extends React.Component {
|
|||
this.state = {
|
||||
isPublic: this.props.defaultPublic || false,
|
||||
isEncrypted: privateShouldBeEncrypted(),
|
||||
name: "",
|
||||
name: this.props.defaultName || "",
|
||||
topic: "",
|
||||
alias: "",
|
||||
detailsOpen: false,
|
||||
|
@ -54,26 +73,25 @@ export default class CreateRoomDialog extends React.Component {
|
|||
};
|
||||
|
||||
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private")
|
||||
.then(isForced => this.setState({canChangeEncryption: !isForced}));
|
||||
.then(isForced => this.setState({ canChangeEncryption: !isForced }));
|
||||
}
|
||||
|
||||
_roomCreateOptions() {
|
||||
const opts = {};
|
||||
const createOpts = opts.createOpts = {};
|
||||
private roomCreateOptions() {
|
||||
const opts: IOpts = {};
|
||||
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
|
||||
createOpts.name = this.state.name;
|
||||
if (this.state.isPublic) {
|
||||
createOpts.visibility = "public";
|
||||
createOpts.preset = "public_chat";
|
||||
createOpts.visibility = Visibility.Public;
|
||||
createOpts.preset = Preset.PublicChat;
|
||||
opts.guestAccess = false;
|
||||
const {alias} = this.state;
|
||||
const localPart = alias.substr(1, alias.indexOf(":") - 1);
|
||||
createOpts['room_alias_name'] = localPart;
|
||||
const { alias } = this.state;
|
||||
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
|
||||
}
|
||||
if (this.state.topic) {
|
||||
createOpts.topic = this.state.topic;
|
||||
}
|
||||
if (this.state.noFederate) {
|
||||
createOpts.creation_content = {'m.federate': false};
|
||||
createOpts.creation_content = { 'm.federate': false };
|
||||
}
|
||||
|
||||
if (!this.state.isPublic) {
|
||||
|
@ -98,16 +116,14 @@ export default class CreateRoomDialog extends React.Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
|
||||
// move focus to first field when showing dialog
|
||||
this._nameFieldRef.focus();
|
||||
this.nameField.current.focus();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
|
||||
}
|
||||
|
||||
_onKeyDown = event => {
|
||||
private onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === Key.ENTER) {
|
||||
this.onOk();
|
||||
event.preventDefault();
|
||||
|
@ -115,26 +131,26 @@ export default class CreateRoomDialog extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onOk = async () => {
|
||||
const activeElement = document.activeElement;
|
||||
private onOk = async () => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
await this._nameFieldRef.validate({allowEmpty: false});
|
||||
if (this._aliasFieldRef) {
|
||||
await this._aliasFieldRef.validate({allowEmpty: false});
|
||||
await this.nameField.current.validate({allowEmpty: false});
|
||||
if (this.aliasField.current) {
|
||||
await this.aliasField.current.validate({allowEmpty: false});
|
||||
}
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) {
|
||||
this.props.onFinished(true, this._roomCreateOptions());
|
||||
await new Promise<void>(resolve => this.setState({}, resolve));
|
||||
if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
|
||||
this.props.onFinished(true, this.roomCreateOptions());
|
||||
} else {
|
||||
let field;
|
||||
if (!this.state.nameIsValid) {
|
||||
field = this._nameFieldRef;
|
||||
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) {
|
||||
field = this._aliasFieldRef;
|
||||
field = this.nameField.current;
|
||||
} else if (this.aliasField.current && !this.aliasField.current.isValid) {
|
||||
field = this.aliasField.current;
|
||||
}
|
||||
if (field) {
|
||||
field.focus();
|
||||
|
@ -143,49 +159,45 @@ export default class CreateRoomDialog extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
private onCancel = () => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
onNameChange = ev => {
|
||||
this.setState({name: ev.target.value});
|
||||
private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ name: ev.target.value });
|
||||
};
|
||||
|
||||
onTopicChange = ev => {
|
||||
this.setState({topic: ev.target.value});
|
||||
private onTopicChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ topic: ev.target.value });
|
||||
};
|
||||
|
||||
onPublicChange = isPublic => {
|
||||
this.setState({isPublic});
|
||||
private onPublicChange = (isPublic: boolean) => {
|
||||
this.setState({ isPublic });
|
||||
};
|
||||
|
||||
onEncryptedChange = isEncrypted => {
|
||||
this.setState({isEncrypted});
|
||||
private onEncryptedChange = (isEncrypted: boolean) => {
|
||||
this.setState({ isEncrypted });
|
||||
};
|
||||
|
||||
onAliasChange = alias => {
|
||||
this.setState({alias});
|
||||
private onAliasChange = (alias: string) => {
|
||||
this.setState({ alias });
|
||||
};
|
||||
|
||||
onDetailsToggled = ev => {
|
||||
this.setState({detailsOpen: ev.target.open});
|
||||
private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>) => {
|
||||
this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
|
||||
};
|
||||
|
||||
onNoFederateChange = noFederate => {
|
||||
this.setState({noFederate});
|
||||
private onNoFederateChange = (noFederate: boolean) => {
|
||||
this.setState({ noFederate });
|
||||
};
|
||||
|
||||
collectDetailsRef = ref => {
|
||||
this._detailsRef = ref;
|
||||
};
|
||||
|
||||
onNameValidate = async fieldState => {
|
||||
const result = await CreateRoomDialog._validateRoomName(fieldState);
|
||||
private onNameValidate = async (fieldState: IFieldState) => {
|
||||
const result = await CreateRoomDialog.validateRoomName(fieldState);
|
||||
this.setState({nameIsValid: result.valid});
|
||||
return result;
|
||||
};
|
||||
|
||||
static _validateRoomName = withValidation({
|
||||
private static validateRoomName = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -196,18 +208,17 @@ export default class CreateRoomDialog extends React.Component {
|
|||
});
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const Field = sdk.getComponent('views.elements.Field');
|
||||
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
|
||||
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
|
||||
|
||||
let aliasField;
|
||||
if (this.state.isPublic) {
|
||||
const domain = MatrixClientPeg.get().getDomain();
|
||||
aliasField = (
|
||||
<div className="mx_CreateRoomDialog_aliasContainer">
|
||||
<RoomAliasField ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
|
||||
<RoomAliasField
|
||||
ref={this.aliasField}
|
||||
onChange={this.onAliasChange}
|
||||
domain={domain}
|
||||
value={this.state.alias}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -270,16 +281,34 @@ export default class CreateRoomDialog extends React.Component {
|
|||
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}>
|
||||
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
|
||||
<div className="mx_Dialog_content">
|
||||
<Field ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" />
|
||||
<Field label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} className="mx_CreateRoomDialog_topic" />
|
||||
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} />
|
||||
<Field
|
||||
ref={this.nameField}
|
||||
label={_t('Name')}
|
||||
onChange={this.onNameChange}
|
||||
onValidate={this.onNameValidate}
|
||||
value={this.state.name}
|
||||
className="mx_CreateRoomDialog_name"
|
||||
/>
|
||||
<Field
|
||||
label={_t('Topic (optional)')}
|
||||
onChange={this.onTopicChange}
|
||||
value={this.state.topic}
|
||||
className="mx_CreateRoomDialog_topic"
|
||||
/>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("Make this room public")}
|
||||
onChange={this.onPublicChange}
|
||||
value={this.state.isPublic}
|
||||
/>
|
||||
{ publicPrivateLabel }
|
||||
{ e2eeSection }
|
||||
{ aliasField }
|
||||
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary>
|
||||
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">
|
||||
{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }
|
||||
</summary>
|
||||
<LabelledToggleSwitch
|
||||
label={_t(
|
||||
"Block anyone not part of %(serverName)s from ever joining this room.",
|
|
@ -70,8 +70,16 @@ class GenericEditor extends React.PureComponent {
|
|||
}
|
||||
|
||||
textInput(id, label) {
|
||||
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on"
|
||||
value={this.state[id]} onChange={this._onChange} />;
|
||||
return <Field
|
||||
id={id}
|
||||
label={label}
|
||||
size="42"
|
||||
autoFocus={true}
|
||||
type="text"
|
||||
autoComplete="on"
|
||||
value={this.state[id]}
|
||||
onChange={this._onChange}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -155,7 +163,7 @@ export class SendCustomEvent extends GenericEditor {
|
|||
<br />
|
||||
|
||||
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
|
||||
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
|
||||
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
|
@ -239,7 +247,7 @@ class SendAccountData extends GenericEditor {
|
|||
<br />
|
||||
|
||||
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
|
||||
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
|
||||
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
|
@ -315,15 +323,15 @@ class FilteredList extends React.PureComponent {
|
|||
const TruncatedList = sdk.getComponent("elements.TruncatedList");
|
||||
return <div>
|
||||
<Field label={_t('Filter results')} autoFocus={true} size={64}
|
||||
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
|
||||
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
|
||||
// force re-render so that autoFocus is applied when this component is re-used
|
||||
key={this.props.children[0] ? this.props.children[0].key : ''} />
|
||||
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
|
||||
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
|
||||
// force re-render so that autoFocus is applied when this component is re-used
|
||||
key={this.props.children[0] ? this.props.children[0].key : ''} />
|
||||
|
||||
<TruncatedList getChildren={this.getChildren}
|
||||
getChildCount={this.getChildCount}
|
||||
truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this.createOverflowElement} />
|
||||
getChildCount={this.getChildCount}
|
||||
truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this.createOverflowElement} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@ -647,7 +655,7 @@ function VerificationRequest({txnId, request}) {
|
|||
|
||||
/* Note that request.timeout is a getter, so its value changes */
|
||||
const id = setInterval(() => {
|
||||
setRequestTimeout(request.timeout);
|
||||
setRequestTimeout(request.timeout);
|
||||
}, 500);
|
||||
|
||||
return () => { clearInterval(id); };
|
||||
|
@ -941,35 +949,35 @@ class SettingsExplorer extends React.Component {
|
|||
/>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{_t("Setting ID")}</th>
|
||||
<th>{_t("Value")}</th>
|
||||
<th>{_t("Value in this room")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{_t("Setting ID")}</th>
|
||||
<th>{_t("Value")}</th>
|
||||
<th>{_t("Value in this room")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allSettings.map(i => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<a href="" onClick={(e) => this.onViewClick(e, i)}>
|
||||
<code>{i}</code>
|
||||
</a>
|
||||
<a href="" onClick={(e) => this.onEditClick(e, i)}
|
||||
className='mx_DevTools_SettingsExplorer_edit'
|
||||
>
|
||||
{allSettings.map(i => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<a href="" onClick={(e) => this.onViewClick(e, i)}>
|
||||
<code>{i}</code>
|
||||
</a>
|
||||
<a href="" onClick={(e) => this.onEditClick(e, i)}
|
||||
className='mx_DevTools_SettingsExplorer_edit'
|
||||
>
|
||||
✏
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -998,11 +1006,11 @@ class SettingsExplorer extends React.Component {
|
|||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{_t("Level")}</th>
|
||||
<th>{_t("Settable at global")}</th>
|
||||
<th>{_t("Settable at room")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{_t("Level")}</th>
|
||||
<th>{_t("Settable at global")}</th>
|
||||
<th>{_t("Settable at room")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{LEVEL_ORDER.map(lvl => (
|
||||
|
|
|
@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component {
|
|||
const oppProfile = this.state.opponentProfile;
|
||||
if (oppProfile) {
|
||||
const url = oppProfile.avatar_url
|
||||
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio))
|
||||
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
|
||||
: null;
|
||||
profile = <div className="mx_IncomingSasDialog_opponentProfile">
|
||||
<BaseAvatar name={oppProfile.displayname}
|
||||
|
|
|
@ -42,9 +42,12 @@ export default class IntegrationsDisabledDialog extends React.Component {
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_IntegrationsDisabledDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Integrations are disabled")}>
|
||||
<BaseDialog
|
||||
className='mx_IntegrationsDisabledDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Integrations are disabled")}
|
||||
>
|
||||
<div className='mx_IntegrationsDisabledDialog_content'>
|
||||
<p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p>
|
||||
</div>
|
||||
|
|
|
@ -37,9 +37,12 @@ export default class IntegrationsImpossibleDialog extends React.Component {
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_IntegrationsImpossibleDialog' hasCancel={false}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Integrations not allowed")}>
|
||||
<BaseDialog
|
||||
className='mx_IntegrationsImpossibleDialog'
|
||||
hasCancel={false}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Integrations not allowed")}
|
||||
>
|
||||
<div className='mx_IntegrationsImpossibleDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
|
|
|
@ -31,6 +31,7 @@ import Modal from "../../../Modal";
|
|||
import {humanizeTime} from "../../../utils/humanize";
|
||||
import createRoom, {
|
||||
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
|
||||
IInvite3PID,
|
||||
} from "../../../createRoom";
|
||||
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
|
||||
import {Key} from "../../../Keyboard";
|
||||
|
@ -618,13 +619,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
|
||||
_startDm = async () => {
|
||||
this.setState({busy: true});
|
||||
const client = MatrixClientPeg.get();
|
||||
const targets = this._convertFilter();
|
||||
const targetIds = targets.map(t => t.userId);
|
||||
|
||||
// Check if there is already a DM with these people and reuse it if possible.
|
||||
let existingRoom: Room;
|
||||
if (targetIds.length === 1) {
|
||||
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
|
||||
existingRoom = findDMForUser(client, targetIds[0]);
|
||||
} else {
|
||||
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
|
||||
}
|
||||
|
@ -646,7 +648,6 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
// If so, enable encryption in the new room.
|
||||
const has3PidMembers = targets.some(t => t instanceof ThreepidMember);
|
||||
if (!has3PidMembers) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds);
|
||||
if (allHaveDeviceKeys) {
|
||||
createRoomOptions.encryption = true;
|
||||
|
@ -656,35 +657,41 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
|
||||
// Check if it's a traditional DM and create the room if required.
|
||||
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
|
||||
let createRoomPromise = Promise.resolve(null) as Promise<string | null | boolean>;
|
||||
const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId();
|
||||
if (targetIds.length === 1 && !isSelf) {
|
||||
createRoomOptions.dmUserId = targetIds[0];
|
||||
createRoomPromise = createRoom(createRoomOptions);
|
||||
} else if (isSelf) {
|
||||
createRoomPromise = createRoom(createRoomOptions);
|
||||
} else {
|
||||
// Create a boring room and try to invite the targets manually.
|
||||
createRoomPromise = createRoom(createRoomOptions).then(roomId => {
|
||||
return inviteMultipleToRoom(roomId, targetIds);
|
||||
}).then(result => {
|
||||
if (this._shouldAbortAfterInviteError(result)) {
|
||||
return true; // abort
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId();
|
||||
if (targetIds.length === 1 && !isSelf) {
|
||||
createRoomOptions.dmUserId = targetIds[0];
|
||||
}
|
||||
|
||||
// the createRoom call will show the room for us, so we don't need to worry about that.
|
||||
createRoomPromise.then(abort => {
|
||||
if (abort === true) return; // only abort on true booleans, not roomIds or something
|
||||
if (targetIds.length > 1) {
|
||||
createRoomOptions.createOpts = targetIds.reduce(
|
||||
(roomOptions, address) => {
|
||||
const type = getAddressType(address);
|
||||
if (type === 'email') {
|
||||
const invite: IInvite3PID = {
|
||||
id_server: client.getIdentityServerUrl(true),
|
||||
medium: 'email',
|
||||
address,
|
||||
};
|
||||
roomOptions.invite_3pid.push(invite);
|
||||
} else if (type === 'mx-user-id') {
|
||||
roomOptions.invite.push(address);
|
||||
}
|
||||
return roomOptions;
|
||||
},
|
||||
{ invite: [], invite_3pid: [] },
|
||||
)
|
||||
}
|
||||
|
||||
await createRoom(createRoomOptions);
|
||||
this.props.onFinished();
|
||||
}).catch(err => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: _t("We couldn't create your DM."),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_inviteUsers = async () => {
|
||||
|
@ -1305,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
goButtonFn = this._startDm;
|
||||
} else if (this.props.kind === KIND_INVITE) {
|
||||
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
|
||||
const isSpace = room?.isSpaceRoom();
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
|
||||
title = isSpace
|
||||
? _t("Invite to %(spaceName)s", {
|
||||
spaceName: room.name || _t("Unnamed Space"),
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function KeySignatureUploadFailedDialog({
|
|||
source,
|
||||
continuation,
|
||||
onFinished,
|
||||
}) {
|
||||
}) {
|
||||
const RETRIES = 2;
|
||||
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
@ -84,10 +84,10 @@ export default function KeySignatureUploadFailedDialog({
|
|||
} else {
|
||||
body = (<div>
|
||||
{success ?
|
||||
<span>{_t("Upload completed")}</span> :
|
||||
cancelled ?
|
||||
<span>{_t("Cancelled signature upload")}</span> :
|
||||
<span>{_t("Unable to upload")}</span>}
|
||||
<span>{_t("Upload completed")}</span> :
|
||||
cancelled ?
|
||||
<span>{_t("Cancelled signature upload")}</span> :
|
||||
<span>{_t("Unable to upload")}</span>}
|
||||
<DialogButtons
|
||||
primaryButton={_t("OK")}
|
||||
hasCancel={false}
|
||||
|
|
|
@ -164,8 +164,12 @@ export default class MessageEditHistoryDialog extends React.PureComponent {
|
|||
}
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished} title={_t("Message edits")}>
|
||||
<BaseDialog
|
||||
className='mx_MessageEditHistoryDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Message edits")}
|
||||
>
|
||||
{content}
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import * as React from 'react';
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, getUserLanguage } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
|
@ -39,6 +39,8 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
|||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {ELEMENT_CLIENT_ID} from "../../../identifiers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
|
@ -129,6 +131,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
|||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
clientId: ELEMENT_CLIENT_ID,
|
||||
clientTheme: SettingsStore.getValue("theme"),
|
||||
clientLanguage: getUserLanguage(),
|
||||
});
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
|
|
@ -116,8 +116,12 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
|
||||
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
|
||||
return (
|
||||
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}>
|
||||
<BaseDialog
|
||||
className='mx_RoomSettingsDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Room Settings - %(roomName)s", {roomName})}
|
||||
>
|
||||
<div className='mx_SettingsDialog_content'>
|
||||
<TabbedView tabs={this._getTabs()} />
|
||||
</div>
|
||||
|
|
|
@ -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.
|
||||
|
@ -110,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
console.error(e);
|
||||
|
||||
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
if (stateForError.isFatalError) {
|
||||
if (stateForError.serverErrorIsFatal) {
|
||||
let error = _t("Unable to validate homeserver");
|
||||
if (e.translatedMessage) {
|
||||
error = e.translatedMessage;
|
||||
|
@ -168,7 +168,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
text = _t("Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.");
|
||||
}
|
||||
|
||||
let defaultServerName = this.defaultServer.hsName;
|
||||
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
|
||||
if (this.defaultServer.hsNameIsDifferent) {
|
||||
defaultServerName = (
|
||||
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
|
||||
|
@ -217,6 +217,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
value={this.state.otherHomeserver}
|
||||
validateOnChange={false}
|
||||
validateOnFocus={false}
|
||||
id="mx_homeserverInput"
|
||||
/>
|
||||
</StyledRadioButton>
|
||||
<p>
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class SeshatResetDialog extends React.PureComponent<IDialogProps>
|
|||
{_t("You most likely do not want to reset your event index store")}
|
||||
<br />
|
||||
{_t("If you do, please note that none of your messages will be deleted, " +
|
||||
"but the search experience might be degraded for a few moments" +
|
||||
"but the search experience might be degraded for a few moments " +
|
||||
"whilst the index is recreated",
|
||||
)}
|
||||
</p>
|
||||
|
|
|
@ -98,7 +98,7 @@ export default class SessionRestoreErrorDialog extends React.Component {
|
|||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.",
|
||||
{ brand },
|
||||
) }</p>
|
||||
) }</p>
|
||||
|
||||
<p>{ _t(
|
||||
"Clearing your browser's storage may fix the problem, but will sign you " +
|
||||
|
|
|
@ -32,6 +32,7 @@ import Modal from "../../../Modal";
|
|||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {allSettled} from "../../../utils/promise";
|
||||
import {useDispatcher} from "../../../hooks/useDispatcher";
|
||||
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
|
@ -111,15 +112,17 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
|
|||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
|
||||
<SpaceBasicSettings
|
||||
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
|
||||
avatarDisabled={!canSetAvatar}
|
||||
avatarDisabled={busy || !canSetAvatar}
|
||||
setAvatar={setNewAvatar}
|
||||
name={name}
|
||||
nameDisabled={!canSetName}
|
||||
nameDisabled={busy || !canSetName}
|
||||
setName={setName}
|
||||
topic={topic}
|
||||
topicDisabled={!canSetTopic}
|
||||
topicDisabled={busy || !canSetTopic}
|
||||
setTopic={setTopic}
|
||||
/>
|
||||
|
||||
|
|
|
@ -45,10 +45,12 @@ export default class StorageEvictedDialog extends React.Component {
|
|||
let logRequest;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
logRequest = _t(
|
||||
"To help us prevent this in future, please <a>send us logs</a>.", {},
|
||||
{
|
||||
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
|
||||
});
|
||||
"To help us prevent this in future, please <a>send us logs</a>.",
|
||||
{},
|
||||
{
|
||||
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal file
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2019, 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.
|
||||
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 { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import E2EIcon from "../rooms/E2EIcon";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { IDevice } from "../right_panel/UserInfo";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
user: User;
|
||||
device: IDevice;
|
||||
}
|
||||
|
||||
const UntrustedDeviceDialog: React.FC<IProps> = ({device, user, onFinished}) => {
|
||||
let askToVerifyText;
|
||||
let newSessionText;
|
||||
|
||||
if (MatrixClientPeg.get().getUserId() === user.userId) {
|
||||
newSessionText = _t("You signed in to a new session without verifying it:");
|
||||
askToVerifyText = _t("Verify your other session using one of the options below.");
|
||||
} else {
|
||||
newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:",
|
||||
{name: user.displayName, userId: user.userId});
|
||||
askToVerifyText = _t("Ask this user to verify their session, or manually verify it below.");
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
onFinished={onFinished}
|
||||
className="mx_UntrustedDeviceDialog"
|
||||
title={<>
|
||||
<E2EIcon status="warning" size={24} hideTooltip={true} />
|
||||
{ _t("Not Trusted")}
|
||||
</>}
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{newSessionText}</p>
|
||||
<p>{device.getDisplayName()} ({device.deviceId})</p>
|
||||
<p>{askToVerifyText}</p>
|
||||
</div>
|
||||
<div className='mx_Dialog_buttons'>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("legacy")}>
|
||||
{ _t("Manually Verify by Text") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("sas")}>
|
||||
{ _t("Interactively verify by Emoji") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={() => onFinished(false)}>
|
||||
{ _t("Done") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default UntrustedDeviceDialog;
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -16,20 +16,23 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import filesize from "filesize";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { getBlobSafeMimeType } from '../../../utils/blobs';
|
||||
|
||||
interface IProps {
|
||||
file: File;
|
||||
currentIndex: number;
|
||||
totalFiles?: number;
|
||||
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.UploadConfirmDialog")
|
||||
export default class UploadConfirmDialog extends React.Component {
|
||||
static propTypes = {
|
||||
file: PropTypes.object.isRequired,
|
||||
currentIndex: PropTypes.number,
|
||||
totalFiles: PropTypes.number,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
}
|
||||
export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
private objectUrl: string;
|
||||
private mimeType: string;
|
||||
|
||||
static defaultProps = {
|
||||
totalFiles: 1,
|
||||
|
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._objectUrl = URL.createObjectURL(props.file);
|
||||
// Create a fresh `Blob` for previewing (even though `File` already is
|
||||
// one) so we can adjust the MIME type if needed.
|
||||
this.mimeType = getBlobSafeMimeType(props.file.type);
|
||||
const blob = new Blob([props.file], { type:
|
||||
this.mimeType,
|
||||
});
|
||||
this.objectUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
private onCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onUploadClick = () => {
|
||||
private onUploadClick = () => {
|
||||
this.props.onFinished(true);
|
||||
}
|
||||
|
||||
_onUploadAllClick = () => {
|
||||
private onUploadAllClick = () => {
|
||||
this.props.onFinished(true, true);
|
||||
}
|
||||
|
||||
|
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
}
|
||||
|
||||
let preview;
|
||||
if (this.props.file.type.startsWith('image/')) {
|
||||
if (this.mimeType.startsWith('image/')) {
|
||||
preview = <div className="mx_UploadConfirmDialog_previewOuter">
|
||||
<div className="mx_UploadConfirmDialog_previewInner">
|
||||
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
|
||||
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
|
||||
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
|
||||
let uploadAllButton;
|
||||
if (this.props.currentIndex + 1 < this.props.totalFiles) {
|
||||
uploadAllButton = <button onClick={this._onUploadAllClick}>
|
||||
uploadAllButton = <button onClick={this.onUploadAllClick}>
|
||||
{_t("Upload all")}
|
||||
</button>;
|
||||
}
|
||||
|
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
return (
|
||||
<BaseDialog className='mx_UploadConfirmDialog'
|
||||
fixedWidth={false}
|
||||
onFinished={this._onCancelClick}
|
||||
onFinished={this.onCancelClick}
|
||||
title={title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
|
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
|
|||
|
||||
<DialogButtons primaryButton={_t('Upload')}
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={this._onUploadClick}
|
||||
onPrimaryButtonClick={this.onUploadClick}
|
||||
focus={true}
|
||||
>
|
||||
{uploadAllButton}
|
|
@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component {
|
|||
"mx_UserSettingsDialog_securityIcon",
|
||||
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
if (SdkConfig.get()['showLabsSettings']) {
|
||||
// Show the Labs tab if enabled or if there are any active betas
|
||||
if (SdkConfig.get()['showLabsSettings']
|
||||
|| SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
|
||||
) {
|
||||
tabs.push(new Tab(
|
||||
USER_LABS_TAB,
|
||||
_td("Labs"),
|
||||
|
@ -155,8 +158,12 @@ export default class UserSettingsDialog extends React.Component {
|
|||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished} title={_t("Settings")}>
|
||||
<BaseDialog
|
||||
className='mx_UserSettingsDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Settings")}
|
||||
>
|
||||
<div className='mx_SettingsDialog_content'>
|
||||
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
|
||||
</div>
|
||||
|
|
|
@ -52,11 +52,13 @@ export default class VerificationRequestDialog extends React.Component {
|
|||
const title = request && request.isSelfVerification ?
|
||||
_t("Verify other login") : _t("Verification Request");
|
||||
|
||||
return <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
|
||||
contentId="mx_Dialog_content"
|
||||
title={title}
|
||||
hasCancel={true}
|
||||
>
|
||||
return <BaseDialog
|
||||
className="mx_InfoDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
contentId="mx_Dialog_content"
|
||||
title={title}
|
||||
hasCancel={true}
|
||||
>
|
||||
<EncryptionPanel
|
||||
layout="dialog"
|
||||
verificationRequest={this.props.verificationRequest}
|
||||
|
|
|
@ -70,9 +70,12 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Allow this widget to verify your identity")}>
|
||||
<BaseDialog
|
||||
className='mx_WidgetOpenIDPermissionsDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Allow this widget to verify your identity")}
|
||||
>
|
||||
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
|
||||
<p>
|
||||
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")}
|
||||
|
|
|
@ -25,6 +25,8 @@ import Field from '../../elements/Field';
|
|||
import AccessibleButton from '../../elements/AccessibleButton';
|
||||
import {_t} from '../../../../languageHandler';
|
||||
import {IDialogProps} from "../IDialogProps";
|
||||
import {accessSecretStorage} from "../../../../SecurityManager";
|
||||
import Modal from "../../../../Modal";
|
||||
|
||||
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
|
||||
// so this should be plenty and allow for people putting extra whitespace in the file because
|
||||
|
@ -47,6 +49,7 @@ interface IState {
|
|||
forceRecoveryKey: boolean;
|
||||
passPhrase: string;
|
||||
keyMatches: boolean | null;
|
||||
resetting: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -66,10 +69,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
forceRecoveryKey: false,
|
||||
passPhrase: '',
|
||||
keyMatches: null,
|
||||
resetting: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onCancel = () => {
|
||||
if (this.state.resetting) {
|
||||
this.setState({resetting: false});
|
||||
}
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
|
@ -201,6 +208,55 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
});
|
||||
};
|
||||
|
||||
private onResetAllClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
ev.preventDefault();
|
||||
this.setState({resetting: true});
|
||||
};
|
||||
|
||||
private onConfirmResetAllClick = async () => {
|
||||
// Hide ourselves so the user can interact with the reset dialogs.
|
||||
// We don't conclude the promise chain (onFinished) yet to avoid confusing
|
||||
// any upstream code flows.
|
||||
//
|
||||
// Note: this will unmount us, so don't call `setState` or anything in the
|
||||
// rest of this function.
|
||||
Modal.toggleCurrentDialogVisibility();
|
||||
|
||||
try {
|
||||
// Force reset secret storage (which resets the key backup)
|
||||
await accessSecretStorage(async () => {
|
||||
// Now reset cross-signing so everything Just Works™ again.
|
||||
const cli = MatrixClientPeg.get();
|
||||
await cli.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (makeRequest) => {
|
||||
// XXX: Making this an import breaks the app.
|
||||
const InteractiveAuthDialog = sdk.getComponent("views.dialogs.InteractiveAuthDialog");
|
||||
const {finished} = Modal.createTrackedDialog(
|
||||
'Cross-signing keys dialog', '', InteractiveAuthDialog,
|
||||
{
|
||||
title: _t("Setting up keys"),
|
||||
matrixClient: cli,
|
||||
makeRequest,
|
||||
},
|
||||
);
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
|
||||
// Now we can indicate that the user is done pressing buttons, finally.
|
||||
// Upstream flows will detect the new secret storage, key backup, etc and use it.
|
||||
this.props.onFinished(true);
|
||||
}, true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
};
|
||||
|
||||
private getKeyValidationText(): string {
|
||||
if (this.state.recoveryKeyFileError) {
|
||||
return _t("Wrong file type");
|
||||
|
@ -216,8 +272,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
}
|
||||
|
||||
render() {
|
||||
// Caution: Making this an import will break tests.
|
||||
// Caution: Making these an import will break tests.
|
||||
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
|
||||
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
|
||||
|
||||
const hasPassphrase = (
|
||||
this.props.keyInfo &&
|
||||
|
@ -226,11 +283,36 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
this.props.keyInfo.passphrase.iterations
|
||||
);
|
||||
|
||||
const resetButton = (
|
||||
<div className="mx_AccessSecretStorageDialog_reset">
|
||||
{_t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
|
||||
a: (sub) => <a
|
||||
href="" onClick={this.onResetAllClick}
|
||||
className="mx_AccessSecretStorageDialog_reset_link">{sub}</a>,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
let content;
|
||||
let title;
|
||||
let titleClass;
|
||||
if (hasPassphrase && !this.state.forceRecoveryKey) {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
if (this.state.resetting) {
|
||||
title = _t("Reset everything");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge'];
|
||||
content = <div>
|
||||
<p>{_t("Only do this if you have no other device to complete verification with.")}</p>
|
||||
<p>{_t("If you reset everything, you will restart with no trusted sessions, no trusted users, and "
|
||||
+ "might not be able to see past messages.")}</p>
|
||||
<DialogButtons
|
||||
primaryButton={_t('Reset')}
|
||||
onPrimaryButtonClick={this.onConfirmResetAllClick}
|
||||
hasCancel={true}
|
||||
onCancel={this.onCancel}
|
||||
focus={false}
|
||||
primaryButtonClass="danger"
|
||||
/>
|
||||
</div>;
|
||||
} else if (hasPassphrase && !this.state.forceRecoveryKey) {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
title = _t("Security Phrase");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle'];
|
||||
|
@ -263,6 +345,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}>
|
||||
<input
|
||||
type="password"
|
||||
id="mx_passPhraseInput"
|
||||
className="mx_AccessSecretStorageDialog_passPhraseInput"
|
||||
onChange={this.onPassPhraseChange}
|
||||
value={this.state.passPhrase}
|
||||
|
@ -278,13 +361,13 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
onCancel={this.onCancel}
|
||||
focus={false}
|
||||
primaryDisabled={this.state.passPhrase.length === 0}
|
||||
additive={resetButton}
|
||||
/>
|
||||
</form>
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Security Key");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
const feedbackClasses = classNames({
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
|
||||
|
@ -339,6 +422,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
|||
onCancel={this.onCancel}
|
||||
focus={false}
|
||||
primaryDisabled={!this.state.recoveryKeyValid}
|
||||
additive={resetButton}
|
||||
/>
|
||||
</form>
|
||||
</div>;
|
||||
|
|
|
@ -40,10 +40,11 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component {
|
|||
|
||||
return (
|
||||
<BaseDialog
|
||||
className='mx_ConfirmDestroyCrossSigningDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Destroy cross-signing keys?")}>
|
||||
className='mx_ConfirmDestroyCrossSigningDialog'
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Destroy cross-signing keys?")}
|
||||
>
|
||||
<div className='mx_ConfirmDestroyCrossSigningDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
|
|
|
@ -373,21 +373,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
{_t(
|
||||
"If you've forgotten your Security Phrase you can "+
|
||||
"<button1>use your Security Key</button1> or " +
|
||||
"<button2>set up new recovery options</button2>"
|
||||
, {}, {
|
||||
button1: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
button2: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
"<button2>set up new recovery options</button2>",
|
||||
{},
|
||||
{
|
||||
button1: s => <AccessibleButton
|
||||
className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
button2: s => <AccessibleButton
|
||||
className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Enter Security Key");
|
||||
|
@ -435,15 +438,17 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
</div>
|
||||
{_t(
|
||||
"If you've forgotten your Security Key you can "+
|
||||
"<button>set up new recovery options</button>"
|
||||
, {}, {
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
"<button>set up new recovery options</button>",
|
||||
{},
|
||||
{
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
},
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -452,9 +457,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
onFinished={this.props.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<div className='mx_RestoreKeyBackupDialog_content'>
|
||||
{content}
|
||||
</div>
|
||||
<div className='mx_RestoreKeyBackupDialog_content'>
|
||||
{content}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2016, 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.
|
||||
|
@ -16,39 +15,54 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
useContextMenu,
|
||||
ContextMenuButton,
|
||||
MenuItemRadio,
|
||||
MenuItem,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
MenuItemRadio,
|
||||
useContextMenu,
|
||||
} from "../../structures/ContextMenu";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {useSettingValue} from "../../../hooks/useSettings";
|
||||
import * as sdk from "../../../index";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import Modal from "../../../Modal";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import withValidation from "../elements/Validation";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import TextInputDialog from "../dialogs/TextInputDialog";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
|
||||
export const ALL_ROOMS = Symbol("ALL_ROOMS");
|
||||
|
||||
const SETTING_NAME = "room_directory_servers";
|
||||
|
||||
const inPlaceOf = (elementRect) => ({
|
||||
const inPlaceOf = (elementRect: Pick<DOMRect, "right" | "top">) => ({
|
||||
right: window.innerWidth - elementRect.right,
|
||||
top: elementRect.top,
|
||||
chevronOffset: 0,
|
||||
chevronFace: "none",
|
||||
chevronFace: ChevronFace.None,
|
||||
});
|
||||
|
||||
const validServer = withValidation({
|
||||
const validServer = withValidation<undefined, { error?: MatrixError }>({
|
||||
deriveData: async ({ value }) => {
|
||||
try {
|
||||
// check if we can successfully load this server's room directory
|
||||
await MatrixClientPeg.get().publicRooms({
|
||||
limit: 1,
|
||||
server: value,
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -57,34 +71,58 @@ const validServer = withValidation({
|
|||
}, {
|
||||
key: "available",
|
||||
final: true,
|
||||
test: async ({ value }) => {
|
||||
try {
|
||||
const opts = {
|
||||
limit: 1,
|
||||
server: value,
|
||||
};
|
||||
// check if we can successfully load this server's room directory
|
||||
await MatrixClientPeg.get().publicRooms(opts);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
test: async (_, { error }) => !error,
|
||||
valid: () => _t("Looks good"),
|
||||
invalid: () => _t("Can't find this server or its room list"),
|
||||
invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
|
||||
? _t("You are not allowed to view this server's rooms list")
|
||||
: _t("Can't find this server or its room list"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IFieldType {
|
||||
regexp: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export interface IInstance {
|
||||
desc: string;
|
||||
icon?: string;
|
||||
fields: object;
|
||||
network_id: string;
|
||||
// XXX: this is undocumented but we rely on it.
|
||||
// we inject a fake entry with a symbolic instance_id.
|
||||
instance_id: string | symbol;
|
||||
}
|
||||
|
||||
export interface IProtocol {
|
||||
user_fields: string[];
|
||||
location_fields: string[];
|
||||
icon: string;
|
||||
field_types: Record<string, IFieldType>;
|
||||
instances: IInstance[];
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export type Protocols = Record<string, IProtocol>;
|
||||
|
||||
interface IProps {
|
||||
protocols: Protocols;
|
||||
selectedServerName: string;
|
||||
selectedInstanceId: string | symbol;
|
||||
onOptionChange(server: string, instanceId?: string | symbol): void;
|
||||
}
|
||||
|
||||
// This dropdown sources homeservers from three places:
|
||||
// + your currently connected homeserver
|
||||
// + homeservers in config.json["roomDirectory"]
|
||||
// + homeservers in SettingsStore["room_directory_servers"]
|
||||
// if a server exists in multiple, only keep the top-most entry.
|
||||
|
||||
const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
const _userDefinedServers = useSettingValue(SETTING_NAME);
|
||||
const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||
const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
|
||||
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
|
||||
|
||||
const handlerFactory = (server, instanceId) => {
|
||||
|
@ -96,7 +134,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
|
||||
const setUserDefinedServers = servers => {
|
||||
_setUserDefinedServers(servers);
|
||||
SettingsStore.setValue(SETTING_NAME, null, "account", servers);
|
||||
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
|
||||
};
|
||||
// keep local echo up to date with external changes
|
||||
useEffect(() => {
|
||||
|
@ -110,7 +148,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
const roomDirectory = config.roomDirectory || {};
|
||||
|
||||
const hsName = MatrixClientPeg.getHomeserverName();
|
||||
const configServers = new Set(roomDirectory.servers);
|
||||
const configServers = new Set<string>(roomDirectory.servers);
|
||||
|
||||
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
|
||||
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
|
||||
|
@ -134,9 +172,15 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
// add a fake protocol with the ALL_ROOMS symbol
|
||||
protocolsList.push({
|
||||
instances: [{
|
||||
fields: [],
|
||||
network_id: "",
|
||||
instance_id: ALL_ROOMS,
|
||||
desc: _t("All rooms"),
|
||||
}],
|
||||
location_fields: [],
|
||||
user_fields: [],
|
||||
field_types: {},
|
||||
icon: "",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -170,7 +214,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
if (removableServers.has(server)) {
|
||||
const onClick = async () => {
|
||||
closeMenu();
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
|
||||
title: _t("Are you sure?"),
|
||||
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
|
||||
|
@ -189,7 +232,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
setUserDefinedServers(servers.filter(s => s !== server));
|
||||
|
||||
// the selected server is being removed, reset to our HS
|
||||
if (serverSelected === server) {
|
||||
if (serverSelected) {
|
||||
onOptionChange(hsName, undefined);
|
||||
}
|
||||
};
|
||||
|
@ -221,7 +264,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
|
||||
const onClick = async () => {
|
||||
closeMenu();
|
||||
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
|
||||
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
|
||||
title: _t("Add a new server"),
|
||||
description: _t("Enter the name of a new server you want to explore."),
|
||||
|
@ -282,9 +324,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
|
|||
</div>;
|
||||
};
|
||||
|
||||
NetworkDropdown.propTypes = {
|
||||
onOptionChange: PropTypes.func.isRequired,
|
||||
protocols: PropTypes.object,
|
||||
};
|
||||
|
||||
export default NetworkDropdown;
|
|
@ -32,6 +32,7 @@ export default class ActionButton extends React.Component {
|
|||
label: PropTypes.string.isRequired,
|
||||
iconPath: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -70,8 +71,8 @@ export default class ActionButton extends React.Component {
|
|||
}
|
||||
|
||||
const icon = this.props.iconPath ?
|
||||
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
|
||||
undefined;
|
||||
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
|
||||
undefined;
|
||||
|
||||
const classNames = ["mx_RoleButton"];
|
||||
if (this.props.className) {
|
||||
|
@ -79,7 +80,8 @@ export default class ActionButton extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton className={classNames.join(" ")}
|
||||
<AccessibleButton
|
||||
className={classNames.join(" ")}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onMouseLeave={this._onMouseLeave}
|
||||
|
@ -87,6 +89,7 @@ export default class ActionButton extends React.Component {
|
|||
>
|
||||
{ icon }
|
||||
{ tooltip }
|
||||
{ this.props.children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -109,7 +109,7 @@ export default class AppTile extends React.Component {
|
|||
const childContentProtocol = u.protocol;
|
||||
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
|
||||
console.warn("Refusing to load mixed-content app:",
|
||||
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
|
||||
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -65,12 +65,18 @@ export class EditableItem extends React.Component {
|
|||
<span className="mx_EditableItem_promptText">
|
||||
{_t("Are you sure?")}
|
||||
</span>
|
||||
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm"
|
||||
className="mx_EditableItem_confirmBtn">
|
||||
<AccessibleButton
|
||||
onClick={this._onActuallyRemove}
|
||||
kind="primary_sm"
|
||||
className="mx_EditableItem_confirmBtn"
|
||||
>
|
||||
{_t("Yes")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm"
|
||||
className="mx_EditableItem_confirmBtn">
|
||||
<AccessibleButton
|
||||
onClick={this._onDontRemove}
|
||||
kind="danger_sm"
|
||||
className="mx_EditableItem_confirmBtn"
|
||||
>
|
||||
{_t("No")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
@ -121,11 +127,15 @@ export default class EditableItemList extends React.Component {
|
|||
|
||||
_renderNewItemField() {
|
||||
return (
|
||||
<form onSubmit={this._onItemAdded} autoComplete="off"
|
||||
noValidate={true} className="mx_EditableItemList_newItem">
|
||||
<form
|
||||
onSubmit={this._onItemAdded}
|
||||
autoComplete="off"
|
||||
noValidate={true}
|
||||
className="mx_EditableItemList_newItem"
|
||||
>
|
||||
<Field label={this.props.placeholder} type="text"
|
||||
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
|
||||
list={this.props.suggestionsListId} />
|
||||
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
|
||||
list={this.props.suggestionsListId} />
|
||||
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
|
||||
{_t("Add")}
|
||||
</AccessibleButton>
|
||||
|
|
|
@ -221,13 +221,15 @@ export default class EditableText extends React.Component {
|
|||
</div>;
|
||||
} else {
|
||||
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
|
||||
editableEl = <div ref={this._editable_div}
|
||||
contentEditable={true}
|
||||
className={className}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur} />;
|
||||
editableEl = <div
|
||||
ref={this._editable_div}
|
||||
contentEditable={true}
|
||||
className={className}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
/>;
|
||||
}
|
||||
|
||||
return editableEl;
|
||||
|
|
|
@ -37,7 +37,7 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
|
|||
effect = new Effect(options);
|
||||
effectsRef.current[name] = effect;
|
||||
} catch (err) {
|
||||
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
|
||||
console.warn(`Unable to load effect module at '../../../effects/${name}.`, err);
|
||||
}
|
||||
}
|
||||
return effect;
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { HTMLAttributes } from "react";
|
||||
import React, { HTMLAttributes, ReactNode, useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { sortBy } from "lodash";
|
||||
|
@ -24,6 +24,7 @@ import { _t } from "../../../languageHandler";
|
|||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import { useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const DEFAULT_NUM_FACES = 5;
|
||||
|
||||
|
@ -36,6 +37,7 @@ interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
|||
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
|
||||
|
||||
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
let members = useRoomMembers(room);
|
||||
|
||||
// sort users with an explicit avatar first
|
||||
|
@ -46,21 +48,42 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, .
|
|||
// sort known users first
|
||||
iteratees.unshift(member => isKnownMember(member));
|
||||
}
|
||||
if (members.length < 1) return null;
|
||||
|
||||
const shownMembers = sortBy(members, iteratees).slice(0, numShown);
|
||||
// exclude ourselves from the shown members list
|
||||
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
|
||||
if (shownMembers.length < 1) return null;
|
||||
|
||||
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
|
||||
// reverse members in tooltip order to make the order between the two match up.
|
||||
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
|
||||
|
||||
let tooltip: ReactNode;
|
||||
if (props.onClick) {
|
||||
tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("View all %(count)s members", { count: members.length }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) }
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
|
||||
count: members.length,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
}
|
||||
|
||||
return <div {...props} className="mx_FacePile">
|
||||
<div className="mx_FacePile_faces">
|
||||
{ shownMembers.map(member => {
|
||||
return <TextWithTooltip key={member.userId} tooltip={member.name}>
|
||||
<MemberAvatar member={member} width={28} height={28} />
|
||||
</TextWithTooltip>;
|
||||
}) }
|
||||
</div>
|
||||
{ onlyKnownUsers && <span>
|
||||
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
|
||||
{ shownMembers.map(m =>
|
||||
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
|
||||
</TextWithTooltip>
|
||||
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||
</span> }
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default FacePile;
|
||||
|
|
|
@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
|
||||
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
|
||||
label={tooltipContent || this.state.feedback}
|
||||
forceOnRight
|
||||
alignment={Tooltip.Alignment.Right}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,235 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {formatDate} from '../../../DateUtils';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import filesize from "filesize";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Modal from "../../../Modal";
|
||||
import * as sdk from "../../../index";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.ImageView")
|
||||
export default class ImageView extends React.Component {
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired, // the source of the image being displayed
|
||||
name: PropTypes.string, // the main title ('name') for the image
|
||||
link: PropTypes.string, // the link (if any) applied to the name of the image
|
||||
width: PropTypes.number, // width of the image src in pixels
|
||||
height: PropTypes.number, // height of the image src in pixels
|
||||
fileSize: PropTypes.number, // size of the image src in bytes
|
||||
onFinished: PropTypes.func.isRequired, // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { rotationDegrees: 0 };
|
||||
}
|
||||
|
||||
onKeyDown = (ev) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
onRedactClick = () => {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
this.props.onFinished();
|
||||
MatrixClientPeg.get().redactEvent(
|
||||
this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(),
|
||||
).catch(function(e) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
// display error message stating you couldn't delete this.
|
||||
const code = e.errcode || e.statusCode;
|
||||
Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('You cannot delete this image. (%(code)s)', {code: code}),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
getName() {
|
||||
let name = this.props.name;
|
||||
if (name && this.props.link) {
|
||||
name = <a href={ this.props.link } target="_blank" rel="noreferrer noopener">{ name }</a>;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
rotateCounterClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur - 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
rotateClockwise = () => {
|
||||
const cur = this.state.rotationDegrees;
|
||||
const rotationDegrees = (cur + 90) % 360;
|
||||
this.setState({ rotationDegrees });
|
||||
};
|
||||
|
||||
render() {
|
||||
/*
|
||||
// In theory max-width: 80%, max-height: 80% on the CSS should work
|
||||
// but in practice, it doesn't, so do it manually:
|
||||
|
||||
var width = this.props.width || 500;
|
||||
var height = this.props.height || 500;
|
||||
|
||||
var maxWidth = document.documentElement.clientWidth * 0.8;
|
||||
var maxHeight = document.documentElement.clientHeight * 0.8;
|
||||
|
||||
var widthFrac = width / maxWidth;
|
||||
var heightFrac = height / maxHeight;
|
||||
|
||||
var displayWidth;
|
||||
var displayHeight;
|
||||
if (widthFrac > heightFrac) {
|
||||
displayWidth = Math.min(width, maxWidth);
|
||||
displayHeight = (displayWidth / width) * height;
|
||||
} else {
|
||||
displayHeight = Math.min(height, maxHeight);
|
||||
displayWidth = (displayHeight / height) * width;
|
||||
}
|
||||
|
||||
var style = {
|
||||
width: displayWidth,
|
||||
height: displayHeight
|
||||
};
|
||||
*/
|
||||
let style = {};
|
||||
let res;
|
||||
|
||||
if (this.props.width && this.props.height) {
|
||||
style = {
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
};
|
||||
res = style.width + "x" + style.height + "px";
|
||||
}
|
||||
|
||||
let size;
|
||||
if (this.props.fileSize) {
|
||||
size = filesize(this.props.fileSize);
|
||||
}
|
||||
|
||||
let sizeRes;
|
||||
if (size && res) {
|
||||
sizeRes = size + ", " + res;
|
||||
} else {
|
||||
sizeRes = size || res;
|
||||
}
|
||||
|
||||
let mayRedact = false;
|
||||
const showEventMeta = !!this.props.mxEvent;
|
||||
|
||||
let eventMeta;
|
||||
if (showEventMeta) {
|
||||
// Figure out the sender, defaulting to mxid
|
||||
let sender = this.props.mxEvent.getSender();
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
if (room) {
|
||||
mayRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
|
||||
const member = room.getMember(sender);
|
||||
if (member) sender = member.name;
|
||||
}
|
||||
|
||||
eventMeta = (<div className="mx_ImageView_metadata">
|
||||
{ _t('Uploaded on %(date)s by %(user)s', {
|
||||
date: formatDate(new Date(this.props.mxEvent.getTs())),
|
||||
user: sender,
|
||||
}) }
|
||||
</div>);
|
||||
}
|
||||
|
||||
let eventRedact;
|
||||
if (mayRedact) {
|
||||
eventRedact = (<div className="mx_ImageView_button" onClick={this.onRedactClick}>
|
||||
{ _t('Remove') }
|
||||
</div>);
|
||||
}
|
||||
|
||||
const rotationDegrees = this.state.rotationDegrees;
|
||||
const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style};
|
||||
|
||||
return (
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
}}
|
||||
className="mx_ImageView"
|
||||
>
|
||||
<div className="mx_ImageView_lhs">
|
||||
</div>
|
||||
<div className="mx_ImageView_content">
|
||||
<img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" />
|
||||
<div className="mx_ImageView_labelWrapper">
|
||||
<div className="mx_ImageView_label">
|
||||
<AccessibleButton className="mx_ImageView_rotateCounterClockwise" title={_t("Rotate Left")} onClick={ this.rotateCounterClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_rotateClockwise" title={_t("Rotate Right")} onClick={ this.rotateClockwise }>
|
||||
<img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" />
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_ImageView_cancel" title={_t("Close")} onClick={ this.props.onFinished }>
|
||||
<img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } />
|
||||
</AccessibleButton>
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
<div className="mx_ImageView_name">
|
||||
{ this.getName() }
|
||||
</div>
|
||||
{ eventMeta }
|
||||
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
|
||||
<div className="mx_ImageView_download">
|
||||
{ _t('Download this file') }<br />
|
||||
<span className="mx_ImageView_size">{ sizeRes }</span>
|
||||
</div>
|
||||
</a>
|
||||
{ eventRedact }
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_ImageView_rhs">
|
||||
</div>
|
||||
</FocusLock>
|
||||
);
|
||||
}
|
||||
}
|
491
src/components/views/elements/ImageView.tsx
Normal file
491
src/components/views/elements/ImageView.tsx
Normal file
|
@ -0,0 +1,491 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2020, 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||
import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu';
|
||||
import MessageTimestamp from "../messages/MessageTimestamp";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {formatFullDate} from "../../../DateUtils";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {normalizeWheelEvent} from "../../../utils/Mouse";
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
// This is used for the buttons
|
||||
const ZOOM_STEP = 0.10;
|
||||
// This is used for mouse wheel events
|
||||
const ZOOM_COEFFICIENT = 0.0025;
|
||||
// If we have moved only this much we can zoom
|
||||
const ZOOM_DISTANCE = 10;
|
||||
|
||||
interface IProps {
|
||||
src: string, // the source of the image being displayed
|
||||
name?: string, // the main title ('name') for the image
|
||||
link?: string, // the link (if any) applied to the name of the image
|
||||
width?: number, // width of the image src in pixels
|
||||
height?: number, // height of the image src in pixels
|
||||
fileSize?: number, // size of the image src in bytes
|
||||
onFinished(): void, // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: MatrixEvent,
|
||||
permalinkCreator: RoomPermalinkCreator,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
zoom: number,
|
||||
minZoom: number,
|
||||
maxZoom: number,
|
||||
rotation: number,
|
||||
translationX: number,
|
||||
translationY: number,
|
||||
moving: boolean,
|
||||
contextMenuDisplayed: boolean,
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.ImageView")
|
||||
export default class ImageView extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
zoom: 0,
|
||||
minZoom: MAX_SCALE,
|
||||
maxZoom: MAX_SCALE,
|
||||
rotation: 0,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
moving: false,
|
||||
contextMenuDisplayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// XXX: Refs to functional components
|
||||
private contextMenuButton = createRef<any>();
|
||||
private focusLock = createRef<any>();
|
||||
private imageWrapper = createRef<HTMLDivElement>();
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private lastX = 0;
|
||||
private lastY = 0;
|
||||
private previousX = 0;
|
||||
private previousY = 0;
|
||||
|
||||
componentDidMount() {
|
||||
// We have to use addEventListener() because the listener
|
||||
// needs to be passive in order to work with Chromium
|
||||
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
|
||||
// We want to recalculate zoom whenever the window's size changes
|
||||
window.addEventListener("resize", this.calculateZoom);
|
||||
// After the image loads for the first time we want to calculate the zoom
|
||||
this.image.current.addEventListener("load", this.calculateZoom);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.focusLock.current.removeEventListener('wheel', this.onWheel);
|
||||
window.removeEventListener("resize", this.calculateZoom);
|
||||
this.image.current.removeEventListener("load", this.calculateZoom);
|
||||
}
|
||||
|
||||
private calculateZoom = () => {
|
||||
const image = this.image.current;
|
||||
const imageWrapper = this.imageWrapper.current;
|
||||
|
||||
const zoomX = imageWrapper.clientWidth / image.naturalWidth;
|
||||
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
|
||||
|
||||
// If the image is smaller in both dimensions set its the zoom to 1 to
|
||||
// display it in its original size
|
||||
if (zoomX >= 1 && zoomY >= 1) {
|
||||
this.setState({
|
||||
zoom: 1,
|
||||
minZoom: 1,
|
||||
maxZoom: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
|
||||
// any direction. We also multiply by MAX_SCALE to get a gap around the
|
||||
// image by default
|
||||
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
|
||||
|
||||
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
|
||||
this.setState({
|
||||
minZoom: minZoom,
|
||||
maxZoom: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newZoom = this.state.zoom + delta;
|
||||
|
||||
if (newZoom <= this.state.minZoom) {
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (newZoom >= this.state.maxZoom) {
|
||||
this.setState({zoom: this.state.maxZoom});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
zoom: newZoom,
|
||||
});
|
||||
}
|
||||
|
||||
private onWheel = (ev: WheelEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
const {deltaY} = normalizeWheelEvent(ev);
|
||||
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
|
||||
};
|
||||
|
||||
private onZoomInClick = () => {
|
||||
this.zoom(ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onZoomOutClick = () => {
|
||||
this.zoom(-ZOOM_STEP);
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
private onRotateCounterClockwiseClick = () => {
|
||||
const cur = this.state.rotation;
|
||||
const rotationDegrees = cur - 90;
|
||||
this.setState({ rotation: rotationDegrees });
|
||||
};
|
||||
|
||||
private onRotateClockwiseClick = () => {
|
||||
const cur = this.state.rotation;
|
||||
const rotationDegrees = cur + 90;
|
||||
this.setState({ rotation: rotationDegrees });
|
||||
};
|
||||
|
||||
private onDownloadClick = () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = this.props.src;
|
||||
a.download = this.props.name;
|
||||
a.target = "_blank";
|
||||
a.rel = "noreferrer noopener";
|
||||
a.click();
|
||||
};
|
||||
|
||||
private onOpenContextMenu = () => {
|
||||
this.setState({
|
||||
contextMenuDisplayed: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseContextMenu = () => {
|
||||
this.setState({
|
||||
contextMenuDisplayed: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onPermalinkClicked = (ev: React.MouseEvent) => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
ev.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
});
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
private onStartMoving = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// Don't do anything if we pressed any
|
||||
// other button than the left one
|
||||
if (ev.button !== 0) return;
|
||||
|
||||
// Zoom in if we are completely zoomed out
|
||||
if (this.state.zoom === this.state.minZoom) {
|
||||
this.setState({zoom: this.state.maxZoom});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({moving: true});
|
||||
this.previousX = this.state.translationX;
|
||||
this.previousY = this.state.translationY;
|
||||
this.initX = ev.pageX - this.lastX;
|
||||
this.initY = ev.pageY - this.lastY;
|
||||
};
|
||||
|
||||
private onMoving = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.state.moving) return;
|
||||
|
||||
this.lastX = ev.pageX - this.initX;
|
||||
this.lastY = ev.pageY - this.initY;
|
||||
this.setState({
|
||||
translationX: this.lastX,
|
||||
translationY: this.lastY,
|
||||
});
|
||||
};
|
||||
|
||||
private onEndMoving = () => {
|
||||
// Zoom out if we haven't moved much
|
||||
if (
|
||||
this.state.moving === true &&
|
||||
Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE &&
|
||||
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
|
||||
) {
|
||||
this.setState({
|
||||
zoom: this.state.minZoom,
|
||||
translationX: 0,
|
||||
translationY: 0,
|
||||
});
|
||||
}
|
||||
this.setState({moving: false});
|
||||
};
|
||||
|
||||
private renderContextMenu() {
|
||||
let contextMenu = null;
|
||||
if (this.state.contextMenuDisplayed) {
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
|
||||
onFinished={this.onCloseContextMenu}
|
||||
>
|
||||
<MessageContextMenu
|
||||
mxEvent={this.props.mxEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFinished={this.onCloseContextMenu}
|
||||
onCloseDialog={this.props.onFinished}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{ contextMenu }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const showEventMeta = !!this.props.mxEvent;
|
||||
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
|
||||
|
||||
let cursor;
|
||||
if (this.state.moving) {
|
||||
cursor= "grabbing";
|
||||
} else if (zoomingDisabled) {
|
||||
cursor = "default";
|
||||
} else if (this.state.zoom === this.state.minZoom) {
|
||||
cursor = "zoom-in";
|
||||
} else {
|
||||
cursor = "zoom-out";
|
||||
}
|
||||
const rotationDegrees = this.state.rotation + "deg";
|
||||
const zoom = this.state.zoom;
|
||||
const translatePixelsX = this.state.translationX + "px";
|
||||
const translatePixelsY = this.state.translationY + "px";
|
||||
// The order of the values is important!
|
||||
// First, we translate and only then we rotate, otherwise
|
||||
// we would apply the translation to an already rotated
|
||||
// image causing it translate in the wrong direction.
|
||||
const style = {
|
||||
cursor: cursor,
|
||||
transition: this.state.moving ? null : "transform 200ms ease 0s",
|
||||
transform: `translateX(${translatePixelsX})
|
||||
translateY(${translatePixelsY})
|
||||
scale(${zoom})
|
||||
rotate(${rotationDegrees})`,
|
||||
};
|
||||
|
||||
let info;
|
||||
if (showEventMeta) {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
|
||||
const sender = (
|
||||
<div className="mx_ImageView_info_sender">
|
||||
{senderName}
|
||||
</div>
|
||||
);
|
||||
const messageTimestamp = (
|
||||
<a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatFullDate(new Date(this.props.mxEvent.getTs()), showTwelveHour, false)}
|
||||
>
|
||||
<MessageTimestamp
|
||||
showFullDate={true}
|
||||
showTwelveHour={showTwelveHour}
|
||||
ts={mxEvent.getTs()}
|
||||
showSeconds={false}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
const avatar = (
|
||||
<MemberAvatar
|
||||
member={mxEvent.sender}
|
||||
width={32} height={32}
|
||||
viewUserOnClick={true}
|
||||
/>
|
||||
);
|
||||
|
||||
info = (
|
||||
<div className="mx_ImageView_info_wrapper">
|
||||
{avatar}
|
||||
<div className="mx_ImageView_info">
|
||||
{sender}
|
||||
{messageTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// If there is no event - we're viewing an avatar, we set
|
||||
// an empty div here, since the panel uses space-between
|
||||
// and we want the same placement of elements
|
||||
info = (
|
||||
<div></div>
|
||||
);
|
||||
}
|
||||
|
||||
let contextMenuButton;
|
||||
if (this.props.mxEvent) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_more"
|
||||
title={_t("Options")}
|
||||
onClick={this.onOpenContextMenu}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.contextMenuDisplayed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let zoomOutButton;
|
||||
let zoomInButton;
|
||||
if (!zoomingDisabled) {
|
||||
zoomOutButton = (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomOut"
|
||||
title={_t("Zoom out")}
|
||||
onClick={this.onZoomOutClick}>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
zoomInButton = (
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_zoomIn"
|
||||
title={_t("Zoom in")}
|
||||
onClick={ this.onZoomInClick }>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
}}
|
||||
className="mx_ImageView"
|
||||
ref={this.focusLock}
|
||||
>
|
||||
<div className="mx_ImageView_panel">
|
||||
{info}
|
||||
<div className="mx_ImageView_toolbar">
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCCW"
|
||||
title={_t("Rotate Left")}
|
||||
onClick={ this.onRotateCounterClockwiseClick }>
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
title={_t("Rotate Right")}
|
||||
onClick={this.onRotateClockwiseClick}>
|
||||
</AccessibleTooltipButton>
|
||||
{zoomOutButton}
|
||||
{zoomInButton}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={_t("Download")}
|
||||
onClick={ this.onDownloadClick }>
|
||||
</AccessibleTooltipButton>
|
||||
{contextMenuButton}
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ImageView_button mx_ImageView_button_close"
|
||||
title={_t("Close")}
|
||||
onClick={ this.props.onFinished }>
|
||||
</AccessibleTooltipButton>
|
||||
{this.renderContextMenu()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mx_ImageView_image_wrapper"
|
||||
ref={this.imageWrapper}>
|
||||
<img
|
||||
src={this.props.src}
|
||||
title={this.props.name}
|
||||
style={style}
|
||||
ref={this.image}
|
||||
className="mx_ImageView_image"
|
||||
draggable={true}
|
||||
onMouseDown={this.onStartMoving}
|
||||
onMouseMove={this.onMoving}
|
||||
onMouseUp={this.onEndMoving}
|
||||
onMouseLeave={this.onEndMoving}
|
||||
/>
|
||||
</div>
|
||||
</FocusLock>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,8 +18,8 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Tooltip from './Tooltip';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Tooltip, {Alignment} from './Tooltip';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
interface ITooltipProps {
|
||||
|
@ -61,7 +61,7 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
|
|||
className="mx_InfoTooltip_container"
|
||||
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
forceOnRight={true}
|
||||
alignment={Alignment.Right}
|
||||
/> : <div />;
|
||||
return (
|
||||
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.elements.InlineSpinner")
|
||||
|
@ -24,24 +23,14 @@ export default class InlineSpinner extends React.Component {
|
|||
render() {
|
||||
const w = this.props.w || 16;
|
||||
const h = this.props.h || 16;
|
||||
const imgClass = this.props.imgClassName || "";
|
||||
|
||||
let imageSource;
|
||||
if (SettingsStore.getValue('feature_new_spinner')) {
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_InlineSpinner">
|
||||
<img
|
||||
src={imageSource}
|
||||
width={w}
|
||||
height={h}
|
||||
className={imgClass}
|
||||
<div
|
||||
className="mx_InlineSpinner_icon mx_Spinner_icon"
|
||||
style={{width: w, height: h}}
|
||||
aria-label={_t("Loading...")}
|
||||
/>
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -46,8 +46,12 @@ export default class LabelledToggleSwitch extends React.Component {
|
|||
// This is a minimal version of a SettingsFlag
|
||||
|
||||
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
|
||||
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
|
||||
onChange={this.props.onChange} aria-label={this.props.label} />;
|
||||
let secondPart = <ToggleSwitch
|
||||
checked={this.props.value}
|
||||
disabled={this.props.disabled}
|
||||
onChange={this.props.onChange}
|
||||
aria-label={this.props.label}
|
||||
/>;
|
||||
|
||||
if (this.props.toggleInFront) {
|
||||
const temp = firstPart;
|
||||
|
|
|
@ -58,13 +58,8 @@ export default class LanguageDropdown extends React.Component {
|
|||
// If no value is given, we start with the first
|
||||
// country selected, but our parent component
|
||||
// doesn't know this, therefore we do this.
|
||||
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
if (language) {
|
||||
this.props.onOptionChange(language);
|
||||
} else {
|
||||
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
const language = languageHandler.getUserLanguage();
|
||||
this.props.onOptionChange(language);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import {EventType} from 'matrix-js-sdk/src/@types/event';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Spinner from "./Spinner";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {useTimeout} from "../../../hooks/useTimeout";
|
||||
import Analytics from "../../../Analytics";
|
||||
|
@ -88,6 +89,12 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
|
|||
>
|
||||
{ children }
|
||||
|
||||
<div className="mx_MiniAvatarUploader_indicator">
|
||||
{ busy ?
|
||||
<Spinner w={20} h={20} /> :
|
||||
<div className="mx_MiniAvatarUploader_cameraIcon"></div> }
|
||||
</div>
|
||||
|
||||
<div className={classNames("mx_Tooltip", {
|
||||
"mx_Tooltip_visible": visible,
|
||||
"mx_Tooltip_invisible": !visible,
|
||||
|
|
|
@ -139,6 +139,8 @@ export default class PersistedElement extends React.Component {
|
|||
_onAction(payload) {
|
||||
if (payload.action === 'timeline_resize') {
|
||||
this._repositionChild();
|
||||
} else if (payload.action === 'logout') {
|
||||
PersistedElement.destroyElement(this.props.persistKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -225,19 +225,19 @@ class Pill extends React.Component {
|
|||
}
|
||||
break;
|
||||
case Pill.TYPE_USER_MENTION: {
|
||||
// If this user is not a member of this room, default to the empty member
|
||||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
member.rawDisplayName = member.rawDisplayName || '';
|
||||
linkText = member.rawDisplayName;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
|
||||
}
|
||||
pillClass = 'mx_UserPill';
|
||||
href = null;
|
||||
onClick = this.onUserPillClicked;
|
||||
// If this user is not a member of this room, default to the empty member
|
||||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
member.rawDisplayName = member.rawDisplayName || '';
|
||||
linkText = member.rawDisplayName;
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
|
||||
}
|
||||
pillClass = 'mx_UserPill';
|
||||
href = null;
|
||||
onClick = this.onUserPillClicked;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Pill.TYPE_ROOM_MENTION: {
|
||||
|
|
|
@ -135,9 +135,13 @@ export default class PowerSelector extends React.Component {
|
|||
if (this.state.custom) {
|
||||
picker = (
|
||||
<Field type="number"
|
||||
label={label} max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
|
||||
value={String(this.state.customValue)} disabled={this.props.disabled} />
|
||||
label={label} max={this.props.maxValue}
|
||||
onBlur={this.onCustomBlur}
|
||||
onKeyDown={this.onCustomKeyDown}
|
||||
onChange={this.onCustomChange}
|
||||
value={String(this.state.customValue)}
|
||||
disabled={this.props.disabled}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Each level must have a definition in this.state.levelRoleMap
|
||||
|
@ -154,8 +158,9 @@ export default class PowerSelector extends React.Component {
|
|||
|
||||
picker = (
|
||||
<Field element="select"
|
||||
label={label} onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}>
|
||||
label={label} onChange={this.onSelectChange}
|
||||
value={String(this.state.selectValue)} disabled={this.props.disabled}
|
||||
>
|
||||
{options}
|
||||
</Field>
|
||||
);
|
||||
|
|
|
@ -46,17 +46,18 @@ export default class RoomAliasField extends React.PureComponent {
|
|||
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
|
||||
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
|
||||
return (
|
||||
<Field
|
||||
label={_t("Room address")}
|
||||
className="mx_RoomAliasField"
|
||||
prefixComponent={poundSign}
|
||||
postfixComponent={domain}
|
||||
ref={ref => this._fieldRef = ref}
|
||||
onValidate={this._onValidate}
|
||||
placeholder={_t("e.g. my-room")}
|
||||
onChange={this._onChange}
|
||||
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
|
||||
maxLength={maxlength} />
|
||||
<Field
|
||||
label={_t("Room address")}
|
||||
className="mx_RoomAliasField"
|
||||
prefixComponent={poundSign}
|
||||
postfixComponent={domain}
|
||||
ref={ref => this._fieldRef = ref}
|
||||
onValidate={this._onValidate}
|
||||
placeholder={_t("e.g. my-room")}
|
||||
onChange={this._onChange}
|
||||
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
|
||||
maxLength={maxlength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
@ -67,7 +67,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
|
|||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
|
||||
let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
|
||||
if (serverConfig.hsNameIsDifferent) {
|
||||
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
|
||||
{serverConfig.hsName}
|
||||
|
|
|
@ -18,33 +18,21 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
|
||||
let imageSource;
|
||||
if (SettingsStore.getValue('feature_new_spinner')) {
|
||||
imageSource = require("../../../../res/img/spinner.svg");
|
||||
} else {
|
||||
imageSource = require("../../../../res/img/spinner.gif");
|
||||
}
|
||||
const Spinner = ({w = 32, h = 32, message}) => (
|
||||
<div className="mx_Spinner">
|
||||
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div> </React.Fragment> }
|
||||
<div
|
||||
className="mx_Spinner_icon"
|
||||
style={{width: w, height: h}}
|
||||
aria-label={_t("Loading...")}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_Spinner">
|
||||
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div> </React.Fragment> }
|
||||
<img
|
||||
src={imageSource}
|
||||
width={w}
|
||||
height={h}
|
||||
className={imgClassName}
|
||||
aria-label={_t("Loading...")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Spinner.propTypes = {
|
||||
w: PropTypes.number,
|
||||
h: PropTypes.number,
|
||||
imgClassName: PropTypes.string,
|
||||
message: PropTypes.node,
|
||||
};
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ export default class TextWithTooltip extends React.Component {
|
|||
class: PropTypes.string,
|
||||
tooltipClass: PropTypes.string,
|
||||
tooltip: PropTypes.node.isRequired,
|
||||
tooltipProps: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -46,15 +47,17 @@ export default class TextWithTooltip extends React.Component {
|
|||
render() {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
|
||||
const {class: className, children, tooltip, tooltipClass, ...props} = this.props;
|
||||
const {class: className, children, tooltip, tooltipClass, tooltipProps, ...props} = this.props;
|
||||
|
||||
return (
|
||||
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
|
||||
{children}
|
||||
{this.state.hover && <Tooltip
|
||||
{...tooltipProps}
|
||||
label={tooltip}
|
||||
tooltipClassName={tooltipClass}
|
||||
className={"mx_TextWithTooltip_tooltip"} /> }
|
||||
className={"mx_TextWithTooltip_tooltip"}
|
||||
/> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -59,13 +59,13 @@ class TintableSvg extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<object className={"mx_TintableSvg " + (this.props.className ? this.props.className : "")}
|
||||
type="image/svg+xml"
|
||||
data={this.props.src}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
onLoad={this.onLoad}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
type="image/svg+xml"
|
||||
data={this.props.src}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
onLoad={this.onLoad}
|
||||
tabIndex="-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,14 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
|
|||
|
||||
const MIN_TOOLTIP_HEIGHT = 25;
|
||||
|
||||
export enum Alignment {
|
||||
Natural, // Pick left or right
|
||||
Left,
|
||||
Right,
|
||||
Top, // Centered
|
||||
Bottom, // Centered
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Class applied to the element used to position the tooltip
|
||||
className?: string;
|
||||
|
@ -36,7 +44,7 @@ interface IProps {
|
|||
visible?: boolean;
|
||||
// the react element to put into the tooltip
|
||||
label: React.ReactNode;
|
||||
forceOnRight?: boolean;
|
||||
alignment?: Alignment; // defaults to Natural
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
|
@ -46,10 +54,14 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
private tooltip: void | Element | Component<Element, any, any>;
|
||||
private parent: Element;
|
||||
|
||||
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
|
||||
// so we expose the Alignment options off of us statically.
|
||||
public static readonly Alignment = Alignment;
|
||||
|
||||
public static readonly defaultProps = {
|
||||
visible: true,
|
||||
yOffset: 0,
|
||||
alignment: Alignment.Natural,
|
||||
};
|
||||
|
||||
// Create a wrapper for the tooltip outside the parent and attach it to the body element
|
||||
|
@ -86,11 +98,35 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
|
||||
}
|
||||
|
||||
style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
|
||||
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
|
||||
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
|
||||
} else {
|
||||
style.left = parentBox.right + window.pageXOffset + 6;
|
||||
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
|
||||
const top = baseTop + offset;
|
||||
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
|
||||
const left = parentBox.right + window.pageXOffset + 6;
|
||||
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
|
||||
switch (this.props.alignment) {
|
||||
case Alignment.Natural:
|
||||
if (parentBox.right > window.innerWidth / 2) {
|
||||
style.right = right;
|
||||
style.top = top;
|
||||
break;
|
||||
}
|
||||
// fall through to Right
|
||||
case Alignment.Right:
|
||||
style.left = left;
|
||||
style.top = top;
|
||||
break;
|
||||
case Alignment.Left:
|
||||
style.right = right;
|
||||
style.top = top;
|
||||
break;
|
||||
case Alignment.Top:
|
||||
style.top = baseTop - 16;
|
||||
style.left = horizontalCenter;
|
||||
break;
|
||||
case Alignment.Bottom:
|
||||
style.top = baseTop + parentBox.height;
|
||||
style.left = horizontalCenter;
|
||||
break;
|
||||
}
|
||||
|
||||
return style;
|
||||
|
|
|
@ -178,9 +178,15 @@ export default class GroupMemberList extends React.Component {
|
|||
}
|
||||
|
||||
const inputBox = (
|
||||
<input className="mx_GroupMemberList_query mx_textinput" id="mx_GroupMemberList_query" type="text"
|
||||
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
|
||||
placeholder={_t('Filter community members')} autoComplete="off" />
|
||||
<input
|
||||
className="mx_GroupMemberList_query mx_textinput"
|
||||
id="mx_GroupMemberList_query"
|
||||
type="text"
|
||||
onChange={this.onSearchQueryChanged}
|
||||
value={this.state.searchQuery}
|
||||
placeholder={_t('Filter community members')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
||||
const joined = this.state.members ? <div className="mx_MemberList_joined">
|
||||
|
|
|
@ -67,11 +67,11 @@ export default class GroupPublicityToggle extends React.Component {
|
|||
const GroupTile = sdk.getComponent('groups.GroupTile');
|
||||
return <div className="mx_GroupPublicity_toggle">
|
||||
<GroupTile groupId={this.props.groupId} showDescription={false}
|
||||
avatarHeight={40} draggable={false}
|
||||
avatarHeight={40} draggable={false}
|
||||
/>
|
||||
<ToggleSwitch checked={this.state.isGroupPublicised}
|
||||
disabled={!this.state.ready || this.state.busy}
|
||||
onChange={this._onPublicityToggle} />
|
||||
disabled={!this.state.ready || this.state.busy}
|
||||
onChange={this._onPublicityToggle} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -141,9 +141,14 @@ export default class GroupRoomList extends React.Component {
|
|||
);
|
||||
}
|
||||
const inputBox = (
|
||||
<input className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query" type="text"
|
||||
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
|
||||
placeholder={_t('Filter community rooms')} autoComplete="off" />
|
||||
<input
|
||||
className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query"
|
||||
type="text"
|
||||
onChange={this.onSearchQueryChanged}
|
||||
value={this.state.searchQuery}
|
||||
placeholder={_t('Filter community rooms')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
||||
const TruncatedList = sdk.getComponent("elements.TruncatedList");
|
||||
|
@ -152,7 +157,7 @@ export default class GroupRoomList extends React.Component {
|
|||
{ inviteButton }
|
||||
<AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
|
||||
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile}>
|
||||
createOverflowElement={this._createOverflowTile}>
|
||||
{ this.makeGroupRoomTiles(this.state.searchQuery) }
|
||||
</TruncatedList>
|
||||
</AutoHideScrollbar>
|
||||
|
|
|
@ -160,7 +160,6 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
"mx_EventTile": true,
|
||||
// Note: we keep the `sending` state class for tests, not for our styles
|
||||
"mx_EventTile_sending": isSending,
|
||||
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
|
||||
});
|
||||
return (
|
||||
<li>
|
||||
|
|
|
@ -41,6 +41,9 @@ export default class MImageBody extends React.Component {
|
|||
|
||||
/* the maximum image height to use */
|
||||
maxImageHeight: PropTypes.number,
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator: PropTypes.object,
|
||||
};
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
@ -106,6 +109,7 @@ export default class MImageBody extends React.Component {
|
|||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t('Attachment'),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
|
@ -114,16 +118,16 @@ export default class MImageBody extends React.Component {
|
|||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
}
|
||||
}
|
||||
|
||||
_isGif() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
return (
|
||||
content &&
|
||||
content.info &&
|
||||
content.info.mimetype === "image/gif"
|
||||
content &&
|
||||
content.info &&
|
||||
content.info.mimetype === "image/gif"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -181,9 +185,8 @@ export default class MImageBody extends React.Component {
|
|||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
// custom timeline widths seems preferable.
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
const thumbWidth = Math.round(800 * pixelRatio);
|
||||
const thumbHeight = Math.round(600 * pixelRatio);
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
|
@ -214,7 +217,7 @@ export default class MImageBody extends React.Component {
|
|||
const info = content.info;
|
||||
if (
|
||||
this._isGif() ||
|
||||
pixelRatio === 1.0 ||
|
||||
window.devicePixelRatio === 1.0 ||
|
||||
(!info || !info.w || !info.h || !info.size)
|
||||
) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
|
@ -343,9 +346,9 @@ export default class MImageBody extends React.Component {
|
|||
} else {
|
||||
imageElement = (
|
||||
<img style={{display: 'none'}} src={thumbUrl} ref={this._image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -381,12 +384,12 @@ export default class MImageBody extends React.Component {
|
|||
// mx_MImageBody_thumbnail resizes img to exactly container size
|
||||
img = (
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image}
|
||||
style={{ maxWidth: maxWidth + "px" }}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave} />
|
||||
style={{ maxWidth: maxWidth + "px" }}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave} />
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -464,9 +467,9 @@ export default class MImageBody extends React.Component {
|
|||
const contentUrl = this._getContentUrl();
|
||||
let thumbUrl;
|
||||
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
thumbUrl = contentUrl;
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this._getThumbUrl();
|
||||
thumbUrl = this._getThumbUrl();
|
||||
}
|
||||
|
||||
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);
|
||||
|
|
|
@ -82,9 +82,7 @@ export default class MKeyVerificationConclusion extends React.Component {
|
|||
}
|
||||
|
||||
// User isn't actually verified
|
||||
if (!MatrixClientPeg.get()
|
||||
.checkUserTrust(request.otherUserId)
|
||||
.isCrossSigningVerified()) {
|
||||
if (!MatrixClientPeg.get().checkUserTrust(request.otherUserId).isCrossSigningVerified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
106
src/components/views/messages/MVoiceMessageBody.tsx
Normal file
106
src/components/views/messages/MVoiceMessageBody.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {Playback} from "../../../voice/Playback";
|
||||
import MFileBody from "./MFileBody";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {mediaFromContent} from "../../../customisations/Media";
|
||||
import {decryptFile} from "../../../utils/DecryptFile";
|
||||
import RecordingPlayback from "../voice_messages/RecordingPlayback";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
error?: Error;
|
||||
playback?: Playback;
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceMessageBody")
|
||||
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
let buffer: ArrayBuffer;
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
if (media.isEncrypted) {
|
||||
try {
|
||||
const blob = await decryptFile(content.file);
|
||||
buffer = await blob.arrayBuffer();
|
||||
this.setState({decryptedBlob: blob});
|
||||
} catch (e) {
|
||||
this.setState({error: e});
|
||||
console.warn("Unable to decrypt voice message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
|
||||
} catch (e) {
|
||||
this.setState({error: e});
|
||||
console.warn("Unable to download voice message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
}
|
||||
|
||||
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
|
||||
|
||||
// We should have a buffer to work with now: let's set it up
|
||||
const playback = new Playback(buffer, waveform);
|
||||
this.setState({playback});
|
||||
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
// TODO: @@TR: Verify error state
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
{ _t("Error processing voice message") }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.playback) {
|
||||
// TODO: @@TR: Verify loading/decrypting state
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<InlineSpinner />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// At this point we should have a playable state
|
||||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<RecordingPlayback playback={this.state.playback} />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
39
src/components/views/messages/MVoiceOrAudioBody.tsx
Normal file
39
src/components/views/messages/MVoiceOrAudioBody.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MVoiceMessageBody from "./MVoiceMessageBody";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceOrAudioBody")
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
|
||||
public render() {
|
||||
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
|
||||
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
|
||||
if (isVoiceMessage && voiceMessagesEnabled) {
|
||||
return <MVoiceMessageBody {...this.props} />;
|
||||
} else {
|
||||
return <MAudioBody {...this.props} />;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -29,6 +29,9 @@ import RoomContext from "../../../contexts/RoomContext";
|
|||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {canCancel} from "../context_menus/MessageContextMenu";
|
||||
import Resend from "../../../Resend";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
@ -120,6 +123,10 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
|
||||
this.props.mxEvent.on("Event.status", this.onSent);
|
||||
}
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.decryptEventIfNeeded(this.props.mxEvent);
|
||||
|
||||
if (this.props.mxEvent.isBeingDecrypted()) {
|
||||
this.props.mxEvent.once("Event.decrypted", this.onDecrypted);
|
||||
}
|
||||
|
@ -169,45 +176,118 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
let reactButton;
|
||||
let replyButton;
|
||||
let editButton;
|
||||
/**
|
||||
* Runs a given fn on the set of possible events to test. The first event
|
||||
* that passes the checkFn will have fn executed on it. Both functions take
|
||||
* a MatrixEvent object. If no particular conditions are needed, checkFn can
|
||||
* be null/undefined. If no functions pass the checkFn, no action will be
|
||||
* taken.
|
||||
* @param {Function} fn The execution function.
|
||||
* @param {Function} checkFn The test function.
|
||||
*/
|
||||
runActionOnFailedEv(fn, checkFn) {
|
||||
if (!checkFn) checkFn = () => true;
|
||||
|
||||
if (isContentActionable(this.props.mxEvent)) {
|
||||
if (this.context.canReact) {
|
||||
reactButton = (
|
||||
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} />
|
||||
);
|
||||
}
|
||||
if (this.context.canReply) {
|
||||
replyButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
||||
title={_t("Reply")}
|
||||
onClick={this.onReplyClick}
|
||||
/>;
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editEvent = mxEvent.replacingEvent();
|
||||
const redactEvent = mxEvent.localRedactionEvent();
|
||||
const tryOrder = [redactEvent, editEvent, mxEvent];
|
||||
for (const ev of tryOrder) {
|
||||
if (ev && checkFn(ev)) {
|
||||
fn(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onResendClick = (ev) => {
|
||||
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
|
||||
};
|
||||
|
||||
onCancelClick = (ev) => {
|
||||
this.runActionOnFailedEv(
|
||||
(tarEv) => Resend.removeFromQueue(tarEv),
|
||||
(testEv) => canCancel(testEv.status),
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const toolbarOpts = [];
|
||||
if (canEditContent(this.props.mxEvent)) {
|
||||
editButton = <RovingAccessibleTooltipButton
|
||||
toolbarOpts.push(<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
|
||||
title={_t("Edit")}
|
||||
onClick={this.onEditClick}
|
||||
/>;
|
||||
key="edit"
|
||||
/>);
|
||||
}
|
||||
|
||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
{reactButton}
|
||||
{replyButton}
|
||||
{editButton}
|
||||
<OptionsButton
|
||||
const cancelSendingButton = <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
|
||||
title={_t("Delete")}
|
||||
onClick={this.onCancelClick}
|
||||
key="cancel"
|
||||
/>;
|
||||
|
||||
// We show a different toolbar for failed events, so detect that first.
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
|
||||
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
|
||||
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
|
||||
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
|
||||
if (allowCancel && isFailed) {
|
||||
// The resend button needs to appear ahead of the edit button, so insert to the
|
||||
// start of the opts
|
||||
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_resendButton"
|
||||
title={_t("Retry")}
|
||||
onClick={this.onResendClick}
|
||||
key="resend"
|
||||
/>);
|
||||
|
||||
// The delete button should appear last, so we can just drop it at the end
|
||||
toolbarOpts.push(cancelSendingButton);
|
||||
} else {
|
||||
if (isContentActionable(this.props.mxEvent)) {
|
||||
// Like the resend button, the react and reply buttons need to appear before the edit.
|
||||
// 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"
|
||||
/>);
|
||||
}
|
||||
if (this.context.canReact) {
|
||||
toolbarOpts.splice(0, 0, <ReactButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
reactions={this.props.reactions}
|
||||
onFocusChange={this.onFocusChange}
|
||||
key="react"
|
||||
/>);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowCancel) {
|
||||
toolbarOpts.push(cancelSendingButton);
|
||||
}
|
||||
|
||||
// The menu button should be last, so dump it there.
|
||||
toolbarOpts.push(<OptionsButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
getReplyThread={this.props.getReplyThread}
|
||||
getTile={this.props.getTile}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFocusChange={this.onFocusChange}
|
||||
/>
|
||||
key="menu"
|
||||
/>);
|
||||
}
|
||||
|
||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
{toolbarOpts}
|
||||
</Toolbar>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,9 @@ export default class MessageEvent extends React.Component {
|
|||
|
||||
/* the maximum image height to use, if the event is an image */
|
||||
maxImageHeight: PropTypes.number,
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -69,12 +72,8 @@ export default class MessageEvent extends React.Component {
|
|||
'm.emote': sdk.getComponent('messages.TextualBody'),
|
||||
'm.image': sdk.getComponent('messages.MImageBody'),
|
||||
'm.file': sdk.getComponent('messages.MFileBody'),
|
||||
'm.audio': sdk.getComponent('messages.MAudioBody'),
|
||||
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
|
||||
'm.video': sdk.getComponent('messages.MVideoBody'),
|
||||
|
||||
// TODO: @@ TravisR: Use labs flag determination.
|
||||
// MSC: https://github.com/matrix-org/matrix-doc/pull/2516
|
||||
'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'),
|
||||
};
|
||||
const evTypes = {
|
||||
'm.sticker': sdk.getComponent('messages.MStickerBody'),
|
||||
|
@ -126,6 +125,7 @@ export default class MessageEvent extends React.Component {
|
|||
editState={this.props.editState}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {formatFullDate, formatTime} from '../../../DateUtils';
|
||||
import {formatFullDate, formatTime, formatFullTime} from '../../../DateUtils';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.messages.MessageTimestamp")
|
||||
|
@ -25,13 +25,24 @@ export default class MessageTimestamp extends React.Component {
|
|||
static propTypes = {
|
||||
ts: PropTypes.number.isRequired,
|
||||
showTwelveHour: PropTypes.bool,
|
||||
showFullDate: PropTypes.bool,
|
||||
showSeconds: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
const date = new Date(this.props.ts);
|
||||
let timestamp;
|
||||
if (this.props.showFullDate) {
|
||||
timestamp = formatFullDate(date, this.props.showTwelveHour, this.props.showSeconds);
|
||||
} else if (this.props.showSeconds) {
|
||||
timestamp = formatFullTime(date, this.props.showTwelveHour);
|
||||
} else {
|
||||
timestamp = formatTime(date, this.props.showTwelveHour);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mx_MessageTimestamp" title={formatFullDate(date, this.props.showTwelveHour)} aria-hidden={true}>
|
||||
{ formatTime(date, this.props.showTwelveHour) }
|
||||
{timestamp}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,29 +14,72 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu";
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import ReactionsRowButton from "./ReactionsRowButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
// The maximum number of reactions to initially show on a message.
|
||||
const MAX_ITEMS_WHEN_LIMITED = 8;
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRow")
|
||||
export default class ReactionsRow extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions: PropTypes.object,
|
||||
const ReactButton = ({ mxEvent, reactions }: IProps) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
|
||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className={classNames("mx_ReactionsRow_addReactionButton", {
|
||||
mx_ReactionsRow_addReactionButton_active: menuDisplayed,
|
||||
})}
|
||||
title={_t("Add reaction")}
|
||||
onClick={openMenu}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
openMenu();
|
||||
}}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={button}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The Relations model from the JS SDK for reactions to `mxEvent`
|
||||
reactions?: Relations;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
myReactions: MatrixEvent[];
|
||||
showAll: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRow")
|
||||
export default class ReactionsRow extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
if (props.reactions) {
|
||||
props.reactions.on("Relations.add", this.onReactionsChange);
|
||||
|
@ -92,7 +135,7 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
if (!reactions) {
|
||||
return null;
|
||||
}
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const userId = this.context.getUserId();
|
||||
const myReactions = reactions.getAnnotationsBySender()[userId];
|
||||
if (!myReactions) {
|
||||
return null;
|
||||
|
@ -114,7 +157,6 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
|
||||
let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
|
||||
const count = events.size;
|
||||
if (!count) {
|
||||
|
@ -136,6 +178,8 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
/>;
|
||||
}).filter(item => !!item);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
|
||||
// The "+ 1" ensure that the "show all" reveals something that takes up
|
||||
// more space than the button itself.
|
||||
|
@ -151,13 +195,22 @@ export default class ReactionsRow extends React.PureComponent {
|
|||
</a>;
|
||||
}
|
||||
|
||||
const cli = this.context;
|
||||
|
||||
let addReactionButton;
|
||||
const room = cli.getRoom(mxEvent.getRoomId());
|
||||
if (room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
|
||||
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
|
||||
}
|
||||
|
||||
return <div
|
||||
className="mx_ReactionsRow"
|
||||
role="toolbar"
|
||||
aria-label={_t("Reactions")}
|
||||
>
|
||||
{items}
|
||||
{showAllButton}
|
||||
{ items }
|
||||
{ showAllButton }
|
||||
{ addReactionButton }
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,49 +14,54 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The reaction content / key / emoji
|
||||
content: string;
|
||||
// The count of votes for this key
|
||||
count: number;
|
||||
// A Set of Matrix reaction events for this key
|
||||
reactionEvents: Set<MatrixEvent>;
|
||||
// A possible Matrix event if the current user has voted for this type
|
||||
myReactionEvent?: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
tooltipRendered: boolean;
|
||||
tooltipVisible: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRowButton")
|
||||
export default class ReactionsRowButton extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The reaction content / key / emoji
|
||||
content: PropTypes.string.isRequired,
|
||||
// The count of votes for this key
|
||||
count: PropTypes.number.isRequired,
|
||||
// A Set of Martix reaction events for this key
|
||||
reactionEvents: PropTypes.object.isRequired,
|
||||
// A possible Matrix event if the current user has voted for this type
|
||||
myReactionEvent: PropTypes.object,
|
||||
}
|
||||
export default class ReactionsRowButton extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
state = {
|
||||
tooltipRendered: false,
|
||||
tooltipVisible: false,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
tooltipVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
onClick = () => {
|
||||
const { mxEvent, myReactionEvent, content } = this.props;
|
||||
if (myReactionEvent) {
|
||||
MatrixClientPeg.get().redactEvent(
|
||||
this.context.redactEvent(
|
||||
mxEvent.getRoomId(),
|
||||
myReactionEvent.getId(),
|
||||
);
|
||||
} else {
|
||||
MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
|
||||
this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": mxEvent.getId(),
|
||||
|
@ -83,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const ReactionsRowButtonTooltip =
|
||||
sdk.getComponent('messages.ReactionsRowButtonTooltip');
|
||||
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
|
||||
|
||||
const classes = classNames({
|
||||
|
@ -102,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
/>;
|
||||
}
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
||||
const room = this.context.getRoom(mxEvent.getRoomId());
|
||||
let label;
|
||||
if (room) {
|
||||
const senders = [];
|
||||
|
@ -129,12 +132,12 @@ export default class ReactionsRowButton extends React.PureComponent {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const isPeeking = room.getMyMembership() !== "join";
|
||||
return <AccessibleButton
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
onClick={this.onClick}
|
||||
disabled={isPeeking}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,33 +14,34 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import { unicodeToShortcode } from '../../../HtmlUtils';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: MatrixEvent;
|
||||
// The reaction content / key / emoji
|
||||
content: string;
|
||||
// A Set of Matrix reaction events for this key
|
||||
reactionEvents: Set<MatrixEvent>;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ReactionsRowButtonTooltip")
|
||||
export default class ReactionsRowButtonTooltip extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The event we're displaying reactions for
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
// The reaction content / key / emoji
|
||||
content: PropTypes.string.isRequired,
|
||||
// A Set of Martix reaction events for this key
|
||||
reactionEvents: PropTypes.object.isRequired,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
}
|
||||
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
const { content, reactionEvents, mxEvent, visible } = this.props;
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
|
||||
const room = this.context.getRoom(mxEvent.getRoomId());
|
||||
let tooltipLabel;
|
||||
if (room) {
|
||||
const senders = [];
|
|
@ -49,7 +49,7 @@ export default class RoomAvatarEvent extends React.Component {
|
|||
src: httpUrl,
|
||||
name: text,
|
||||
};
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
@ -521,11 +521,12 @@ export default class TextualBody extends React.Component {
|
|||
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
|
||||
widgets = this.state.links.map((link)=>{
|
||||
return <LinkPreviewWidget
|
||||
key={link}
|
||||
link={link}
|
||||
mxEvent={this.props.mxEvent}
|
||||
onCancelClick={this.onCancelClick}
|
||||
onHeightChanged={this.props.onHeightChanged} />;
|
||||
key={link}
|
||||
link={link}
|
||||
mxEvent={this.props.mxEvent}
|
||||
onCancelClick={this.onCancelClick}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
/>;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
@replaceableComponent("views.messages.ViewSourceEvent")
|
||||
export default class ViewSourceEvent extends React.PureComponent {
|
||||
|
@ -36,6 +37,10 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
|
||||
componentDidMount() {
|
||||
const {mxEvent} = this.props;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
client.decryptEventIfNeeded(mxEvent);
|
||||
|
||||
if (mxEvent.isBeingDecrypted()) {
|
||||
mxEvent.once("Event.decrypted", () => this.forceUpdate());
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../struc
|
|||
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
|
||||
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import RoomName from "../elements/RoomName";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -249,7 +250,13 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<h2 title={room.name}>{ room.name }</h2>
|
||||
<RoomName room={room}>
|
||||
{ name => (
|
||||
<h2 title={name}>
|
||||
{ name }
|
||||
</h2>
|
||||
)}
|
||||
</RoomName>
|
||||
<div className="mx_RoomSummaryCard_alias" title={alias}>
|
||||
{ alias }
|
||||
</div>
|
||||
|
|
|
@ -24,6 +24,7 @@ import {RoomMember} from 'matrix-js-sdk/src/models/room-member';
|
|||
import {User} from 'matrix-js-sdk/src/models/user';
|
||||
import {Room} from 'matrix-js-sdk/src/models/room';
|
||||
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
|
||||
import {MatrixEvent} from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import Modal from '../../../Modal';
|
||||
|
@ -66,7 +67,7 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
|||
import RoomName from "../elements/RoomName";
|
||||
import {mediaFromMxc} from "../../../customisations/Media";
|
||||
|
||||
interface IDevice {
|
||||
export interface IDevice {
|
||||
deviceId: string;
|
||||
ambiguous?: boolean;
|
||||
getDisplayName(): string;
|
||||
|
@ -186,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
|
|||
verifyDevice(cli.getUser(userId), device);
|
||||
};
|
||||
|
||||
const deviceName = device.ambiguous ?
|
||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
let deviceName;
|
||||
if (!device.getDisplayName()?.trim()) {
|
||||
deviceName = device.deviceId;
|
||||
} else {
|
||||
deviceName = device.ambiguous ?
|
||||
device.getDisplayName() + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
}
|
||||
|
||||
let trustedLabel = null;
|
||||
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
||||
|
||||
|
@ -439,7 +446,7 @@ const UserOptionsSection: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const warnSelfDemote = async (isSpace) => {
|
||||
const warnSelfDemote = async (isSpace: boolean) => {
|
||||
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
|
||||
title: _t("Demote yourself?"),
|
||||
description:
|
||||
|
@ -496,11 +503,11 @@ const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) =>
|
|||
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
|
||||
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
const event = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const update = useCallback((ev?: MatrixEvent) => {
|
||||
if (!room) return;
|
||||
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
|
||||
|
||||
const event = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
||||
if (event) {
|
||||
setPowerLevels(event.getContent());
|
||||
} else {
|
||||
|
@ -511,7 +518,7 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
|
|||
};
|
||||
}, [room]);
|
||||
|
||||
useEventEmitter(cli, "RoomState.members", update);
|
||||
useEventEmitter(cli, "RoomState.events", update);
|
||||
useEffect(() => {
|
||||
update();
|
||||
return () => {
|
||||
|
@ -726,7 +733,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
|
|||
// if muting self, warn as it may be irreversible
|
||||
if (target === cli.getUserId()) {
|
||||
try {
|
||||
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
||||
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
return;
|
||||
|
@ -815,7 +822,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
|||
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
|
||||
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
|
||||
}
|
||||
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
|
||||
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
|
||||
redactButton = (
|
||||
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
|
||||
);
|
||||
|
@ -1094,7 +1101,7 @@ const PowerLevelEditor: React.FC<{
|
|||
} else if (myUserId === target) {
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
try {
|
||||
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
|
||||
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
|
@ -1300,7 +1307,7 @@ const BasicUserInfo: React.FC<{
|
|||
}
|
||||
|
||||
if (pendingUpdateCount > 0) {
|
||||
spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
|
||||
spinner = <Spinner />;
|
||||
}
|
||||
|
||||
let memberDetails;
|
||||
|
@ -1324,10 +1331,10 @@ const BasicUserInfo: React.FC<{
|
|||
if (!isRoomEncrypted) {
|
||||
if (!cryptoEnabled) {
|
||||
text = _t("This client does not support end-to-end encryption.");
|
||||
} else if (room && !room.isSpaceRoom()) {
|
||||
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
|
||||
text = _t("Messages in this room are not end-to-end encrypted.");
|
||||
}
|
||||
} else if (!room.isSpaceRoom()) {
|
||||
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) {
|
||||
text = _t("Messages in this room are end-to-end encrypted.");
|
||||
}
|
||||
|
||||
|
@ -1404,7 +1411,7 @@ const BasicUserInfo: React.FC<{
|
|||
canInvite={roomPermissions.canInvite}
|
||||
isIgnored={isIgnored}
|
||||
member={member}
|
||||
isSpace={room?.isSpaceRoom()}
|
||||
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
|
||||
/>
|
||||
|
||||
{ adminToolsContainer }
|
||||
|
@ -1431,7 +1438,7 @@ const UserInfoHeader: React.FC<{
|
|||
name: member.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
}, [member]);
|
||||
|
||||
const avatarElement = (
|
||||
|
@ -1566,7 +1573,7 @@ const UserInfo: React.FC<Props> = ({
|
|||
previousPhase = RightPanelPhases.RoomMemberInfo;
|
||||
refireParams = {member: member};
|
||||
} else if (room) {
|
||||
previousPhase = previousPhase = room.isSpaceRoom()
|
||||
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
|
||||
? RightPanelPhases.SpaceMemberList
|
||||
: RightPanelPhases.RoomMemberList;
|
||||
}
|
||||
|
@ -1615,7 +1622,7 @@ const UserInfo: React.FC<Props> = ({
|
|||
}
|
||||
|
||||
let scopeHeader;
|
||||
if (room?.isSpaceRoom()) {
|
||||
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
|
||||
scopeHeader = <div className="mx_RightPanel_scopeHeader">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
<RoomName room={room} />
|
||||
|
|
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