Merge branch 'develop' into gsouquet/threads-forceenablelabsflag

This commit is contained in:
Germain 2023-01-11 11:51:57 +00:00
commit d4f247d1fe
97 changed files with 3280 additions and 1325 deletions

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 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 { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
export const createCantStartVoiceMessageBroadcastDialog = (): void => {
Modal.createDialog(InfoDialog, {
title: _t("Can't start voice message"),
description: (
<p>
{_t(
"You can't start a voice message as you are currently recording a live broadcast. " +
"Please end your live broadcast in order to start recording a voice message.",
)}
</p>
),
hasCloseButton: true,
});
};

View file

@ -96,7 +96,7 @@ export default abstract class ScrollableBaseModal<
aria-label={_t("Close dialog")}
/>
</div>
<form onSubmit={this.onSubmit}>
<form onSubmit={this.onSubmit} className="mx_CompoundDialog_form">
<div className="mx_CompoundDialog_content">{this.renderContent()}</div>
<div className="mx_CompoundDialog_footer">
<AccessibleButton onClick={this.onCancel} kind="primary_outline">

View file

@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
this.inputRef = inputRef || React.createRef();
inputProps.placeholder = inputProps.placeholder || inputProps.label;
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
inputProps.id = this.id; // this overwrites the id from props
inputProps.onFocus = this.onFocus;

View file

@ -18,7 +18,6 @@ import React, { createRef, SyntheticEvent, MouseEvent, ReactNode } from "react";
import ReactDOM from "react-dom";
import highlight from "highlight.js";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import { isEventLike, LegacyMsgType, M_MESSAGE, MessageEvent } from "matrix-events-sdk";
import * as HtmlUtils from "../../../HtmlUtils";
import { formatDate } from "../../../DateUtils";
@ -579,29 +578,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent);
let body: ReactNode;
if (SettingsStore.isEnabled("feature_extensible_events")) {
const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent;
if (extev?.isEquivalentTo(M_MESSAGE)) {
isEmote = isEventLike(extev.wireFormat, LegacyMsgType.Emote);
isNotice = isEventLike(extev.wireFormat, LegacyMsgType.Notice);
body = HtmlUtils.bodyToHtml(
{
body: extev.text,
format: extev.html ? "org.matrix.custom.html" : undefined,
formatted_body: extev.html,
msgtype: MsgType.Text,
},
this.props.highlights,
{
disableBigEmoji: isEmote || !SettingsStore.getValue<boolean>("TextualBody.enableBigEmoji"),
// Part of Replies fallback support
stripReplyFallback: stripReply,
ref: this.contentRef,
returnString: false,
},
);
}
}
if (!body) {
isEmote = content.msgtype === MsgType.Emote;
isNotice = content.msgtype === MsgType.Notice;

View file

@ -69,7 +69,7 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
if (onClose) {
closeButton = (
<AccessibleButton
data-test-id="base-card-close-button"
data-testid="base-card-close-button"
className="mx_BaseCard_close"
onClick={onClose}
title={closeLabel || _t("Close")}

View file

@ -21,6 +21,7 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ThreadEvent } from "matrix-js-sdk/src/models/thread";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { _t } from "../../../languageHandler";
@ -44,6 +45,7 @@ import { NotificationStateEvents } from "../../../stores/notifications/Notificat
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary,
@ -154,7 +156,17 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
if (!this.supportsThreadNotifications) {
this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
// Notification badge may change if the notification counts from the
// server change, if a new thread is created or updated, or if a
// receipt is sent in the thread.
this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate);
}
this.onNotificationUpdate();
RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
@ -166,6 +178,13 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate);
}
RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
@ -191,9 +210,17 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
return NotificationColor.Red;
case NotificationCountType.Total:
return NotificationColor.Grey;
default:
return NotificationColor.None;
}
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
for (const thread of this.props.room!.getThreads()) {
// If the current thread has unread messages, we're done.
if (doesRoomOrThreadHaveUnreadMessages(thread)) {
return NotificationColor.Bold;
}
}
// Otherwise, no notification color.
return NotificationColor.None;
}
private onUpdateStatus = (notificationState: SummarizedNotificationState): void => {

View file

@ -29,6 +29,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
@ -84,7 +85,7 @@ export interface IDevice {
getDisplayName(): string;
}
const disambiguateDevices = (devices: IDevice[]) => {
export const disambiguateDevices = (devices: IDevice[]) => {
const names = Object.create(null);
for (let i = 0; i < devices.length; i++) {
const name = devices[i].getDisplayName();
@ -94,7 +95,7 @@ const disambiguateDevices = (devices: IDevice[]) => {
}
for (const name in names) {
if (names[name].length > 1) {
names[name].forEach((j) => {
names[name].forEach((j: number) => {
devices[j].ambiguous = true;
});
}
@ -149,7 +150,7 @@ function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: bool
}, [cli, member, canVerify]);
}
function DeviceItem({ userId, device }: { userId: string; device: IDevice }) {
export function DeviceItem({ userId, device }: { userId: string; device: IDevice }) {
const cli = useContext(MatrixClientContext);
const isMe = userId === cli.getUserId();
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
@ -172,7 +173,10 @@ function DeviceItem({ userId, device }: { userId: string; device: IDevice }) {
});
const onDeviceClick = () => {
verifyDevice(cli.getUser(userId), device);
const user = cli.getUser(userId);
if (user) {
verifyDevice(user, device);
}
};
let deviceName;
@ -315,7 +319,7 @@ const MessageButton = ({ member }: { member: RoomMember }) => {
);
};
const UserOptionsSection: React.FC<{
export const UserOptionsSection: React.FC<{
member: RoomMember;
isIgnored: boolean;
canInvite: boolean;
@ -367,7 +371,8 @@ const UserOptionsSection: React.FC<{
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
event_id: room.getEventReadUpTo(member.userId),
// this could return null, the default prevents a type error
event_id: room?.getEventReadUpTo(member.userId) || undefined,
room_id: member.roomId,
metricsTrigger: undefined, // room doesn't change
});
@ -402,16 +407,18 @@ const UserOptionsSection: React.FC<{
const onInviteUserButton = async (ev: ButtonEvent) => {
try {
// We use a MultiInviter to re-use the invite logic, even though we're only inviting one user.
const inviter = new MultiInviter(roomId);
const inviter = new MultiInviter(roomId || "");
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {
throw new Error(inviter.getErrorText(member.userId));
}
});
} catch (err) {
const description = err instanceof Error ? err.message : _t("Operation failed");
Modal.createDialog(ErrorDialog, {
title: _t("Failed to invite"),
description: err && err.message ? err.message : _t("Operation failed"),
description,
});
}
@ -432,10 +439,7 @@ const UserOptionsSection: React.FC<{
</AccessibleButton>
);
let directMessageButton: JSX.Element;
if (!isMe) {
directMessageButton = <MessageButton member={member} />;
}
const directMessageButton = isMe ? null : <MessageButton member={member} />;
return (
<div className="mx_UserInfo_container">
@ -499,16 +503,24 @@ interface IPowerLevelsContent {
redact?: number;
}
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => {
export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => {
if (!powerLevelContent || !member) return false;
const levelToSend =
(powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
powerLevelContent.events_default;
// levelToSend could be undefined as .events_default is optional. Coercing in this case using
// Number() would always return false, so this preserves behaviour
// FIXME: per the spec, if `events_default` is unset, it defaults to zero. If
// the member has a negative powerlevel, this will give an incorrect result.
if (levelToSend === undefined) return false;
return member.powerLevel < levelToSend;
};
const getPowerLevels = (room) => room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const getPowerLevels = (room: Room): IPowerLevelsContent =>
room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
@ -538,7 +550,7 @@ interface IBaseProps {
stopUpdating(): void;
}
const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
export const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited
@ -566,7 +578,7 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBas
space: room,
spaceChildFilter: (child: Room) => {
// Return true if the target member is not banned and we have sufficient PL to ban them
const myMember = child.getMember(cli.credentials.userId);
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
myMember &&
@ -648,7 +660,7 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
);
};
const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
export const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext);
const isBanned = member.membership === "ban";
@ -674,7 +686,7 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBa
spaceChildFilter: isBanned
? (child: Room) => {
// Return true if the target member is banned and we have sufficient PL to unban
const myMember = child.getMember(cli.credentials.userId);
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
myMember &&
@ -686,7 +698,7 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBa
}
: (child: Room) => {
// Return true if the target member isn't banned and we have sufficient PL to ban
const myMember = child.getMember(cli.credentials.userId);
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
myMember &&
@ -835,7 +847,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
);
};
const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
@ -855,7 +867,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
// if these do not exist in the event then they should default to 50 as per the spec
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
const me = room.getMember(cli.getUserId());
const me = room.getMember(cli.getUserId() || "");
if (!me) {
// we aren't in the room, so return no admin tooling
return <div />;
@ -879,7 +891,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
<BanToggleButton room={room} member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
}
if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
@ -949,7 +961,7 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;
const me = room.getMember(cli.getUserId());
const me = room.getMember(cli.getUserId() || "");
if (!me) return;
const them = user;
@ -1006,7 +1018,7 @@ const PowerLevelSection: React.FC<{
}
};
const PowerLevelEditor: React.FC<{
export const PowerLevelEditor: React.FC<{
user: RoomMember;
room: Room;
roomPermissions: IRoomPermissions;
@ -1022,8 +1034,13 @@ const PowerLevelEditor: React.FC<{
async (powerLevel: number) => {
setSelectedPowerLevel(powerLevel);
const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {
return cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then(
const applyPowerChange = (
roomId: string,
target: string,
powerLevel: number,
powerLevelEvent: MatrixEvent,
) => {
return cli.setPowerLevel(roomId, target, powerLevel, powerLevelEvent).then(
function () {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
@ -1046,7 +1063,7 @@ const PowerLevelEditor: React.FC<{
if (!powerLevelEvent) return;
const myUserId = cli.getUserId();
const myPower = powerLevelEvent.getContent().users[myUserId];
const myPower = powerLevelEvent.getContent().users[myUserId || ""];
if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("Warning!"),
@ -1085,7 +1102,7 @@ const PowerLevelEditor: React.FC<{
return (
<div className="mx_UserInfo_profileField">
<PowerSelector
label={null}
label={undefined}
value={selectedPowerLevel}
maxValue={roomPermissions.modifyLevelMax}
usersDefault={powerLevelUsersDefault}
@ -1099,7 +1116,7 @@ export const useDevices = (userId: string) => {
const cli = useContext(MatrixClientContext);
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
const [devices, setDevices] = useState(undefined);
const [devices, setDevices] = useState<undefined | null | IDevice[]>(undefined);
// Download device lists
useEffect(() => {
setDevices(undefined);
@ -1116,8 +1133,8 @@ export const useDevices = (userId: string) => {
return;
}
disambiguateDevices(devices);
setDevices(devices);
disambiguateDevices(devices as IDevice[]);
setDevices(devices as IDevice[]);
} catch (err) {
setDevices(null);
}
@ -1136,17 +1153,17 @@ export const useDevices = (userId: string) => {
const updateDevices = async () => {
const newDevices = cli.getStoredDevicesForUser(userId);
if (cancel) return;
setDevices(newDevices);
setDevices(newDevices as IDevice[]);
};
const onDevicesUpdated = (users) => {
const onDevicesUpdated = (users: string[]) => {
if (!users.includes(userId)) return;
updateDevices();
};
const onDeviceVerificationChanged = (_userId, device) => {
const onDeviceVerificationChanged = (_userId: string, deviceId: string) => {
if (_userId !== userId) return;
updateDevices();
};
const onUserTrustStatusChanged = (_userId, trustStatus) => {
const onUserTrustStatusChanged = (_userId: string, trustLevel: UserTrustLevel) => {
if (_userId !== userId) return;
updateDevices();
};
@ -1229,9 +1246,11 @@ const BasicUserInfo: React.FC<{
logger.error("Failed to deactivate user");
logger.error(err);
const description = err instanceof Error ? err.message : _t("Operation failed");
Modal.createDialog(ErrorDialog, {
title: _t("Failed to deactivate user"),
description: err && err.message ? err.message : _t("Operation failed"),
description,
});
}
}, [cli, member.userId]);
@ -1317,12 +1336,12 @@ const BasicUserInfo: React.FC<{
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
const userTrust = cryptoEnabled && cli.checkUserTrust(member.userId);
const userVerified = cryptoEnabled && userTrust.isCrossSigningVerified();
const userVerified = cryptoEnabled && userTrust && userTrust.isCrossSigningVerified();
const isMe = member.userId === cli.getUserId();
const canVerify =
cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe && devices && devices.length > 0;
const setUpdating = (updating) => {
const setUpdating: SetUpdating = (updating) => {
setPendingUpdateCount((count) => count + (updating ? 1 : -1));
};
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating);
@ -1408,9 +1427,9 @@ const BasicUserInfo: React.FC<{
export type Member = User | RoomMember;
const UserInfoHeader: React.FC<{
export const UserInfoHeader: React.FC<{
member: Member;
e2eStatus: E2EStatus;
e2eStatus?: E2EStatus;
roomId?: string;
}> = ({ member, e2eStatus, roomId }) => {
const cli = useContext(MatrixClientContext);
@ -1427,9 +1446,11 @@ const UserInfoHeader: React.FC<{
name: (member as RoomMember).name || (member as User).displayName,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
}, [member]);
const avatarUrl = (member as User).avatarUrl;
const avatarElement = (
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
@ -1442,7 +1463,7 @@ const UserInfoHeader: React.FC<{
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={(member as User).avatarUrl ? [(member as User).avatarUrl] : undefined}
urls={avatarUrl ? [avatarUrl] : undefined}
/>
</div>
</div>
@ -1475,10 +1496,7 @@ const UserInfoHeader: React.FC<{
);
}
let e2eIcon;
if (e2eStatus) {
e2eIcon = <E2EIcon size={18} status={e2eStatus} isUser={true} />;
}
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const displayName = (member as RoomMember).rawDisplayName;
return (
@ -1496,7 +1514,7 @@ const UserInfoHeader: React.FC<{
</h2>
</div>
<div className="mx_UserInfo_profile_mxid">
{UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
{UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
})}
@ -1533,7 +1551,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
const classes = ["mx_UserInfo"];
let cardState: IRightPanelCardState;
let cardState: IRightPanelCardState = {};
// We have no previousPhase for when viewing a UserInfo without a Room at this time
if (room && phase === RightPanelPhases.EncryptionPanel) {
cardState = { member };
@ -1551,10 +1569,10 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
case RightPanelPhases.SpaceMemberInfo:
content = (
<BasicUserInfo
room={room}
room={room as Room}
member={member as User}
devices={devices}
isRoomEncrypted={isRoomEncrypted}
devices={devices as IDevice[]}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
break;
@ -1565,7 +1583,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
{...(props as React.ComponentProps<typeof EncryptionPanel>)}
member={member as User | RoomMember}
onClose={onEncryptionPanelClose}
isRoomEncrypted={isRoomEncrypted}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
break;
@ -1582,7 +1600,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let scopeHeader;
if (room?.isSpaceRoom()) {
scopeHeader = (
<div data-test-id="space-header" className="mx_RightPanel_scopeHeader">
<div data-testid="space-header" className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>

View file

@ -58,6 +58,8 @@ import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysi
import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext";
import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { VoiceBroadcastInfoState } from "../../../voice-broadcast";
import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog";
let instanceCount = 0;
@ -445,6 +447,20 @@ export class MessageComposer extends React.Component<IProps, IState> {
}
}
private onRecordStartEndClick = (): void => {
const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent();
if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) {
createCantStartVoiceMessageBroadcastDialog();
} else {
this.voiceRecordingButton.current?.onRecordStartEndClick();
}
if (this.context.narrow) {
this.toggleButtonMenu();
}
};
public render() {
const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus);
const e2eIcon = hasE2EIcon && (
@ -588,12 +604,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
isStickerPickerOpen={this.state.isStickerPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={() => {
this.voiceRecordingButton.current?.onRecordStartEndClick();
if (this.context.narrow) {
this.toggleButtonMenu();
}
}}
onRecordStartEndClick={this.onRecordStartEndClick}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={!window.electron}
showPollsButton={this.state.showPollsButton}

View file

@ -376,8 +376,8 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName={classNames({
mx_MessageComposer_plain_text: isRichTextEnabled,
mx_MessageComposer_rich_text: !isRichTextEnabled,
mx_MessageComposer_plain_text: !isRichTextEnabled,
mx_MessageComposer_rich_text: isRichTextEnabled,
})}
onClick={onClick}
title={title}

View file

@ -120,7 +120,7 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
<Button
isActive={actionStates.link === "reversed"}
label={_td("Link")}
onClick={() => openLinkModal(composer, composerContext)}
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
/>
</div>

View file

@ -17,17 +17,28 @@ limitations under the License.
import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import React, { ChangeEvent, useState } from "react";
import { _td } from "../../../../../languageHandler";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import Field from "../../../elements/Field";
import { ComposerContextState } from "../ComposerContext";
import { isSelectionEmpty, setSelection } from "../utils/selection";
import BaseDialog from "../../../dialogs/BaseDialog";
import DialogButtons from "../../../elements/DialogButtons";
export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) {
export function openLinkModal(
composer: FormattingFunctions,
composerContext: ComposerContextState,
isEditing: boolean,
) {
const modal = Modal.createDialog(
LinkModal,
{ composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() },
{
composerContext,
composer,
onClose: () => modal.close(),
isTextEnabled: isSelectionEmpty(),
isEditing,
},
"mx_CompoundDialog",
false,
true,
@ -43,48 +54,86 @@ interface LinkModalProps {
isTextEnabled: boolean;
onClose: () => void;
composerContext: ComposerContextState;
isEditing: boolean;
}
export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) {
const [fields, setFields] = useState({ text: "", link: "" });
const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link);
export function LinkModal({ composer, isTextEnabled, onClose, composerContext, isEditing }: LinkModalProps) {
const [hasLinkChanged, setHasLinkChanged] = useState(false);
const [fields, setFields] = useState({ text: "", link: isEditing ? composer.getLink() : "" });
const hasText = !isEditing && isTextEnabled;
const isSaveDisabled = !hasLinkChanged || (hasText && isEmpty(fields.text)) || isEmpty(fields.link);
return (
<QuestionDialog
<BaseDialog
className="mx_LinkModal"
title={_td("Create a link")}
button={_td("Save")}
buttonDisabled={isSaveDisabled}
hasCancelButton={true}
onFinished={async (isClickOnSave: boolean) => {
if (isClickOnSave) {
title={isEditing ? _t("Edit link") : _t("Create a link")}
hasCancel={true}
onFinished={onClose}
>
<form
className="mx_LinkModal_content"
onSubmit={async (evt) => {
evt.preventDefault();
evt.stopPropagation();
onClose();
// When submitting is done when pressing enter when the link field has the focus,
// The link field is getting back the focus (due to react-focus-lock)
// So we are waiting that the focus stuff is done to play with the composer selection
await new Promise((resolve) => setTimeout(resolve, 0));
await setSelection(composerContext.selection);
composer.link(fields.link, isTextEnabled ? fields.text : undefined);
}
onClose();
}}
description={
<div className="mx_LinkModal_content">
{isTextEnabled && (
<Field
autoFocus={true}
label={_td("Text")}
value={fields.text}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, text: e.target.value }))
}
/>
)}
}}
>
{hasText && (
<Field
autoFocus={!isTextEnabled}
label={_td("Link")}
value={fields.link}
required={true}
autoFocus={true}
label={_t("Text")}
value={fields.text}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, link: e.target.value }))
setFields((fields) => ({ ...fields, text: e.target.value }))
}
/>
)}
<Field
required={true}
autoFocus={!hasText}
label={_t("Link")}
value={fields.link}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setFields((fields) => ({ ...fields, link: e.target.value }));
setHasLinkChanged(true);
}}
/>
<div className="mx_LinkModal_buttons">
{isEditing && (
<button
type="button"
className="danger"
onClick={() => {
composer.removeLinks();
onClose();
}}
>
{_t("Remove")}
</button>
)}
<DialogButtons
primaryButton={_t("Save")}
primaryDisabled={isSaveDisabled}
primaryIsSubmit={true}
onCancel={onClose}
/>
</div>
}
/>
</form>
</BaseDialog>
);
}

View file

@ -35,7 +35,7 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
import { getDeviceClientInformation, pruneClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
@ -116,8 +116,8 @@ export type DevicesState = {
export const useOwnDevices = (): DevicesState => {
const matrixClient = useContext(MatrixClientContext);
const currentDeviceId = matrixClient.getDeviceId();
const userId = matrixClient.getUserId();
const currentDeviceId = matrixClient.getDeviceId()!;
const userId = matrixClient.getSafeUserId();
const [devices, setDevices] = useState<DevicesState["devices"]>({});
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
@ -138,11 +138,6 @@ export const useOwnDevices = (): DevicesState => {
const refreshDevices = useCallback(async () => {
setIsLoadingDeviceList(true);
try {
// realistically we should never hit this
// but it satisfies types
if (!userId) {
throw new Error("Cannot fetch devices without user id");
}
const devices = await fetchDevicesWithVerification(matrixClient, userId);
setDevices(devices);
@ -176,6 +171,15 @@ export const useOwnDevices = (): DevicesState => {
refreshDevices();
}, [refreshDevices]);
useEffect(() => {
const deviceIds = Object.keys(devices);
// empty devices means devices have not been fetched yet
// as there is always at least the current device
if (deviceIds.length) {
pruneClientInformation(deviceIds, matrixClient);
}
}, [devices, matrixClient]);
useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => {
if (users.includes(userId)) {
refreshDevices();