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

This commit is contained in:
Michael Telatynski 2021-10-12 10:25:21 +01:00
commit 9f039b5a26
33 changed files with 656 additions and 397 deletions

View file

@ -42,10 +42,15 @@ export interface IInviteResult {
*
* @param {string} roomId The ID of the room to invite to
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @param {function} progressCallback optional callback, fired after each invite.
* @returns {Promise} Promise
*/
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId);
export function inviteMultipleToRoom(
roomId: string,
addresses: string[],
progressCallback?: () => void,
): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId, progressCallback);
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
}
@ -104,8 +109,8 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
return true;
}
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
export function inviteUsersToRoom(roomId: string, userIds: string[], progressCallback?: () => void): Promise<void> {
return inviteMultipleToRoom(roomId, userIds, progressCallback).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {

View file

@ -452,7 +452,9 @@ function setBotOptions(event: MessageEvent<any>, roomId: string, userId: string)
});
}
function setBotPower(event: MessageEvent<any>, roomId: string, userId: string, level: number): void {
async function setBotPower(
event: MessageEvent<any>, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean,
): Promise<void> {
if (!(Number.isInteger(level) && level >= 0)) {
sendError(event, _t('Power level must be positive integer.'));
return;
@ -465,22 +467,34 @@ function setBotPower(event: MessageEvent<any>, roomId: string, userId: string, l
return;
}
client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => {
const powerEvent = new MatrixEvent(
try {
const powerLevels = await client.getStateEvent(roomId, "m.room.power_levels", "");
// If the PL is equal to or greater than the requested PL, ignore.
if (ignoreIfGreater === true) {
// As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels
const currentPl = (
powerLevels.content.users && powerLevels.content.users[userId]
) || powerLevels.content.users_default || 0;
if (currentPl >= level) {
return sendResponse(event, {
success: true,
});
}
}
await client.setPowerLevel(roomId, userId, level, new MatrixEvent(
{
type: "m.room.power_levels",
content: powerLevels,
},
);
client.setPowerLevel(roomId, userId, level, powerEvent).then(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
));
return sendResponse(event, {
success: true,
});
});
} catch (err) {
sendError(event, err.message ? err.message : _t('Failed to send request.'), err);
}
}
function getMembershipState(event: MessageEvent<any>, roomId: string, userId: string): void {
@ -678,7 +692,7 @@ const onMessage = function(event: MessageEvent<any>): void {
setBotOptions(event, roomId, userId);
break;
case Action.SetBotPower:
setBotPower(event, roomId, userId, event.data.level);
setBotPower(event, roomId, userId, event.data.level, event.data.ignoreIfGreater);
break;
default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'");

View file

@ -19,6 +19,7 @@ limitations under the License.
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import FocusLock from "react-focus-lock";
import { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common";
@ -43,8 +44,6 @@ function getOrCreateContainer(): HTMLDivElement {
return container;
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
export interface IPosition {
top?: number;
bottom?: number;
@ -84,6 +83,10 @@ export interface IProps extends IPosition {
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
// within an existing FocusLock e.g inside a modal.
focusLock?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
@ -99,7 +102,7 @@ interface IState {
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
private readonly initialFocus: HTMLElement;
static defaultProps = {
hasBackground: true,
@ -108,6 +111,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
contextMenuElem: null,
};
@ -121,14 +125,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
this.initialFocus.focus();
}
private collectContextMenuRect = (element) => {
private collectContextMenuRect = (element: HTMLDivElement) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
let first = element.querySelector('[role^="menuitem"]');
if (!first) {
first = element.querySelector('[tab-index]');
}
const first = element.querySelector<HTMLElement>('[role^="menuitem"]')
|| element.querySelector<HTMLElement>('[tab-index]');
if (first) {
first.focus();
}
@ -205,7 +208,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
descending = true;
}
}
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
} while (element && !element.getAttribute("role")?.startsWith("menuitem"));
if (element) {
(element as HTMLElement).focus();
@ -383,6 +386,17 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
);
}
let body = <>
{ chevron }
{ props.children }
</>;
if (props.focusLock) {
body = <FocusLock>
{ body }
</FocusLock>;
}
return (
<div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
@ -397,8 +411,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron }
{ props.children }
{ body }
</div>
{ background }
</div>

View file

@ -37,6 +37,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps {
roomId: string;
@ -57,6 +58,7 @@ class FilePanel extends React.Component<IProps, IState> {
// added to the timeline.
private decryptingEvents = new Set<string>();
public noRoom: boolean;
static contextType = RoomContext;
state = {
timelineSet: null,
@ -249,38 +251,46 @@ class FilePanel extends React.Component<IProps, IState> {
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer
>
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer
>
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
</RoomContext.Provider>
);
} else {
return (
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Spinner />
</BaseCard>
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Spinner />
</BaseCard>
</RoomContext.Provider>
);
}
}

View file

@ -24,6 +24,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
interface IProps {
onClose(): void;
@ -34,6 +35,7 @@ interface IProps {
*/
@replaceableComponent("structures.NotificationPanel")
export default class NotificationPanel extends React.PureComponent<IProps> {
static contextType = RoomContext;
render() {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{ _t('Youre all caught up') }</h2>
@ -61,8 +63,13 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
content = <Spinner />;
}
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
{ content }
</BaseCard>;
return <RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Notification,
}}>
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
{ content }
</BaseCard>
</RoomContext.Provider>;
}
}

View file

@ -15,17 +15,17 @@ limitations under the License.
*/
import React, {
Dispatch,
KeyboardEvent,
KeyboardEventHandler,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
@ -33,7 +33,8 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { sortBy } from "lodash";
import { sortBy, uniqBy } from "lodash";
import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials";
import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -333,6 +334,30 @@ interface IHierarchyLevelProps {
onToggleClick?(parentId: string, childId: string): void;
}
const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => {
const history = cli.getRoomUpgradeHistory(room.room_id, true);
const cliRoom = history[history.length - 1];
if (cliRoom) {
return {
...room,
room_id: cliRoom.roomId,
room_type: cliRoom.getType(),
name: cliRoom.name,
topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic,
avatar_url: cliRoom.getMxcAvatarUrl(),
canonical_alias: cliRoom.getCanonicalAlias(),
aliases: cliRoom.getAltAliases(),
world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent()
.history_visibility === HistoryVisibility.WorldReadable,
guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent()
.guest_access === GuestAccess.CanJoin,
num_joined_members: cliRoom.getJoinedMemberCount(),
};
}
return room;
};
export const HierarchyLevel = ({
root,
roomSet,
@ -353,7 +378,7 @@ export const HierarchyLevel = ({
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room));
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
@ -361,7 +386,7 @@ export const HierarchyLevel = ({
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
childRooms.map(room => (
uniqBy(childRooms, "room_id").map(room => (
<Tile
key={room.room_id}
room={room}
@ -410,50 +435,39 @@ export const HierarchyLevel = ({
const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): {
export const useRoomHierarchy = (space: Room): {
loading: boolean;
rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>;
} => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => {
if (discard) return;
if (space !== hierarchy.root) return; // discard stale results
setRooms(hierarchy.rooms);
setLoading(false);
});
return () => {
discard = true;
};
setHierarchy(hierarchy);
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO
resetHierarchy();
}
}));
const loadMore = useCallback(async (pageSize?: number) => {
if (loading || !hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport) return;
await hierarchy.load(pageSize);
setRooms(hierarchy.rooms);
setLoading(false);
}, [loading, hierarchy]);
}, [hierarchy]);
const loading = hierarchy?.loading ?? true;
return { loading, rooms, hierarchy, loadMore };
};
@ -587,7 +601,7 @@ const SpaceHierarchy = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const { loading, rooms, hierarchy, loadMore } = useRoomHierarchy(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms?.length) return new Set();

View file

@ -729,7 +729,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill />
{ _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>,

View file

@ -39,6 +39,8 @@ import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
interface IProps {
matrixClient: MatrixClient;
@ -90,10 +92,22 @@ export interface IGroupSummary {
}
/* eslint-enable camelcase */
enum Progress {
NotStarted,
ValidatingInputs,
FetchingData,
CreatingSpace,
InvitingUsers,
// anything beyond here is inviting user n - 4
}
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(Progress.NotStarted);
const [numInvites, setNumInvites] = useState(0);
const busy = progress > 0;
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState("");
@ -122,30 +136,34 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
if (busy) return;
setError(null);
setBusy(true);
setProgress(Progress.ValidatingInputs);
// require & validate the space name field
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
setBusy(false);
setProgress(0);
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) {
setBusy(false);
setProgress(0);
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return;
}
try {
setProgress(Progress.FetchingData);
const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]);
setNumInvites(members.length + invitedMembers.length);
const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId);
@ -167,6 +185,8 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
}
}
setProgress(Progress.CreatingSpace);
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: {
@ -179,11 +199,16 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
via: viaMap.get(roomId) || [],
},
})),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
// we do not specify the inviters here because Synapse applies a limit and this may cause it to trip
}, {
andView: false,
});
setProgress(Progress.InvitingUsers);
const userIds = [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId());
await inviteUsersToRoom(roomId, userIds, () => setProgress(p => p + 1));
// eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
@ -250,7 +275,7 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
setError(e);
}
setBusy(false);
setProgress(Progress.NotStarted);
};
let footer;
@ -267,13 +292,41 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
let description: string;
switch (progress) {
case Progress.ValidatingInputs:
case Progress.FetchingData:
description = _t("Fetching data...");
break;
case Progress.CreatingSpace:
description = _t("Creating Space...");
break;
case Progress.InvitingUsers:
default:
description = _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: numInvites,
progress,
});
break;
}
footer = <span>
<ProgressBar
value={progress > Progress.FetchingData ? progress : 0}
max={numInvites + Progress.InvitingUsers}
/>
<div className="mx_CreateSpaceFromCommunityDialog_progressText">
{ description }
</div>
</span>;
} else {
footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") }
<AccessibleButton kind="primary" onClick={onCreateSpaceClick}>
{ _t("Create Space") }
</AccessibleButton>
</>;
}

View file

@ -44,18 +44,31 @@ interface IProps {
initialTabId?: string;
}
interface IState {
roomName: string;
}
@replaceableComponent("views.dialogs.RoomSettingsDialog")
export default class RoomSettingsDialog extends React.Component<IProps> {
export default class RoomSettingsDialog extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = { roomName: '' };
}
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
this.onRoomName();
}
public componentWillUnmount() {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
}
private onAction = (payload): void => {
@ -66,6 +79,12 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
}
};
private onRoomName = (): void => {
this.setState({
roomName: MatrixClientPeg.get().getRoom(this.props.roomId).name,
});
};
private getTabs(): Tab[] {
const tabs: Tab[] = [];
@ -122,7 +141,7 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
}
render() {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
const roomName = this.state.roomName;
return (
<BaseDialog
className='mx_RoomSettingsDialog'

View file

@ -268,7 +268,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
};
const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}>
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu} focusLock>
<div className="mx_NetworkDropdown_menu">
{ options }
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>

View file

@ -289,7 +289,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
// 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 && this.context.timelineRenderingType === TimelineRenderingType.Room) {
if (this.context.canReply && this.context.timelineRenderingType !== TimelineRenderingType.Thread) {
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
@ -325,6 +325,19 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
/>);
}
}
// Show thread icon even for deleted messages, but only within main timeline
if (this.context.timelineRenderingType === TimelineRenderingType.Room &&
SettingsStore.getValue("feature_thread") &&
this.props.mxEvent.getThread() &&
!isContentActionable(this.props.mxEvent)
) {
toolbarOpts.unshift(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>);
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);

View file

@ -817,7 +817,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
@ -825,10 +825,10 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
}
if (canAffectUser && me.powerLevel >= banPowerLevel) {
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}

View file

@ -35,7 +35,7 @@ interface IState {
avatarFile: File;
originalTopic: string;
topic: string;
enableProfileSave: boolean;
profileFieldsTouched: Record<string, boolean>;
canSetName: boolean;
canSetTopic: boolean;
canSetAvatar: boolean;
@ -71,7 +71,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
avatarFile: null,
originalTopic: topic,
topic: topic,
enableProfileSave: false,
profileFieldsTouched: {},
canSetName: room.currentState.maySendStateEvent('m.room.name', client.getUserId()),
canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
@ -88,17 +88,24 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
private isSaveEnabled = () => {
return Boolean(Object.values(this.state.profileFieldsTouched).length);
};
private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
if (!this.isSaveEnabled()) return;
this.setState({
enableProfileSave: false,
profileFieldsTouched: {},
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
@ -110,8 +117,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this.setState({ enableProfileSave: false });
if (!this.isSaveEnabled()) return;
this.setState({ profileFieldsTouched: {} });
const client = MatrixClientPeg.get();
@ -156,18 +163,38 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ displayName: e.target.value });
if (this.state.originalDisplayName === e.target.value) {
this.setState({ enableProfileSave: false });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: false,
},
});
} else {
this.setState({ enableProfileSave: true });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: true,
},
});
}
};
private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ topic: e.target.value });
if (this.state.originalTopic === e.target.value) {
this.setState({ enableProfileSave: false });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: false,
},
});
} else {
this.setState({ enableProfileSave: true });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: true,
},
});
}
};
@ -176,7 +203,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
enableProfileSave: false,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: false,
},
});
return;
}
@ -187,7 +217,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: String(ev.target.result),
avatarFile: file,
enableProfileSave: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
reader.readAsDataURL(file);
@ -205,14 +238,14 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
<AccessibleButton
onClick={this.cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
disabled={!this.isSaveEnabled()}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this.saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
disabled={!this.isSaveEnabled()}
>
{ _t("Save") }
</AccessibleButton>

View file

@ -268,7 +268,7 @@ export default class PhoneNumbers extends React.Component<IProps, IState> {
<AccessibleButton
onClick={this.onContinueClick}
kind="primary"
disabled={this.state.continueDisabled}
disabled={this.state.continueDisabled || this.state.newPhoneNumberCode.length === 0}
>
{ _t("Continue") }
</AccessibleButton>

View file

@ -17,9 +17,8 @@ limitations under the License.
import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react";
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@ -361,9 +360,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false}
>
<FocusLock returnFocus={true}>
{ body }
</FocusLock>
{ body }
</ContextMenu>;
};

View file

@ -21,6 +21,8 @@ import { Layout } from "../settings/Layout";
export enum TimelineRenderingType {
Room,
File,
Notification,
Thread
}

View file

@ -74,7 +74,7 @@ export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit<IEmoji, "shortcode
// If there's ever a gap in shortcode coverage, we fudge it by
// filling it in with the emoji's CLDR annotation
const shortcodeData = SHORTCODES[emojiData.hexcode] ??
[emojiData.annotation.toLowerCase().replace(/ /g, "_")];
[emojiData.annotation.toLowerCase().replace(/\W+/g, "_")];
const emoji: IEmoji = {
...emojiData,

View file

@ -2280,6 +2280,8 @@
"<SpaceName/> has been made and everyone who was a part of the community has been invited to it.": "<SpaceName/> has been made and everyone who was a part of the community has been invited to it.",
"To create a Space from another community, just pick the community in Preferences.": "To create a Space from another community, just pick the community in Preferences.",
"Failed to migrate community": "Failed to migrate community",
"Fetching data...": "Fetching data...",
"Creating Space...": "Creating Space...",
"Create Space from community": "Create Space from community",
"A link to the Space will be put in your community description.": "A link to the Space will be put in your community description.",
"All rooms will be added and all community members will be invited.": "All rooms will be added and all community members will be invited.",

View file

@ -162,9 +162,10 @@ export default class SettingsStore {
const watcherId = `${new Date().getTime()}_${SettingsStore.watcherCount++}_${settingName}_${roomId}`;
const localizedCallback = (changedInRoomId, atLevel, newValAtLevel) => {
const localizedCallback = (changedInRoomId: string | null, atLevel: SettingLevel, newValAtLevel: any) => {
const newValue = SettingsStore.getValue(originalSettingName);
callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue);
const newValueAtLevel = SettingsStore.getValueAt(atLevel, originalSettingName) ?? newValAtLevel;
callbackFn(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue);
};
SettingsStore.watchers.set(watcherId, localizedCallback);

View file

@ -283,7 +283,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs();
return getChildOrder(ev.getContent().order, createTs, roomId);
}).map(ev => {
return this.matrixClient.getRoom(ev.getStateKey());
const history = this.matrixClient.getRoomUpgradeHistory(ev.getStateKey(), true);
return history[history.length - 1];
}).filter(room => {
return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite";
}) || [];
@ -511,8 +512,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
hiddenChildren.get(spaceId)?.forEach(roomId => {
roomIds.add(roomId);
});
this.spaceFilteredRooms.set(spaceId, roomIds);
return roomIds;
// Expand room IDs to all known versions of the given rooms
const expandedRoomIds = new Set(Array.from(roomIds).flatMap(roomId => {
return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId);
}));
this.spaceFilteredRooms.set(spaceId, expandedRoomIds);
return expandedRoomIds;
};
fn(s.roomId, new Set());
@ -793,7 +799,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// 1 is Home, 2-9 are the spaces after Home
if (payload.num === 1) {
this.setActiveSpace(null);
} else if (this.spacePanelSpaces.length >= payload.num) {
} else if (payload.num > 0 && this.spacePanelSpaces.length > payload.num - 2) {
this.setActiveSpace(this.spacePanelSpaces[payload.num - 2]);
}
break;

View file

@ -62,8 +62,9 @@ export default class MultiInviter {
/**
* @param {string} targetId The ID of the room or group to invite to
* @param {function} progressCallback optional callback, fired after each invite.
*/
constructor(targetId: string) {
constructor(targetId: string, private readonly progressCallback?: () => void) {
if (targetId[0] === '+') {
this.roomId = null;
this.groupId = targetId;
@ -181,6 +182,7 @@ export default class MultiInviter {
delete this.errors[address];
resolve();
this.progressCallback?.();
}).catch((err) => {
if (this.canceled) {
return;