From c353b6daadf5fde1f6e3ab0eaae97146e3723358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 26 Aug 2021 17:39:48 +0200 Subject: [PATCH 01/19] Render guest settings only in public rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../tabs/room/SecurityRoomSettingsTab.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index d9e97d570b..cade206dad 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -616,6 +616,22 @@ export default class SecurityRoomSettingsTab extends React.Component + + { this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced") } + + { this.state.showAdvancedSection && this.renderAdvanced() } + + ); + } + return (
{ _t("Security & Privacy") }
@@ -641,15 +657,7 @@ export default class SecurityRoomSettingsTab extends React.Component - - { this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced") } - - { this.state.showAdvancedSection && this.renderAdvanced() } - + { advanced } { historySection }
); From 4f1ff134dc9b7505dc21e6e64d61ebe0b84c99dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 26 Aug 2021 17:39:54 +0200 Subject: [PATCH 02/19] Render guest settings only in public spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .../spaces/SpaceSettingsVisibilityTab.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index b48f5c79c6..90d598cb16 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -94,30 +94,32 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => { const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); let advancedSection; - if (showAdvancedSection) { - advancedSection = <> - - { _t("Hide advanced") } - + if (visibility === SpaceVisibility.Unlisted) { + if (showAdvancedSection) { + advancedSection = <> + + { _t("Hide advanced") } + - -

- { _t("Guests can join a space without having an account.") } -
- { _t("This may be useful for public spaces.") } -

- ; - } else { - advancedSection = <> - - { _t("Show advanced") } - - ; + +

+ { _t("Guests can join a space without having an account.") } +
+ { _t("This may be useful for public spaces.") } +

+ ; + } else { + advancedSection = <> + + { _t("Show advanced") } + + ; + } } let addressesSection; From 979bc71609beb7dfff0868135a12231d7535a1b1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Sep 2021 15:52:56 +0100 Subject: [PATCH 03/19] Deduplicate join rule management between rooms and spaces --- .../views/settings/JoinRuleSettings.tsx | 243 +++++++++++++ .../tabs/room/SecurityRoomSettingsTab.tsx | 320 ++++-------------- .../spaces/SpaceSettingsVisibilityTab.tsx | 56 +-- src/hooks/useLocalEcho.ts | 36 ++ src/i18n/strings/en_EN.json | 35 +- 5 files changed, 363 insertions(+), 327 deletions(-) create mode 100644 src/components/views/settings/JoinRuleSettings.tsx create mode 100644 src/hooks/useLocalEcho.ts diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx new file mode 100644 index 0000000000..3cb4456643 --- /dev/null +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -0,0 +1,243 @@ +/* +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 { IJoinRuleEventContent, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import StyledRadioGroup, { IDefinition } from "../elements/StyledRadioGroup"; +import { _t } from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import RoomAvatar from "../avatars/RoomAvatar"; +import SpaceStore from "../../../stores/SpaceStore"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import Modal from "../../../Modal"; +import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog"; +import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog"; +import { upgradeRoom } from "../../../utils/RoomUpgrade"; +import { arrayHasDiff } from "../../../utils/arrays"; +import { useLocalEcho } from "../../../hooks/useLocalEcho"; + +interface IProps { + room: Room; + promptUpgrade?: boolean; + onError(error: Error): void; + beforeChange?(joinRule: JoinRule): Promise; // if returns false then aborts the change +} + +const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange }: IProps) => { + const cli = room.client; + + const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport; + const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support) + && restrictedRoomCapabilities.support.includes(room.getVersion()); + const preferredRestrictionVersion = !roomSupportsRestricted && promptUpgrade + ? restrictedRoomCapabilities?.preferred + : undefined; + + const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli); + + const [content, setContent] = useLocalEcho( + () => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(), + content => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""), + onError, + ); + + const { join_rule: joinRule } = content; + const restrictedAllowRoomIds = joinRule === JoinRule.Restricted + ? content.allow.filter(o => o.type === RestrictedAllowType.RoomMembership).map(o => o.room_id) + : undefined; + + const editRestrictedRoomIds = async (): Promise => { + let selected = restrictedAllowRoomIds; + if (!selected?.length && SpaceStore.instance.activeSpace) { + selected = [SpaceStore.instance.activeSpace.roomId]; + } + + const matrixClient = MatrixClientPeg.get(); + const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, { + matrixClient, + room, + selected, + }, "mx_ManageRestrictedJoinRuleDialog_wrapper"); + + const [roomIds] = await finished; + return roomIds; + }; + + const definitions: IDefinition[] = [{ + value: JoinRule.Invite, + label: _t("Private (invite only)"), + description: _t("Only invited people can join."), + checked: joinRule === JoinRule.Invite || (joinRule === JoinRule.Restricted && !restrictedAllowRoomIds?.length), + }, { + value: JoinRule.Public, + label: _t("Public"), + description: _t("Anyone can find and join."), + }]; + + if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) { + let upgradeRequiredPill; + if (preferredRestrictionVersion) { + upgradeRequiredPill = + { _t("Upgrade required") } + ; + } + + let description; + if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) { + const shownSpaces = restrictedAllowRoomIds + .map(roomId => cli.getRoom(roomId)) + .filter(room => room?.isSpaceRoom()) + .slice(0, 4); + + let moreText; + if (shownSpaces.length < restrictedAllowRoomIds.length) { + if (shownSpaces.length > 0) { + moreText = _t("& %(count)s more", { + count: restrictedAllowRoomIds.length - shownSpaces.length, + }); + } else { + moreText = _t("Currently, %(count)s spaces have access", { + count: restrictedAllowRoomIds.length, + }); + } + } + + const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]) => { + if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return; + + const newContent: IJoinRuleEventContent = { + join_rule: JoinRule.Restricted, + allow: newAllowRoomIds.map(roomId => ({ + "type": RestrictedAllowType.RoomMembership, + "room_id": roomId, + })), + }; + setContent(newContent); + }; + + const onEditRestrictedClick = async () => { + const restrictedAllowRoomIds = await editRestrictedRoomIds(); + if (!Array.isArray(restrictedAllowRoomIds)) return; + if (restrictedAllowRoomIds.length > 0) { + onRestrictedRoomIdsChange(restrictedAllowRoomIds); + } else { + onChange(JoinRule.Invite); + } + }; + + description =
+ + { _t("Anyone in a space can find and join. Edit which spaces can access here.", {}, { + a: sub => + { sub } + , + }) } + + +
+

{ _t("Spaces with access") }

+ { shownSpaces.map(room => { + return + + { room.name } + ; + }) } + { moreText && { moreText } } +
+
; + } else if (SpaceStore.instance.activeSpace) { + description = _t("Anyone in can find and join. You can select other spaces too.", {}, { + spaceName: () => { SpaceStore.instance.activeSpace.name }, + }); + } else { + description = _t("Anyone in a space can find and join. You can select multiple spaces."); + } + + definitions.splice(1, 0, { + value: JoinRule.Restricted, + label: <> + { _t("Space members") } + { upgradeRequiredPill } + , + description, + // if there are 0 allowed spaces then render it as invite only instead + checked: joinRule === JoinRule.Restricted && !!restrictedAllowRoomIds?.length, + }); + } + + const onChange = async (joinRule: JoinRule) => { + const beforeJoinRule = content.join_rule; + + let restrictedAllowRoomIds: string[]; + if (joinRule === JoinRule.Restricted) { + if (beforeJoinRule === JoinRule.Restricted || roomSupportsRestricted) { + // Have the user pick which spaces to allow joins from + restrictedAllowRoomIds = await editRestrictedRoomIds(); + if (!Array.isArray(restrictedAllowRoomIds)) return; + } else if (preferredRestrictionVersion) { + // Block this action on a room upgrade otherwise it'd make their room unjoinable + const targetVersion = preferredRestrictionVersion; + Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, { + roomId: room.roomId, + targetVersion, + description: _t("This upgrade will allow members of selected spaces " + + "access to this room without an invite."), + onFinished: (resp) => { + if (!resp?.continue) return; + upgradeRoom(room, targetVersion, resp.invite); + }, + }); + return; + } + } + + if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return; + if (beforeChange && !await beforeChange(joinRule)) return; + + const newContent: IJoinRuleEventContent = { + join_rule: joinRule, + }; + + // pre-set the accepted spaces with the currently viewed one as per the microcopy + if (joinRule === JoinRule.Restricted) { + newContent.allow = restrictedAllowRoomIds.map(roomId => ({ + "type": RestrictedAllowType.RoomMembership, + "room_id": roomId, + })); + } + + setContent(newContent); + }; + + return ( + + ); +}; + +export default JoinRuleSettings; diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index 081b1a8698..40b15cfc78 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials"; -import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { _t } from "../../../../../languageHandler"; @@ -24,35 +24,28 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import Modal from "../../../../../Modal"; import QuestionDialog from "../../../dialogs/QuestionDialog"; -import StyledRadioGroup, { IDefinition } from '../../../elements/StyledRadioGroup'; +import StyledRadioGroup from '../../../elements/StyledRadioGroup'; import { SettingLevel } from "../../../../../settings/SettingLevel"; import SettingsStore from "../../../../../settings/SettingsStore"; import { UIFeature } from "../../../../../settings/UIFeature"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import AccessibleButton from "../../../elements/AccessibleButton"; -import SpaceStore from "../../../../../stores/SpaceStore"; -import RoomAvatar from "../../../avatars/RoomAvatar"; -import ManageRestrictedJoinRuleDialog from '../../../dialogs/ManageRestrictedJoinRuleDialog'; -import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog'; -import { upgradeRoom } from "../../../../../utils/RoomUpgrade"; -import { arrayHasDiff } from "../../../../../utils/arrays"; import SettingsFlag from '../../../elements/SettingsFlag'; import createRoom, { IOpts } from '../../../../../createRoom'; import CreateRoomDialog from '../../../dialogs/CreateRoomDialog'; +import JoinRuleSettings from "../../JoinRuleSettings"; +import ErrorDialog from "../../../dialogs/ErrorDialog"; interface IProps { roomId: string; } interface IState { - joinRule: JoinRule; restrictedAllowRoomIds?: string[]; guestAccess: GuestAccess; history: HistoryVisibility; hasAliases: boolean; encrypted: boolean; - roomSupportsRestricted?: boolean; - preferredRestrictionVersion?: string; showAdvancedSection: boolean; } @@ -62,7 +55,6 @@ export default class SecurityRoomSettingsTab extends React.Component this.setState({ hasAliases })); } @@ -132,7 +119,7 @@ export default class SecurityRoomSettingsTab extends React.Component { - if (this.state.joinRule == "public") { + if (MatrixClientPeg.get().getRoom(this.props.roomId)?.getJoinRule() === JoinRule.Public) { const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, { title: _t('Are you sure you want to add encryption to this public room?'), description:
@@ -199,117 +186,6 @@ export default class SecurityRoomSettingsTab extends React.Component { - const beforeJoinRule = this.state.joinRule; - - let restrictedAllowRoomIds: string[]; - if (joinRule === JoinRule.Restricted) { - const matrixClient = MatrixClientPeg.get(); - const roomId = this.props.roomId; - const room = matrixClient.getRoom(roomId); - - if (beforeJoinRule === JoinRule.Restricted || this.state.roomSupportsRestricted) { - // Have the user pick which spaces to allow joins from - restrictedAllowRoomIds = await this.editRestrictedRoomIds(); - if (!Array.isArray(restrictedAllowRoomIds)) return; - } else if (this.state.preferredRestrictionVersion) { - // Block this action on a room upgrade otherwise it'd make their room unjoinable - const targetVersion = this.state.preferredRestrictionVersion; - Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, { - roomId, - targetVersion, - description: _t("This upgrade will allow members of selected spaces " + - "access to this room without an invite."), - onFinished: (resp) => { - if (!resp?.continue) return; - upgradeRoom(room, targetVersion, resp.invite); - }, - }); - return; - } - } - - if ( - this.state.encrypted && - this.state.joinRule !== JoinRule.Public && - joinRule === JoinRule.Public - ) { - const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, { - title: _t("Are you sure you want to make this encrypted room public?"), - description:
-

{ _t( - "It's not recommended to make encrypted rooms public. " + - "It will mean anyone can find and join the room, so anyone can read messages. " + - "You'll get none of the benefits of encryption. Encrypting messages in a public " + - "room will make receiving and sending messages slower.", - null, - { "b": (sub) => { sub } }, - ) }

-

{ _t( - "To avoid these issues, create a new public room for the conversation " + - "you plan to have.", - null, - { - "a": (sub) => { - dialog.close(); - this.createNewRoom(true, false); - }}> { sub } , - }, - ) }

-
, - }); - - const { finished } = dialog; - const [confirm] = await finished; - if (!confirm) return; - } - - if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return; - - const content: IContent = { - join_rule: joinRule, - }; - - // pre-set the accepted spaces with the currently viewed one as per the microcopy - if (joinRule === JoinRule.Restricted) { - content.allow = restrictedAllowRoomIds.map(roomId => ({ - "type": RestrictedAllowType.RoomMembership, - "room_id": roomId, - })); - } - - this.setState({ joinRule, restrictedAllowRoomIds }); - - const client = MatrixClientPeg.get(); - client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, content, "").catch((e) => { - console.error(e); - this.setState({ - joinRule: beforeJoinRule, - restrictedAllowRoomIds: undefined, - }); - }); - }; - - private onRestrictedRoomIdsChange = (restrictedAllowRoomIds: string[]) => { - const beforeRestrictedAllowRoomIds = this.state.restrictedAllowRoomIds; - if (!arrayHasDiff(beforeRestrictedAllowRoomIds || [], restrictedAllowRoomIds)) return; - this.setState({ restrictedAllowRoomIds }); - - const client = MatrixClientPeg.get(); - client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, { - join_rule: JoinRule.Restricted, - allow: restrictedAllowRoomIds.map(roomId => ({ - "type": RestrictedAllowType.RoomMembership, - "room_id": roomId, - })), - }, "").catch((e) => { - console.error(e); - this.setState({ restrictedAllowRoomIds: beforeRestrictedAllowRoomIds }); - }); - }; - private onGuestAccessChange = (allowed: boolean) => { const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden; const beforeGuestAccess = this.state.guestAccess; @@ -371,42 +247,12 @@ export default class SecurityRoomSettingsTab extends React.Component => { - let selected = this.state.restrictedAllowRoomIds; - if (!selected?.length && SpaceStore.instance.activeSpace) { - selected = [SpaceStore.instance.activeSpace.roomId]; - } - - const matrixClient = MatrixClientPeg.get(); - const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, { - matrixClient, - room: matrixClient.getRoom(this.props.roomId), - selected, - }, "mx_ManageRestrictedJoinRuleDialog_wrapper"); - - const [restrictedAllowRoomIds] = await finished; - return restrictedAllowRoomIds; - }; - - private onEditRestrictedClick = async () => { - const restrictedAllowRoomIds = await this.editRestrictedRoomIds(); - if (!Array.isArray(restrictedAllowRoomIds)) return; - if (restrictedAllowRoomIds.length > 0) { - this.onRestrictedRoomIdsChange(restrictedAllowRoomIds); - } else { - this.onJoinRuleChange(JoinRule.Invite); - } - }; - private renderJoinRule() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); - const joinRule = this.state.joinRule; - - const canChangeJoinRule = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client); let aliasWarning = null; - if (joinRule === JoinRule.Public && !this.state.hasAliases) { + if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) { aliasWarning = (
@@ -417,111 +263,67 @@ export default class SecurityRoomSettingsTab extends React.Component[] = [{ - value: JoinRule.Invite, - label: _t("Private (invite only)"), - description: _t("Only invited people can join."), - checked: this.state.joinRule === JoinRule.Invite - || (this.state.joinRule === JoinRule.Restricted && !this.state.restrictedAllowRoomIds?.length), - }, { - value: JoinRule.Public, - label: _t("Public"), - description: _t("Anyone can find and join."), - }]; + return
+
+ { _t("Decide who can join %(roomName)s.", { + roomName: room?.name, + }) } +
- if (this.state.roomSupportsRestricted || - this.state.preferredRestrictionVersion || - joinRule === JoinRule.Restricted - ) { - let upgradeRequiredPill; - if (this.state.preferredRestrictionVersion) { - upgradeRequiredPill = - { _t("Upgrade required") } - ; - } + { aliasWarning } - let description; - if (joinRule === JoinRule.Restricted && this.state.restrictedAllowRoomIds?.length) { - const shownSpaces = this.state.restrictedAllowRoomIds - .map(roomId => client.getRoom(roomId)) - .filter(room => room?.isSpaceRoom()) - .slice(0, 4); + +
; + } - let moreText; - if (shownSpaces.length < this.state.restrictedAllowRoomIds.length) { - if (shownSpaces.length > 0) { - moreText = _t("& %(count)s more", { - count: this.state.restrictedAllowRoomIds.length - shownSpaces.length, - }); - } else { - moreText = _t("Currently, %(count)s spaces have access", { - count: this.state.restrictedAllowRoomIds.length, - }); - } - } + private onJoinRuleChangeError = (error: Error) => { + Modal.createTrackedDialog('Room not found', '', ErrorDialog, { + title: _t("Failed to update the join rules"), + description: error.message ?? _t("Unknown failure"), + }); + }; - description =
- - { _t("Anyone in a space can find and join. Edit which spaces can access here.", {}, { - a: sub => - { sub } - , - }) } - - -
-

{ _t("Spaces with access") }

- { shownSpaces.map(room => { - return - - { room.name } - ; - }) } - { moreText && { moreText } } -
-
; - } else if (SpaceStore.instance.activeSpace) { - description = _t("Anyone in %(spaceName)s can find and join. You can select other spaces too.", { - spaceName: SpaceStore.instance.activeSpace.name, - }); - } else { - description = _t("Anyone in a space can find and join. You can select multiple spaces."); - } - - radioDefinitions.splice(1, 0, { - value: JoinRule.Restricted, - label: <> - { _t("Space members") } - { upgradeRequiredPill } - , - description, - // if there are 0 allowed spaces then render it as invite only instead - checked: this.state.joinRule === JoinRule.Restricted && !!this.state.restrictedAllowRoomIds?.length, + private onBeforeJoinRuleChange = async (joinRule: JoinRule): Promise => { + if (this.state.encrypted && joinRule === JoinRule.Public) { + const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, { + title: _t("Are you sure you want to make this encrypted room public?"), + description:
+

{ _t( + "It's not recommended to make encrypted rooms public. " + + "It will mean anyone can find and join the room, so anyone can read messages. " + + "You'll get none of the benefits of encryption. Encrypting messages in a public " + + "room will make receiving and sending messages slower.", + null, + { "b": (sub) => { sub } }, + ) }

+

{ _t( + "To avoid these issues, create a new public room for the conversation " + + "you plan to have.", + null, + { + "a": (sub) => { + dialog.close(); + this.createNewRoom(true, false); + }}> { sub } , + }, + ) }

+
, }); + + const { finished } = dialog; + const [confirm] = await finished; + if (!confirm) return false; } - return ( -
-
- { _t("Decide who can join %(roomName)s.", { - roomName: client.getRoom(this.props.roomId)?.name, - }) } -
- { aliasWarning } - -
- ); - } + return true; + }; private renderHistory() { const client = MatrixClientPeg.get(); diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index b48f5c79c6..83587f54e6 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -25,49 +25,19 @@ import AccessibleButton from "../elements/AccessibleButton"; import AliasSettings from "../room_settings/AliasSettings"; import { useStateToggle } from "../../../hooks/useStateToggle"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import StyledRadioGroup from "../elements/StyledRadioGroup"; +import { useLocalEcho } from "../../../hooks/useLocalEcho"; +import JoinRuleSettings from "../settings/JoinRuleSettings"; interface IProps { matrixClient: MatrixClient; space: Room; } -enum SpaceVisibility { - Unlisted = "unlisted", - Private = "private", -} - -const useLocalEcho = ( - currentFactory: () => T, - setterFn: (value: T) => Promise, - errorFn: (error: Error) => void, -): [value: T, handler: (value: T) => void] => { - const [value, setValue] = useState(currentFactory); - const handler = async (value: T) => { - setValue(value); - try { - await setterFn(value); - } catch (e) { - setValue(currentFactory()); - errorFn(e); - } - }; - - return [value, handler]; -}; - const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => { const [error, setError] = useState(""); const userId = cli.getUserId(); - const [visibility, setVisibility] = useLocalEcho( - () => space.getJoinRule() === JoinRule.Invite ? SpaceVisibility.Private : SpaceVisibility.Unlisted, - visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { - join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Invite, - }, ""), - () => setError(_t("Failed to update the visibility of this space")), - ); const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho( () => space.currentState.getStateEvents(EventType.RoomGuestAccess, "") ?.getContent()?.guest_access === GuestAccess.CanJoin, @@ -87,7 +57,6 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => { const [showAdvancedSection, toggleAdvancedSection] = useStateToggle(); - const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId); const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId); const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId); const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli); @@ -121,7 +90,7 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => { } let addressesSection; - if (visibility !== SpaceVisibility.Private) { + if (space.getJoinRule() === JoinRule.Public) { addressesSection = <> { _t("Address") }
@@ -147,22 +116,9 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
- setError(_t("Failed to update the visibility of this space"))} />
diff --git a/src/hooks/useLocalEcho.ts b/src/hooks/useLocalEcho.ts new file mode 100644 index 0000000000..4b30fd2f00 --- /dev/null +++ b/src/hooks/useLocalEcho.ts @@ -0,0 +1,36 @@ +/* +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 { useState } from "react"; + +export const useLocalEcho = ( + currentFactory: () => T, + setterFn: (value: T) => Promise, + errorFn: (error: Error) => void, +): [value: T, handler: (value: T) => void] => { + const [value, setValue] = useState(currentFactory); + const handler = async (value: T) => { + setValue(value); + try { + await setterFn(value); + } catch (e) { + setValue(currentFactory()); + errorFn(e); + } + }; + + return [value, handler]; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2cb0a546aa..8e9f41f101 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1058,7 +1058,6 @@ "Saving...": "Saving...", "Save Changes": "Save Changes", "Leave Space": "Leave Space", - "Failed to update the visibility of this space": "Failed to update the visibility of this space", "Failed to update the guest access of this space": "Failed to update the guest access of this space", "Failed to update the history visibility of this space": "Failed to update the history visibility of this space", "Hide advanced": "Hide advanced", @@ -1068,9 +1067,7 @@ "Show advanced": "Show advanced", "Visibility": "Visibility", "Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.", - "anyone with the link can view and join": "anyone with the link can view and join", - "Invite only": "Invite only", - "only invited people can view and join": "only invited people can view and join", + "Failed to update the visibility of this space": "Failed to update the visibility of this space", "Preview Space": "Preview Space", "Allow people to preview your space before they join.": "Allow people to preview your space before they join.", "Recommended for public spaces.": "Recommended for public spaces.", @@ -1144,6 +1141,18 @@ "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", + "Private (invite only)": "Private (invite only)", + "Only invited people can join.": "Only invited people can join.", + "Anyone can find and join.": "Anyone can find and join.", + "Upgrade required": "Upgrade required", + "& %(count)s more|other": "& %(count)s more", + "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access", + "Anyone in a space can find and join. Edit which spaces can access here.": "Anyone in a space can find and join. Edit which spaces can access here.", + "Spaces with access": "Spaces with access", + "Anyone in can find and join. You can select other spaces too.": "Anyone in can find and join. You can select other spaces too.", + "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.", + "Space members": "Space members", + "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", "Message layout": "Message layout", "IRC": "IRC", "Modern": "Modern", @@ -1452,23 +1461,13 @@ "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "To avoid these issues, create a new encrypted room for the conversation you plan to have.", "Enable encryption?": "Enable encryption?", "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.", - "This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", + "To link to this room, please add an address.": "To link to this room, please add an address.", + "Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.", + "Failed to update the join rules": "Failed to update the join rules", + "Unknown failure": "Unknown failure", "Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?", "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "It's not recommended to make encrypted rooms public. It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.", "To avoid these issues, create a new public room for the conversation you plan to have.": "To avoid these issues, create a new public room for the conversation you plan to have.", - "To link to this room, please add an address.": "To link to this room, please add an address.", - "Private (invite only)": "Private (invite only)", - "Only invited people can join.": "Only invited people can join.", - "Anyone can find and join.": "Anyone can find and join.", - "Upgrade required": "Upgrade required", - "& %(count)s more|other": "& %(count)s more", - "Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access", - "Anyone in a space can find and join. Edit which spaces can access here.": "Anyone in a space can find and join. Edit which spaces can access here.", - "Spaces with access": "Spaces with access", - "Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.", - "Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.", - "Space members": "Space members", - "Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.", "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", "Members only (since they were invited)": "Members only (since they were invited)", "Members only (since they joined)": "Members only (since they joined)", From 329292eb9b731489abc9605ed3ba05d87e274c3d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 6 Sep 2021 22:11:35 -0600 Subject: [PATCH 04/19] Revert "Revert "Create narrow mode for Composer"" --- res/css/views/rooms/_MessageComposer.scss | 14 ++ .../views/rooms/MessageComposer.tsx | 178 +++++++++++++++--- src/components/views/rooms/Stickerpicker.tsx | 96 ++++------ .../views/rooms/VoiceRecordComposerTile.tsx | 26 +-- src/i18n/strings/en_EN.json | 7 +- 5 files changed, 209 insertions(+), 112 deletions(-) diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 9445242306..5c8f6809de 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -237,6 +237,15 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg'); } +.mx_MessageComposer_buttonMenu::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_MessageComposer_closeButtonMenu::before { + transform: rotate(90deg); + transform-origin: center; +} + .mx_MessageComposer_sendMessage { cursor: pointer; position: relative; @@ -356,3 +365,8 @@ limitations under the License. margin-right: 0; } } + +.mx_MessageComposer_Menu .mx_CallContextMenu_item { + display: flex; + align-items: center; +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 466675ac64..6b66ae4ba3 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { createRef } from 'react'; import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -27,7 +27,13 @@ import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalin import ContentMessages from '../../../ContentMessages'; import E2EIcon from './E2EIcon'; import SettingsStore from "../../../settings/SettingsStore"; -import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; +import { + aboveLeftOf, + ContextMenu, + ContextMenuTooltipButton, + useContextMenu, + MenuItem, +} from "../../structures/ContextMenu"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import { UIFeature } from "../../../settings/UIFeature"; @@ -45,6 +51,9 @@ import { Action } from "../../../dispatcher/actions"; import EditorModel from "../../../editor/model"; import EmojiPicker from '../emojipicker/EmojiPicker'; import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar"; +import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; + +const NARROW_MODE_BREAKPOINT = 500; interface IComposerAvatarProps { me: object; @@ -71,13 +80,13 @@ function SendButton(props: ISendButtonProps) { ); } -const EmojiButton = ({ addEmoji }) => { +const EmojiButton = ({ addEmoji, menuPosition }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); let contextMenu; if (menuDisplayed) { - const buttonRect = button.current.getBoundingClientRect(); - contextMenu = + const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); + contextMenu = ; } @@ -196,6 +205,9 @@ interface IState { haveRecording: boolean; recordingTimeLeftSeconds?: number; me?: RoomMember; + narrowMode?: boolean; + isMenuOpen: boolean; + showStickers: boolean; } @replaceableComponent("views.rooms.MessageComposer") @@ -203,6 +215,7 @@ export default class MessageComposer extends React.Component { private dispatcherRef: string; private messageComposerInput: SendMessageComposer; private voiceRecordingButton: VoiceRecordComposerTile; + private ref: React.RefObject = createRef(); static defaultProps = { replyInThread: false, @@ -220,6 +233,8 @@ export default class MessageComposer extends React.Component { isComposerEmpty: true, haveRecording: false, recordingTimeLeftSeconds: null, // when set to a number, shows a toast + isMenuOpen: false, + showStickers: false, }; } @@ -227,8 +242,21 @@ export default class MessageComposer extends React.Component { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); this.waitForOwnMember(); + UIStore.instance.trackElementDimensions("MessageComposer", this.ref.current); + UIStore.instance.on("MessageComposer", this.onResize); } + private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => { + if (type === UI_EVENTS.Resize) { + const narrowMode = entry.contentRect.width <= NARROW_MODE_BREAKPOINT; + this.setState({ + narrowMode, + isMenuOpen: !narrowMode ? false : this.state.isMenuOpen, + showStickers: false, + }); + } + }; + private onAction = (payload: ActionPayload) => { if (payload.action === 'reply_to_event') { // add a timeout for the reply preview to be rendered, so @@ -263,6 +291,8 @@ export default class MessageComposer extends React.Component { } VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); dis.unregister(this.dispatcherRef); + UIStore.instance.stopTrackingElementDimensions("MessageComposer"); + UIStore.instance.removeListener("MessageComposer", this.onResize); } private onRoomStateEvents = (ev, state) => { @@ -369,6 +399,96 @@ export default class MessageComposer extends React.Component { } }; + private shouldShowStickerPicker = (): boolean => { + return SettingsStore.getValue(UIFeature.Widgets) + && SettingsStore.getValue("MessageComposerInput.showStickersButton") + && !this.state.haveRecording; + }; + + private showStickers = (showStickers: boolean) => { + this.setState({ showStickers }); + }; + + private toggleButtonMenu = (): void => { + this.setState({ + isMenuOpen: !this.state.isMenuOpen, + }); + }; + + private renderButtons(menuPosition): JSX.Element | JSX.Element[] { + const buttons = new Map(); + if (!this.state.haveRecording) { + buttons.set( + _t("Send File"), + , + ); + buttons.set( + _t("Show Emojis"), + , + ); + } + if (this.shouldShowStickerPicker()) { + buttons.set( + _t("Show Stickers"), + this.showStickers(!this.state.showStickers)} + title={this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers")} + />, + ); + } + if (!this.state.haveRecording && !this.state.narrowMode) { + buttons.set( + _t("Send voice message"), + this.voiceRecordingButton?.onRecordStartEndClick()} + title={_t("Send voice message")} + />, + ); + } + + if (!this.state.narrowMode) { + return Array.from(buttons.values()); + } else { + const classnames = classNames({ + mx_MessageComposer_button: true, + mx_MessageComposer_buttonMenu: true, + mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen, + }); + + return <> + { buttons[0] } + + { this.state.isMenuOpen && ( + + { Array.from(buttons).slice(1).map(([label, button]) => ( + + { button } + { label } + + )) } + + ) } + ; + } + } + render() { const controls = [ this.state.me && !this.props.compact ? : null, @@ -377,6 +497,12 @@ export default class MessageComposer extends React.Component { null, ]; + let menuPosition; + if (this.ref.current) { + const contentRect = this.ref.current.getBoundingClientRect(); + menuPosition = aboveLeftOf(contentRect); + } + if (!this.state.tombstone && this.state.canSendMessages) { controls.push( { />, ); - if (!this.state.haveRecording) { - controls.push( - , - , - ); - } - - if (SettingsStore.getValue(UIFeature.Widgets) && - SettingsStore.getValue("MessageComposerInput.showStickersButton") && - !this.state.haveRecording) { - controls.push(); - } - controls.push( this.voiceRecordingButton = c} room={this.props.room} />); - - if (!this.state.isComposerEmpty || this.state.haveRecording) { - controls.push( - , - ); - } } else if (this.state.tombstone) { const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; @@ -459,6 +562,15 @@ export default class MessageComposer extends React.Component { yOffset={-50} />; } + controls.push( + , + ); + + const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording; const classes = classNames({ "mx_MessageComposer": true, @@ -467,7 +579,7 @@ export default class MessageComposer extends React.Component { }); return ( -
+
{ recordingTooltip }
{ this.props.showReplyPreview && ( @@ -475,6 +587,14 @@ export default class MessageComposer extends React.Component { ) }
{ controls } + { this.renderButtons(menuPosition) } + { showSendButton && ( + + ) }
diff --git a/src/components/views/rooms/Stickerpicker.tsx b/src/components/views/rooms/Stickerpicker.tsx index 33367c1151..0806b4ab9d 100644 --- a/src/components/views/rooms/Stickerpicker.tsx +++ b/src/components/views/rooms/Stickerpicker.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { _t, _td } from '../../../languageHandler'; import AppTile from '../elements/AppTile'; @@ -27,7 +26,6 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import { ChevronFace, ContextMenu } from "../../structures/ContextMenu"; import { WidgetType } from "../../../widgets/WidgetType"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { Action } from "../../../dispatcher/actions"; import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -44,10 +42,12 @@ const PERSISTED_ELEMENT_KEY = "stickerPicker"; interface IProps { room: Room; + showStickers: boolean; + menuPosition?: any; + setShowStickers: (showStickers: boolean) => void; } interface IState { - showStickers: boolean; imError: string; stickerpickerX: number; stickerpickerY: number; @@ -72,7 +72,6 @@ export default class Stickerpicker extends React.PureComponent { constructor(props: IProps) { super(props); this.state = { - showStickers: false, imError: null, stickerpickerX: null, stickerpickerY: null, @@ -114,7 +113,7 @@ export default class Stickerpicker extends React.PureComponent { console.warn('No widget ID specified, not disabling assets'); } - this.setState({ showStickers: false }); + this.props.setShowStickers(false); WidgetUtils.removeStickerpickerWidgets().then(() => { this.forceUpdate(); }).catch((e) => { @@ -146,15 +145,15 @@ export default class Stickerpicker extends React.PureComponent { } public componentDidUpdate(prevProps: IProps, prevState: IState): void { - this.sendVisibilityToWidget(this.state.showStickers); + this.sendVisibilityToWidget(this.props.showStickers); } private imError(errorMsg: string, e: Error): void { console.error(errorMsg, e); this.setState({ - showStickers: false, imError: _t(errorMsg), }); + this.props.setShowStickers(false); } private updateWidget = (): void => { @@ -194,12 +193,12 @@ export default class Stickerpicker extends React.PureComponent { this.forceUpdate(); break; case "stickerpicker_close": - this.setState({ showStickers: false }); + this.props.setShowStickers(false); break; case Action.AfterRightPanelPhaseChange: case "show_left_panel": case "hide_left_panel": - this.setState({ showStickers: false }); + this.props.setShowStickers(false); break; } }; @@ -338,8 +337,8 @@ export default class Stickerpicker extends React.PureComponent { const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; + this.props.setShowStickers(true); this.setState({ - showStickers: true, stickerpickerX: x, stickerpickerY: y, stickerpickerChevronOffset, @@ -351,8 +350,8 @@ export default class Stickerpicker extends React.PureComponent { * @param {Event} ev Event that triggered the function call */ private onHideStickersClick = (ev: React.MouseEvent): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -360,8 +359,8 @@ export default class Stickerpicker extends React.PureComponent { * Called when the window is resized */ private onResize = (): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -369,8 +368,8 @@ export default class Stickerpicker extends React.PureComponent { * The stickers picker was hidden */ private onFinished = (): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -395,54 +394,23 @@ export default class Stickerpicker extends React.PureComponent { }; public render(): JSX.Element { - let stickerPicker; - let stickersButton; - const className = classNames( - "mx_MessageComposer_button", - "mx_MessageComposer_stickers", - "mx_Stickers_hideStickers", - "mx_MessageComposer_button_highlight", - ); - if (this.state.showStickers) { - // Show hide-stickers button - stickersButton = - ; + if (!this.props.showStickers) return null; - stickerPicker = - - ; - } else { - // Show show-stickers button - stickersButton = - ; - } - return - { stickersButton } - { stickerPicker } - ; + return + + ; } } diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index bd573fa474..288d97fc50 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -20,7 +20,6 @@ import React, { ReactNode } from "react"; import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import classNames from "classnames"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; @@ -137,7 +136,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent { + public onRecordStartEndClick = async () => { if (this.state.recorder) { await this.state.recorder.stop(); return; @@ -215,27 +214,23 @@ export default class VoiceRecordComposerTile extends React.PureComponent; if (this.state.recorder && !this.state.recorder?.isRecording) { - stopOrRecordBtn = null; + stopBtn = null; } } @@ -264,13 +259,10 @@ export default class VoiceRecordComposerTile extends React.PureComponent; } - // The record button (mic icon) is meant to be on the right edge, but we also want the - // stop button to be left of the waveform area. Luckily, none of the surrounding UI is - // rendered when we're not recording, so the record button ends up in the correct spot. return (<> { uploadIndicator } { deleteButton } - { stopOrRecordBtn } + { stopBtn } { this.renderWaveformArea() } ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7d754a618a..1f8104da1d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1564,7 +1564,12 @@ "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", + "Send File": "Send File", + "Show Emojis": "Show Emojis", + "Show Stickers": "Show Stickers", + "Hide Stickers": "Hide Stickers", "Send voice message": "Send voice message", + "Composer menu": "Composer menu", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", @@ -1725,8 +1730,6 @@ "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", "Stickerpack": "Stickerpack", - "Hide Stickers": "Hide Stickers", - "Show Stickers": "Show Stickers", "Failed to revoke invite": "Failed to revoke invite", "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.", "Admin Tools": "Admin Tools", From 646ef197fe5212ebac395966f38d918785016414 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 7 Sep 2021 16:02:26 +0100 Subject: [PATCH 05/19] Fix PR UI defects --- res/css/views/rooms/_MessageComposer.scss | 6 +++- src/components/structures/RightPanel.tsx | 5 ++- src/components/structures/RoomView.tsx | 3 +- src/components/structures/ThreadView.tsx | 3 ++ .../views/rooms/MessageComposer.tsx | 33 ++++++++++++------- src/i18n/strings/en_EN.json | 12 ++++--- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 5c8f6809de..26db5dbafe 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -358,12 +358,16 @@ limitations under the License. margin-right: 0; .mx_MessageComposer_wrapper { - padding: 0; + padding: 0 0 0 25px; } .mx_MessageComposer_button:last-child { margin-right: 0; } + + .mx_MessageComposer_e2eIcon { + left: 0; + } } .mx_MessageComposer_Menu .mx_CallContextMenu_item { diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 67634c63d2..114d020c66 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -53,6 +53,7 @@ import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import { throttle } from 'lodash'; import SpaceStore from "../../stores/SpaceStore"; import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; +import { E2EStatus } from '../../utils/ShieldUtils'; interface IProps { room?: Room; // if showing panels for a given room, this is set @@ -60,6 +61,7 @@ interface IProps { user?: User; // used if we know the user ahead of opening the panel resizeNotifier: ResizeNotifier; permalinkCreator?: RoomPermalinkCreator; + e2eStatus?: E2EStatus; } interface IState { @@ -319,7 +321,8 @@ export default class RightPanel extends React.Component { resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} mxEvent={this.state.event} - permalinkCreator={this.props.permalinkCreator} />; + permalinkCreator={this.props.permalinkCreator} + e2eStatus={this.props.e2eStatus} />; break; case RightPanelPhases.ThreadPanel: diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8223c12e77..9dea4b1d1d 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2054,7 +2054,8 @@ export default class RoomView extends React.Component { ? + permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + e2eStatus={this.state.e2eStatus} /> : null; const timelineClasses = classNames("mx_RoomView_timeline", { diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 134f018aed..304479cce3 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -33,6 +33,7 @@ import { ActionPayload } from '../../dispatcher/payloads'; import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload'; import { Action } from '../../dispatcher/actions'; import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { E2EStatus } from '../../utils/ShieldUtils'; interface IProps { room: Room; @@ -40,6 +41,7 @@ interface IProps { resizeNotifier: ResizeNotifier; mxEvent: MatrixEvent; permalinkCreator?: RoomPermalinkCreator; + e2eStatus?: E2EStatus; } interface IState { @@ -144,6 +146,7 @@ export default class ThreadView extends React.Component { replyToEvent={this.state?.thread?.replyToEvent} showReplyPreview={false} permalinkCreator={this.props.permalinkCreator} + e2eStatus={this.props.e2eStatus} compact={true} /> diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 6b66ae4ba3..49acb13592 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -53,6 +53,7 @@ import EmojiPicker from '../emojipicker/EmojiPicker'; import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar"; import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; +let instanceCount = 0; const NARROW_MODE_BREAKPOINT = 500; interface IComposerAvatarProps { @@ -216,6 +217,7 @@ export default class MessageComposer extends React.Component { private messageComposerInput: SendMessageComposer; private voiceRecordingButton: VoiceRecordComposerTile; private ref: React.RefObject = createRef(); + private instanceId: number; static defaultProps = { replyInThread: false, @@ -236,14 +238,16 @@ export default class MessageComposer extends React.Component { isMenuOpen: false, showStickers: false, }; + + this.instanceId = instanceCount++; } componentDidMount() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); this.waitForOwnMember(); - UIStore.instance.trackElementDimensions("MessageComposer", this.ref.current); - UIStore.instance.on("MessageComposer", this.onResize); + UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current); + UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize); } private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => { @@ -291,8 +295,8 @@ export default class MessageComposer extends React.Component { } VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); dis.unregister(this.dispatcherRef); - UIStore.instance.stopTrackingElementDimensions("MessageComposer"); - UIStore.instance.removeListener("MessageComposer", this.onResize); + UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`); + UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize); } private onRoomStateEvents = (ev, state) => { @@ -342,7 +346,11 @@ export default class MessageComposer extends React.Component { private renderPlaceholderText = () => { if (this.props.replyToEvent) { - if (this.props.e2eStatus) { + if (this.props.replyInThread && this.props.e2eStatus) { + return _t('Reply to encrypted thread…'); + } else if (this.props.replyInThread) { + return _t('Reply to thread…'); + } else if (this.props.e2eStatus) { return _t('Send an encrypted reply…'); } else { return _t('Send a reply…'); @@ -419,17 +427,17 @@ export default class MessageComposer extends React.Component { const buttons = new Map(); if (!this.state.haveRecording) { buttons.set( - _t("Send File"), + _t("Send a file"), , ); buttons.set( - _t("Show Emojis"), + _t("Add emoji"), , ); } if (this.shouldShowStickerPicker()) { buttons.set( - _t("Show Stickers"), + _t("Send a sticker"), { } if (!this.state.haveRecording && !this.state.narrowMode) { buttons.set( - _t("Send voice message"), + _t("Send a voice message"), this.voiceRecordingButton?.onRecordStartEndClick()} @@ -450,8 +458,9 @@ export default class MessageComposer extends React.Component { ); } + const buttonsArray = Array.from(buttons.values()); if (!this.state.narrowMode) { - return Array.from(buttons.values()); + return buttonsArray; } else { const classnames = classNames({ mx_MessageComposer_button: true, @@ -460,11 +469,11 @@ export default class MessageComposer extends React.Component { }); return <> - { buttons[0] } + { buttonsArray[0] } { this.state.isMenuOpen && ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1f8104da1d..b4b1071013 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1560,16 +1560,20 @@ "Send message": "Send message", "Emoji picker": "Emoji picker", "Upload file": "Upload file", + "Reply to encrypted thread…": "Reply to encrypted thread…", + "Reply to thread…": "Reply to thread…", "Send an encrypted reply…": "Send an encrypted reply…", "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", - "Send File": "Send File", - "Show Emojis": "Show Emojis", - "Show Stickers": "Show Stickers", + "Send a file": "Send a file", + "Add emoji": "Add emoji", + "Send a sticker": "Send a sticker", "Hide Stickers": "Hide Stickers", + "Show Stickers": "Show Stickers", + "Send a voice message": "Send a voice message", "Send voice message": "Send voice message", - "Composer menu": "Composer menu", + "More options": "More options", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", From bbf66a001136cb6240b06b86085891678fce08f7 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 7 Sep 2021 17:10:09 +0100 Subject: [PATCH 06/19] Make label clickable on narrow mode context menu --- res/css/views/rooms/_MessageComposer.scss | 22 ++++++-- .../elements/AccessibleTooltipButton.tsx | 4 +- .../views/rooms/MessageComposer.tsx | 52 +++++++++++-------- src/i18n/strings/en_EN.json | 6 +-- 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 26db5dbafe..faa3171d67 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -186,11 +186,14 @@ limitations under the License. } .mx_MessageComposer_button { + --size: 26px; position: relative; margin-right: 6px; cursor: pointer; - height: 26px; - width: 26px; + height: var(--size); + line-height: var(--size); + width: auto; + padding-left: calc(var(--size) + 5px); border-radius: 100%; &::before { @@ -207,8 +210,21 @@ limitations under the License. mask-position: center; } + &::after { + content: ''; + position: absolute; + left: 0; + top: 0; + z-index: 0; + width: var(--size); + height: var(--size); + border-radius: 50%; + } + &:hover { - background: rgba($accent-color, 0.1); + &::after { + background: rgba($accent-color, 0.1); + } &::before { background-color: $accent-color; diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 8ac41ad1a2..d2a4801a2d 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -25,6 +25,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; interface ITooltipProps extends React.ComponentProps { title: string; tooltip?: React.ReactNode; + label?: React.ReactNode; tooltipClassName?: string; forceHide?: boolean; yOffset?: number; @@ -84,7 +85,8 @@ export default class AccessibleTooltipButton extends React.PureComponent { children } - { tip } + { this.props.label } + { (tooltip || title) && tip } ); } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 49acb13592..6372ecc1f5 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -81,7 +81,13 @@ function SendButton(props: ISendButtonProps) { ); } -const EmojiButton = ({ addEmoji, menuPosition }) => { +interface IEmojiButtonProps { + addEmoji: (unicode: string) => boolean; + menuPosition: any; // TODO: Types + narrowMode: boolean; +} + +const EmojiButton: React.FC = ({ addEmoji, menuPosition, narrowMode }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); let contextMenu; @@ -103,12 +109,11 @@ const EmojiButton = ({ addEmoji, menuPosition }) => { // TODO: replace ContextMenuTooltipButton with a unified representation of // the header buttons and the right panel buttons return - { contextMenu } @@ -364,11 +369,12 @@ export default class MessageComposer extends React.Component { } }; - private addEmoji(emoji: string) { + private addEmoji(emoji: string): boolean { dis.dispatch({ action: Action.ComposerInsert, text: emoji, }); + return true; } private sendMessage = async () => { @@ -424,32 +430,34 @@ export default class MessageComposer extends React.Component { }; private renderButtons(menuPosition): JSX.Element | JSX.Element[] { - const buttons = new Map(); + const buttons: JSX.Element[] = []; if (!this.state.haveRecording) { - buttons.set( - _t("Send a file"), + buttons.push( , ); - buttons.set( - _t("Add emoji"), - , + buttons.push( + , ); } if (this.shouldShowStickerPicker()) { - buttons.set( - _t("Send a sticker"), + let title; + if (!this.state.narrowMode) { + title = this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers"); + } + + buttons.push( this.showStickers(!this.state.showStickers)} - title={this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers")} + title={title} + label={this.state.narrowMode && _t("Send a sticker")} />, ); } if (!this.state.haveRecording && !this.state.narrowMode) { - buttons.set( - _t("Send a voice message"), + buttons.push( this.voiceRecordingButton?.onRecordStartEndClick()} @@ -458,9 +466,8 @@ export default class MessageComposer extends React.Component { ); } - const buttonsArray = Array.from(buttons.values()); if (!this.state.narrowMode) { - return buttonsArray; + return buttons; } else { const classnames = classNames({ mx_MessageComposer_button: true, @@ -469,7 +476,7 @@ export default class MessageComposer extends React.Component { }); return <> - { buttonsArray[0] } + { buttons[0] } { menuWidth={150} wrapperClassName="mx_MessageComposer_Menu" > - { Array.from(buttons).slice(1).map(([label, button]) => ( - + { buttons.slice(1).map((button, index) => ( + { button } - { label } )) } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b4b1071013..1200042f2f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1559,6 +1559,7 @@ "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "Send message": "Send message", "Emoji picker": "Emoji picker", + "Send an emoji": "Send an emoji", "Upload file": "Upload file", "Reply to encrypted thread…": "Reply to encrypted thread…", "Reply to thread…": "Reply to thread…", @@ -1566,12 +1567,9 @@ "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", - "Send a file": "Send a file", - "Add emoji": "Add emoji", - "Send a sticker": "Send a sticker", "Hide Stickers": "Hide Stickers", "Show Stickers": "Show Stickers", - "Send a voice message": "Send a voice message", + "Send an sticker": "Send an sticker", "Send voice message": "Send voice message", "More options": "More options", "The conversation continues here.": "The conversation continues here.", From 21e33362e5a92c22dcf659d83ee4d4578d99407d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Sep 2021 11:26:54 -0600 Subject: [PATCH 07/19] Add config option to turn on in-room event sending timing metrics This is intended to be hooked up to an external system. Due to the extra events and metadata concerns, this is only available if turned on from the config. See `sendTimePerformanceMetrics.ts` for event schemas. --- src/ContentMessages.tsx | 11 +++++ .../views/rooms/SendMessageComposer.tsx | 10 ++++ src/sendTimePerformanceMetrics.ts | 48 +++++++++++++++++++ src/settings/Settings.tsx | 4 ++ 4 files changed, 73 insertions(+) create mode 100644 src/sendTimePerformanceMetrics.ts diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 14a0c1ed51..40f8e307a5 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -39,6 +39,8 @@ import { import { IUpload } from "./models/IUpload"; import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { BlurhashEncoder } from "./BlurhashEncoder"; +import SettingsStore from "./settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -539,6 +541,10 @@ export default class ContentMessages { msgtype: "", // set later }; + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + decorateStartSendingTime(content); + } + // if we have a mime type for the file, add it to the message metadata if (file.type) { content.info.mimetype = file.type; @@ -614,6 +620,11 @@ export default class ContentMessages { }).then(function() { if (upload.canceled) throw new UploadCanceledError(); const prom = matrixClient.sendMessage(roomId, content); + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + prom.then(resp => { + sendRoundTripMetric(matrixClient, roomId, resp.event_id); + }); + } CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content); return prom; }, function(err) { diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index aca397b6b2..bb5d537895 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -54,6 +54,7 @@ import { Room } from 'matrix-js-sdk/src/models/room'; import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import { ActionPayload } from "../../../dispatcher/payloads"; +import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; function addReplyToMessageContent( content: IContent, @@ -418,6 +419,10 @@ export default class SendMessageComposer extends React.Component { // don't bother sending an empty message if (!content.body.trim()) return; + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + decorateStartSendingTime(content); + } + const prom = this.context.sendMessage(roomId, content); if (replyToEvent) { // Clear reply_to_event as we put the message into the queue @@ -433,6 +438,11 @@ export default class SendMessageComposer extends React.Component { dis.dispatch({ action: `effects.${effect.command}` }); } }); + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { + prom.then(resp => { + sendRoundTripMetric(this.context, roomId, resp.event_id); + }); + } CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); } diff --git a/src/sendTimePerformanceMetrics.ts b/src/sendTimePerformanceMetrics.ts new file mode 100644 index 0000000000..ef461db939 --- /dev/null +++ b/src/sendTimePerformanceMetrics.ts @@ -0,0 +1,48 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk"; + +/** + * Decorates the given event content object with the "send start time". The + * object will be modified in-place. + * @param {object} content The event content. + */ +export function decorateStartSendingTime(content: object) { + content['io.element.performance_metrics'] = { + sendStartTs: Date.now(), + }; +} + +/** + * Called when an event decorated with `decorateStartSendingTime()` has been sent + * by the server (the client now knows the event ID). + * @param {MatrixClient} client The client to send as. + * @param {string} inRoomId The room ID where the original event was sent. + * @param {string} forEventId The event ID for the decorated event. + */ +export function sendRoundTripMetric(client: MatrixClient, inRoomId: string, forEventId: string) { + // noinspection JSIgnoredPromiseFromCall + client.sendEvent(inRoomId, 'io.element.performance_metric', { + // XXX: We stick all of this into `m.relates_to` so it doesn't end up encrypted. + "m.relates_to": { + rel_type: "io.element.metric", + event_id: forEventId, + responseTs: Date.now(), + kind: 'send_time', + } as any, // override types because we're actually allowed to add extra metadata to relates_to + }); +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 40f57a0a1c..6dbefd4b8e 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -759,6 +759,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: true, controller: new ReducedMotionController(), }, + "Performance.addSendMessageTimingMetadata": { + supportedLevels: [SettingLevel.CONFIG], + default: false, + }, "Widgets.pinned": { // deprecated supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: {}, From 70e28e7e137ced3700c4fd8473f00683edf8e92e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Sep 2021 11:31:37 -0600 Subject: [PATCH 08/19] Move fields into consistent location for js-sdk to target --- src/sendTimePerformanceMetrics.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/sendTimePerformanceMetrics.ts b/src/sendTimePerformanceMetrics.ts index ef461db939..a8846d3cbf 100644 --- a/src/sendTimePerformanceMetrics.ts +++ b/src/sendTimePerformanceMetrics.ts @@ -37,12 +37,10 @@ export function decorateStartSendingTime(content: object) { export function sendRoundTripMetric(client: MatrixClient, inRoomId: string, forEventId: string) { // noinspection JSIgnoredPromiseFromCall client.sendEvent(inRoomId, 'io.element.performance_metric', { - // XXX: We stick all of this into `m.relates_to` so it doesn't end up encrypted. - "m.relates_to": { - rel_type: "io.element.metric", - event_id: forEventId, + "io.element.performance_metrics": { + forEventId: forEventId, responseTs: Date.now(), kind: 'send_time', - } as any, // override types because we're actually allowed to add extra metadata to relates_to + }, }); } From b4f42e1103262b0f55a5e06e7dd0d254d986e638 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Sep 2021 11:35:25 -0600 Subject: [PATCH 09/19] Appease the linter --- src/sendTimePerformanceMetrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sendTimePerformanceMetrics.ts b/src/sendTimePerformanceMetrics.ts index a8846d3cbf..ee5caa05a9 100644 --- a/src/sendTimePerformanceMetrics.ts +++ b/src/sendTimePerformanceMetrics.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-js-sdk"; +import { MatrixClient } from "matrix-js-sdk/src"; /** * Decorates the given event content object with the "send start time". The From fa60b24a9f9f047cc99653dd45c09a3c77c16976 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 9 Sep 2021 10:50:12 +0100 Subject: [PATCH 10/19] fix react error in console --- src/components/views/spaces/SpaceTreeLevel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index dda58ae944..35c0275240 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -260,7 +260,7 @@ export class SpaceItem extends React.PureComponent { render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, + const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, dragHandleProps, ...otherProps } = this.props; const collapsed = this.isCollapsed; @@ -299,7 +299,7 @@ export class SpaceItem extends React.PureComponent { /> : null; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { tabIndex, ...dragHandleProps } = this.props.dragHandleProps || {}; + const { tabIndex, ...restDragHandleProps } = dragHandleProps || {}; return (
  • { role="treeitem" > Date: Thu, 9 Sep 2021 10:54:31 +0100 Subject: [PATCH 11/19] Tweak edge case behaviour --- .../views/settings/JoinRuleSettings.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index 3cb4456643..1ff7eb83f6 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -121,14 +121,20 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange }: IProps const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]) => { if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return; - const newContent: IJoinRuleEventContent = { + if (!newAllowRoomIds.length) { + setContent({ + join_rule: JoinRule.Invite, + }); + return; + } + + setContent({ join_rule: JoinRule.Restricted, allow: newAllowRoomIds.map(roomId => ({ "type": RestrictedAllowType.RoomMembership, "room_id": roomId, })), - }; - setContent(newContent); + }); }; const onEditRestrictedClick = async () => { @@ -209,6 +215,11 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange }: IProps }); return; } + + // when setting to 0 allowed rooms/spaces set to invite only instead as per the note + if (!restrictedAllowRoomIds.length) { + joinRule = JoinRule.Invite; + } } if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return; From 82cd7acdae5cb6047f52ad0219213d195e1ecaaf Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 9 Sep 2021 11:40:43 +0100 Subject: [PATCH 12/19] Use cursor:pointer on space panel buttons --- res/css/structures/_SpacePanel.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 702936523d..bbb1867f16 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -114,6 +114,7 @@ $activeBorderColor: $secondary-content; align-items: center; padding: 4px 4px 4px 0; width: 100%; + cursor: pointer; &.mx_SpaceButton_active { &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { From aa534442670f817ff700a509721a31ace645e61f Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 9 Sep 2021 13:27:25 +0100 Subject: [PATCH 13/19] Improve narrow composer usability --- res/css/views/rooms/_EventTile.scss | 4 ++++ res/css/views/rooms/_MessageComposer.scss | 4 ++-- src/components/views/rooms/MessageComposer.tsx | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 351abc5cd9..4495ec4f29 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -733,4 +733,8 @@ $hover-select-border: 4px; padding-bottom: 5px; margin-bottom: 5px; } + + .mx_MessageComposer_sendMessage { + margin-right: 0; + } } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index faa3171d67..9ba966c083 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -221,7 +221,8 @@ limitations under the License. border-radius: 50%; } - &:hover { + &:hover, + &.mx_MessageComposer_closeButtonMenu { &::after { background: rgba($accent-color, 0.1); } @@ -265,7 +266,6 @@ limitations under the License. .mx_MessageComposer_sendMessage { cursor: pointer; position: relative; - margin-right: 6px; width: 32px; height: 32px; border-radius: 100%; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 6372ecc1f5..dd6ce10825 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -30,7 +30,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import { aboveLeftOf, ContextMenu, - ContextMenuTooltipButton, useContextMenu, MenuItem, } from "../../structures/ContextMenu"; @@ -113,7 +112,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition, narr className={className} onClick={openMenu} title={!narrowMode && _t('Emoji picker')} - label={narrowMode && _t("Send an emoji")} + label={narrowMode && _t("Add emoji")} /> { contextMenu } From b5bed3297390091d3c1a6418a5274195e64d5ddb Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 9 Sep 2021 13:40:07 +0100 Subject: [PATCH 14/19] Fix i18n --- src/i18n/strings/en_EN.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a2262a6afa..f3ae9424e0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1559,7 +1559,7 @@ "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "Send message": "Send message", "Emoji picker": "Emoji picker", - "Send an emoji": "Send an emoji", + "Add emoji": "Add emoji", "Upload file": "Upload file", "Reply to encrypted thread…": "Reply to encrypted thread…", "Reply to thread…": "Reply to thread…", @@ -1569,7 +1569,7 @@ "Send a message…": "Send a message…", "Hide Stickers": "Hide Stickers", "Show Stickers": "Show Stickers", - "Send an sticker": "Send an sticker", + "Send a sticker": "Send a sticker", "Send voice message": "Send voice message", "More options": "More options", "The conversation continues here.": "The conversation continues here.", From a4aa6dfcd744d40195e102c54f2831411873f491 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 9 Sep 2021 15:58:19 +0100 Subject: [PATCH 15/19] Debounce read marker update on scroll Reverts https://github.com/matrix-org/matrix-react-sdk/pull/6751 in favour of debouncing the updates to read markers, because it seems allowing the scroll to be 1px away from the bottom was important for some browsers and meant they never got to the bottom. We can fix the problem instead by debouncing the update to read markers, because the scroll state gets reset back to the bottom when componentDidUpdate() runs which happens after the read marker code does a setState(). This should probably be debounced anyway since it doesn't need to be run that frequently. Fixes https://github.com/vector-im/element-web/issues/18961 Type: bugfix --- src/components/structures/ScrollPanel.tsx | 2 +- src/components/structures/TimelinePanel.tsx | 42 ++++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index abc71bfcb2..193553361d 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -276,7 +276,7 @@ export default class ScrollPanel extends React.Component { // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. // so check difference < 1; - return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) < 1; + return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; }; // returns the vertical height in the given direction that can be removed from diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index e5fa6967dc..0dfb5c414a 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -47,11 +47,14 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Spinner from "../views/elements/Spinner"; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import ErrorDialog from '../views/dialogs/ErrorDialog'; +import { debounce } from 'lodash'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; const READ_RECEIPT_INTERVAL_MS = 500; +const READ_MARKER_DEBOUNCE_MS = 100; + const DEBUG = false; let debuglog = function(...s: any[]) {}; @@ -475,22 +478,35 @@ class TimelinePanel extends React.Component { } if (this.props.manageReadMarkers) { - const rmPosition = this.getReadMarkerPosition(); - // we hide the read marker when it first comes onto the screen, but if - // it goes back off the top of the screen (presumably because the user - // clicks on the 'jump to bottom' button), we need to re-enable it. - if (rmPosition < 0) { - this.setState({ readMarkerVisible: true }); - } - - // if read marker position goes between 0 and -1/1, - // (and user is active), switch timeout - const timeout = this.readMarkerTimeout(rmPosition); - // NO-OP when timeout already has set to the given value - this.readMarkerActivityTimer.changeTimeout(timeout); + this.doManageReadMarkers(); } }; + /* + * Debounced function to manage read markers because we don't need to + * do this on every tiny scroll update. It also sets state which causes + * a component update, which can in turn reset the scroll position, so + * it's important we allow the browser to scroll a bit before running this + * (hence trailing edge only and debounce rather than throttle because + * we really only need to update this once the user has finished scrolling, + * not periodically while they scroll). + */ + private doManageReadMarkers = debounce(() => { + const rmPosition = this.getReadMarkerPosition(); + // we hide the read marker when it first comes onto the screen, but if + // it goes back off the top of the screen (presumably because the user + // clicks on the 'jump to bottom' button), we need to re-enable it. + if (rmPosition < 0) { + this.setState({ readMarkerVisible: true }); + } + + // if read marker position goes between 0 and -1/1, + // (and user is active), switch timeout + const timeout = this.readMarkerTimeout(rmPosition); + // NO-OP when timeout already has set to the given value + this.readMarkerActivityTimer.changeTimeout(timeout); + }, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true }); + private onAction = (payload: ActionPayload): void => { switch (payload.action) { case "ignore_state_changed": From 672dab199866fd81f67cb7e77da39480647b7948 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 9 Sep 2021 17:31:05 +0100 Subject: [PATCH 16/19] Force refresh threads timeline Fixes vector-im/element-web#18947 In the absence of a proper pending events / remote echo setup it seems fairly difficult to get the timeline to update Adding a temporary helper to force refresh the timeline and not swallow local events when sending a message from the thread sidebar --- src/components/structures/ThreadView.tsx | 11 ++++++++--- src/components/structures/TimelinePanel.tsx | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 134f018aed..48e08f075f 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -50,6 +50,7 @@ interface IState { @replaceableComponent("structures.ThreadView") export default class ThreadView extends React.Component { private dispatcherRef: string; + private timelinePanelRef: React.RefObject = React.createRef(); constructor(props: IProps) { super(props); @@ -110,10 +111,13 @@ export default class ThreadView extends React.Component { private updateThread = (thread?: Thread) => { if (thread) { - this.setState({ thread }); - } else { - this.forceUpdate(); + this.setState({ + thread, + replyToEvent: thread.replyToEvent, + }); } + + this.timelinePanelRef.current?.refreshTimeline(); }; public render(): JSX.Element { @@ -126,6 +130,7 @@ export default class ThreadView extends React.Component { > { this.state.thread && ( { this.setState(this.getEvents()); } + // Force refresh the timeline before threads support pending events + public refreshTimeline(): void { + this.loadTimeline(); + this.reloadEvents(); + } + // get the list of events from the timeline window and the pending event list private getEvents(): Pick { const events: MatrixEvent[] = this.timelineWindow.getEvents(); From bd5d02d69076900bd46c2f735ba5b1b1670154e8 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 9 Sep 2021 18:15:51 +0100 Subject: [PATCH 17/19] Update comment too Co-authored-by: Travis Ralston --- src/components/structures/ScrollPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 193553361d..112f8d2c21 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -275,7 +275,7 @@ export default class ScrollPanel extends React.Component { // fractional values (both too big and too small) // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. - // so check difference < 1; + // so check difference <= 1; return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; }; From def1c68c16d02108b3deff2ebee9abb07fc91ed9 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 9 Sep 2021 16:11:36 -0400 Subject: [PATCH 18/19] Fix message bubble corners being wrong in the presence of hidden events Signed-off-by: Robin Townsend --- src/components/structures/MessagePanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 7da0b75407..589947af73 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -705,9 +705,9 @@ export default class MessagePanel extends React.Component { let willWantDateSeparator = false; let lastInSection = true; - if (nextEvent) { - willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); - lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender(); + if (nextEventWithTile) { + willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date()); + lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender(); } // is this a continuation of the previous message? From 2a8e8b93aa5bb2f652e3ecd396403df1ca5dce2a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Sep 2021 09:48:46 +0100 Subject: [PATCH 19/19] add comment --- src/components/views/settings/JoinRuleSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index 2f457f2916..94c70f861e 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -103,6 +103,7 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet let description; if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) { + // only show the first 4 spaces we know about, so that the UI doesn't grow out of proportion there are lots. const shownSpaces = restrictedAllowRoomIds .map(roomId => cli.getRoom(roomId)) .filter(room => room?.isSpaceRoom())