From e7f5261eda1d685285f81c6e30c2aa6d50fc0bcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 May 2023 17:01:56 +0100 Subject: [PATCH 001/253] Update dependency memoize-one to v6 (#10765) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e110a52de4..fadb02aae3c 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "matrix-events-sdk": "0.0.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.3.1", - "memoize-one": "^5.1.1", + "memoize-one": "^6.0.0", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", diff --git a/yarn.lock b/yarn.lock index 7991d2117b9..cb68636173e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6536,6 +6536,11 @@ memoize-one@^5.1.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + memoizee@^0.4.15: version "0.4.15" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72" From 4736f0e44ccfabb53164b1cb636652f43234bc1f Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 3 May 2023 09:14:36 +1200 Subject: [PATCH 002/253] Use semantic headings for room settings content (#10734) * split SettingsSection out of SettingsTab, replace usage * correct copyright * use semantic headings in GeneralRoomSettingsTab * use SettingsTab and SettingsSubsection in room settings * fix VoipRoomSettingsTab --- res/css/views/settings/tabs/_SettingsTab.pcss | 2 +- .../tabs/room/_RolesRoomSettingsTab.pcss | 2 +- .../tabs/room/_SecurityRoomSettingsTab.pcss | 16 +- .../tabs/room/AdvancedRoomSettingsTab.tsx | 48 +-- .../settings/tabs/room/BridgeSettingsTab.tsx | 9 +- .../tabs/room/GeneralRoomSettingsTab.tsx | 54 ++-- .../tabs/room/NotificationSettingsTab.tsx | 282 +++++++++--------- .../tabs/room/RolesRoomSettingsTab.tsx | 41 +-- .../tabs/room/SecurityRoomSettingsTab.tsx | 85 +++--- .../AdvancedRoomSettingsTab-test.tsx.snap | 100 ++++--- .../BridgeSettingsTab-test.tsx.snap | 178 ++++++----- .../RolesRoomSettingsTab-test.tsx.snap | 4 +- 12 files changed, 440 insertions(+), 381 deletions(-) diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 4f240109c18..fac189e8587 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -103,5 +103,5 @@ limitations under the License. grid-template-columns: 1fr; grid-gap: $spacing-32; - padding: $spacing-16 0; + padding-bottom: $spacing-16; } diff --git a/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.pcss b/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.pcss index 5d0a8ed1423..ce8dff9f0c2 100644 --- a/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.pcss +++ b/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RolesRoomSettingsTab ul { +.mx_RolesRoomSettingsTab_bannedList { margin-bottom: 0; } diff --git a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.pcss b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.pcss index cf3e16bc044..339b5d2590e 100644 --- a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.pcss +++ b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.pcss @@ -14,14 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SecurityRoomSettingsTab { - .mx_SettingsTab_showAdvanced { - margin-bottom: $spacing-16; - } +.mx_SecurityRoomSettingsTab_advancedSection { + margin-top: $spacing-16; +} - .mx_SecurityRoomSettingsTab_warning { - display: flex; - align-items: center; - column-gap: $spacing-4; - } +.mx_SecurityRoomSettingsTab_warning { + display: flex; + align-items: center; + column-gap: $spacing-4; } diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx index e8ce957c3b5..a7068ea1cfa 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx @@ -27,6 +27,9 @@ import { Action } from "../../../../../dispatcher/actions"; import CopyableText from "../../../elements/CopyableText"; import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload"; import SettingsStore from "../../../../../settings/SettingsStore"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; +import SettingsSubsection from "../../shared/SettingsSubsection"; interface IProps { room: Room; @@ -154,30 +157,27 @@ export default class AdvancedRoomSettingsTab extends React.Component -
{_t("Advanced")}
-
- - {room.isSpaceRoom() ? _t("Space information") : _t("Room information")} - -
- {_t("Internal room ID")} - this.props.room.roomId}> - {this.props.room.roomId} - -
- {unfederatableSection} -
-
- {_t("Room version")} -
- {_t("Room version:")}  - {room.getVersion()} -
- {oldRoomLink} - {roomUpgradeButton} -
- + + + +
+ {_t("Internal room ID")} + this.props.room.roomId}> + {this.props.room.roomId} + +
+ {unfederatableSection} +
+ +
+ {_t("Room version:")}  + {room.getVersion()} +
+ {oldRoomLink} + {roomUpgradeButton} +
+
+
); } } diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx index 8731cee0a6a..04b75cb4cc6 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx @@ -21,6 +21,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import BridgeTile from "../../BridgeTile"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; const BRIDGE_EVENT_TYPES = [ "uk.half-shot.bridge", @@ -99,10 +101,9 @@ export default class BridgeSettingsTab extends React.Component { } return ( -
-
{_t("Bridges")}
-
{content}
-
+ + {content} + ); } } diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index a915aa42e38..cc8db528a12 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -27,6 +27,9 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import UrlPreviewSettings from "../../../room_settings/UrlPreviewSettings"; import AliasSettings from "../../../room_settings/AliasSettings"; import PosthogTrackers from "../../../../../PosthogTrackers"; +import SettingsSubsection from "../../shared/SettingsSubsection"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; interface IProps { room: Room; @@ -72,35 +75,36 @@ export default class GeneralRoomSettingsTab extends React.Component - {_t("Leave room")} -
- - {_t("Leave room")} - -
- + + + {_t("Leave room")} + + ); } return ( -
-
{_t("General")}
-
- -
- -
{_t("Room Addresses")}
- -
{_t("Other")}
- {urlPreviewSettings} - {leaveSection} -
+ + +
+ +
+
+ + + + + + + {urlPreviewSettings} + {leaveSection} + +
); } } diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index d8232210fa8..f1304d44ed7 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -32,6 +32,9 @@ import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { UserTab } from "../../../dialogs/UserTab"; import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; +import SettingsSubsection from "../../shared/SettingsSubsection"; interface IProps { roomId: string; @@ -168,149 +171,148 @@ export default class NotificationsSettingsTab extends React.Component -
{_t("Notifications")}
- -
- - {_t("Default")} -
- {_t( - "Get notifications as set up in your settings", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} -
- - ), - }, - { - value: RoomNotifState.AllMessagesLoud, - className: "mx_NotificationSettingsTab_allMessagesEntry", - label: ( - <> - {_t("All messages")} -
- {_t("Get notified for every message")} -
- - ), - }, - { - value: RoomNotifState.MentionsOnly, - className: "mx_NotificationSettingsTab_mentionsKeywordsEntry", - label: ( - <> - {_t("@mentions & keywords")} -
- {_t( - "Get notified only with mentions and keywords " + - "as set up in your settings", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} -
- - ), - }, - { - value: RoomNotifState.Mute, - className: "mx_NotificationSettingsTab_noneEntry", - label: ( - <> - {_t("Off")} -
- {_t("You won't get any notifications")} -
- - ), - }, - ]} - onChange={this.onRoomNotificationChange} - value={this.roomProps.notificationVolume} - /> -
+ + +
+ + {_t("Default")} +
+ {_t( + "Get notifications as set up in your settings", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + )} +
+ + ), + }, + { + value: RoomNotifState.AllMessagesLoud, + className: "mx_NotificationSettingsTab_allMessagesEntry", + label: ( + <> + {_t("All messages")} +
+ {_t("Get notified for every message")} +
+ + ), + }, + { + value: RoomNotifState.MentionsOnly, + className: "mx_NotificationSettingsTab_mentionsKeywordsEntry", + label: ( + <> + {_t("@mentions & keywords")} +
+ {_t( + "Get notified only with mentions and keywords " + + "as set up in your settings", + {}, + { + a: (sub) => ( + + {sub} + + ), + }, + )} +
+ + ), + }, + { + value: RoomNotifState.Mute, + className: "mx_NotificationSettingsTab_noneEntry", + label: ( + <> + {_t("Off")} +
+ {_t("You won't get any notifications")} +
+ + ), + }, + ]} + onChange={this.onRoomNotificationChange} + value={this.roomProps.notificationVolume} + /> +
-
- {_t("Sounds")} -
-
- - {_t("Notification sound")}: {this.state.currentSound} - + +
+
+ + {_t("Notification sound")}: {this.state.currentSound} + +
+ + {_t("Reset")} +
- - {_t("Reset")} - -
-
-

{_t("Set a new custom sound")}

-
-
- -
- - {currentUploadedFile} +
+

{_t("Set a new custom sound")}

+
+
+ +
+ + {currentUploadedFile} +
+ + + {_t("Browse")} + + + + {_t("Save")} + +
- - - {_t("Browse")} - - - - {_t("Save")} - -
-
-
-
+ + + ); } } diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 3f2c6d65fe0..0ed44cf173c 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -36,6 +36,8 @@ import { VoiceBroadcastInfoEventType } from "../../../../../voice-broadcast"; import { ElementCall } from "../../../../../models/Call"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; import { AddPrivilegedUsers } from "../../AddPrivilegedUsers"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; interface IEventShowOpts { isState?: boolean; @@ -399,7 +401,7 @@ export default class RolesRoomSettingsTab extends React.Component { const canBanUsers = currentUserLevel >= banLevel; bannedUsersSection = ( -
    +
      {banned.map((member) => { const banEvent = member.events.member?.getContent(); const bannedById = member.events.member?.getSender(); @@ -479,24 +481,25 @@ export default class RolesRoomSettingsTab extends React.Component { .filter(Boolean); return ( -
      -
      {_t("Roles & Permissions")}
      - {privilegedUsersSection} - {canChangeLevels && } - {mutedUsersSection} - {bannedUsersSection} - - {powerSelectors} - {eventPowerSelectors} - -
      + + + {privilegedUsersSection} + {canChangeLevels && } + {mutedUsersSection} + {bannedUsersSection} + + {powerSelectors} + {eventPowerSelectors} + + + ); } } diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index aad1535505a..71afe65d98b 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -41,6 +41,8 @@ import SettingsFieldset from "../../SettingsFieldset"; import ExternalLink from "../../../elements/ExternalLink"; import PosthogTrackers from "../../../../../PosthogTrackers"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; +import { SettingsSection } from "../../shared/SettingsSection"; +import SettingsTab from "../SettingsTab"; interface IProps { room: Room; @@ -265,6 +267,23 @@ export default class SecurityRoomSettingsTab extends React.Component + + {this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced")} + + {this.state.showAdvancedSection && this.renderAdvanced()} +
+ ); + } + return ( + {advanced} ); } @@ -399,7 +419,7 @@ export default class SecurityRoomSettingsTab extends React.Component +
- +
); } @@ -437,45 +457,30 @@ 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")}
- - - - {encryptionSettings} - - - {this.renderJoinRule()} - - {advanced} - {historySection} -
+ + + + + {encryptionSettings} + + + {this.renderJoinRule()} + {historySection} + + + //
+ //
{_t("Security & Privacy")}
+ + //
); } } diff --git a/test/components/views/settings/tabs/room/__snapshots__/AdvancedRoomSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/AdvancedRoomSettingsTab-test.tsx.snap index fa0aa0338e9..f8ec32b7f3a 100644 --- a/test/components/views/settings/tabs/room/__snapshots__/AdvancedRoomSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/room/__snapshots__/AdvancedRoomSettingsTab-test.tsx.snap @@ -6,50 +6,78 @@ exports[`AdvancedRoomSettingsTab should render as expected 1`] = ` class="mx_SettingsTab" >
- Advanced -
-
- - Room information - -
- - Internal room ID - +

+ Advanced +

- !room:example.com
+ class="mx_SettingsSubsection" + > +
+

+ Room information +

+
+
+
+ + Internal room ID + +
+ !room:example.com +
+
+
+
+
+
+
+

+ Room version +

+
+
+
+ + Room version: + +  1 +
+
+
-
- - Room version - -
- - Room version: - -  1 -
-
`; diff --git a/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap index c38e07d15f8..ed78d3834cb 100644 --- a/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/room/__snapshots__/BridgeSettingsTab-test.tsx.snap @@ -6,75 +6,83 @@ exports[` renders when room is bridging messages 1`] = ` class="mx_SettingsTab" >
- Bridges -
-
-
-

- - This room is bridging messages to the following platforms. - - Learn more. - - -

-
    +

    -
  • -
    -
    -
    -
    +
    +
    +

    + + This room is bridging messages to the following platforms. + + Learn more. + + +

    +
      -

      - protocol-test -

      -

      - +

      +
      +
      - - Channel: - - channel-test +

      + protocol-test +

      +

      + + + Channel: + + channel-test + + - - -

      - -
      - -
    +

    + +
    +
  • +

+
+
@@ -87,25 +95,33 @@ exports[` renders when room is not bridging messages to any class="mx_SettingsTab" >
- Bridges -
-
-

- - This room isn't bridging messages to any platforms. - - Learn more. - - -

+
+

+ Bridges +

+
+

+ + This room isn't bridging messages to any platforms. + + Learn more. + + +

+
+
diff --git a/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap index d771a152d2a..aea48398946 100644 --- a/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/room/__snapshots__/RolesRoomSettingsTab-test.tsx.snap @@ -9,7 +9,9 @@ exports[`RolesRoomSettingsTab Banned users renders banned users 1`] = ` > Banned users -
    +
    • Date: Tue, 2 May 2023 21:22:09 +0000 Subject: [PATCH 003/253] Update dependency stylelint-scss to v5 (#10766) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Kerry --- package.json | 2 +- yarn.lock | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index fadb02aae3c..bf7a093494f 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,7 @@ "rimraf": "^5.0.0", "stylelint": "^15.0.0", "stylelint-config-standard": "^32.0.0", - "stylelint-scss": "^4.2.0", + "stylelint-scss": "^5.0.0", "ts-node": "^10.9.1", "typescript": "5.0.4", "walk": "^2.3.14" diff --git a/yarn.lock b/yarn.lock index cb68636173e..5550847544b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3823,11 +3823,6 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -dlv@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" - integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -8114,12 +8109,11 @@ stylelint-config-standard@^32.0.0: dependencies: stylelint-config-recommended "^11.0.0" -stylelint-scss@^4.2.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-4.6.0.tgz#f7602d6d562bb256802e38e3fd5e49c46d2e31b6" - integrity sha512-M+E0BQim6G4XEkaceEhfVjP/41C9Klg5/tTPTCQVlgw/jm2tvB+OXJGaU0TDP5rnTCB62aX6w+rT+gqJW/uwjA== +stylelint-scss@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-5.0.0.tgz#2ec6323bac8003fa64871f3fbb75108270e99b83" + integrity sha512-5Ee5kG3JIcP2jk2PMoFMiNmW/815V+wK5o37X5ke90ihWMpPXI9iyqeA6zEWipWSRXeQc0kqbd7hKqiR+wPKNA== dependencies: - dlv "^1.1.3" postcss-media-query-parser "^0.2.3" postcss-resolve-nested-selector "^0.1.1" postcss-selector-parser "^6.0.11" From ede21325601263a8d0cfa644c5135a9606584100 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 May 2023 21:39:58 +0000 Subject: [PATCH 004/253] Update dependency @percy/cli to v1.24.0 (#10764) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Kerry --- yarn.lock | 184 +++++++++++++++++++++++++++--------------------------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5550847544b..c5950a31238 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,105 +1749,105 @@ tslib "^2.5.0" webcrypto-core "^1.7.7" -"@percy/cli-app@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-app/-/cli-app-1.23.0.tgz#70970e6fcafe0bca1b616d4dfa7bbc6df041f2cd" - integrity sha512-2L5chuBFp016LlkB7BihGtm0XJFCZEDNIcOFchsK7l2REBUkxVeM6hNQ89uuP2F9eKXwWKqtDEIYCzdzW0hfIQ== +"@percy/cli-app@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-app/-/cli-app-1.24.0.tgz#1d5bfb392a40e496dd1ba98b3b4f6e66388e9fd7" + integrity sha512-z7ksv+SvdgDuAZ4WDnluuLuS72xb18DKauuwikSKipdICHHFQuXdRc0ngloADC/6IFzp0JhiukiRanntbBkPvg== dependencies: - "@percy/cli-command" "1.23.0" - "@percy/cli-exec" "1.23.0" + "@percy/cli-command" "1.24.0" + "@percy/cli-exec" "1.24.0" -"@percy/cli-build@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-build/-/cli-build-1.23.0.tgz#caa76082c2e533a248bd58c03b5d8f6b5db7b795" - integrity sha512-qIhfU/UtPl181Dw2kR8klEYLUlA5C8GE0M9781vz7D0W3LriccaLLLo1wBp4q4bo83uvUBvNJhq9/S4T38kPEQ== +"@percy/cli-build@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-build/-/cli-build-1.24.0.tgz#409b9ef214fa94edba4b702954fbeb87c1819b9f" + integrity sha512-p/wmO0OzqJ2Uou7QNAdxioqKmxu7U+6Al02GvVhYcPja/MkVjfJT/jDl+XstXawR76txQW9QWrNsK5YOAWUupQ== dependencies: - "@percy/cli-command" "1.23.0" + "@percy/cli-command" "1.24.0" -"@percy/cli-command@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-command/-/cli-command-1.23.0.tgz#96ca558bba6329f58fdba0acaec802dc2d959c15" - integrity sha512-tXj5vv2BQMBmn3ZL2YNqYYrmJLyYnBqwyJkecY2BwXQsKAIv3qBgTzr1d5+LxTOi5ArjFCHAgk2w4ohy6h6t4w== +"@percy/cli-command@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-command/-/cli-command-1.24.0.tgz#36b51e1e41c10db8ceb3f9bbd624e8199a16cee7" + integrity sha512-n4qyDdUc+TiX/YykGg59IS1DBmm4UdA7ZaiTdw/D5AZohzwwVbwL+Q4QMYqcohtfYZ/F8UT7Qy3Jma3+YKTnxw== dependencies: - "@percy/config" "1.23.0" - "@percy/core" "1.23.0" - "@percy/logger" "1.23.0" + "@percy/config" "1.24.0" + "@percy/core" "1.24.0" + "@percy/logger" "1.24.0" -"@percy/cli-config@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-config/-/cli-config-1.23.0.tgz#34f1b8f4c7734156528e3a24866a851f72f29a3e" - integrity sha512-tI4c4MhU41rx9n7fYZrpn4gaOD9dA6PnefP397v7smqEWh7MJ+cxI/nyKU0/9G2wGjMhYACaLoR4BiCWOQZAkw== +"@percy/cli-config@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-config/-/cli-config-1.24.0.tgz#5a62eb9cb1d2e772481dffa6efcd75a0e6604c0f" + integrity sha512-7T70Y3vC0hIGBe+WOmdzspN8N5uflBRwuPoRXn2PdzxvH55hUhCGFT/Wxb8C6rTMJ9k++POkxMoQaSErVANYYg== dependencies: - "@percy/cli-command" "1.23.0" + "@percy/cli-command" "1.24.0" -"@percy/cli-exec@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-exec/-/cli-exec-1.23.0.tgz#7d79b6cec643203486377ffbcd1956a38db5d5d6" - integrity sha512-ecxnMWxUlVx0EswGraHgN4LvWbXeUZQZUxJ9wYmMSgDEaKfEiEZ5WTLSKzQAxyfw2SjoQ3cHRZbKh4qMlCgbAg== +"@percy/cli-exec@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-exec/-/cli-exec-1.24.0.tgz#02f8c6a417ef13303c7ab692a080480c9cb7d9ce" + integrity sha512-T5B8HLjPde0js5lkO14uk02QZKmgxILjALh5SX9VFL2Qx4cUXw+A29epPPv6OLI2x2oww8e5nTdlnmykX8n4kQ== dependencies: - "@percy/cli-command" "1.23.0" + "@percy/cli-command" "1.24.0" cross-spawn "^7.0.3" which "^2.0.2" -"@percy/cli-snapshot@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-snapshot/-/cli-snapshot-1.23.0.tgz#e22b1bbdb55798e061b4af27af3b41935a31e1e8" - integrity sha512-QOrUfyPCnjfIAcUBjNlO299NRPDxofcYQUCBYZE3CtemsNFtygFt0yPnZCwWmt0voSpnPl1Izc6/FA3wYUfuBQ== +"@percy/cli-snapshot@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-snapshot/-/cli-snapshot-1.24.0.tgz#6516710bc095158bcb20ece4ba4861214d4cc428" + integrity sha512-zxoE1SbdTvUlP7QAjTs7+M7U8cHEDF1ec7ov06m1i+bul68YhZ0S+P4a1Mbt6oWBsAxjYz06h4jnq32JitbSDg== dependencies: - "@percy/cli-command" "1.23.0" + "@percy/cli-command" "1.24.0" yaml "^2.0.0" -"@percy/cli-upload@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli-upload/-/cli-upload-1.23.0.tgz#f5df72938cf2b70eec54f5ad4ee55c744e3a3113" - integrity sha512-faRHjzaUf21RK9Ra051gKUl4HmMNPZxUKSZNmdG0yP+tc5KxU9cXkmEeCKGH7LOcVs0IfyRX0vv58YEZ6GsIRw== +"@percy/cli-upload@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli-upload/-/cli-upload-1.24.0.tgz#a6761d93d66668fd3182ee3c22efbebba5e74751" + integrity sha512-/4XNzMAhbccYSsPhw/KWRVjnd13nd17LB178dVNX4UEtaETDbBF+VZSlU3scgs8mlpuqY8b8bHDaSJNfI71UwQ== dependencies: - "@percy/cli-command" "1.23.0" + "@percy/cli-command" "1.24.0" fast-glob "^3.2.11" image-size "^1.0.0" "@percy/cli@^1.11.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/cli/-/cli-1.23.0.tgz#a74fc49650a978a3aaa203e294513c1e05110e4c" - integrity sha512-3S+QUWdeJq6ZUWoRNLuX+wdJx8civJdrSmYG9WS2CP9auJNbuA+13xQnB5AkkWUvHEcC/yXzZpi5NAjoW86jgw== - dependencies: - "@percy/cli-app" "1.23.0" - "@percy/cli-build" "1.23.0" - "@percy/cli-command" "1.23.0" - "@percy/cli-config" "1.23.0" - "@percy/cli-exec" "1.23.0" - "@percy/cli-snapshot" "1.23.0" - "@percy/cli-upload" "1.23.0" - "@percy/client" "1.23.0" - "@percy/logger" "1.23.0" - -"@percy/client@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/client/-/client-1.23.0.tgz#a514c3db9dd4b36161e04afd4f5e7d0de58abefb" - integrity sha512-m0qNCrlfh6Pf0t2GfoeShuK7r2GeRk5rWVjIbdnDigvmtL0G+HJM1gvysLOxzKFHkZ1cLBfM1SnH1Yn6RM/6qQ== - dependencies: - "@percy/env" "1.23.0" - "@percy/logger" "1.23.0" - -"@percy/config@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/config/-/config-1.23.0.tgz#800142c57f4420fffbb3d188e134e68be7d8abec" - integrity sha512-giPIdNLcG1Qg0dkc/VDOkTzI4szzM4QAoJfMLEP0UYPkIU2Y0Xc8NH5GN3DEiudRJge72iGfeah6GugxmXmKXw== - dependencies: - "@percy/logger" "1.23.0" + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/cli/-/cli-1.24.0.tgz#260555b8990404ed63e08b0d3db4a7f6e49bfb1d" + integrity sha512-n8dxQfA2GoPk468EQ+sO7P/P5sBl3Q+s7UrljQhf4wPt4l+CBmoxMML8Ib71MyISzwxY7bOSw2QMr26r6n06/A== + dependencies: + "@percy/cli-app" "1.24.0" + "@percy/cli-build" "1.24.0" + "@percy/cli-command" "1.24.0" + "@percy/cli-config" "1.24.0" + "@percy/cli-exec" "1.24.0" + "@percy/cli-snapshot" "1.24.0" + "@percy/cli-upload" "1.24.0" + "@percy/client" "1.24.0" + "@percy/logger" "1.24.0" + +"@percy/client@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/client/-/client-1.24.0.tgz#b72696269f0925a06571bfa4e75ee8d632c63da2" + integrity sha512-mCMIGryE+0oxJN6v+riZ+XqnubEL9rajLOJI7xNOj5gNBNNvwgvkpTiNId9d6LNZVhA7dN9ZHTW+zFK+i4nU8A== + dependencies: + "@percy/env" "1.24.0" + "@percy/logger" "1.24.0" + +"@percy/config@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/config/-/config-1.24.0.tgz#41e606b021a44385a795dd01820405a72c5f5579" + integrity sha512-FOV8VkW/MjLI7PXzKSjxFBK7z0ND1s8LtXuLQNIrux3oiCKHIVBAQWIV86LLnXSSn+G5i3tfQua9YED5ATyNFQ== + dependencies: + "@percy/logger" "1.24.0" ajv "^8.6.2" cosmiconfig "^7.0.0" yaml "^2.0.0" -"@percy/core@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/core/-/core-1.23.0.tgz#f4c2d9a869ebb357be015ddde9dfdd4077d0b992" - integrity sha512-/BNHdvbD7r1p3k3HWgxYLBo2L2Ye9RDcmTuA6en2xUYaagf+0vfcAK8iyBvVm6ir2ZjAsMW0PGRa7OIfetvHHg== +"@percy/core@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/core/-/core-1.24.0.tgz#1e35c4fb4c0851fed1f92a32e39b26519d774e1c" + integrity sha512-wys1k3RmENOWT4MeS2+8yGHNqzYuy64lqPi36dFoHwZHzSGHH52+6EPPDb+gXLFIxBUHVTwbdaNimstIO3F9Ww== dependencies: - "@percy/client" "1.23.0" - "@percy/config" "1.23.0" - "@percy/dom" "1.23.0" - "@percy/logger" "1.23.0" + "@percy/client" "1.24.0" + "@percy/config" "1.24.0" + "@percy/dom" "1.24.0" + "@percy/logger" "1.24.0" content-disposition "^0.5.4" cross-spawn "^7.0.3" extract-zip "^2.0.1" @@ -1865,20 +1865,20 @@ dependencies: "@percy/sdk-utils" "^1.3.1" -"@percy/dom@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/dom/-/dom-1.23.0.tgz#8b278f5cf91d4383b97e948a58a2a7394303d89a" - integrity sha512-68q3ceCWsWpUFyF/pnELSCTdbTAibGVyNwp+iZCFd/914sUhERYrrX8AqCgkCDerOzCwAQZQDe2Nv3jaB+d0ng== +"@percy/dom@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/dom/-/dom-1.24.0.tgz#48529fe344123b30ef3153c5218725eab5bcfc4f" + integrity sha512-URMLvsOPkCKayx/Wtyj5IymmIhzrtf4en6IKeW2sSTsm7X+kJQ+3wOa3017mX3HXJPIS5xEJKpiCR7hP9BtcUA== -"@percy/env@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/env/-/env-1.23.0.tgz#50dbb7dd61efdc18eb85949ffcbc567f9b8616f3" - integrity sha512-oKvJBC/Zhfwp2QpFBpfHeAVuGhgaPeI7S4H2/68XT30pInfVJzaCjD/8ySAELGyMWmgHc51s+k09DZCo3C3Gyg== +"@percy/env@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/env/-/env-1.24.0.tgz#c8942c3580a305ce6b7148627644654ddd88d047" + integrity sha512-fUUWWDZJ71kv+Po5yOaoS8t7eLmQL5NN6hqRdLhgqN9PZnu+OKIGaeK1GNaTWiHL9+zANRBc1pZjQWhRlleWVA== -"@percy/logger@1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@percy/logger/-/logger-1.23.0.tgz#097ac39ec16e2e1ffd381a160840fa1ecb73f602" - integrity sha512-kNtdKQ9Kou/RcWgDoSK+ofOVqOzuzyHBNsK+I92XNh8HHO6ow08Cmw+LtZbDxmj3uq7nXG9Nhgj4ZqSgdk7J6Q== +"@percy/logger@1.24.0": + version "1.24.0" + resolved "https://registry.yarnpkg.com/@percy/logger/-/logger-1.24.0.tgz#427db543680f27d95f9a2169c8cd7fbfbbdada39" + integrity sha512-yaAo08FMED1o8jZycTEnTob1CZIVGaNluJc4R9fCRw7wWS88IAu4F9sdbzUZQZwZ/QGvtfI+55dNQaaesk69Bw== "@percy/sdk-utils@^1.3.1": version "1.23.0" @@ -2250,9 +2250,9 @@ form-data "^3.0.0" "@types/node@*": - version "18.15.11" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" - integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== + version "18.16.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.3.tgz#6bda7819aae6ea0b386ebc5b24bdf602f1b42b01" + integrity sha512-OPs5WnnT1xkCBiuQrZA4+YAV4HEJejmHneyraIaxsbev5yCEr6KMwINNFP9wQeFIw8FWcoTqF3vQsa5CDaI+8Q== "@types/node@^14.14.31": version "14.18.42" @@ -8890,9 +8890,9 @@ yaml@^1.10.0: integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== yaml@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" - integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== + version "2.2.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.2.tgz#ec551ef37326e6d42872dad1970300f8eb83a073" + integrity sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA== yargs-parser@^18.1.2: version "18.1.3" From 5a73d8e1b01c894839551037a955b3b9edd8073c Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 3 May 2023 13:55:55 +1200 Subject: [PATCH 005/253] Use semantic headings in space settings (#10751) * split SettingsSection out of SettingsTab, replace usage * correct copyright * use semantic headings in GeneralRoomSettingsTab * use SettingsTab and SettingsSubsection in room settings * fix VoipRoomSettingsTab * use SettingsSection components in space settings --- res/css/_components.pcss | 1 - .../views/room_settings/_AliasSettings.pcss | 24 +- .../tabs/room/_GeneralRoomSettingsTab.pcss | 19 -- .../views/room_settings/AliasSettings.tsx | 19 +- .../tabs/room/GeneralRoomSettingsTab.tsx | 4 +- .../views/spaces/SpaceSettingsGeneralTab.tsx | 90 ++++---- .../spaces/SpaceSettingsVisibilityTab.tsx | 87 ++++---- .../SpaceSettingsVisibilityTab-test.tsx.snap | 210 +++++++++--------- 8 files changed, 218 insertions(+), 236 deletions(-) delete mode 100644 res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.pcss diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f200429ced3..94153b2e894 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -341,7 +341,6 @@ @import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss"; -@import "./views/settings/tabs/room/_GeneralRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; @import "./views/settings/tabs/room/_RolesRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_SecurityRoomSettingsTab.pcss"; diff --git a/res/css/views/room_settings/_AliasSettings.pcss b/res/css/views/room_settings/_AliasSettings.pcss index 66ac17d8422..15a7b8a8708 100644 --- a/res/css/views/room_settings/_AliasSettings.pcss +++ b/res/css/views/room_settings/_AliasSettings.pcss @@ -27,20 +27,14 @@ limitations under the License. box-shadow: none; } -.mx_AliasSettings { - summary { - cursor: pointer; - color: $accent; - font-weight: var(--font-semi-bold); - list-style: none; - - /* list-style doesn't do it for webkit */ - &::-webkit-details-marker { - display: none; - } - } - - .mx_AliasSettings_localAliasHeader { - margin-top: 35px; +.mx_AliasSettings_localAddresses { + cursor: pointer; + color: $accent; + font-weight: var(--font-semi-bold); + list-style: none; + + /* list-style doesn't do it for webkit */ + &::-webkit-details-marker { + display: none; } } diff --git a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.pcss b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.pcss deleted file mode 100644 index af55820d665..00000000000 --- a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.pcss +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_GeneralRoomSettingsTab_profileSection { - margin-top: 10px; -} diff --git a/src/components/views/room_settings/AliasSettings.tsx b/src/components/views/room_settings/AliasSettings.tsx index 16388953afd..f6a6f32e4fb 100644 --- a/src/components/views/room_settings/AliasSettings.tsx +++ b/src/components/views/room_settings/AliasSettings.tsx @@ -1,5 +1,5 @@ /* -Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2023 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. @@ -402,7 +402,7 @@ export default class AliasSettings extends React.Component { } return ( -
      + <> { } > - {/* - { _t("Published Addresses") } -

      - { isSpaceRoom - ? _t("Published addresses can be used by anyone on any server to join your space.") - : _t("Published addresses can be used by anyone on any server to join your room.") } -   - { _t("To publish an address, it needs to be set as a local address first.") } -

      */} {canonicalAliasSection} {this.props.hidePublishSetting ? null : ( { } >
      - {this.state.detailsOpen ? _t("Show less") : _t("Show more")} + + {this.state.detailsOpen ? _t("Show less") : _t("Show more")} + {localAliasesList}
      -
      + ); } } diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index cc8db528a12..631af29fa22 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -86,9 +86,7 @@ export default class GeneralRoomSettingsTab extends React.Component -
      - -
      +
      diff --git a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx index 329a580b798..78803490644 100644 --- a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx +++ b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx @@ -27,6 +27,9 @@ import { avatarUrlForRoom } from "../../../Avatar"; import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize"; import { leaveSpace } from "../../../utils/leave-behaviour"; import { getTopic } from "../../../hooks/room/useTopic"; +import SettingsTab from "../settings/tabs/SettingsTab"; +import { SettingsSection } from "../settings/shared/SettingsSection"; +import SettingsSubsection from "../settings/shared/SettingsSubsection"; interface IProps { matrixClient: MatrixClient; @@ -94,50 +97,49 @@ const SpaceSettingsGeneralTab: React.FC = ({ matrixClient: cli, space }) }; return ( -
      -
      {_t("General")}
      - -
      {_t("Edit settings relating to your space.")}
      - - {error &&
      {error}
      } - -
      - - - - {_t("Cancel")} - - - {busy ? _t("Saving…") : _t("Save Changes")} - -
      - - {_t("Leave Space")} -
      - { - leaveSpace(space); - }} - > - {_t("Leave Space")} - -
      -
      + + +
      +
      {_t("Edit settings relating to your space.")}
      + + {error &&
      {error}
      } + + + + + {_t("Cancel")} + + + {busy ? _t("Saving…") : _t("Save Changes")} + +
      + + + { + leaveSpace(space); + }} + > + {_t("Leave Space")} + + +
      +
      ); }; diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index 368d6c96fc0..d94bc52ee53 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021-2023 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. @@ -30,6 +30,8 @@ import JoinRuleSettings from "../settings/JoinRuleSettings"; import { useRoomState } from "../../../hooks/useRoomState"; import SettingsFieldset from "../settings/SettingsFieldset"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import { SettingsSection } from "../settings/shared/SettingsSection"; +import SettingsTab from "../settings/tabs/SettingsTab"; interface IProps { matrixClient: MatrixClient; @@ -124,8 +126,7 @@ const SpaceSettingsVisibilityTab: React.FC = ({ matrixClient: cli, space let addressesSection: JSX.Element | undefined; if (space.getJoinRule() === JoinRule.Public) { addressesSection = ( - <> - {_t("Address")} + = ({ matrixClient: cli, space canonicalAliasEvent={canonicalAliasEv ?? undefined} hidePublishSetting={!serverSupportsExploringSpaces} /> - + ); } return ( -
      -
      {_t("Visibility")}
      - - {error && ( -
      - {error} -
      - )} - - - setError(_t("Failed to update the visibility of this space"))} - closeSettingsFn={closeSettingsFn} - /> - {advancedSection} -
      - { - setHistoryVisibility(checked ? HistoryVisibility.WorldReadable : HistoryVisibility.Shared); - }} - disabled={!canSetHistoryVisibility} - label={_t("Preview Space")} + + + {error && ( +
      + {error} +
      + )} + + + setError(_t("Failed to update the visibility of this space"))} + closeSettingsFn={closeSettingsFn} /> -

      - {_t("Allow people to preview your space before they join.")} -
      - {_t("Recommended for public spaces.")} -

      -
      -
      - - {addressesSection} -
      + {advancedSection} +
      + { + setHistoryVisibility( + checked ? HistoryVisibility.WorldReadable : HistoryVisibility.Shared, + ); + }} + disabled={!canSetHistoryVisibility} + label={_t("Preview Space")} + /> +

      + {_t("Allow people to preview your space before they join.")} +
      + {_t("Recommended for public spaces.")} +

      +
      + + + {addressesSection} +
      + ); }; diff --git a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap index a93fda9d6a5..34d9f5c9313 100644 --- a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap @@ -21,118 +21,130 @@ exports[` renders container 1`] = ` class="mx_SettingsTab" >
      - Visibility -
      -
      - - Access -
      - Decide who can view and join mock-space. -
      -
      -

      - Allow people to preview your space before they join. -
      - - Recommended for public spaces. - -

      - + `; From 5162cefa78b854ae9d34a90cfab255d4691bd0e3 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 09:24:41 +0000 Subject: [PATCH 006/253] Replace Sass variables with CSS custom properties - $container-gap-width and $container-border-width (#10776) * Replace a Sass variable with a CSS custom property - $container-gap-width * Replace a Sass variable with a CSS custom property - $container-border-width --- res/css/_common.pcss | 5 ++--- res/css/structures/_MainSplit.pcss | 4 ++-- res/css/structures/_MatrixChat.pcss | 4 ++-- res/css/structures/_RightPanel.pcss | 2 +- res/css/structures/_RoomView.pcss | 2 +- res/css/views/right_panel/_TimelineCard.pcss | 4 ++-- res/css/views/rooms/_AppsDrawer.pcss | 16 ++++++++-------- res/css/views/rooms/_RoomPreviewBar.pcss | 4 ++-- res/css/views/voip/_CallView.pcss | 4 ++-- res/css/views/voip/_LegacyCallView.pcss | 4 ++-- 10 files changed, 24 insertions(+), 25 deletions(-) diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 79dd8218f26..1502b60c70b 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -33,13 +33,12 @@ $MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $selected-mess $slider-dot-size: 1em; $slider-selection-dot-size: 2.4em; -$container-border-width: 8px; -$container-gap-width: 8px; /* only even numbers should be used because otherwise we get 0.5px margin values. */ - $timeline-image-border-radius: 8px; :root { font-size: 10px; + --container-border-width: 8px; + --container-gap-width: 8px; /* only even numbers should be used because otherwise we get 0.5px margin values. */ --transition-short: 0.1s; --transition-standard: 0.3s; --MessageTimestamp-width: $MessageTimestamp_width; diff --git a/res/css/structures/_MainSplit.pcss b/res/css/structures/_MainSplit.pcss index 07dabdfa210..35e4ea25c7d 100644 --- a/res/css/structures/_MainSplit.pcss +++ b/res/css/structures/_MainSplit.pcss @@ -23,10 +23,10 @@ limitations under the License. } .mx_MainSplit > .mx_RightPanel_ResizeWrapper { - padding: $container-gap-width; + padding: var(--container-gap-width); /* The resizer should be centered: only half of the gap-width is handled by the right panel. */ /* The other half by the RoomView. */ - padding-left: calc($container-gap-width / 2); + padding-left: calc(var(--container-gap-width) / 2); height: calc(100vh - 51px); /* height of .mx_RoomHeader.light-panel */ &:hover .mx_ResizeHandle_horizontal::before { diff --git a/res/css/structures/_MatrixChat.pcss b/res/css/structures/_MatrixChat.pcss index 09157eac1d4..760527a7ffd 100644 --- a/res/css/structures/_MatrixChat.pcss +++ b/res/css/structures/_MatrixChat.pcss @@ -81,7 +81,7 @@ limitations under the License. /* or less, Safari and other WebKit based browsers get confused about overflows somehow and */ /* https://github.com/vector-im/element-web/issues/19863 happens. */ .mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle_horizontal { - margin: 0 calc(-5.5px - $container-gap-width / 2) 0 calc(-6.5px + $container-gap-width / 2); + margin: 0 calc(-5.5px - var(--container-gap-width) / 2) 0 calc(-6.5px + var(--container-gap-width) / 2); /* The condition to prevent bleeding is: (margin-left + margin-right < -11px) */ /* (IF there is NO margin on the leftPanel_wrapper) */ /* The resizeHandle does not change the gap between the left panel and the room view: */ @@ -91,7 +91,7 @@ limitations under the License. /* the handle requires no space */ /* right: -6px left: -6px positions the element exactly on the edge of leftPanel. */ /* left+=1 and right-=1 => resizeHandle moves 1px to the right closer to the center of the gap. */ - /* We want the handle to be in the middle of the gap so it is shifted by ($container-gap-width / 2) */ + /* We want the handle to be in the middle of the gap so it is shifted by (var(--container-gap-width) / 2) */ } .mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 3c6a292c9cd..4bfd555b3c7 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -22,7 +22,7 @@ limitations under the License. display: flex; flex-direction: column; border-radius: 8px; - padding: $container-border-width 0; + padding: var(--container-border-width) 0; box-sizing: border-box; height: 100%; contain: strict; diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 1ef90f80db2..67626af2877 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -109,7 +109,7 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; - margin-right: calc($container-gap-width / 2); + margin-right: calc(var(--container-gap-width) / 2); } } diff --git a/res/css/views/right_panel/_TimelineCard.pcss b/res/css/views/right_panel/_TimelineCard.pcss index 13f949b39e5..acfeaf75551 100644 --- a/res/css/views/right_panel/_TimelineCard.pcss +++ b/res/css/views/right_panel/_TimelineCard.pcss @@ -134,7 +134,7 @@ limitations under the License. &.mx_EventTile_info .mx_MessageActionBar { /* 1px: border width */ - inset-inline-end: calc($container-gap-width + var(--BaseCard_padding-inline) + 1px); + inset-inline-end: calc(var(--container-gap-width) + var(--BaseCard_padding-inline) + 1px); } .mx_ReactionsRow { @@ -178,7 +178,7 @@ limitations under the License. /* 6px: scroll bar width (magic number) */ /* stylelint-disable-next-line declaration-colon-space-after */ inset-inline-end: calc( - -1 * var(--ReadReceiptGroup_EventBubbleTile-spacing-end) + $container-gap-width + 6px + -1 * var(--ReadReceiptGroup_EventBubbleTile-spacing-end) + var(--container-gap-width) + 6px ); } diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index bb11e92acd1..00ec5d8d4fa 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -20,9 +20,9 @@ $MiniAppTileHeight: 220px; $MinWidth: 240px; .mx_AppsDrawer { - margin: $container-gap-width; + margin: var(--container-gap-width); /* The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. */ - margin-right: calc($container-gap-width / 2); + margin-right: calc(var(--container-gap-width) / 2); margin-bottom: 0; /* No bottom margin for the correct gap to the CallView below. */ position: relative; display: flex; @@ -31,7 +31,7 @@ $MinWidth: 240px; flex-grow: 1; &.mx_AppsDrawer_maximise { - margin-bottom: $container-gap-width; + margin-bottom: var(--container-gap-width); } .mx_AppsContainer_resizerHandleContainer { @@ -118,7 +118,7 @@ $MinWidth: 240px; } .mx_AppsContainer_resizer { - margin-bottom: $container-gap-width; + margin-bottom: var(--container-gap-width); } .mx_AppsContainer { @@ -132,11 +132,11 @@ $MinWidth: 240px; min-height: 0; .mx_AppTile:first-of-type { - border-left-width: $container-border-width; + border-left-width: var(--container-border-width); border-radius: 10px 0 0 10px; } .mx_AppTile:last-of-type { - border-right-width: $container-border-width; + border-right-width: var(--container-border-width); border-radius: 0 10px 10px 0; } @@ -150,7 +150,7 @@ $MinWidth: 240px; } .mx_AppTile { - border: $container-border-width solid $widget-menu-bar-bg-color; + border: var(--container-border-width) solid $widget-menu-bar-bg-color; display: flex; flex-direction: column; box-sizing: border-box; @@ -161,7 +161,7 @@ $MinWidth: 240px; width: 100% !important; /* to override the inline style set by the resizer */ margin: 0; padding: 0; - border: $container-border-width solid $widget-menu-bar-bg-color; + border: var(--container-border-width) solid $widget-menu-bar-bg-color; border-radius: 8px; display: flex; flex-direction: column; diff --git a/res/css/views/rooms/_RoomPreviewBar.pcss b/res/css/views/rooms/_RoomPreviewBar.pcss index 8d35cab7173..e57ea372134 100644 --- a/res/css/views/rooms/_RoomPreviewBar.pcss +++ b/res/css/views/rooms/_RoomPreviewBar.pcss @@ -100,8 +100,8 @@ limitations under the License. /* With maximised widgets, the panel fits in better when rounded */ .mx_MainSplit_maximisedWidget .mx_RoomPreviewBar_panel { - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); /* Shared with right panel */ + margin: var(--container-gap-width); + margin-right: calc(var(--container-gap-width) / 2); /* Shared with right panel */ margin-top: 0; /* Already covered by apps drawer */ border-radius: 8px; } diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_CallView.pcss index 72c5dc1839a..8241ff92270 100644 --- a/res/css/views/voip/_CallView.pcss +++ b/res/css/views/voip/_CallView.pcss @@ -20,8 +20,8 @@ limitations under the License. display: flex; flex-direction: column; - margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); + margin: var(--container-gap-width); + margin-right: calc(var(--container-gap-width) / 2); background-color: $header-panel-bg-color; padding: 8px; diff --git a/res/css/views/voip/_LegacyCallView.pcss b/res/css/views/voip/_LegacyCallView.pcss index ec221c4c6fb..730a02b6356 100644 --- a/res/css/views/voip/_LegacyCallView.pcss +++ b/res/css/views/voip/_LegacyCallView.pcss @@ -187,9 +187,9 @@ limitations under the License. padding-bottom: 10px; - margin: $container-gap-width; + margin: var(--container-gap-width); /* The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. */ - margin-right: calc($container-gap-width / 2); + margin-right: calc(var(--container-gap-width) / 2); margin-bottom: 10px; } From 23174904ed3884101eb910a4ec03fb2d4057d90b Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 11:06:51 +0000 Subject: [PATCH 007/253] Merge style rules related to EventTile on IRC layout (#10700) * Manage rules related to EventTile on `_EventTile.pcss` The style rules with the selector "mx_EventTile" should be managed on _EventTile.pcss. Signed-off-by: Suguru Hirahara * Merge - `--EventTile_irc_line_info-margin-inline-start` Signed-off-by: Suguru Hirahara * Merge - `mx_EventTile_msgOption` Signed-off-by: Suguru Hirahara * Merge - `mx_MessageTimestamp` Signed-off-by: Suguru Hirahara * Merge - `mx_EventTileBubble` Signed-off-by: Suguru Hirahara * Merge - `mx_ReplyChain` Signed-off-by: Suguru Hirahara * Merge - `mx_ReplyTile .mx_EventTileBubble` Signed-off-by: Suguru Hirahara * Merge - `&.mx_EventTile_isEditing > .mx_EventTile_line` Signed-off-by: Suguru Hirahara * Merge - `&.mx_EventTile_info` Signed-off-by: Suguru Hirahara * Convert the variable with a custom property To fix the syntax error "Undefined variable $irc-line-height" because of cascading order. Signed-off-by: Suguru Hirahara * Merge - '.mx_EventTile_emote' The class is both specified by the const 'classes' (for classNames of mx_EventTile) and const 'lineClasses' (for mx_EventTile_line), but it is irrelevant here because the style rule does not expect mx_EventTile_avatar to be a direct descendant. In other words, both ".mx_EventTile.mx_EventTile_emote" and ".mx_EventTile .mx_EventTile_emote" are accepted. Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/e2e/timeline/timeline.spec.ts | 2 +- res/css/views/rooms/_EventTile.pcss | 152 ++++++++++++++++++++++++- res/css/views/rooms/_IRCLayout.pcss | 156 +------------------------- 3 files changed, 153 insertions(+), 157 deletions(-) diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 06b289d1e2f..c2743a3c891 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -207,7 +207,7 @@ describe("Timeline", () => { cy.get(".mx_GenericEventListSummary[data-layout=irc] .mx_GenericEventListSummary_spacer").should( "have.css", "line-height", - "18px", // $irc-line-height: $font-18px (See: _IRCLayout.pcss) + "18px", // var(--irc-line-height): $font-18px (See: _IRCLayout.pcss) ); cy.get(".mx_MainSplit").percySnapshotElement("Expanded GELS on IRC layout", { percyCSS }); diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 88b600365a6..bde48014d33 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -281,22 +281,156 @@ $left-gutter: 64px; } &[data-layout="irc"] { + --EventTile_irc_line-padding-block: 1px; + /* add --right-padding value of MessageTimestamp only */ /* stylelint-disable-next-line declaration-colon-space-after */ --EventTile_irc_line_info-margin-inline-start: calc( var(--name-width) + var(--icon-width) + 1 * var(--right-padding) ); + display: flex; + align-items: flex-start; + padding-top: 0; + + > a { + text-decoration: none; /* timestamps are links which shouldn't be underlined */ + min-width: $MessageTimestamp_width; /* ensure space for EventTile without timestamp */ + } + + > * { + margin-right: var(--right-padding); + } + + .mx_EventTile_avatar, + .mx_EventTile_e2eIcon { + height: var(--irc-line-height); + } + + .mx_EventTile_avatar, + .mx_DisambiguatedProfile, + .mx_EventTile_e2eIcon, + .mx_EventTile_msgOption { + flex-shrink: 0; + } + + .mx_EventTile_avatar { + order: 1; + position: relative; + display: flex; + align-items: center; + + /* Need to use important to override the js provided height and width values. */ + > .mx_BaseAvatar, + > .mx_BaseAvatar > * { + height: $font-14px !important; + width: $font-14px !important; + font-size: $font-10px !important; + line-height: $font-15px !important; + } + } + + .mx_DisambiguatedProfile { + order: 2; + width: var(--name-width); + margin-inline-end: 0; /* override mx_EventTile > * */ + + > .mx_DisambiguatedProfile_displayName { + width: 100%; + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + } + + > .mx_DisambiguatedProfile_mxid { + visibility: collapse; + margin-left: 0; /* Override the inherited margin. */ + padding: 0 5px; /* TODO: Use a spacing variable */ + } + + &:hover { + overflow: visible; + z-index: 10; + + > .mx_DisambiguatedProfile_displayName { + overflow: visible; + display: inline; + background-color: $event-selected-color; + border-radius: 8px 0 0 8px; + padding-right: $spacing-8; + } + + > .mx_DisambiguatedProfile_mxid { + visibility: visible; + opacity: 1; + background-color: $event-selected-color; + } + } + } + + .mx_EventTile_e2eIcon { + padding: 0; + flex-grow: 0; + background-position: center; + } + + .mx_EventTile_line { + .mx_EventTile_e2eIcon, + .mx_TextualEvent, + .mx_ViewSourceEvent, + .mx_MTextBody { + /* add a 1px padding top and bottom because our larger + emoji font otherwise gets cropped by anti-zalgo */ + padding: var(--EventTile_irc_line-padding-block) 0; + } + + .mx_EventTile_e2eIcon, + .mx_TextualEvent, + .mx_MTextBody { + display: inline-block; + } + + .mx_ReplyTile { + .mx_MTextBody { + display: -webkit-box; /* Enable -webkit-line-clamp */ + } + } + } + + .mx_EventTile_line, + .mx_EventTile_reply { + order: 3; + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + } + + .mx_EventTile_reply { + order: 4; + } + .mx_EventTile_msgOption { + order: 5; + .mx_ReadReceiptGroup { - inset-block-start: -0.3rem; /* ($irc-line-height - avatar height) / 2 */ + inset-block-start: -0.3rem; /* (var(--irc-line-height) - avatar height) / 2 */ } } + .mx_ReplyChain { + margin: 0; + } + .mx_MessageTimestamp { text-align: right; } + .mx_EditMessageComposer_buttons { + position: relative; + } + .mx_EventTileBubble { position: relative; left: var(--EventTile_irc_line_info-margin-inline-start); @@ -306,10 +440,6 @@ $left-gutter: 64px; } } - .mx_ReplyChain { - margin: 0; - } - .mx_ReplyTile .mx_EventTileBubble { left: unset; /* Cancel the value specified above for the tile inside ReplyTile */ } @@ -332,6 +462,18 @@ $left-gutter: 64px; .mx_EventTile_line { margin-inline-start: var(--EventTile_irc_line_info-margin-inline-start); } + + .mx_ViewSourceEvent, /* For hidden events */ + .mx_TextualEvent { + line-height: var(--irc-line-height); + } + } + + &.mx_EventTile_emote { + .mx_EventTile_avatar { + /* add --right-padding value of MessageTimestamp only */ + margin-left: calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)); + } } } diff --git a/res/css/views/rooms/_IRCLayout.pcss b/res/css/views/rooms/_IRCLayout.pcss index cd4371b6a02..07400f2f497 100644 --- a/res/css/views/rooms/_IRCLayout.pcss +++ b/res/css/views/rooms/_IRCLayout.pcss @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -$irc-line-height: $font-18px; +:root { + --irc-line-height: $font-18px; +} .mx_IRCLayout { --name-width: 80px; /* cf. ircDisplayNameWidth on Settings.tsx */ --icon-width: 14px; - --line-height: $irc-line-height; + --line-height: var(--irc-line-height); --right-padding: 5px; /* TODO: Use a spacing variable */ line-height: var(--line-height) !important; @@ -30,155 +32,7 @@ $irc-line-height: $font-18px; .mx_NewRoomIntro { > h2 { - line-height: initial; /* Cancel $irc-line-height */ - } - } - - .mx_EventTile[data-layout="irc"] { - --EventTile_irc_line-padding-block: 1px; - - display: flex; - align-items: flex-start; - padding-top: 0; - - > a { - text-decoration: none; /* timestamps are links which shouldn't be underlined */ - min-width: $MessageTimestamp_width; /* ensure space for EventTile without timestamp */ - } - - > * { - margin-right: var(--right-padding); - } - - .mx_EventTile_avatar, - .mx_EventTile_e2eIcon { - height: $irc-line-height; - } - - .mx_EventTile_avatar, - .mx_DisambiguatedProfile, - .mx_EventTile_e2eIcon, - .mx_EventTile_msgOption { - flex-shrink: 0; - } - - .mx_EventTile_avatar { - order: 1; - position: relative; - display: flex; - align-items: center; - - /* Need to use important to override the js provided height and width values. */ - > .mx_BaseAvatar, - > .mx_BaseAvatar > * { - height: $font-14px !important; - width: $font-14px !important; - font-size: $font-10px !important; - line-height: $font-15px !important; - } - } - - .mx_DisambiguatedProfile { - order: 2; - width: var(--name-width); - margin-inline-end: 0; /* override mx_EventTile > * */ - - > .mx_DisambiguatedProfile_displayName { - width: 100%; - text-align: end; - overflow: hidden; - text-overflow: ellipsis; - } - - > .mx_DisambiguatedProfile_mxid { - visibility: collapse; - margin-left: 0; /* Override the inherited margin. */ - padding: 0 5px; /* TODO: Use a spacing variable */ - } - - &:hover { - overflow: visible; - z-index: 10; - - > .mx_DisambiguatedProfile_displayName { - overflow: visible; - display: inline; - background-color: $event-selected-color; - border-radius: 8px 0 0 8px; - padding-right: $spacing-8; - } - - > .mx_DisambiguatedProfile_mxid { - visibility: visible; - opacity: 1; - background-color: $event-selected-color; - } - } - } - - .mx_EventTile_e2eIcon { - padding: 0; - flex-grow: 0; - background-position: center; - } - - .mx_EventTile_line { - .mx_EventTile_e2eIcon, - .mx_TextualEvent, - .mx_ViewSourceEvent, - .mx_MTextBody { - /* add a 1px padding top and bottom because our larger - emoji font otherwise gets cropped by anti-zalgo */ - padding: var(--EventTile_irc_line-padding-block) 0; - } - - .mx_EventTile_e2eIcon, - .mx_TextualEvent, - .mx_MTextBody { - display: inline-block; - } - - .mx_ReplyTile { - .mx_MTextBody { - display: -webkit-box; /* Enable -webkit-line-clamp */ - } - } - } - - .mx_EventTile_line, - .mx_EventTile_reply { - order: 3; - display: flex; - flex-direction: column; - flex-grow: 1; - flex-shrink: 1; - min-width: 0; - } - - .mx_EventTile_reply { - order: 4; - } - - .mx_EventTile_msgOption { - order: 5; - } - - .mx_EditMessageComposer_buttons { - position: relative; - } - - &.mx_EventTile_info { - .mx_ViewSourceEvent, /* For hidden events */ - .mx_TextualEvent { - line-height: $irc-line-height; - } - } - } - - .mx_EventTile_emote { - .mx_EventTile_avatar { - /* add --right-padding value of MessageTimestamp only */ - margin-left: calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)); + line-height: initial; /* Cancel var(--irc-line-height) */ } } From d494b459104a22202cda49841aa9c2ba6c464527 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 3 May 2023 12:13:46 +0100 Subject: [PATCH 008/253] Test for element-web#24629 - read receipts on main and unthreaded don't clash (#10769) --- .../e2e/read-receipts/read-receipts.spec.ts | 133 ++++++++++++++++++ cypress/support/client.ts | 19 ++- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 cypress/e2e/read-receipts/read-receipts.spec.ts diff --git a/cypress/e2e/read-receipts/read-receipts.spec.ts b/cypress/e2e/read-receipts/read-receipts.spec.ts new file mode 100644 index 00000000000..da84b0060f5 --- /dev/null +++ b/cypress/e2e/read-receipts/read-receipts.spec.ts @@ -0,0 +1,133 @@ +/* +Copyright 2023 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 type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; +import type { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +describe("Read receipts", () => { + const userName = "Mae"; + const botName = "Other User"; + const selectedRoomName = "Selected Room"; + const otherRoomName = "Other Room"; + + let homeserver: HomeserverInstance; + let otherRoomId: string; + let selectedRoomId: string; + let bot: MatrixClient | undefined; + + const botSendMessage = (): Cypress.Chainable => { + return cy.botSendMessage(bot, otherRoomId, "Message"); + }; + + const fakeEventFromSent = (eventResponse: ISendEventResponse): MatrixEvent => { + return { + getRoomId: () => otherRoomId, + getId: () => eventResponse.event_id, + threadRootId: undefined, + getTs: () => 1, + } as any as MatrixEvent; + }; + + beforeEach(() => { + /* + * Create 2 rooms: + * + * - Selected room - this one is clicked in the UI + * - Other room - this one contains the bot, which will send events so + * we can check its unread state. + */ + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, userName) + .then(() => { + cy.createRoom({ name: selectedRoomName }).then((createdRoomId) => { + selectedRoomId = createdRoomId; + }); + }) + .then(() => { + cy.createRoom({ name: otherRoomName }).then((createdRoomId) => { + otherRoomId = createdRoomId; + }); + }) + .then(() => { + cy.getBot(homeserver, { displayName: botName }).then((botClient) => { + bot = botClient; + }); + }) + .then(() => { + // Invite the bot to Other room + cy.inviteUser(otherRoomId, bot.getUserId()); + cy.visit("/#/room/" + otherRoomId); + cy.findByText(botName + " joined the room").should("exist"); + + // Then go into Selected room + cy.visit("/#/room/" + selectedRoomId); + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it( + "Considers room read if there's a receipt for main even if an earlier unthreaded receipt exists #24629", + { + // When #24629 exists, the test fails the first time but passes later, so we disable retries + // to be sure we are going to fail if the bug comes back. + // Why does it pass the second time? I wish I knew. (andyb) + retries: 0, + }, + () => { + // Details are in https://github.com/vector-im/element-web/issues/24629 + // This proves we've fixed one of the "stuck unreads" issues. + + // Given we sent 3 events on the main thread + botSendMessage(); + botSendMessage().then((main2) => { + botSendMessage().then((main3) => { + // (So the room starts off unread) + cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist"); + + // When we send a threaded receipt for the last event in main + // And an unthreaded receipt for an earlier event + cy.sendReadReceipt(fakeEventFromSent(main3)); + cy.sendReadReceipt(fakeEventFromSent(main2), "m.read" as any as ReceiptType, true); + + // (So the room has no unreads) + cy.findByLabelText(`${otherRoomName}`).should("exist"); + + // And we persuade the app to persist its state to indexeddb by reloading and waiting + cy.reload(); + cy.findByLabelText(`${selectedRoomName}`).should("exist"); + + // And we reload again, fetching the persisted state FROM indexeddb + cy.reload(); + + // Then the room is read, because the persisted state correctly remembers both + // receipts. (In #24629, the unthreaded receipt overwrote the main thread one, + // meaning that the room still said it had unread messages.) + cy.findByLabelText(`${otherRoomName}`).should("exist"); + cy.findByLabelText(`${otherRoomName} Unread messages.`).should("not.exist"); + }); + }); + }, + ); +}); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 535669d6be4..fca966613e6 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -20,7 +20,8 @@ import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api"; import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; -import type { IContent } from "matrix-js-sdk/src/models/event"; +import type { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import Chainable = Cypress.Chainable; import { UserCredentials } from "./login"; @@ -69,6 +70,13 @@ declare global { eventType: string, content: IContent, ): Chainable; + /** + * @param {MatrixEvent} event + * @param {ReceiptType} receiptType + * @param {boolean} unthreaded + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ + sendReadReceipt(event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}>; /** * @param {string} name * @param {module:client.callback} callback Optional. @@ -195,6 +203,15 @@ Cypress.Commands.add( }, ); +Cypress.Commands.add( + "sendReadReceipt", + (event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.sendReadReceipt(event, receiptType, unthreaded); + }); + }, +); + Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { return cy.getClient().then(async (cli: MatrixClient) => { return cli.setDisplayName(name); From 9b7e7864c98009afccb46309d2cba1ee5ad55544 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 11:34:32 +0000 Subject: [PATCH 009/253] Merge styles of `_EmailAddresses.pcss` and `_PhoneNumbers.pcss` (#10679) * Rename: `mx_ExistingPhoneNumber_*` to `mx_PhoneNumber--existing_*` Signed-off-by: Suguru Hirahara * Rename: `mx_ExistingEmailAddress_*` to `mx_EmailAddress--existing_*` Signed-off-by: Suguru Hirahara * Merge styles for maitainability: `mx_GeneralUserSettingsTab_discovery_existing_*` `mx_EmailAddress--existing_*` and `mx_PhoneNumber--existing_*` adopt the same declarations, so maintaining them with common selectors should improve the maintainability. Signed-off-by: Suguru Hirahara * Remove empty selectors Signed-off-by: Suguru Hirahara * Remove a duplicate selector: `.mx_GeneralUserSettingsTab_discovery--existing` Signed-off-by: Suguru Hirahara * Rename the button The button with the class name 'mx_GeneralUserSettingsTab_discovery_existing_button' is used for various types of action, so 'confirm' seems to be a bit misleading. Signed-off-by: Suguru Hirahara * Include: `mx_GeneralUserSettingsTab_discovery_existing_*` Signed-off-by: Suguru Hirahara * Run prettier Signed-off-by: Suguru Hirahara * lint Signed-off-by: Suguru Hirahara * Review Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara Signed-off-by: Suguru Hirahara - +
      + {_t("Remove %(email)s?", { email: this.props.email.address })} {_t("Remove")} {_t("Cancel")} @@ -117,8 +117,8 @@ export class ExistingEmailAddress extends React.Component - {this.props.email.address} +
      + {this.props.email.address} {_t("Remove")} diff --git a/src/components/views/settings/account/PhoneNumbers.tsx b/src/components/views/settings/account/PhoneNumbers.tsx index 113baa4639f..ede650642de 100644 --- a/src/components/views/settings/account/PhoneNumbers.tsx +++ b/src/components/views/settings/account/PhoneNumbers.tsx @@ -89,21 +89,21 @@ export class ExistingPhoneNumber extends React.Component - +
      + {_t("Remove %(phone)s?", { phone: this.props.msisdn.address })} {_t("Remove")} {_t("Cancel")} @@ -112,8 +112,10 @@ export class ExistingPhoneNumber extends React.Component - +{this.props.msisdn.address} +
      + + +{this.props.msisdn.address} + {_t("Remove")} diff --git a/src/components/views/settings/discovery/EmailAddresses.tsx b/src/components/views/settings/discovery/EmailAddresses.tsx index 7c524266a4e..f11222af481 100644 --- a/src/components/views/settings/discovery/EmailAddresses.tsx +++ b/src/components/views/settings/discovery/EmailAddresses.tsx @@ -217,7 +217,7 @@ export class EmailAddress extends React.Component {_t("Verify the link in your inbox")} @@ -239,7 +239,7 @@ export class EmailAddress extends React.Component @@ -249,8 +249,8 @@ export class EmailAddress extends React.Component - {address} +
      + {address} {status}
      ); diff --git a/src/components/views/settings/discovery/PhoneNumbers.tsx b/src/components/views/settings/discovery/PhoneNumbers.tsx index 16f53385aa5..9ae1719259b 100644 --- a/src/components/views/settings/discovery/PhoneNumbers.tsx +++ b/src/components/views/settings/discovery/PhoneNumbers.tsx @@ -222,7 +222,7 @@ export class PhoneNumber extends React.Component + {_t("Please enter verification code sent via text.")}
      @@ -243,7 +243,7 @@ export class PhoneNumber extends React.Component @@ -253,7 +253,7 @@ export class PhoneNumber extends React.Component @@ -263,8 +263,8 @@ export class PhoneNumber extends React.Component - +{address} +
      + +{address} {status}
      ); From 62569e209e063ae1ce59e1851cceac975e7b816b Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 12:25:33 +0000 Subject: [PATCH 010/253] Remove unused prop from ResizeHandle - reverse (#10771) It was added by 928b6d47c8449b1b784e4ea04606085ab8dc446c and soon deprecated by e5d1b3328c509ff86c7c023aa10d12ec521de13b --- src/components/structures/LoggedInView.tsx | 1 - src/components/views/elements/ResizeHandle.tsx | 6 +----- src/components/views/rooms/AppsDrawer.tsx | 3 +-- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index fb3cb8e2913..0acfef92c1a 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -265,7 +265,6 @@ class LoggedInView extends React.Component { resizer.setClassNames({ handle: "mx_ResizeHandle", vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", }); return resizer; } diff --git a/src/components/views/elements/ResizeHandle.tsx b/src/components/views/elements/ResizeHandle.tsx index 10dba1b735f..c3609f31296 100644 --- a/src/components/views/elements/ResizeHandle.tsx +++ b/src/components/views/elements/ResizeHandle.tsx @@ -19,21 +19,17 @@ import React from "react"; // eslint-disable-line no-unused-vars //see src/resizer for the actual resizing code, this is just the DOM for the resize handle interface IResizeHandleProps { vertical?: boolean; - reverse?: boolean; id?: string; passRef?: React.RefObject; } -const ResizeHandle: React.FC = ({ vertical, reverse, id, passRef }) => { +const ResizeHandle: React.FC = ({ vertical, id, passRef }) => { const classNames = ["mx_ResizeHandle"]; if (vertical) { classNames.push("mx_ResizeHandle_vertical"); } else { classNames.push("mx_ResizeHandle_horizontal"); } - if (reverse) { - classNames.push("mx_ResizeHandle_reverse"); - } return (
      diff --git a/src/components/views/rooms/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index cd5e9bc889e..bf4e0fb0ac5 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -110,7 +110,6 @@ export default class AppsDrawer extends React.Component { const classNames = { handle: "mx_ResizeHandle", vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", }; const collapseConfig = { onResizeStart: () => { @@ -267,7 +266,7 @@ export default class AppsDrawer extends React.Component { if (i < 1) return app; return ( - apps.length / 2} /> + {app} ); From af3a0e3d59f4b2b844da64de1fc37ab8cc5bec5f Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 12:42:36 +0000 Subject: [PATCH 011/253] Update some class names of AppTile (AppTileMenuBar_iconButton) on the naming policy (#10778) --- res/css/views/rooms/_AppsDrawer.pcss | 10 +++++----- src/components/views/elements/AppTile.tsx | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 00ec5d8d4fa..7c9ae0b51a9 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -251,23 +251,23 @@ $MinWidth: 240px; width: 24px; } - &.mx_AppTileMenuBar_iconButton_collapse::before { + &.mx_AppTileMenuBar_iconButton--collapse::before { mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); } - &.mx_AppTileMenuBar_iconButton_maximise::before { + &.mx_AppTileMenuBar_iconButton--maximise::before { mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); } - &.mx_AppTileMenuBar_iconButton_minimise::before { + &.mx_AppTileMenuBar_iconButton--minimise::before { mask-image: url("$(res)/img/element-icons/minus-button.svg"); } - &.mx_AppTileMenuBar_iconButton_popout::before { + &.mx_AppTileMenuBar_iconButton--popout::before { mask-image: url("$(res)/img/feather-customised/widget/external-link.svg"); } - &.mx_AppTileMenuBar_iconButton_menu::before { + &.mx_AppTileMenuBar_iconButton--menu::before { mask-image: url("$(res)/img/element-icons/room/ellipsis.svg"); } } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index bf1ccb35d19..ddb14ebff1e 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -693,9 +693,9 @@ export default class AppTile extends React.Component { this.props.room && WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center); const maximisedClasses = classNames({ - mx_AppTileMenuBar_iconButton: true, - mx_AppTileMenuBar_iconButton_collapse: isMaximised, - mx_AppTileMenuBar_iconButton_maximise: !isMaximised, + "mx_AppTileMenuBar_iconButton": true, + "mx_AppTileMenuBar_iconButton--collapse": isMaximised, + "mx_AppTileMenuBar_iconButton--maximise": !isMaximised, }); layoutButtons.push( { layoutButtons.push( , @@ -731,13 +731,13 @@ export default class AppTile extends React.Component { {layoutButtons} {this.props.showPopout && !this.state.requiresClient && ( )} Date: Wed, 3 May 2023 13:26:10 +0000 Subject: [PATCH 012/253] Remove some obsolete CSS rules (#10754) * Remove `mx_MImageReplyBody_info` from `_MImageReplyBody.pcss` Edited by 866a11d7e39b6746689453639018d221f40f94f3 Deprecated by 6b3098d8fe0cfd1633f5f9474030650337fa2a60 Signed-off-by: Suguru Hirahara * Remove `mx_WidgetCard_maxPinnedTooltip` from `_WidgetCard.pcss` Added by ef0843d4ad00bfd9c3572469351c5d771f858836 Deprecated by ada6d1aa46ee909eb27e055faa255193a3416d05 Signed-off-by: Suguru Hirahara * Remove `mx_AliasSettings_editable` from `_AliasSettings.pcss` Added by eac50aa800887583e46b19a2fd91455dc1c76bcb Deprecated by 2903a0e71218830c8b1c9ef072f1e8d98f589a67 Signed-off-by: Suguru Hirahara * Remove `mx_AliasSettings_localAliasHeader` from `_AliasSettings.pcss` Added by 3253d0b93dc8a61a9a1b230ae441dfa83aeda4ab Deprecated by 2e3f225520d2224212c596c44108824df5535361 Signed-off-by: Suguru Hirahara * Remove `mx_RoomList_explorePrompt` from `_RoomList.pcss` Added by e20b3754339e77eff3add18ca10854c830bb32c4 Deprecated by 328d7ea5ebb0af8056d77660545aa3d069cf0b2c Signed-off-by: Suguru Hirahara * Remove `mx_Stickers_hideStickers` from `_Stickers.pcss` Added by 79d3cca6e9a2769239220d6eb057b176535fc4c3 Deprecated by 31b3b2e2ed7d4006fb603e4f72c197ac76d9898b Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- res/css/views/messages/_MImageReplyBody.pcss | 17 +------ res/css/views/right_panel/_WidgetCard.pcss | 5 -- .../views/room_settings/_AliasSettings.pcss | 13 ------ res/css/views/rooms/_RoomList.pcss | 46 ------------------- res/css/views/rooms/_Stickers.pcss | 4 -- 5 files changed, 1 insertion(+), 84 deletions(-) diff --git a/res/css/views/messages/_MImageReplyBody.pcss b/res/css/views/messages/_MImageReplyBody.pcss index f1b9514adc9..51ed8ed2bdf 100644 --- a/res/css/views/messages/_MImageReplyBody.pcss +++ b/res/css/views/messages/_MImageReplyBody.pcss @@ -18,23 +18,8 @@ limitations under the License. display: flex; column-gap: $spacing-4; - .mx_MImageBody_thumbnail_container, - .mx_MImageReplyBody_info { + .mx_MImageBody_thumbnail_container { flex: 1; min-width: 0; /* Prevent a blowout */ } - - .mx_MImageReplyBody_info { - .mx_MImageReplyBody_sender { - grid-area: sender; - - .mx_DisambiguatedProfile { - max-width: 100%; - } - } - - .mx_MImageReplyBody_filename { - grid-area: filename; - } - } } diff --git a/res/css/views/right_panel/_WidgetCard.pcss b/res/css/views/right_panel/_WidgetCard.pcss index 6759cbc0d8a..b50f30145e3 100644 --- a/res/css/views/right_panel/_WidgetCard.pcss +++ b/res/css/views/right_panel/_WidgetCard.pcss @@ -22,8 +22,3 @@ limitations under the License. border: 0; } } - -.mx_WidgetCard_maxPinnedTooltip { - background-color: $alert; - color: #ffffff; -} diff --git a/res/css/views/room_settings/_AliasSettings.pcss b/res/css/views/room_settings/_AliasSettings.pcss index 15a7b8a8708..31f111fa698 100644 --- a/res/css/views/room_settings/_AliasSettings.pcss +++ b/res/css/views/room_settings/_AliasSettings.pcss @@ -14,19 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_AliasSettings_editable { - border: 0px; - border-bottom: 1px solid $strong-input-border-color; - padding: 0px; - min-width: 240px; -} - -.mx_AliasSettings_editable:focus { - border-bottom: 1px solid $accent; - outline: none; - box-shadow: none; -} - .mx_AliasSettings_localAddresses { cursor: pointer; color: $accent; diff --git a/res/css/views/rooms/_RoomList.pcss b/res/css/views/rooms/_RoomList.pcss index 93a8fc09946..32d03253d4d 100644 --- a/res/css/views/rooms/_RoomList.pcss +++ b/res/css/views/rooms/_RoomList.pcss @@ -42,49 +42,3 @@ limitations under the License. .mx_RoomList_iconInvite::before { mask-image: url("$(res)/img/element-icons/room/share.svg"); } - -.mx_RoomList_explorePrompt { - margin: 4px 12px 4px; - padding-top: 12px; - border-top: 1px solid $input-border-color; - font-size: $font-14px; - - div:first-child { - font-weight: var(--font-semi-bold); - line-height: $font-18px; - color: $primary-content; - } - - .mx_AccessibleButton { - color: $primary-content; - position: relative; - padding: 8px 8px 8px 32px; - font-size: inherit; - margin-top: 12px; - display: block; - text-align: start; - background-color: $panel-actions; - border-radius: 4px; - - &::before { - content: ""; - width: 16px; - height: 16px; - position: absolute; - top: 8px; - left: 8px; - background: $secondary-content; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - } - - &.mx_RoomList_explorePrompt_startChat::before { - mask-image: url("$(res)/img/element-icons/feedback.svg"); - } - - &.mx_RoomList_explorePrompt_explore::before { - mask-image: url("$(res)/img/element-icons/roomlist/explore.svg"); - } - } -} diff --git a/res/css/views/rooms/_Stickers.pcss b/res/css/views/rooms/_Stickers.pcss index 2aafca71bcc..75d947f4994 100644 --- a/res/css/views/rooms/_Stickers.pcss +++ b/res/css/views/rooms/_Stickers.pcss @@ -44,7 +44,3 @@ cursor: pointer; color: $accent; } - -.mx_Stickers_hideStickers { - z-index: 2001; -} From e8cddcac3f6b897fd7b37ebce9f46f61885fdd3b Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 3 May 2023 14:28:35 +0100 Subject: [PATCH 013/253] Support launching Cypress tests in Podman on Ubuntu (#10768) * Support launching Cypress tests in Podman on Ubuntu * Add a comment about why we are adding UID=0 GUI=0 Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Note that this setup is for rootless podman * Add a comment about why we're requesting -u 0:0 * Clarify wording of comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Reword another comment --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- cypress/plugins/docker/index.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cypress/plugins/docker/index.ts b/cypress/plugins/docker/index.ts index 9f755da6742..66bab0b8532 100644 --- a/cypress/plugins/docker/index.ts +++ b/cypress/plugins/docker/index.ts @@ -36,12 +36,21 @@ export async function dockerRun(opts: { const params = opts.params ?? []; if (params?.includes("-v") && userInfo.uid >= 0) { - // On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult - params.push("-u", `${userInfo.uid}:${userInfo.gid}`); - + // Run the docker container as our uid:gid to prevent problems with permissions. if (await isPodman()) { - // keep the user ID if the docker command is actually podman - params.push("--userns=keep-id"); + // Note: this setup is for podman rootless containers. + + // In podman, run as root in the container, which maps to the current + // user on the host. This is probably the default since Synapse's + // Dockerfile doesn't specify, but we're being explicit here + // because it's important for the permissions to work. + params.push("-u", "0:0"); + + // Tell Synapse not to switch UID + params.push("-e", "UID=0"); + params.push("-e", "GID=0"); + } else { + params.push("-u", `${userInfo.uid}:${userInfo.gid}`); } } From 42e6c9839c7263686fd3c5d768779b58f98d988a Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Wed, 3 May 2023 15:02:58 +0100 Subject: [PATCH 014/253] Replace remaining use of `checkDeviceTrust` (#10716) * Fix remaing use of `checkDeviceTrust` Followup to #10663 * fix strict type-checking error --- src/stores/SetupEncryptionStore.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 46c0193f94f..353d361795e 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -31,6 +31,7 @@ import Modal from "../Modal"; import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDialog"; import { _t } from "../languageHandler"; import { SdkContextClass } from "../contexts/SDKContext"; +import { asyncSome } from "../utils/arrays"; export enum Phase { Loading = 0, @@ -108,20 +109,12 @@ export class SetupEncryptionStore extends EventEmitter { // do we have any other verified devices which are E2EE which we can verify against? const dehydratedDevice = await cli.getDehydratedDevice(); const ownUserId = cli.getUserId()!; - const crossSigningInfo = cli.getStoredCrossSigningForUser(ownUserId); - this.hasDevicesToVerifyAgainst = cli.getStoredDevicesForUser(ownUserId).some((device) => { + this.hasDevicesToVerifyAgainst = await asyncSome(cli.getStoredDevicesForUser(ownUserId), async (device) => { if (!device.getIdentityKey() || (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id)) { return false; } - // check if the device is signed by the cross-signing key stored for our user. Note that this is - // *different* to calling `cryptoApi.getDeviceVerificationStatus`, because even if we have stored - // a cross-signing key for our user, we don't necessarily trust it yet (In legacy Crypto, we have not - // yet imported it into `Crypto.crossSigningInfo`, which for maximal confusion is a different object to - // `Crypto.getStoredCrossSigningForUser(ownUserId)`). - // - // TODO: figure out wtf to to here for rust-crypto - const verificationStatus = crossSigningInfo?.checkDeviceTrust(crossSigningInfo, device, false, true); - return !!verificationStatus?.isCrossSigningVerified(); + const verificationStatus = await cli.getCrypto()?.getDeviceVerificationStatus(ownUserId, device.deviceId); + return !!verificationStatus?.signedByOwner; }); this.phase = Phase.Intro; From 37b7dfe9431a413d3bb123a0413ba3b83fcee68c Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 4 May 2023 09:26:26 +1200 Subject: [PATCH 015/253] use ExternalLink components for external links (#10758) * use ExternalLink components for external links * test * strict --- src/components/structures/RoomStatusBar.tsx | 5 +- src/components/structures/SpaceRoomView.tsx | 5 +- .../dialogs/AnalyticsLearnMoreDialog.tsx | 5 +- .../views/dialogs/FeedbackDialog.tsx | 13 +- .../views/dialogs/ServerPickerDialog.tsx | 9 +- src/components/views/dialogs/TermsDialog.tsx | 5 +- .../views/settings/EventIndexPanel.tsx | 13 +- .../tabs/user/HelpUserSettingsTab.tsx | 65 +++++---- src/utils/ErrorUtils.tsx | 5 +- .../structures/RoomStatusBar-test.tsx | 65 ++++++++- .../__snapshots__/RoomStatusBar-test.tsx.snap | 126 ++++++++++++++++++ .../FeedbackDialog-test.tsx.snap | 8 ++ .../__snapshots__/ErrorUtils-test.ts.snap | 4 + 13 files changed, 281 insertions(+), 47 deletions(-) create mode 100644 test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 24530d4cc51..7afc9025e29 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -30,6 +30,7 @@ import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages"; +import ExternalLink from "../views/elements/ExternalLink"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -213,9 +214,9 @@ export default class RoomStatusBar extends React.PureComponent { {}, { consentLink: (sub) => ( - + {sub} - + ), }, ); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 85806913110..c1614b4e0ec 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -77,6 +77,7 @@ import MainSplit from "./MainSplit"; import RightPanel from "./RightPanel"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import ExternalLink from "../views/elements/ExternalLink"; interface IProps { space: Room; @@ -593,9 +594,9 @@ const SpaceSetupPrivateInvite: React.FC<{ { b: (sub) => {sub}, link: () => ( - + app.element.io - + ), }, )} diff --git a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx index f4cf78681b2..5ced239e9a9 100644 --- a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx +++ b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx @@ -22,6 +22,7 @@ import DialogButtons from "../elements/DialogButtons"; import Modal, { ComponentProps } from "../../../Modal"; import SdkConfig from "../../../SdkConfig"; import { getPolicyUrl } from "../../../toasts/AnalyticsToast"; +import ExternalLink from "../elements/ExternalLink"; export enum ButtonClicked { Primary, @@ -55,10 +56,10 @@ export const AnalyticsLearnMoreDialog: React.FC = ({ { PrivacyPolicyUrl: (sub) => { return ( - + {sub} - + ); }, }, diff --git a/src/components/views/dialogs/FeedbackDialog.tsx b/src/components/views/dialogs/FeedbackDialog.tsx index 5c8d1e4acf3..2ed1d967c28 100644 --- a/src/components/views/dialogs/FeedbackDialog.tsx +++ b/src/components/views/dialogs/FeedbackDialog.tsx @@ -27,6 +27,7 @@ import InfoDialog from "./InfoDialog"; import { submitFeedback } from "../../../rageshake/submit-rageshake"; import { useStateToggle } from "../../../hooks/useStateToggle"; import StyledCheckbox from "../elements/StyledCheckbox"; +import ExternalLink from "../elements/ExternalLink"; interface IProps { feature?: string; @@ -130,16 +131,20 @@ const FeedbackDialog: React.FC = (props: IProps) => { { existingIssuesLink: (sub) => { return ( - + {sub} - + ); }, newIssueLink: (sub) => { return ( - + {sub} - + ); }, }, diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index aca963e422b..8627e8b3d28 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -28,6 +28,7 @@ import StyledRadioButton from "../elements/StyledRadioButton"; import TextWithTooltip from "../elements/TextWithTooltip"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import ExternalLink from "../elements/ExternalLink"; interface IProps { title?: string; @@ -236,9 +237,13 @@ export default class ServerPickerDialog extends React.PureComponent

      {_t("Learn more")}

      - + {_t("About homeservers")} - + ); diff --git a/src/components/views/dialogs/TermsDialog.tsx b/src/components/views/dialogs/TermsDialog.tsx index b0835c3afc5..ce697f1f1e2 100644 --- a/src/components/views/dialogs/TermsDialog.tsx +++ b/src/components/views/dialogs/TermsDialog.tsx @@ -22,6 +22,7 @@ import { _t, pickBestLanguage } from "../../../languageHandler"; import DialogButtons from "../elements/DialogButtons"; import BaseDialog from "./BaseDialog"; import { ServicePolicyPair } from "../../../Terms"; +import ExternalLink from "../elements/ExternalLink"; interface ITermsCheckboxProps { onChange: (url: string, checked: boolean) => void; @@ -148,9 +149,9 @@ export default class TermsDialog extends React.PureComponent{summary} {termDoc[termsLang].name} - + - + { }, { nativeLink: (sub) => ( - + {sub} - + ), }, )} @@ -217,9 +218,13 @@ export default class EventIndexPanel extends React.Component<{}, IState> { }, { desktopLink: (sub) => ( - + {sub} - + ), }, )} diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index f939cba64cc..af0525d6c55 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -31,6 +31,7 @@ import { Action } from "../../../../../dispatcher/actions"; import { UserTab } from "../../../dialogs/UserTab"; import dis from "../../../../../dispatcher/dispatcher"; import CopyableText from "../../../elements/CopyableText"; +import ExternalLink from "../../../elements/ExternalLink"; interface IProps { closeSettingsFn: () => void; @@ -114,9 +115,9 @@ export default class HelpUserSettingsTab extends React.Component for (const tocEntry of tocLinks) { legalLinks.push( , ); } @@ -143,27 +144,31 @@ export default class HelpUserSettingsTab extends React.Component {}, { photo: (sub) => ( - {sub} - + ), author: (sub) => ( - + {sub} - + ), terms: (sub) => ( - {sub} - + ), }, )} @@ -175,27 +180,27 @@ export default class HelpUserSettingsTab extends React.Component {}, { colr: (sub) => ( - {sub} - + ), author: (sub) => ( - + {sub} - + ), terms: (sub) => ( - {sub} - + ), }, )} @@ -208,23 +213,31 @@ export default class HelpUserSettingsTab extends React.Component {}, { twemoji: (sub) => ( - + {sub} - + ), author: (sub) => ( - + {sub} - + ), terms: (sub) => ( - {sub} - + ), }, )} @@ -256,9 +269,9 @@ export default class HelpUserSettingsTab extends React.Component }, { a: (sub) => ( - + {sub} - + ), }, ); @@ -273,9 +286,9 @@ export default class HelpUserSettingsTab extends React.Component }, { a: (sub) => ( - + {sub} - + ), }, )} @@ -321,13 +334,13 @@ export default class HelpUserSettingsTab extends React.Component {}, { a: (sub) => ( - {sub} - + ), }, )} diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index 365bd916c5d..bd37ed44271 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -20,6 +20,7 @@ import { MatrixError, ConnectionError } from "matrix-js-sdk/src/http-api"; import { _t, _td, Tags, TranslatedString } from "../languageHandler"; import SdkConfig from "../SdkConfig"; import { ValidatedServerConfig } from "./ValidatedServerConfig"; +import ExternalLink from "../components/views/elements/ExternalLink"; export const resourceLimitStrings = { "monthly_active_user": _td("This homeserver has hit its Monthly Active User limit."), @@ -183,9 +184,9 @@ export function messageForConnectionError( {}, { a: (sub) => ( - + {sub} - + ), }, )} diff --git a/test/components/structures/RoomStatusBar-test.tsx b/test/components/structures/RoomStatusBar-test.tsx index a11bd899d39..de8260b26c3 100644 --- a/test/components/structures/RoomStatusBar-test.tsx +++ b/test/components/structures/RoomStatusBar-test.tsx @@ -14,11 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; +import { render } from "@testing-library/react"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; -import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar"; +import RoomStatusBar, { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { mkEvent, stubClient } from "../../test-utils/test-utils"; import { mkThread } from "../../test-utils/threads"; @@ -34,6 +38,7 @@ describe("RoomStatusBar", () => { stubClient(); client = MatrixClientPeg.get(); + client.getSyncStateData = jest.fn().mockReturnValue({}); room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -47,6 +52,13 @@ describe("RoomStatusBar", () => { event.status = EventStatus.NOT_SENT; }); + const getComponent = () => + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + describe("getUnsentMessages", () => { it("returns no unsent messages", () => { expect(getUnsentMessages(room)).toHaveLength(0); @@ -88,4 +100,55 @@ describe("RoomStatusBar", () => { expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true); }); }); + + describe("", () => { + it("should render nothing when room has no error or unsent messages", () => { + const { container } = getComponent(); + expect(container.firstChild).toBe(null); + }); + + describe("unsent messages", () => { + it("should render warning when messages are unsent due to consent", () => { + const unsentMessage = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + unsentMessage.status = EventStatus.NOT_SENT; + unsentMessage.error = new MatrixError({ + errcode: "M_CONSENT_NOT_GIVEN", + data: { consent_uri: "terms.com" }, + }); + + room.addPendingEvent(unsentMessage, "123"); + + const { container } = getComponent(); + + expect(container).toMatchSnapshot(); + }); + + it("should render warning when messages are unsent due to resource limit", () => { + const unsentMessage = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + unsentMessage.status = EventStatus.NOT_SENT; + unsentMessage.error = new MatrixError({ + errcode: "M_RESOURCE_LIMIT_EXCEEDED", + data: { limit_type: "monthly_active_user" }, + }); + + room.addPendingEvent(unsentMessage, "123"); + + const { container } = getComponent(); + + expect(container).toMatchSnapshot(); + }); + }); + }); }); diff --git a/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap b/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap new file mode 100644 index 00000000000..ed969114ec4 --- /dev/null +++ b/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomStatusBar unsent messages should render warning when messages are unsent due to consent 1`] = ` +
      +
      +
      +
      +
      + + ! + +
      +
      +
      +
      + + You can't send any messages until you review and agree to + + our terms and conditions + + + . + +
      +
      + You can select all or individual messages to retry or delete +
      +
      +
      +
      + Delete all +
      +
      + Retry all +
      +
      +
      +
      +
      +`; + +exports[`RoomStatusBar unsent messages should render warning when messages are unsent due to resource limit 1`] = ` +
      +
      +
      +
      +
      + + ! + +
      +
      +
      +
      + Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service. +
      +
      + You can select all or individual messages to retry or delete +
      +
      +
      +
      + Delete all +
      +
      + Retry all +
      +
      +
      +
      +
      +`; diff --git a/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap index 2682f5234cc..2c02b622479 100644 --- a/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap +++ b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap @@ -38,19 +38,27 @@ exports[`FeedbackDialog should respect feedback config 1`] = ` Please view existing bugs on Github + first. No match? Start a new one + . diff --git a/test/utils/__snapshots__/ErrorUtils-test.ts.snap b/test/utils/__snapshots__/ErrorUtils-test.ts.snap index bad4e0a1447..572c14f91cd 100644 --- a/test/utils/__snapshots__/ErrorUtils-test.ts.snap +++ b/test/utils/__snapshots__/ErrorUtils-test.ts.snap @@ -6,11 +6,15 @@ exports[`messageForConnectionError should match snapshot for ConnectionError 1`] Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate + is trusted, and that a browser extension is not blocking requests. From ee2c809f7a34df7d1ad3c7333a26f68b9edc6db0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 May 2023 21:37:24 +0000 Subject: [PATCH 016/253] Update tj-actions/changed-files digest to b2d17f5 (#10760) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/i18n_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/i18n_check.yml b/.github/workflows/i18n_check.yml index 9e699d7343d..03dda98eca7 100644 --- a/.github/workflows/i18n_check.yml +++ b/.github/workflows/i18n_check.yml @@ -12,7 +12,7 @@ jobs: - name: "Get modified files" id: changed_files if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'RiotTranslateBot' - uses: tj-actions/changed-files@7ecfc6730dff8072d1cc5215a24cc9478f55264d # v35 + uses: tj-actions/changed-files@b2d17f51244a144849c6b37a3a6791b98a51d86f # v35 with: files: | src/i18n/strings/* From 7df07d80ac277737c7bdff16932d6dfc1ddb9a6a Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 21:43:17 +0000 Subject: [PATCH 017/253] Remove obsolete CSS files - `_AuthButtons.pcss`, `_NewSessionReviewDialog.pcss`, and `_ManageIntegsButton.pcss` (#10753) * Remove _AuthButtons.pcss Follow-up to fd6c594a8f4b9df96ddb89b86a61ba341197a9d7 Signed-off-by: Suguru Hirahara * Remove _NewSessionReviewDialog.pcss Follow-up to 7e8bb70621db1e293503f5c5fbfdae19c660135d Signed-off-by: Suguru Hirahara * Remove _ManageIntegsButton.pcss Added by eac50aa800887583e46b19a2fd91455dc1c76bcb Deprecated by a5f296457f53bd264c8e392c0d54b31c465eb508 Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- res/css/_components.pcss | 3 -- res/css/views/auth/_AuthButtons.pcss | 49 ------------------ .../dialogs/_NewSessionReviewDialog.pcss | 37 -------------- .../views/elements/_ManageIntegsButton.pcss | 50 ------------------- 4 files changed, 139 deletions(-) delete mode 100644 res/css/views/auth/_AuthButtons.pcss delete mode 100644 res/css/views/dialogs/_NewSessionReviewDialog.pcss delete mode 100644 res/css/views/elements/_ManageIntegsButton.pcss diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 85ffa5c2382..3a70f71c930 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -96,7 +96,6 @@ @import "./views/audio_messages/_SeekBar.pcss"; @import "./views/audio_messages/_Waveform.pcss"; @import "./views/auth/_AuthBody.pcss"; -@import "./views/auth/_AuthButtons.pcss"; @import "./views/auth/_AuthFooter.pcss"; @import "./views/auth/_AuthHeader.pcss"; @import "./views/auth/_AuthHeaderLogo.pcss"; @@ -144,7 +143,6 @@ @import "./views/dialogs/_ManageRestrictedJoinRuleDialog.pcss"; @import "./views/dialogs/_MessageEditHistoryDialog.pcss"; @import "./views/dialogs/_ModalWidgetDialog.pcss"; -@import "./views/dialogs/_NewSessionReviewDialog.pcss"; @import "./views/dialogs/_PollCreateDialog.pcss"; @import "./views/dialogs/_RegistrationEmailPromptDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialog.pcss"; @@ -191,7 +189,6 @@ @import "./views/elements/_InteractiveTooltip.pcss"; @import "./views/elements/_InviteReason.pcss"; @import "./views/elements/_LabelledCheckbox.pcss"; -@import "./views/elements/_ManageIntegsButton.pcss"; @import "./views/elements/_MiniAvatarUploader.pcss"; @import "./views/elements/_Pill.pcss"; @import "./views/elements/_PowerSelector.pcss"; diff --git a/res/css/views/auth/_AuthButtons.pcss b/res/css/views/auth/_AuthButtons.pcss deleted file mode 100644 index a4a3cac37fc..00000000000 --- a/res/css/views/auth/_AuthButtons.pcss +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2017 OpenMarket Ltd -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_AuthButtons { - min-height: 24px; - height: unset !important; - padding-top: 13px !important; - padding-bottom: 14px !important; - order: 4; -} - -.mx_AuthButtons_loginButton_wrapper { - text-align: center; - width: 100%; -} - -.mx_AuthButtons_loginButton, -.mx_AuthButtons_registerButton { - margin-top: 3px; - height: 40px; - border: 0px; - border-radius: 40px; - margin-left: 4px; - margin-right: 4px; - min-width: 80px; - - background-color: $accent; - color: $background; - - cursor: pointer; - - font-size: $font-15px; - padding: 0 11px; - word-break: break-word; -} diff --git a/res/css/views/dialogs/_NewSessionReviewDialog.pcss b/res/css/views/dialogs/_NewSessionReviewDialog.pcss deleted file mode 100644 index 0992c980f32..00000000000 --- a/res/css/views/dialogs/_NewSessionReviewDialog.pcss +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_NewSessionReviewDialog_header { - display: flex; - align-items: center; - margin-top: 0; -} - -.mx_NewSessionReviewDialog_headerIcon { - width: 24px; - height: 24px; - margin-right: 4px; - position: relative; -} - -.mx_NewSessionReviewDialog_deviceName { - font-weight: var(--font-semi-bold); -} - -.mx_NewSessionReviewDialog_deviceID { - font-size: $font-12px; - color: $tertiary-content; -} diff --git a/res/css/views/elements/_ManageIntegsButton.pcss b/res/css/views/elements/_ManageIntegsButton.pcss deleted file mode 100644 index 6fb82814ada..00000000000 --- a/res/css/views/elements/_ManageIntegsButton.pcss +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_ManageIntegsButton_error { - position: relative; - float: right; - cursor: not-allowed; -} - -.mx_ManageIntegsButton_error img { - position: absolute; - right: -5px; - top: -5px; -} - -.mx_ManageIntegsButton_errorPopup { - position: absolute; - top: 110%; - left: -275%; - width: 550%; - padding: 30%; - font-size: 10pt; - line-height: 1.5em; - border-radius: 5px; - background-color: $accent; - color: $accent-fg-color; - text-align: center; - z-index: 1000; -} - -.mx_ManageIntegsButton_error .mx_ManageIntegsButton_errorPopup { - display: none; -} - -.mx_ManageIntegsButton_error:hover .mx_ManageIntegsButton_errorPopup { - display: inline; -} From bbdca11a029cce0e947c8666f032203f22731749 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Wed, 3 May 2023 22:05:18 +0000 Subject: [PATCH 018/253] Tidy up `_MessageComposer.pcss` (#10767) * Nesting: mx_MessageComposer Signed-off-by: Suguru Hirahara * Nesting: mx_MessageComposer_editor Signed-off-by: Suguru Hirahara * Nesting: mx_MessageComposer_input Signed-off-by: Suguru Hirahara * Nesting: mx_MessageComposer_formatbar Signed-off-by: Suguru Hirahara * Nesting: textarea Signed-off-by: Suguru Hirahara * Run prettier Signed-off-by: Suguru Hirahara * stylelint Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- res/css/views/rooms/_MessageComposer.pcss | 104 +++++++++++----------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index cde4de395a9..a7b0d8787b8 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -65,13 +65,15 @@ limitations under the License. gap: 6px; } -.mx_MessageComposer .mx_MessageComposer_avatar { - position: absolute; - left: 26px; -} +.mx_MessageComposer { + .mx_MessageComposer_avatar { + position: absolute; + left: 26px; -.mx_MessageComposer .mx_MessageComposer_avatar .mx_BaseAvatar { - display: block; + .mx_BaseAvatar { + display: block; + } + } } .mx_MessageComposer_composecontrols { @@ -114,6 +116,40 @@ limitations under the License. align-items: flex-start; font-size: $font-14px; margin-right: 6px; + + pre { + background-color: $rte-code-bg-color; + border-radius: 3px; + padding: 10px; + } + + textarea { + display: block; + width: 100%; + padding: 0px; + margin-top: 6px; + margin-bottom: 6px; + border: 0px; + resize: none; + outline: none; + box-shadow: none; + color: $primary-content; + background-color: $background; + font-size: $font-14px; + max-height: 120px; + overflow: auto; + + /* hack for FF as vertical alignment of custom placeholder text is broken */ + &::-moz-placeholder { + line-height: 100%; + color: $accent; + opacity: 1; + } + + &::-webkit-input-placeholder { + color: $accent; + } + } } .mx_MessageComposer_editor { @@ -123,15 +159,16 @@ limitations under the License. overflow-y: auto; overflow-x: hidden; word-break: break-word; -} -/* FIXME: rather unpleasant hack to get rid of

      margins. */ -/* really we should be mixing in markdown-body from gfm.css instead */ -.mx_MessageComposer_editor > :first-child { - margin-top: 0 !important; -} -.mx_MessageComposer_editor > :last-child { - margin-bottom: 0 !important; + /* FIXME: rather unpleasant hack to get rid of

      margins. */ + /* really we should be mixing in markdown-body from gfm.css instead */ + > :first-child { + margin-top: 0 !important; + } + + > :last-child { + margin-bottom: 0 !important; + } } @keyframes visualbell { @@ -147,39 +184,6 @@ limitations under the License. animation: 0.2s visualbell; } -.mx_MessageComposer_input pre { - background-color: $rte-code-bg-color; - border-radius: 3px; - padding: 10px; -} - -.mx_MessageComposer_input textarea { - display: block; - width: 100%; - padding: 0px; - margin-top: 6px; - margin-bottom: 6px; - border: 0px; - resize: none; - outline: none; - box-shadow: none; - color: $primary-content; - background-color: $background; - font-size: $font-14px; - max-height: 120px; - overflow: auto; -} - -/* hack for FF as vertical alignment of custom placeholder text is broken */ -.mx_MessageComposer_input textarea::-moz-placeholder { - line-height: 100%; - color: $accent; - opacity: 1; -} -.mx_MessageComposer_input textarea::-webkit-input-placeholder { - color: $accent; -} - .mx_MessageComposer_button_highlight { @mixin composerButtonHighLight; } @@ -332,10 +336,10 @@ limitations under the License. align-items: center; font-size: $font-10px; color: $info-plinth-fg-color; -} -.mx_MessageComposer_formatbar * { - margin-right: 4px; + * { + margin-right: 4px; + } } .mx_MessageComposer_format_button, From 692d73dfe8679aef047dcfe579bfb08e1133862a Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 4 May 2023 10:35:43 +1200 Subject: [PATCH 019/253] Use semantic headings in user settings Help & About (#10752) * split SettingsSection out of SettingsTab, replace usage * correct copyright * use semantic headings in GeneralRoomSettingsTab * use SettingsTab and SettingsSubsection in room settings * fix VoipRoomSettingsTab * use SettingsSection components in space settings * settingssubsection text component * use semantic headings in HelpUserSetttings tab * use ExternalLink components for external links * test * strict * lint --- .../settings/shared/_SettingsSubsection.pcss | 21 +- .../shared/_SettingsSubsectionHeading.pcss | 1 - .../settings/shared/SettingsSubsection.tsx | 14 +- .../tabs/user/HelpUserSettingsTab.tsx | 360 ++++++++---------- .../views/dialogs/UserSettingsDialog-test.tsx | 4 +- .../SecurityRecommendations-test.tsx.snap | 18 +- .../SettingsSubsection-test.tsx.snap | 26 +- 7 files changed, 233 insertions(+), 211 deletions(-) diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index a2b3cd35bfa..e90585122b5 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -20,13 +20,30 @@ limitations under the License. } .mx_SettingsSubsection_description { + margin-top: $spacing-8; +} + +.mx_SettingsSubsection_text { width: 100%; box-sizing: inherit; - line-height: $font-24px; - margin-bottom: $spacing-24; + font-size: $font-15px; color: $secondary-content; } .mx_SettingsSubsection_content { width: 100%; + display: grid; + grid-gap: $spacing-8; + grid-template-columns: 1fr; + justify-items: flex-start; + margin-top: $spacing-24; + + summary { + color: $accent; + } + details[open] { + summary { + margin-bottom: $spacing-8; + } + } } diff --git a/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss index e6d4bf4be7c..00bcb4abc21 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsectionHeading.pcss @@ -17,7 +17,6 @@ limitations under the License. .mx_SettingsSubsectionHeading { display: flex; flex-direction: row; - padding-bottom: $spacing-8; gap: $spacing-8; } diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx index 0e78bf47505..8fcdf327a9a 100644 --- a/src/components/views/settings/shared/SettingsSubsection.tsx +++ b/src/components/views/settings/shared/SettingsSubsection.tsx @@ -24,10 +24,20 @@ export interface SettingsSubsectionProps extends HTMLAttributes children?: React.ReactNode; } -const SettingsSubsection: React.FC = ({ heading, description, children, ...rest }) => ( +export const SettingsSubsectionText: React.FC> = ({ children, ...rest }) => ( +

      + {children} +
      +); + +export const SettingsSubsection: React.FC = ({ heading, description, children, ...rest }) => (
      {typeof heading === "string" ? : <>{heading}} - {!!description &&
      {description}
      } + {!!description && ( +
      + {description} +
      + )}
      {children}
      ); diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index af0525d6c55..68887ca6c68 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -31,6 +31,9 @@ import { Action } from "../../../../../dispatcher/actions"; import { UserTab } from "../../../dialogs/UserTab"; import dis from "../../../../../dispatcher/dispatcher"; import CopyableText from "../../../elements/CopyableText"; +import SettingsTab from "../SettingsTab"; +import { SettingsSection } from "../../shared/SettingsSection"; +import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection"; import ExternalLink from "../../../elements/ExternalLink"; interface IProps { @@ -115,18 +118,15 @@ export default class HelpUserSettingsTab extends React.Component for (const tocEntry of tocLinks) { legalLinks.push(
      - - {tocEntry.text} - + {tocEntry.text}
      , ); } return ( -
      - {_t("Legal")} -
      {legalLinks}
      -
      + + {legalLinks} + ); } @@ -134,116 +134,95 @@ export default class HelpUserSettingsTab extends React.Component // Note: This is not translated because it is legal text. // Also,   is ugly but necessary. return ( -
      - {_t("Credits")} -
        -
      • - {_t( - "The default cover photo is © " + - "Jesús Roncero used under the terms of CC-BY-SA 4.0.", - {}, - { - photo: (sub) => ( - - {sub} - - ), - author: (sub) => ( - - {sub} - - ), - terms: (sub) => ( - - {sub} - - ), - }, - )} -
      • -
      • - {_t( - "The twemoji-colr font is © Mozilla Foundation " + - "used under the terms of Apache 2.0.", - {}, - { - colr: (sub) => ( - - {sub} - - ), - author: (sub) => ( - - {sub} - - ), - terms: (sub) => ( - - {sub} - - ), - }, - )} -
      • -
      • - {_t( - "The Twemoji emoji art is © " + - "Twitter, Inc and other contributors used under the terms of " + - "CC-BY 4.0.", - {}, - { - twemoji: (sub) => ( - - {sub} - - ), - author: (sub) => ( - - {sub} - - ), - terms: (sub) => ( - - {sub} - - ), - }, - )} -
      • -
      -
      + + +
        +
      • + {_t( + "The default cover photo is © " + + "Jesús Roncero used under the terms of CC-BY-SA 4.0.", + {}, + { + photo: (sub) => ( + + {sub} + + ), + author: (sub) => ( + {sub} + ), + terms: (sub) => ( + + {sub} + + ), + }, + )} +
      • +
      • + {_t( + "The twemoji-colr font is © Mozilla Foundation " + + "used under the terms of Apache 2.0.", + {}, + { + colr: (sub) => ( + + {sub} + + ), + author: (sub) => {sub}, + terms: (sub) => ( + + {sub} + + ), + }, + )} +
      • +
      • + {_t( + "The Twemoji emoji art is © " + + "Twitter, Inc and other contributors used under the terms of " + + "CC-BY 4.0.", + {}, + { + twemoji: (sub) => ( + {sub} + ), + author: (sub) => ( + {sub} + ), + terms: (sub) => ( + + {sub} + + ), + }, + )} +
      • +
      +
      +
      ); } @@ -268,11 +247,7 @@ export default class HelpUserSettingsTab extends React.Component brand, }, { - a: (sub) => ( - - {sub} - - ), + a: (sub) => {sub}, }, ); if (SdkConfig.get("welcome_user_id") && getCurrentLanguage().startsWith("en")) { @@ -309,77 +284,73 @@ export default class HelpUserSettingsTab extends React.Component let bugReportingSection; if (SdkConfig.get().bug_report_endpoint_url) { bugReportingSection = ( -
      - {_t("Bug reporting")} -
      - {_t( - "If you've submitted a bug via GitHub, debug logs can help " + - "us track down the problem. ", - )} - {_t( - "Debug logs contain application " + - "usage data including your username, the IDs or aliases of " + - "the rooms you have visited, which UI elements you " + - "last interacted with, and the usernames of other users. " + - "They do not contain messages.", - )} -
      + + + {_t( + "If you've submitted a bug via GitHub, debug logs can help " + + "us track down the problem. ", + )} + + {_t( + "Debug logs contain application " + + "usage data including your username, the IDs or aliases of " + + "the rooms you have visited, which UI elements you " + + "last interacted with, and the usernames of other users. " + + "They do not contain messages.", + )} + + } + > {_t("Submit debug logs")} -
      + {_t( "To report a Matrix-related security issue, please read the Matrix.org " + "Security Disclosure Policy.", {}, { a: (sub) => ( - + {sub} ), }, )} -
      -
      + + ); } const { appVersion, olmVersion } = this.getVersionInfo(); return ( -
      -
      {_t("Help & About")}
      - {bugReportingSection} -
      - {_t("FAQ")} -
      {faqText}
      - - {_t("Keyboard Shortcuts")} - -
      -
      - {_t("Versions")} -
      - - {appVersion} -
      - {olmVersion} -
      -
      - {updateButton} -
      -
      - {this.renderLegal()} - {this.renderCredits()} -
      - {_t("Advanced")} -
      -
      + + + {bugReportingSection} + + + {_t("Keyboard Shortcuts")} + + + + + + {appVersion} +
      + {olmVersion} +
      +
      + {updateButton} +
      +
      + {this.renderLegal()} + {this.renderCredits()} + + {_t( "Homeserver is %(homeserverUrl)s", { @@ -389,10 +360,10 @@ export default class HelpUserSettingsTab extends React.Component code: (sub) => {sub}, }, )} -
      -
      - {MatrixClientPeg.get().getIdentityServerUrl() && - _t( + + {MatrixClientPeg.get().getIdentityServerUrl() && ( + + {_t( "Identity server is %(identityServerUrl)s", { identityServerUrl: MatrixClientPeg.get().getIdentityServerUrl(), @@ -401,25 +372,28 @@ export default class HelpUserSettingsTab extends React.Component code: (sub) => {sub}, }, )} -
      -
      - {_t("Access Token")} - - {_t( - "Your access token gives full access to your account." + - " Do not share it with anyone.", - )} - - MatrixClientPeg.get().getAccessToken()}> - {MatrixClientPeg.get().getAccessToken()} - -
      + + )} + +
      + {_t("Access Token")} + + {_t( + "Your access token gives full access to your account." + + " Do not share it with anyone.", + )} + + MatrixClientPeg.get().getAccessToken()}> + {MatrixClientPeg.get().getAccessToken()} + +
      +
      {_t("Clear cache and reload")} -
      -
      -
      + + + ); } } diff --git a/test/components/views/dialogs/UserSettingsDialog-test.tsx b/test/components/views/dialogs/UserSettingsDialog-test.tsx index b5f04c7b787..5e610b87ba7 100644 --- a/test/components/views/dialogs/UserSettingsDialog-test.tsx +++ b/test/components/views/dialogs/UserSettingsDialog-test.tsx @@ -74,7 +74,9 @@ describe("", () => { const getActiveTabLabel = (container: Element) => container.querySelector(".mx_TabbedView_tabLabel_active")?.textContent; - const getActiveTabHeading = (container: Element) => container.querySelector(".mx_SettingsTab_heading")?.textContent; + const getActiveTabHeading = (container: Element) => + container.querySelector(".mx_SettingsTab_heading")?.textContent || + container.querySelector(".mx_SettingsSection .mx_Heading_h2")?.textContent; it("should render general settings tab when no initialTabId", () => { const { container } = render(getComponent()); diff --git a/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap index 011d926b1dd..190fc3d2d05 100644 --- a/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap @@ -18,7 +18,11 @@ exports[` renders both cards when user has both unver
      - Improve your account security by following these recommendations. +
      + Improve your account security by following these recommendations. +
      renders inactive devices section when user
      - Improve your account security by following these recommendations. +
      + Improve your account security by following these recommendations. +
      renders unverified devices section when use
      - Improve your account security by following these recommendations. +
      + Improve your account security by following these recommendations. +
      renders with plain text description 1`] = `
      - This describes the subsection +
      + This describes the subsection +
      renders with react element description 1`] = `
      -

      - This describes the section - - link - -

      +
      +

      + This describes the section + + link + +

      +
      Date: Thu, 4 May 2023 11:40:49 +0100 Subject: [PATCH 020/253] Add a waitFor in case it fixes flaky SecurityRoomSettingsTab test (#10785) --- .../tabs/room/SecurityRoomSettingsTab-test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx index 2595a8f362e..272adf55102 100644 --- a/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { EventType, GuestAccess, HistoryVisibility, JoinRule, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -353,9 +353,11 @@ describe("", () => { expect(within(dialog).getByText("Enable encryption?")).toBeInTheDocument(); fireEvent.click(within(dialog).getByText("OK")); - expect(client.sendStateEvent).toHaveBeenCalledWith(room.roomId, EventType.RoomEncryption, { - algorithm: "m.megolm.v1.aes-sha2", - }); + await waitFor(() => + expect(client.sendStateEvent).toHaveBeenCalledWith(room.roomId, EventType.RoomEncryption, { + algorithm: "m.megolm.v1.aes-sha2", + }), + ); }); it("renders world readable option when room is encrypted and history is already set to world readable", () => { From 9fc4410ee91c79f435aaf47d07dbf56d2f8d569a Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Thu, 4 May 2023 12:19:26 +0000 Subject: [PATCH 021/253] Update style rules of `MessageTimestamp` (#10780) * Remove an obsolete variable - `$MessageTimestamp_width_hover` Added by 9b54aba4c06eab89b494a777c8a77e3f14edd948 Deprecated 5343be7814e9fd120c3779aa8a33951716cb2d0e * Replace a variable with a custom property - $MessageTimestamp_width * Merge with an existing property in :root rename * Manage the variable on _MessageTimestamp.pcss --- cypress/e2e/timeline/timeline.spec.ts | 6 +++--- res/css/_common.pcss | 4 ---- res/css/views/messages/_EventTileBubble.pcss | 2 +- res/css/views/messages/_MessageTimestamp.pcss | 7 +++++-- res/css/views/right_panel/_TimelineCard.pcss | 4 ++-- res/css/views/rooms/_EventTile.pcss | 7 ++++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index c2743a3c891..331496a9695 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -171,7 +171,7 @@ describe("Timeline", () => { // Check the profile resizer's place // See: _IRCLayout // --RoomView_MessageList-padding = 18px (See: _RoomView.pcss) - // --MessageTimestamp-width = $MessageTimestamp_width = 46px (See: _common.pcss) + // --MessageTimestamp-width = 46px (See: _MessageTimestamp.pcss) // --icon-width = 14px // --right-padding = 5px // --name-width = 80px @@ -371,7 +371,7 @@ describe("Timeline", () => { // Check inline start spacing of collapsed GELS // See: _EventTile.pcss // .mx_GenericEventListSummary[data-layout="irc"] > .mx_EventTile_line - // = var(--name-width) + var(--icon-width) + $MessageTimestamp_width + 2 * var(--right-padding) + // = var(--name-width) + var(--icon-width) + var(--MessageTimestamp-width) + 2 * var(--right-padding) // = 80 + 14 + 46 + 2 * 5 // = 150px cy.get(".mx_GenericEventListSummary[data-layout=irc] > .mx_EventTile_line").should( @@ -388,7 +388,7 @@ describe("Timeline", () => { .should("have.css", "margin-inline-end", "0px"); // --icon-width should be applied cy.get(".mx_EventTile .mx_EventTile_avatar > .mx_BaseAvatar").should("have.css", "width", "14px"); - // $MessageTimestamp_width should be applied + // var(--MessageTimestamp-width) should be applied cy.get(".mx_EventTile > a").should("have.css", "min-width", "46px"); // Record alignment of collapsed GELS and messages on messagePanel cy.get(".mx_MainSplit").percySnapshotElement("Collapsed GELS and messages on IRC layout", { percyCSS }); diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 1502b60c70b..40436720445 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -27,9 +27,6 @@ $hover-transition: 0.08s cubic-bezier(0.46, 0.03, 0.52, 0.96); /* quadratic */ $selected-message-border-width: 4px; -$MessageTimestamp_width: 46px; /* 8 + 30 (avatar) + 8 */ -$MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $selected-message-border-width); - $slider-dot-size: 1em; $slider-selection-dot-size: 2.4em; @@ -41,7 +38,6 @@ $timeline-image-border-radius: 8px; --container-gap-width: 8px; /* only even numbers should be used because otherwise we get 0.5px margin values. */ --transition-short: 0.1s; --transition-standard: 0.3s; - --MessageTimestamp-width: $MessageTimestamp_width; --buttons-dialog-gap-row: $spacing-8; --buttons-dialog-gap-column: $spacing-8; } diff --git a/res/css/views/messages/_EventTileBubble.pcss b/res/css/views/messages/_EventTileBubble.pcss index b2741ac59f6..7facf259a11 100644 --- a/res/css/views/messages/_EventTileBubble.pcss +++ b/res/css/views/messages/_EventTileBubble.pcss @@ -21,7 +21,7 @@ limitations under the License. padding: 10px; /* TODO: Use a spacing variable */ border-radius: 8px; /* Reserve space for external timestamps, but also cap the width */ - max-width: min(calc(100% - 2 * $MessageTimestamp_width), 600px); + max-width: min(calc(100% - 2 * var(--MessageTimestamp-width)), 600px); box-sizing: border-box; display: grid; grid-template-columns: 24px minmax(0, 1fr) min-content min-content; diff --git a/res/css/views/messages/_MessageTimestamp.pcss b/res/css/views/messages/_MessageTimestamp.pcss index 1b94ad3a6f1..80f64df09ee 100644 --- a/res/css/views/messages/_MessageTimestamp.pcss +++ b/res/css/views/messages/_MessageTimestamp.pcss @@ -14,15 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MessageTimestamp { +:root { + --MessageTimestamp-width: 46px; /* 8 + 30 (avatar) + 8 */ --MessageTimestamp-max-width: 80px; --MessageTimestamp-color: $event-timestamp-color; +} +.mx_MessageTimestamp { color: var(--MessageTimestamp-color); font-size: $font-10px; font-variant-numeric: tabular-nums; display: block; /* enable the width setting below */ - width: $MessageTimestamp_width; + width: var(--MessageTimestamp-width); white-space: nowrap; user-select: none; } diff --git a/res/css/views/right_panel/_TimelineCard.pcss b/res/css/views/right_panel/_TimelineCard.pcss index acfeaf75551..9142f8d5ece 100644 --- a/res/css/views/right_panel/_TimelineCard.pcss +++ b/res/css/views/right_panel/_TimelineCard.pcss @@ -55,7 +55,7 @@ limitations under the License. &.mx_EventTile_info .mx_EventTile_line, .mx_EventTile_line { padding: var(--BaseCard_EventTile_line-padding-block) var(--BaseCard_EventTile-spacing-inline); - padding-inline-end: $MessageTimestamp_width; /* ensure timestamp is not hidden */ + padding-inline-end: var(--MessageTimestamp-width); /* ensure timestamp is not hidden */ .mx_EventTile_e2eIcon { inset-inline-start: $spacing-8; @@ -157,7 +157,7 @@ limitations under the License. .mx_EventTile_line, .mx_GenericEventListSummary_unstyledList > .mx_EventTile_info .mx_EventTile_avatar ~ .mx_EventTile_line { padding-inline-start: var(--BaseCard_EventTile-spacing-inline); - padding-inline-end: $MessageTimestamp_width; /* ensure timestamp is not hidden */ + padding-inline-end: var(--MessageTimestamp-width); /* ensure timestamp is not hidden */ } } } diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index bde48014d33..0c42a99f76d 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -295,7 +295,7 @@ $left-gutter: 64px; > a { text-decoration: none; /* timestamps are links which shouldn't be underlined */ - min-width: $MessageTimestamp_width; /* ensure space for EventTile without timestamp */ + min-width: var(--MessageTimestamp-width); /* ensure space for EventTile without timestamp */ } > * { @@ -650,7 +650,7 @@ $left-gutter: 64px; /* add --right-padding value of MessageTimestamp and avatar only */ /* stylelint-disable-next-line declaration-colon-space-after */ padding-left: calc( - var(--name-width) + var(--icon-width) + $MessageTimestamp_width + 2 * var(--right-padding) + var(--name-width) + var(--icon-width) + var(--MessageTimestamp-width) + 2 * var(--right-padding) ); } } @@ -1218,7 +1218,8 @@ $left-gutter: 64px; padding-top: 0; .mx_EventTile_avatar { - left: calc($MessageTimestamp_width + 14px - 4px); /* 14px: avatar width, 4px: align with text */ + /* 14px: avatar width, 4px: align with text */ + left: calc(var(--MessageTimestamp-width) + 14px - 4px); z-index: 9; /* position above the hover styling */ } From 0d2af83dbed96154c1a623a62c96d00cfc56d259 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Thu, 4 May 2023 13:09:26 +0000 Subject: [PATCH 022/253] Add E2E test: `invite-dialog.spec.ts` (#10693) * Add `invite-dialog.spec.ts` Signed-off-by: Suguru Hirahara * Apply the latest status The ARIA role of the buttons in 'mx_HeaderButtons' was recently changed from 'tab' to 'button' Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/e2e/invite/invite-dialog.spec.ts | 174 ++++++++++++++++++ src/components/views/dialogs/InviteDialog.tsx | 9 +- 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 cypress/e2e/invite/invite-dialog.spec.ts diff --git a/cypress/e2e/invite/invite-dialog.spec.ts b/cypress/e2e/invite/invite-dialog.spec.ts new file mode 100644 index 00000000000..dedea1fd2bd --- /dev/null +++ b/cypress/e2e/invite/invite-dialog.spec.ts @@ -0,0 +1,174 @@ +/* +Copyright 2023 Suguru Hirahara + +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 type { MatrixClient } from "matrix-js-sdk/src/client"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +describe("Invite dialog", function () { + let homeserver: HomeserverInstance; + let bot: MatrixClient; + const botName = "BotAlice"; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Hanako"); + + cy.getBot(homeserver, { displayName: botName, autoAcceptInvites: true }).then((_bot) => { + bot = _bot; + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should support inviting a user to a room", () => { + // Create and view a room + cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room"); + + // Assert that the room was configured + cy.findByText("Hanako created and configured the room.").should("exist"); + + // Open the room info panel + cy.findByRole("button", { name: "Room info" }).click(); + + // Click "People" button on the panel + // Regex pattern due to the string of "mx_BaseCard_Button_sublabel" + cy.findByRole("button", { name: /People/ }).click(); + + cy.get(".mx_BaseCard_header").within(() => { + // Click "Invite to this room" button + // Regex pattern due to "mx_MemberList_invite span::before" + cy.findByRole("button", { name: /Invite to this room/ }).click(); + }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.get(".mx_Dialog_header .mx_Dialog_title").within(() => { + // Assert that the header is rendered + cy.findByText("Invite to Test Room").should("exist"); + }); + + // Assert that the bar is rendered + cy.get(".mx_InviteDialog_addressBar").should("exist"); + }); + + // TODO: unhide userId + const percyCSS = ".mx_InviteDialog_helpText_userId { visibility: hidden !important; }"; + + // Take a snapshot of the invite dialog including its wrapper + cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Room (without a user)", { percyCSS }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.get(".mx_InviteDialog_identityServer").should("not.exist"); + + cy.findByTestId("invite-dialog-input").type(bot.getUserId()); + + // Assert that notification about identity servers appears after typing userId + cy.get(".mx_InviteDialog_identityServer").should("exist"); + + cy.get(".mx_InviteDialog_tile_nameStack").within(() => { + cy.get(".mx_InviteDialog_tile_nameStack_userId").within(() => { + // Assert that the bot id is rendered properly + cy.findByText(bot.getUserId()).should("exist"); + }); + + cy.get(".mx_InviteDialog_tile_nameStack_name").within(() => { + cy.findByText(botName).click(); + }); + }); + + cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { + cy.findByText(botName).should("exist"); + }); + }); + + // Take a snapshot of the invite dialog with a user pill + cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Room (with a user pill)", { percyCSS }); + + cy.get(".mx_InviteDialog_other").within(() => { + // Invite the bot + cy.findByRole("button", { name: "Invite" }).click(); + }); + + // Assert that the invite dialog disappears + cy.get(".mx_InviteDialog_other").should("not.exist"); + + // Assert that they were invited and joined + cy.findByText(`${botName} joined the room`).should("exist"); + }); + + it("should support inviting a user to Direct Messages", () => { + cy.get(".mx_RoomList").within(() => { + cy.findByRole("button", { name: "Start chat" }).click(); + }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.get(".mx_Dialog_header .mx_Dialog_title").within(() => { + // Assert that the header is rendered + cy.findByText("Direct Messages").should("exist"); + }); + + // Assert that the bar is rendered + cy.get(".mx_InviteDialog_addressBar").should("exist"); + }); + + // TODO: unhide userId and invite link + const percyCSS = + ".mx_InviteDialog_footer_link, .mx_InviteDialog_helpText_userId { visibility: hidden !important; }"; + + // Take a snapshot of the invite dialog including its wrapper + cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Direct Messages (without a user)", { + percyCSS, + }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.findByTestId("invite-dialog-input").type(bot.getUserId()); + + cy.get(".mx_InviteDialog_tile_nameStack").within(() => { + cy.findByText(bot.getUserId()).should("exist"); + cy.findByText(botName).click(); + }); + + cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { + cy.findByText(botName).should("exist"); + }); + }); + + // Take a snapshot of the invite dialog with a user pill + cy.get(".mx_Dialog_wrapper").percySnapshotElement("Invite Dialog - Direct Messages (with a user pill)", { + percyCSS, + }); + + cy.get(".mx_InviteDialog_other").within(() => { + // Open a direct message UI + cy.findByRole("button", { name: "Go" }).click(); + }); + + // Assert that the invite dialog disappears + cy.get(".mx_InviteDialog_other").should("not.exist"); + + // Send a message to invite the bots + cy.getComposer().type("Hello{enter}"); + + // Assert that they were invited and joined + cy.findByText(`${botName} joined the room`).should("exist"); + + // Assert that the message is displayed at the bottom + cy.get(".mx_EventTile_last").findByText("Hello").should("exist"); + }); +}); diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index cf71fde5e6a..ddc37f3f642 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1337,7 +1337,7 @@ export default class InviteDialog extends React.PureComponent

      {_t("Or send invite link")}

      makeUserPermalink(MatrixClientPeg.get().getUserId()!)}> - + {link} @@ -1385,7 +1385,12 @@ export default class InviteDialog extends React.PureComponent ( - + {userId} ), From 3ca957b541bf451227b25482b0b29b9d42280407 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Thu, 4 May 2023 15:19:55 +0000 Subject: [PATCH 023/253] Update `_ResizeHandle.pcss` (#10772) * Nesting: `mx_ResizeHandle` * Nesting: `> div` * Run prettier * Use a custom property * Remove a redundant declaration: `cursor: row-resize` The resizer is either vertical or horizontal, and since `cursor: row-resize` is applied by default, it does not need to be repeated here. * Conform the class names to the naming policy * Revert "Use a custom property" This reverts commit 6116939eec7d9e4220b89a638623e5876e143adf. --- res/css/structures/_MainSplit.pcss | 2 +- res/css/structures/_MatrixChat.pcss | 4 +- res/css/views/elements/_ResizeHandle.pcss | 39 +++++++++---------- res/css/views/rooms/_AppsDrawer.pcss | 4 +- src/components/structures/LoggedInView.tsx | 2 +- src/components/structures/MainSplit.tsx | 2 +- .../views/elements/ResizeHandle.tsx | 4 +- src/components/views/rooms/AppsDrawer.tsx | 2 +- 8 files changed, 29 insertions(+), 30 deletions(-) diff --git a/res/css/structures/_MainSplit.pcss b/res/css/structures/_MainSplit.pcss index 35e4ea25c7d..55e0dec1034 100644 --- a/res/css/structures/_MainSplit.pcss +++ b/res/css/structures/_MainSplit.pcss @@ -29,7 +29,7 @@ limitations under the License. padding-left: calc(var(--container-gap-width) / 2); height: calc(100vh - 51px); /* height of .mx_RoomHeader.light-panel */ - &:hover .mx_ResizeHandle_horizontal::before { + &:hover .mx_ResizeHandle--horizontal::before { position: absolute; top: 50%; left: 50%; diff --git a/res/css/structures/_MatrixChat.pcss b/res/css/structures/_MatrixChat.pcss index 760527a7ffd..c09d32f491f 100644 --- a/res/css/structures/_MatrixChat.pcss +++ b/res/css/structures/_MatrixChat.pcss @@ -80,7 +80,7 @@ limitations under the License. /* negative margin greater than its positive padding. If it's the same */ /* or less, Safari and other WebKit based browsers get confused about overflows somehow and */ /* https://github.com/vector-im/element-web/issues/19863 happens. */ -.mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle_horizontal { +.mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle--horizontal { margin: 0 calc(-5.5px - var(--container-gap-width) / 2) 0 calc(-6.5px + var(--container-gap-width) / 2); /* The condition to prevent bleeding is: (margin-left + margin-right < -11px) */ /* (IF there is NO margin on the leftPanel_wrapper) */ @@ -94,7 +94,7 @@ limitations under the License. /* We want the handle to be in the middle of the gap so it is shifted by (var(--container-gap-width) / 2) */ } -.mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { +.mx_MatrixChat > .mx_ResizeHandle--horizontal:hover { position: relative; &::before { diff --git a/res/css/views/elements/_ResizeHandle.pcss b/res/css/views/elements/_ResizeHandle.pcss index 2af2880654e..be913eec72d 100644 --- a/res/css/views/elements/_ResizeHandle.pcss +++ b/res/css/views/elements/_ResizeHandle.pcss @@ -18,25 +18,24 @@ limitations under the License. cursor: row-resize; flex: 0 0 auto; z-index: 100; -} - -.mx_ResizeHandle.mx_ResizeHandle_horizontal { - margin: 0 -5px; - padding: 0 5px; - cursor: col-resize; -} - -.mx_ResizeHandle.mx_ResizeHandle_vertical { - margin: -5px 0; - padding: 5px 0; - cursor: row-resize; -} - -.mx_ResizeHandle.mx_ResizeHandle_horizontal > div { - width: 1px; - height: 100%; -} -.mx_ResizeHandle.mx_ResizeHandle_vertical > div { - height: 1px; + &.mx_ResizeHandle--horizontal { + margin: 0 -5px; + padding: 0 5px; + cursor: col-resize; + + > div { + width: 1px; + height: 100%; + } + } + + &.mx_ResizeHandle--vertical { + margin: -5px 0; + padding: 5px 0; + + > div { + height: 1px; + } + } } diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 7c9ae0b51a9..c6e141497d2 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -75,7 +75,7 @@ $MinWidth: 240px; background: $primary-content; } - .mx_ResizeHandle_horizontal::before { + .mx_ResizeHandle--horizontal::before { position: absolute; left: 3px; top: 50%; @@ -140,7 +140,7 @@ $MinWidth: 240px; border-radius: 0 10px 10px 0; } - .mx_ResizeHandle_horizontal { + .mx_ResizeHandle--horizontal { position: relative; > div { diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 0acfef92c1a..141f61937d9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -264,7 +264,7 @@ class LoggedInView extends React.Component { const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); resizer.setClassNames({ handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", + vertical: "mx_ResizeHandle--vertical", }); return resizer; } diff --git a/src/components/structures/MainSplit.tsx b/src/components/structures/MainSplit.tsx index b64f703d331..ab29cd2b397 100644 --- a/src/components/structures/MainSplit.tsx +++ b/src/components/structures/MainSplit.tsx @@ -87,7 +87,7 @@ export default class MainSplit extends React.Component { onResize={this.onResize} onResizeStop={this.onResizeStop} className="mx_RightPanel_ResizeWrapper" - handleClasses={{ left: "mx_ResizeHandle_horizontal" }} + handleClasses={{ left: "mx_ResizeHandle--horizontal" }} > {panelView} diff --git a/src/components/views/elements/ResizeHandle.tsx b/src/components/views/elements/ResizeHandle.tsx index c3609f31296..15886d8a198 100644 --- a/src/components/views/elements/ResizeHandle.tsx +++ b/src/components/views/elements/ResizeHandle.tsx @@ -26,9 +26,9 @@ interface IResizeHandleProps { const ResizeHandle: React.FC = ({ vertical, id, passRef }) => { const classNames = ["mx_ResizeHandle"]; if (vertical) { - classNames.push("mx_ResizeHandle_vertical"); + classNames.push("mx_ResizeHandle--vertical"); } else { - classNames.push("mx_ResizeHandle_horizontal"); + classNames.push("mx_ResizeHandle--horizontal"); } return (
      diff --git a/src/components/views/rooms/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index bf4e0fb0ac5..e869c9ee5ba 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -109,7 +109,7 @@ export default class AppsDrawer extends React.Component { // (ie. a vertical resize handle because, the handle itself is vertical...) const classNames = { handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", + vertical: "mx_ResizeHandle--vertical", }; const collapseConfig = { onResizeStart: () => { From 44e073214459a69566ac0a372e41273af2eac2e7 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 5 May 2023 13:53:26 +1200 Subject: [PATCH 024/253] Sort muted rooms to the bottom of their section of the room list (#10592) * muted-to-the-bottom POC * split muted rooms in natural algorithm * add previous event to account data dispatch * add muted to notification state * sort muted rooms to the bottom * only split muted rooms when sorting is RECENT * remove debugs * use RoomNotifState better * add default notifications test util * test getChangedOverrideRoomPushRules * remove file * test roomudpate in roomliststore * unit test ImportanceAlgorithm * strict fixes * test recent x importance with muted rooms * unit test NaturalAlgorithm * test naturalalgorithm with muted rooms * strict fixes * comments * add push rules test utility * strict fixes * more strict * tidy comment * document previousevent on account data dispatch event * simplify (?) room mute rule utilities, comments * remove debug --- src/RoomNotifs.ts | 35 +++- src/actions/MatrixActionCreators.ts | 9 +- src/stores/notifications/NotificationColor.ts | 1 + src/stores/notifications/NotificationState.ts | 12 +- .../notifications/RoomNotificationState.ts | 3 + src/stores/room-list/RoomListStore.ts | 12 ++ .../list-ordering/ImportanceAlgorithm.ts | 18 +- .../list-ordering/NaturalAlgorithm.ts | 167 +++++++++++++++++- .../list-ordering/OrderingAlgorithm.ts | 4 + src/stores/room-list/models.ts | 1 + src/stores/room-list/utils/roomMute.ts | 54 ++++++ test/stores/room-list/RoomListStore-test.ts | 86 ++++++++- .../list-ordering/ImportanceAlgorithm-test.ts | 137 +++++++++++++- .../list-ordering/NaturalAlgorithm-test.ts | 157 +++++++++++++++- test/stores/room-list/utils/roomMute-test.ts | 96 ++++++++++ 15 files changed, 765 insertions(+), 27 deletions(-) create mode 100644 src/stores/room-list/utils/roomMute.ts create mode 100644 test/stores/room-list/utils/roomMute-test.ts diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index aa0d89df796..f386f50ada8 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -182,19 +182,44 @@ function findOverrideMuteRule(roomId: string): IPushRule | null { return null; } for (const rule of cli.pushRules.global.override) { - if (rule.enabled && isRuleForRoom(roomId, rule) && isMuteRule(rule)) { + if (rule.enabled && isRuleRoomMuteRuleForRoomId(roomId, rule)) { return rule; } } return null; } -function isRuleForRoom(roomId: string, rule: IPushRule): boolean { - if (rule.conditions?.length !== 1) { +/** + * Checks if a given rule is a room mute rule as implemented by EW + * - matches every event in one room (one condition that is an event match on roomId) + * - silences notifications (one action that is `DontNotify`) + * @param rule - push rule + * @returns {boolean} - true when rule mutes a room + */ +export function isRuleMaybeRoomMuteRule(rule: IPushRule): boolean { + return ( + // matches every event in one room + rule.conditions?.length === 1 && + rule.conditions[0].kind === ConditionKind.EventMatch && + rule.conditions[0].key === "room_id" && + // silences notifications + isMuteRule(rule) + ); +} + +/** + * Checks if a given rule is a room mute rule as implemented by EW + * @param roomId - id of room to match + * @param rule - push rule + * @returns {boolean} true when rule mutes the given room + */ +function isRuleRoomMuteRuleForRoomId(roomId: string, rule: IPushRule): boolean { + if (!isRuleMaybeRoomMuteRule(rule)) { return false; } - const cond = rule.conditions[0]; - return cond.kind === ConditionKind.EventMatch && cond.key === "room_id" && cond.pattern === roomId; + // isRuleMaybeRoomMuteRule checks this condition exists + const cond = rule.conditions![0]!; + return cond.pattern === roomId; } function isMuteRule(rule: IPushRule): boolean { diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index bb40a463baa..3cc1828a565 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -48,6 +48,7 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState: * @property {MatrixEvent} event the MatrixEvent that triggered the dispatch. * @property {string} event_type the type of the MatrixEvent, e.g. "m.direct". * @property {Object} event_content the content of the MatrixEvent. + * @property {MatrixEvent} previousEvent the previous account data event of the same type, if present */ /** @@ -56,14 +57,20 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState: * * @param {MatrixClient} matrixClient the matrix client. * @param {MatrixEvent} accountDataEvent the account data event. + * @param {MatrixEvent | undefined} previousAccountDataEvent the previous account data event of the same type, if present * @returns {AccountDataAction} an action of type MatrixActions.accountData. */ -function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload { +function createAccountDataAction( + matrixClient: MatrixClient, + accountDataEvent: MatrixEvent, + previousAccountDataEvent?: MatrixEvent, +): ActionPayload { return { action: "MatrixActions.accountData", event: accountDataEvent, event_type: accountDataEvent.getType(), event_content: accountDataEvent.getContent(), + previousEvent: previousAccountDataEvent, }; } diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts index f89bb1728d1..ab7097265ed 100644 --- a/src/stores/notifications/NotificationColor.ts +++ b/src/stores/notifications/NotificationColor.ts @@ -17,6 +17,7 @@ limitations under the License. import { _t } from "../../languageHandler"; export enum NotificationColor { + Muted, // Inverted (None -> Red) because we do integer comparisons on this None, // nothing special // TODO: Remove bold with notifications: https://github.com/vector-im/element-web/issues/14227 diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index 2445ec6d361..b4db29c1354 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -24,6 +24,7 @@ export interface INotificationStateSnapshotParams { symbol: string | null; count: number; color: NotificationColor; + muted: boolean; } export enum NotificationStateEvents { @@ -42,6 +43,7 @@ export abstract class NotificationState protected _symbol: string | null = null; protected _count = 0; protected _color: NotificationColor = NotificationColor.None; + protected _muted = false; private watcherReferences: string[] = []; @@ -66,6 +68,10 @@ export abstract class NotificationState return this._color; } + public get muted(): boolean { + return this._muted; + } + public get isIdle(): boolean { return this.color <= NotificationColor.None; } @@ -110,16 +116,18 @@ export class NotificationStateSnapshot { private readonly symbol: string | null; private readonly count: number; private readonly color: NotificationColor; + private readonly muted: boolean; public constructor(state: INotificationStateSnapshotParams) { this.symbol = state.symbol; this.count = state.count; this.color = state.color; + this.muted = state.muted; } public isDifferentFrom(other: INotificationStateSnapshotParams): boolean { - const before = { count: this.count, symbol: this.symbol, color: this.color }; - const after = { count: other.count, symbol: other.symbol, color: other.color }; + const before = { count: this.count, symbol: this.symbol, color: this.color, muted: this.muted }; + const after = { count: other.count, symbol: other.symbol, color: other.color, muted: other.muted }; return JSON.stringify(before) !== JSON.stringify(after); } } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index cde911f7021..3c0447a1434 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -93,9 +93,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy const snapshot = this.snapshot(); const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room); + const muted = + RoomNotifs.getRoomNotifsState(this.room.client, this.room.roomId) === RoomNotifs.RoomNotifState.Mute; this._color = color; this._symbol = symbol; this._count = count; + this._muted = muted; // finally, publish an update if needed this.emitIfUpdated(snapshot); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index cb7ce972a86..2048aad080a 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -40,6 +40,7 @@ import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute"; interface IState { // state is tracked in underlying classes @@ -289,6 +290,17 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.onDispatchMyMembership(payload); return; } + + const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload); + if (possibleMuteChangeRoomIds) { + for (const roomId of possibleMuteChangeRoomIds) { + const room = roomId && this.matrixClient.getRoom(roomId); + if (room) { + await this.handleRoomUpdate(room, RoomUpdateCause.PossibleMuteChange); + } + } + this.updateFn.trigger(); + } } /** diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index e0dfb5adca3..d29d48f7ea0 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -42,6 +42,7 @@ const CATEGORY_ORDER = [ NotificationColor.Grey, NotificationColor.Bold, NotificationColor.None, // idle + NotificationColor.Muted, ]; /** @@ -81,6 +82,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { [NotificationColor.Grey]: [], [NotificationColor.Bold]: [], [NotificationColor.None]: [], + [NotificationColor.Muted]: [], }; for (const room of rooms) { const category = this.getRoomCategory(room); @@ -94,7 +96,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { // It's fine for us to call this a lot because it's cached, and we shouldn't be // wasting anything by doing so as the store holds single references const state = RoomNotificationStateStore.instance.getRoomState(room); - return state.color; + return this.isMutedToBottom && state.muted ? NotificationColor.Muted : state.color; } public setRooms(rooms: Room[]): void { @@ -164,15 +166,25 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { return this.handleSplice(room, cause); } - if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) { + if ( + cause !== RoomUpdateCause.Timeline && + cause !== RoomUpdateCause.ReadReceipt && + cause !== RoomUpdateCause.PossibleMuteChange + ) { throw new Error(`Unsupported update cause: ${cause}`); } - const category = this.getRoomCategory(room); + // don't react to mute changes when we are not sorting by mute + if (cause === RoomUpdateCause.PossibleMuteChange && !this.isMutedToBottom) { + return false; + } + if (this.sortingAlgorithm === SortAlgorithm.Manual) { return false; // Nothing to do here. } + const category = this.getRoomCategory(room); + const roomIdx = this.getRoomIndex(room); if (roomIdx === -1) { throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`); diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index 61a3d29dd2b..36019599503 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -21,42 +21,191 @@ import { SortAlgorithm } from "../models"; import { sortRoomsWithAlgorithm } from "../tag-sorting"; import { OrderingAlgorithm } from "./OrderingAlgorithm"; import { RoomUpdateCause, TagID } from "../../models"; +import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; + +type NaturalCategorizedRoomMap = { + defaultRooms: Room[]; + mutedRooms: Room[]; +}; /** * Uses the natural tag sorting algorithm order to determine tag ordering. No * additional behavioural changes are present. */ export class NaturalAlgorithm extends OrderingAlgorithm { + private cachedCategorizedOrderedRooms: NaturalCategorizedRoomMap = { + defaultRooms: [], + mutedRooms: [], + }; public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { super(tagId, initialSortingAlgorithm); } public setRooms(rooms: Room[]): void { - this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm); + const { defaultRooms, mutedRooms } = this.categorizeRooms(rooms); + + this.cachedCategorizedOrderedRooms = { + defaultRooms: sortRoomsWithAlgorithm(defaultRooms, this.tagId, this.sortingAlgorithm), + mutedRooms: sortRoomsWithAlgorithm(mutedRooms, this.tagId, this.sortingAlgorithm), + }; + this.buildCachedOrderedRooms(); } public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean { const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved; - const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt; + const isInPlace = + cause === RoomUpdateCause.Timeline || + cause === RoomUpdateCause.ReadReceipt || + cause === RoomUpdateCause.PossibleMuteChange; + const isMuted = this.isMutedToBottom && this.getRoomIsMuted(room); + if (!isSplice && !isInPlace) { throw new Error(`Unsupported update cause: ${cause}`); } if (cause === RoomUpdateCause.NewRoom) { - this.cachedOrderedRooms.push(room); + if (isMuted) { + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.mutedRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + } else { + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.defaultRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + } + this.buildCachedOrderedRooms(); + return true; } else if (cause === RoomUpdateCause.RoomRemoved) { - const idx = this.getRoomIndex(room); - if (idx >= 0) { - this.cachedOrderedRooms.splice(idx, 1); + return this.removeRoom(room); + } else if (cause === RoomUpdateCause.PossibleMuteChange) { + if (this.isMutedToBottom) { + return this.onPossibleMuteChange(room); } else { - logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + return false; } } // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457 // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags - this.cachedOrderedRooms = sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm); - + if (isMuted) { + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + this.cachedCategorizedOrderedRooms.mutedRooms, + this.tagId, + this.sortingAlgorithm, + ); + } else { + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + this.cachedCategorizedOrderedRooms.defaultRooms, + this.tagId, + this.sortingAlgorithm, + ); + } + this.buildCachedOrderedRooms(); return true; } + + /** + * Remove a room from the cached room list + * @param room Room to remove + * @returns {boolean} true when room list should update as result + */ + private removeRoom(room: Room): boolean { + const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex((r) => r.roomId === room.roomId); + if (defaultIndex > -1) { + this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1); + this.buildCachedOrderedRooms(); + return true; + } + const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId); + if (mutedIndex > -1) { + this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1); + this.buildCachedOrderedRooms(); + return true; + } + + logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + // room was not in cached lists, no update + return false; + } + + /** + * Sets cachedOrderedRooms from cachedCategorizedOrderedRooms + */ + private buildCachedOrderedRooms(): void { + this.cachedOrderedRooms = [ + ...this.cachedCategorizedOrderedRooms.defaultRooms, + ...this.cachedCategorizedOrderedRooms.mutedRooms, + ]; + } + + private getRoomIsMuted(room: Room): boolean { + // It's fine for us to call this a lot because it's cached, and we shouldn't be + // wasting anything by doing so as the store holds single references + const state = RoomNotificationStateStore.instance.getRoomState(room); + return state.muted; + } + + private categorizeRooms(rooms: Room[]): NaturalCategorizedRoomMap { + if (!this.isMutedToBottom) { + return { defaultRooms: rooms, mutedRooms: [] }; + } + return rooms.reduce( + (acc, room: Room) => { + if (this.getRoomIsMuted(room)) { + acc.mutedRooms.push(room); + } else { + acc.defaultRooms.push(room); + } + return acc; + }, + { defaultRooms: [], mutedRooms: [] } as NaturalCategorizedRoomMap, + ); + } + + private onPossibleMuteChange(room: Room): boolean { + const isMuted = this.getRoomIsMuted(room); + if (isMuted) { + const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex( + (r) => r.roomId === room.roomId, + ); + + // room has been muted + if (defaultIndex > -1) { + // remove from the default list + this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1); + // add to muted list and reorder + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.mutedRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + // rebuild + this.buildCachedOrderedRooms(); + return true; + } + } else { + const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId); + + // room has been unmuted + if (mutedIndex > -1) { + // remove from the muted list + this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1); + // add to default list and reorder + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.defaultRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + // rebuild + this.buildCachedOrderedRooms(); + return true; + } + } + + return false; + } } diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts index 6cf8b4606fd..2bcb5b52899 100644 --- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts @@ -42,6 +42,10 @@ export abstract class OrderingAlgorithm { return this.cachedOrderedRooms; } + public get isMutedToBottom(): boolean { + return this.sortingAlgorithm === SortAlgorithm.Recent; + } + /** * Sets the sorting algorithm to use within the list. * @param newAlgorithm The new algorithm. Must be defined. diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts index 0345ff60534..d9f17cc2713 100644 --- a/src/stores/room-list/models.ts +++ b/src/stores/room-list/models.ts @@ -43,6 +43,7 @@ export type TagID = string | DefaultTagID; export enum RoomUpdateCause { Timeline = "TIMELINE", PossibleTagChange = "POSSIBLE_TAG_CHANGE", + PossibleMuteChange = "POSSIBLE_MUTE_CHANGE", ReadReceipt = "READ_RECEIPT", NewRoom = "NEW_ROOM", RoomRemoved = "ROOM_REMOVED", diff --git a/src/stores/room-list/utils/roomMute.ts b/src/stores/room-list/utils/roomMute.ts new file mode 100644 index 00000000000..d5b1c3520ff --- /dev/null +++ b/src/stores/room-list/utils/roomMute.ts @@ -0,0 +1,54 @@ +/* +Copyright 2023 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 { MatrixEvent, EventType, IPushRules } from "matrix-js-sdk/src/matrix"; + +import { ActionPayload } from "../../../dispatcher/payloads"; +import { isRuleMaybeRoomMuteRule } from "../../../RoomNotifs"; +import { arrayDiff } from "../../../utils/arrays"; + +/** + * Gets any changed push rules that are room specific overrides + * that mute notifications + * @param actionPayload + * @returns {string[]} ruleIds of added or removed rules + */ +export const getChangedOverrideRoomMutePushRules = (actionPayload: ActionPayload): string[] | undefined => { + if ( + actionPayload.action !== "MatrixActions.accountData" || + actionPayload.event?.getType() !== EventType.PushRules + ) { + return undefined; + } + const event = actionPayload.event as MatrixEvent; + const prevEvent = actionPayload.previousEvent as MatrixEvent | undefined; + + if (!event || !prevEvent) { + return undefined; + } + + const roomPushRules = (event.getContent() as IPushRules)?.global?.override?.filter(isRuleMaybeRoomMuteRule); + const prevRoomPushRules = (prevEvent?.getContent() as IPushRules)?.global?.override?.filter( + isRuleMaybeRoomMuteRule, + ); + + const { added, removed } = arrayDiff( + prevRoomPushRules?.map((rule) => rule.rule_id) || [], + roomPushRules?.map((rule) => rule.rule_id) || [], + ); + + return [...added, ...removed]; +}; diff --git a/test/stores/room-list/RoomListStore-test.ts b/test/stores/room-list/RoomListStore-test.ts index 5c6435780e2..aaec6942132 100644 --- a/test/stores/room-list/RoomListStore-test.ts +++ b/test/stores/room-list/RoomListStore-test.ts @@ -14,16 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; - -import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import { + ConditionKind, + EventType, + IPushRule, + MatrixEvent, + PendingEventOrdering, + PushRuleActionName, + Room, +} from "matrix-js-sdk/src/matrix"; + +import defaultDispatcher, { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import SettingsStore, { CallbackFn } from "../../../src/settings/SettingsStore"; import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models"; import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; -import { stubClient, upsertRoomStateEvents } from "../../test-utils"; +import { flushPromises, stubClient, upsertRoomStateEvents } from "../../test-utils"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../test-utils/pushRules"; describe("RoomListStore", () => { const client = stubClient(); @@ -69,12 +78,15 @@ describe("RoomListStore", () => { }); upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]); const oldRoom = new Room(oldRoomId, client, userId, {}); + const normalRoom = new Room("!normal:server.org", client, userId); client.getRoom = jest.fn().mockImplementation((roomId) => { switch (roomId) { case newRoomId: return roomWithCreatePredecessor; case oldRoomId: return oldRoom; + case normalRoom.roomId: + return normalRoom; default: return null; } @@ -274,4 +286,70 @@ describe("RoomListStore", () => { expect(client.getVisibleRooms).toHaveBeenCalledTimes(1); }); }); + + describe("room updates", () => { + const makeStore = async () => { + const store = new RoomListStoreClass(defaultDispatcher); + await store.start(); + return store; + }; + + describe("push rules updates", () => { + const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => { + return new MatrixEvent({ + type: EventType.PushRules, + content: { + global: { + ...DEFAULT_PUSH_RULES.global, + override: overrideRules, + }, + }, + }); + }; + + it("triggers a room update when room mutes have changed", async () => { + const rule = makePushRule(normalRoom.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], + }); + const event = makePushRulesEvent([rule]); + const previousEvent = makePushRulesEvent(); + + const store = await makeStore(); + // @ts-ignore private property alg + const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); + // @ts-ignore cheat and call protected fn + store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); + // flush setImmediate + await flushPromises(); + + expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); + }); + + it("handles when a muted room is unknown by the room list", async () => { + const rule = makePushRule(normalRoom.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], + }); + const unknownRoomRule = makePushRule("!unknown:server.org", { + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }], + }); + const event = makePushRulesEvent([unknownRoomRule, rule]); + const previousEvent = makePushRulesEvent(); + + const store = await makeStore(); + // @ts-ignore private property alg + const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); + + // @ts-ignore cheat and call protected fn + store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); + // flush setImmediate + await flushPromises(); + + // only one call to update made for normalRoom + expect(algorithmSpy).toHaveBeenCalledTimes(1); + expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); + }); + }); + }); }); diff --git a/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts index 6db3c369fb0..7ffc34d4eb9 100644 --- a/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts +++ b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { ConditionKind, MatrixEvent, PushRuleActionName, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; @@ -25,6 +25,8 @@ import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-li import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; +import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../test-utils/pushRules"; describe("ImportanceAlgorithm", () => { const userId = "@alice:server.org"; @@ -57,6 +59,21 @@ describe("ImportanceAlgorithm", () => { const roomE = makeRoom("!eee:server.org", "Echo", 3); const roomX = makeRoom("!xxx:server.org", "Xylophone", 99); + const muteRoomARule = makePushRule(roomA.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }], + }); + const muteRoomBRule = makePushRule(roomB.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomB.roomId }], + }); + client.pushRules = { + global: { + ...DEFAULT_PUSH_RULES.global, + override: [...DEFAULT_PUSH_RULES.global.override!, muteRoomARule, muteRoomBRule], + }, + }; + const unreadStates: Record> = { red: { symbol: null, count: 1, color: NotificationColor.Red }, grey: { symbol: null, count: 1, color: NotificationColor.Grey }, @@ -240,6 +257,18 @@ describe("ImportanceAlgorithm", () => { ).toThrow("Unsupported update cause: something unexpected"); }); + it("ignores a mute change", () => { + // muted rooms are not pushed to the bottom when sort is alpha + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + // no sorting + expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + describe("time and read receipt updates", () => { it("throws for when a room is not indexed", () => { const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); @@ -295,4 +324,110 @@ describe("ImportanceAlgorithm", () => { }); }); }); + + describe("When sortAlgorithm is recent", () => { + const sortAlgorithm = SortAlgorithm.Recent; + + // mock recent algorithm sorting + const fakeRecentOrder = [roomC, roomB, roomE, roomD, roomA]; + + beforeEach(async () => { + // destroy roomMap so we can start fresh + // @ts-ignore private property + RoomNotificationStateStore.instance.roomMap = new Map(); + + jest.spyOn(RecentAlgorithm.prototype, "sortRooms") + .mockClear() + .mockImplementation((rooms: Room[]) => + fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)), + ); + jest.spyOn(RoomNotifs, "determineUnreadState") + .mockClear() + .mockImplementation((room) => { + switch (room) { + // b, c and e have red notifs + case roomB: + case roomE: + case roomC: + return unreadStates.red; + default: + return unreadStates.none; + } + }); + }); + + it("orders rooms by recent when they have the same notif state", () => { + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to recent + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]); + }); + + it("orders rooms by notification state then recent", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + + expect(algorithm.orderedRooms).toEqual([ + // recent within red + roomC, + roomE, + // recent within none + roomD, + // muted + roomB, + roomA, + ]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB]); + // no re-sorting on a remove + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("warns and returns without change when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // inserted according to notif state and mute + expect(algorithm.orderedRooms).toEqual([roomC, roomE, roomB, roomA]); + // only sorted within category + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomE, roomC], tagId); + }); + + it("re-sorts on a mute change", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(true); + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomE], tagId); + }); + }); + }); }); diff --git a/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts index 21879586b38..46bef644cf4 100644 --- a/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts +++ b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts @@ -14,14 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/matrix"; +import { ConditionKind, EventType, MatrixEvent, PushRuleActionName, Room } from "matrix-js-sdk/src/matrix"; +import { ClientEvent } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { NaturalAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm"; import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models"; import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models"; import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; +import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; +import * as RoomNotifs from "../../../../../src/RoomNotifs"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../test-utils/pushRules"; +import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; describe("NaturalAlgorithm", () => { const userId = "@alice:server.org"; @@ -43,6 +49,21 @@ describe("NaturalAlgorithm", () => { const roomE = makeRoom("!eee:server.org", "Echo"); const roomX = makeRoom("!xxx:server.org", "Xylophone"); + const muteRoomARule = makePushRule(roomA.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }], + }); + const muteRoomDRule = makePushRule(roomD.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomD.roomId }], + }); + client.pushRules = { + global: { + ...DEFAULT_PUSH_RULES.global, + override: [...DEFAULT_PUSH_RULES.global!.override!, muteRoomARule, muteRoomDRule], + }, + }; + const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => { const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm); algorithm.setRooms(rooms || [roomA, roomB, roomC]); @@ -80,7 +101,7 @@ describe("NaturalAlgorithm", () => { const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); - expect(shouldTriggerUpdate).toBe(true); + expect(shouldTriggerUpdate).toBe(false); expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); }); @@ -99,6 +120,29 @@ describe("NaturalAlgorithm", () => { ); }); + it("adds a new muted room", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomA, roomB, roomE]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // muted room mixed in main category + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomD, roomE]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + }); + + it("ignores a mute change update", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + it("throws for an unhandled update cause", () => { const algorithm = setupAlgorithm(sortAlgorithm); @@ -133,4 +177,113 @@ describe("NaturalAlgorithm", () => { }); }); }); + + describe("When sortAlgorithm is recent", () => { + const sortAlgorithm = SortAlgorithm.Recent; + + // mock recent algorithm sorting + const fakeRecentOrder = [roomC, roomA, roomB, roomD, roomE]; + + beforeEach(async () => { + // destroy roomMap so we can start fresh + // @ts-ignore private property + RoomNotificationStateStore.instance.roomMap = new Map(); + + jest.spyOn(RecentAlgorithm.prototype, "sortRooms") + .mockClear() + .mockImplementation((rooms: Room[]) => + fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)), + ); + + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + }); + + it("orders rooms by recent with muted rooms to the bottom", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to recent + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB]); + // no re-sorting on a remove + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("warns and returns without change when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // inserted according to mute then recentness + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomE, roomA]); + // only sorted within category, muted roomA is not resorted + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomB, roomE], tagId); + }); + + it("does not re-sort on possible mute change when room did not change effective mutedness", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("re-sorts on a mute change", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + // mute roomE + const muteRoomERule = makePushRule(roomE.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomE.roomId }], + }); + const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules }); + client.pushRules!.global!.override!.push(muteRoomERule); + client.emit(ClientEvent.AccountData, pushRulesEvent); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([ + // unmuted, sorted by recent + roomC, + roomB, + // muted, sorted by recent + roomA, + roomD, + roomE, + ]); + // only sorted muted category + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId); + }); + }); + }); }); diff --git a/test/stores/room-list/utils/roomMute-test.ts b/test/stores/room-list/utils/roomMute-test.ts new file mode 100644 index 00000000000..b7f8442fb9d --- /dev/null +++ b/test/stores/room-list/utils/roomMute-test.ts @@ -0,0 +1,96 @@ +/* +Copyright 2023 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 { ConditionKind, EventType, IPushRule, MatrixEvent, PushRuleActionName } from "matrix-js-sdk/src/matrix"; + +import { getChangedOverrideRoomMutePushRules } from "../../../../src/stores/room-list/utils/roomMute"; +import { DEFAULT_PUSH_RULES, getDefaultRuleWithKind, makePushRule } from "../../../test-utils/pushRules"; + +describe("getChangedOverrideRoomMutePushRules()", () => { + const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => { + return new MatrixEvent({ + type: EventType.PushRules, + content: { + global: { + ...DEFAULT_PUSH_RULES.global, + override: overrideRules, + }, + }, + }); + }; + + it("returns undefined when dispatched action is not accountData", () => { + const action = { action: "MatrixActions.Event.decrypted", event: new MatrixEvent({}) }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when dispatched action is not pushrules", () => { + const action = { action: "MatrixActions.accountData", event: new MatrixEvent({ type: "not-push-rules" }) }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when actions event is falsy", () => { + const action = { action: "MatrixActions.accountData" }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when actions previousEvent is falsy", () => { + const pushRulesEvent = makePushRulesEvent(); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("filters out non-room specific rules", () => { + // an override rule that exists in default rules + const { rule } = getDefaultRuleWithKind(".m.rule.contains_display_name"); + const updatedRule = { + ...rule, + actions: [PushRuleActionName.DontNotify], + enabled: false, + }; + const previousEvent = makePushRulesEvent([rule]); + const pushRulesEvent = makePushRulesEvent([updatedRule]); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([]); + }); + + it("returns ruleIds for added room rules", () => { + const roomId1 = "!room1:server.org"; + const rule = makePushRule(roomId1, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }], + }); + const previousEvent = makePushRulesEvent(); + const pushRulesEvent = makePushRulesEvent([rule]); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]); + }); + + it("returns ruleIds for removed room rules", () => { + const roomId1 = "!room1:server.org"; + const rule = makePushRule(roomId1, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }], + }); + const previousEvent = makePushRulesEvent([rule]); + const pushRulesEvent = makePushRulesEvent(); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]); + }); +}); From 542bf68c6368066d9becbde3fd2ded9d6cac4d56 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 5 May 2023 15:41:42 +1200 Subject: [PATCH 025/253] Fix: reveal images when image previews are disabled (#10781) * fix image wrapping when showImage previews is disabled * strict v2 --- src/components/views/messages/MImageBody.tsx | 25 ++++++--- .../views/messages/MImageBody-test.tsx | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 84e4e763de0..27ccdd42288 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -389,7 +389,7 @@ export default class MImageBody extends React.Component { thumbUrl: string | null, content: IMediaEventContent, forcedHeight?: number, - ): JSX.Element { + ): ReactNode { if (!thumbUrl) thumbUrl = contentUrl; // fallback // magic number @@ -524,16 +524,25 @@ export default class MImageBody extends React.Component {
      ); - return contentUrl ? this.wrapImage(contentUrl, thumbnail) : thumbnail; + return this.wrapImage(contentUrl, thumbnail); } // Overridden by MStickerBody - protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { - return ( - - {children} - - ); + protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { + if (contentUrl) { + return ( + + {children} + + ); + } else if (!this.state.showImage) { + return ( +
      + {children} +
      + ); + } + return children; } // Overridden by MStickerBody diff --git a/test/components/views/messages/MImageBody-test.tsx b/test/components/views/messages/MImageBody-test.tsx index 6534828bd65..ca400f13ee1 100644 --- a/test/components/views/messages/MImageBody-test.tsx +++ b/test/components/views/messages/MImageBody-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import fetchMock from "fetch-mock-jest"; import encrypt from "matrix-encrypt-attachment"; @@ -31,6 +31,7 @@ import { mockClientMethodsUser, } from "../../../test-utils"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; +import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("matrix-encrypt-attachment", () => ({ decryptAttachment: jest.fn(), @@ -61,6 +62,7 @@ describe("", () => { sender: userId, type: EventType.RoomMessage, content: { + body: "alt for a test image", info: { w: 40, h: 50, @@ -70,12 +72,18 @@ describe("", () => { }, }, }); + const props = { onHeightChanged: jest.fn(), onMessageAllowed: jest.fn(), permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)), }; + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockRestore(); + fetchMock.mockReset(); + }); + it("should show a thumbnail while image is being downloaded", async () => { fetchMock.getOnce(url, { status: 200 }); @@ -102,6 +110,8 @@ describe("", () => { />, ); + expect(fetchMock).toHaveBeenCalledWith(url); + await screen.findByText("Error downloading image"); }); @@ -119,4 +129,46 @@ describe("", () => { await screen.findByText("Error decrypting image"); }); + + describe("with image previews/thumbnails disabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + }); + + it("should not download image", async () => { + fetchMock.getOnce(url, { status: 200 }); + + render( + , + ); + + expect(fetchMock).not.toHaveFetched(url); + }); + + it("should render hidden image placeholder", async () => { + fetchMock.getOnce(url, { status: 200 }); + + render( + , + ); + + expect(screen.getByText("Show image")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button")); + + // image fetched after clicking show image + expect(fetchMock).toHaveFetched(url); + + // spinner while downloading image + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + }); }); From a4f0b8069231c666999596879f21a8e992c4a96a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 5 May 2023 09:11:14 +0100 Subject: [PATCH 026/253] Improve quality of Typescript types (#10742) --- src/DeviceListener.ts | 4 +-- src/IdentityAuthClient.tsx | 4 +-- src/MatrixClientPeg.ts | 2 +- src/NodeAnimator.tsx | 2 +- src/audio/VoiceRecording.ts | 34 +++++++++---------- src/autocomplete/AutocompleteProvider.tsx | 4 +-- src/autocomplete/QueryMatcher.ts | 2 +- src/autocomplete/UserProvider.tsx | 4 +-- src/components/views/auth/PasswordLogin.tsx | 6 ++-- .../views/auth/RegistrationForm.tsx | 10 +++--- .../views/avatars/DecoratedRoomAvatar.tsx | 2 +- src/components/views/rooms/Autocomplete.tsx | 10 +++--- .../views/rooms/BasicMessageComposer.tsx | 2 +- src/components/views/rooms/Stickerpicker.tsx | 4 +-- .../views/settings/ThemeChoicePanel.tsx | 2 +- .../tabs/user/SecurityUserSettingsTab.tsx | 4 +-- .../views/toasts/VerificationRequestToast.tsx | 2 +- src/components/views/voip/LegacyCallView.tsx | 4 +-- src/components/views/voip/VideoFeed.tsx | 2 +- src/editor/autocomplete.ts | 2 +- src/integrations/IntegrationManagers.ts | 4 +-- src/models/LocalRoom.ts | 2 +- src/models/RoomUpload.ts | 2 +- src/stores/ActiveWidgetStore.ts | 4 +-- src/stores/OwnProfileStore.ts | 2 +- src/stores/local-echo/GenericEchoChamber.ts | 2 +- src/utils/LazyValue.ts | 6 ++-- src/utils/ValidatedServerConfig.ts | 16 ++++----- src/widgets/Jitsi.ts | 2 +- .../structures/auth/ForgotPassword-test.tsx | 3 +- 30 files changed, 74 insertions(+), 75 deletions(-) diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index afec1f62f47..ef34746e395 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -51,7 +51,7 @@ import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulk const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; export default class DeviceListener { - private dispatcherRef: string | null; + private dispatcherRef?: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); // has the user dismissed any of the various nag toasts to setup encryption on this device? @@ -119,7 +119,7 @@ export default class DeviceListener { } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); - this.dispatcherRef = null; + this.dispatcherRef = undefined; } this.dismissed.clear(); this.dismissedThisDeviceToast = false; diff --git a/src/IdentityAuthClient.tsx b/src/IdentityAuthClient.tsx index 4df9959511f..5ad918d0a37 100644 --- a/src/IdentityAuthClient.tsx +++ b/src/IdentityAuthClient.tsx @@ -34,8 +34,8 @@ import { abbreviateUrl } from "./utils/UrlUtils"; export class AbortedIdentityActionError extends Error {} export default class IdentityAuthClient { - private accessToken: string; - private tempClient: MatrixClient; + private accessToken: string | null = null; + private tempClient?: MatrixClient; private authEnabled = true; /** diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 3aae805faeb..52d91dc9d13 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -139,7 +139,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { // the credentials used to init the current client object. // used if we tear it down & recreate it with a different store - private currentClientCreds: IMatrixClientCreds; + private currentClientCreds: IMatrixClientCreds | null = null; public get(): MatrixClient { return this.matrixClient; diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index f793b90a924..2a496fa4b26 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -42,7 +42,7 @@ interface IProps { */ export default class NodeAnimator extends React.Component { private nodes: Record = {}; - private children: { [key: string]: ReactElement }; + private children: { [key: string]: ReactElement } = {}; public static defaultProps: Partial = { startStyles: [], }; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index f4d7905c333..033c5da475e 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -66,14 +66,14 @@ export enum RecordingState { } export class VoiceRecording extends EventEmitter implements IDestroyable { - private recorder: Recorder; - private recorderContext: AudioContext; - private recorderSource: MediaStreamAudioSourceNode; - private recorderStream: MediaStream; - private recorderWorklet: AudioWorkletNode; - private recorderProcessor: ScriptProcessorNode; + private recorder?: Recorder; + private recorderContext?: AudioContext; + private recorderSource?: MediaStreamAudioSourceNode; + private recorderStream?: MediaStream; + private recorderWorklet?: AudioWorkletNode; + private recorderProcessor?: ScriptProcessorNode; private recording = false; - private observable: SimpleObservable; + private observable?: SimpleObservable; private targetMaxLength: number | null = TARGET_MAX_LENGTH; public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); @@ -84,7 +84,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } public get durationSeconds(): number { - if (!this.recorder) throw new Error("Duration not available without a recording"); + if (!this.recorder || !this.recorderContext) throw new Error("Duration not available without a recording"); return this.recorderContext.currentTime; } @@ -203,7 +203,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } public get liveData(): SimpleObservable { - if (!this.recording) throw new Error("No observable when not recording"); + if (!this.recording || !this.observable) throw new Error("No observable when not recording"); return this.observable; } @@ -221,7 +221,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private processAudioUpdate = (timeSeconds: number): void => { if (!this.recording) return; - this.observable.update({ + this.observable!.update({ waveform: this.liveWaveform.value.map((v) => clamp(v, 0, 1)), timeSeconds: timeSeconds, }); @@ -272,7 +272,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } this.observable = new SimpleObservable(); await this.makeRecorder(); - await this.recorder.start(); + await this.recorder?.start(); this.recording = true; this.emit(RecordingState.Started); } @@ -284,8 +284,8 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } // Disconnect the source early to start shutting down resources - await this.recorder.stop(); // stop first to flush the last frame - this.recorderSource.disconnect(); + await this.recorder!.stop(); // stop first to flush the last frame + this.recorderSource!.disconnect(); if (this.recorderWorklet) this.recorderWorklet.disconnect(); if (this.recorderProcessor) { this.recorderProcessor.disconnect(); @@ -294,14 +294,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // close the context after the recorder so the recorder doesn't try to // connect anything to the context (this would generate a warning) - await this.recorderContext.close(); + await this.recorderContext!.close(); // Now stop all the media tracks so we can release them back to the user/OS - this.recorderStream.getTracks().forEach((t) => t.stop()); + this.recorderStream!.getTracks().forEach((t) => t.stop()); // Finally do our post-processing and clean up this.recording = false; - await this.recorder.close(); + await this.recorder!.close(); this.emit(RecordingState.Ended); }); } @@ -313,6 +313,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.onDataAvailable = undefined; Singleflight.forgetAllFor(this); // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here - this.observable.close(); + this.observable?.close(); } } diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index 0b1fe2dd9e8..ace9f541e3c 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -36,8 +36,8 @@ export interface IAutocompleteOptions { } export default abstract class AutocompleteProvider { - public commandRegex: RegExp; - public forcedCommandRegex: RegExp; + public commandRegex?: RegExp; + public forcedCommandRegex?: RegExp; protected renderingType: TimelineRenderingType = TimelineRenderingType.Room; diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 031f0b0122f..ba052d19015 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -47,7 +47,7 @@ interface IOptions { */ export default class QueryMatcher { private _options: IOptions; - private _items: Map; + private _items = new Map(); public constructor(objects: T[], options: IOptions = { keys: [] }) { this._options = options; diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 0ba5f656d8f..22bd307b473 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -44,7 +44,7 @@ const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g; export default class UserProvider extends AutocompleteProvider { public matcher: QueryMatcher; - public users: RoomMember[] | null; + public users?: RoomMember[]; public room: Room; public constructor(room: Room, renderingType?: TimelineRenderingType) { @@ -98,7 +98,7 @@ export default class UserProvider extends AutocompleteProvider { if (state.roomId !== this.room.roomId) return; // blow away the users cache - this.users = null; + this.users = undefined; }; public async getCompletions( diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 727505551ac..bbab2242f49 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -67,9 +67,9 @@ const enum LoginField { * The email/username/phone fields are fully-controlled, the password field is not. */ export default class PasswordLogin extends React.PureComponent { - private [LoginField.Email]: Field | null; - private [LoginField.Phone]: Field | null; - private [LoginField.MatrixId]: Field | null; + private [LoginField.Email]: Field | null = null; + private [LoginField.Phone]: Field | null = null; + private [LoginField.MatrixId]: Field | null = null; public static defaultProps = { onUsernameChanged: function () {}, diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 17d540b4ed6..1906365e6f8 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -95,11 +95,11 @@ interface IState { * A pure UI component which displays a registration form. */ export default class RegistrationForm extends React.PureComponent { - private [RegistrationField.Email]: Field | null; - private [RegistrationField.Password]: Field | null; - private [RegistrationField.PasswordConfirm]: Field | null; - private [RegistrationField.Username]: Field | null; - private [RegistrationField.PhoneNumber]: Field | null; + private [RegistrationField.Email]: Field | null = null; + private [RegistrationField.Password]: Field | null = null; + private [RegistrationField.PasswordConfirm]: Field | null = null; + private [RegistrationField.Username]: Field | null = null; + private [RegistrationField.PhoneNumber]: Field | null = null; public static defaultProps = { onValidationChange: logger.error, diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 953ffc2288a..3756e7c41ad 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -78,7 +78,7 @@ function tooltipText(variant: Icon): string | undefined { } export default class DecoratedRoomAvatar extends React.PureComponent { - private _dmUser: User | null; + private _dmUser: User | null = null; private isUnmounted = false; private isWatchingTimeline = false; diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index daf96fd5083..b8f643eebb8 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -50,9 +50,9 @@ interface IState { } export default class Autocomplete extends React.PureComponent { - public autocompleter: Autocompleter; - public queryRequested: string; - public debounceCompletionsRequest: number; + public autocompleter?: Autocompleter; + public queryRequested?: string; + public debounceCompletionsRequest?: number; private containerRef = createRef(); public static contextType = RoomContext; @@ -86,7 +86,7 @@ export default class Autocomplete extends React.PureComponent { private applyNewProps(oldQuery?: string, oldRoom?: Room): void { if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { - this.autocompleter.destroy(); + this.autocompleter?.destroy(); this.autocompleter = new Autocompleter(this.props.room); } @@ -99,7 +99,7 @@ export default class Autocomplete extends React.PureComponent { } public componentWillUnmount(): void { - this.autocompleter.destroy(); + this.autocompleter?.destroy(); } private complete(query: string, selection: ISelectionRange): Promise { diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index fa45e56d199..6a66d52eb20 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -132,7 +132,7 @@ export default class BasicMessageEditor extends React.Component private _isCaretAtEnd: boolean; private lastCaret: DocumentOffset; - private lastSelection: ReturnType | null; + private lastSelection: ReturnType | null = null; private readonly useMarkdownHandle: string; private readonly emoticonSettingHandle: string; diff --git a/src/components/views/rooms/Stickerpicker.tsx b/src/components/views/rooms/Stickerpicker.tsx index 4a3e809185f..dc6c4fd34d1 100644 --- a/src/components/views/rooms/Stickerpicker.tsx +++ b/src/components/views/rooms/Stickerpicker.tsx @@ -64,9 +64,9 @@ export default class Stickerpicker extends React.PureComponent { public static currentWidget?: IWidgetEvent; - private dispatcherRef: string; + private dispatcherRef?: string; - private prevSentVisibility: boolean; + private prevSentVisibility?: boolean; private popoverWidth = 300; private popoverHeight = 300; diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index 32c411cd77e..909e020a7c8 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -49,7 +49,7 @@ interface IState extends IThemeState { } export default class ThemeChoicePanel extends React.Component { - private themeTimer: number; + private themeTimer?: number; public constructor(props: IProps) { super(props); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 9fe4528f5a8..76f243e6ab2 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -87,7 +87,7 @@ interface IState { } export default class SecurityUserSettingsTab extends React.Component { - private dispatcherRef: string; + private dispatcherRef?: string; public constructor(props: IProps) { super(props); @@ -124,7 +124,7 @@ export default class SecurityUserSettingsTab extends React.Component { - private intervalHandle: number; + private intervalHandle?: number; public constructor(props: IProps) { super(props); diff --git a/src/components/views/voip/LegacyCallView.tsx b/src/components/views/voip/LegacyCallView.tsx index 5978acb316f..aa0e24e7352 100644 --- a/src/components/views/voip/LegacyCallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -100,7 +100,7 @@ function exitFullscreen(): void { } export default class LegacyCallView extends React.Component { - private dispatcherRef: string; + private dispatcherRef?: string; private contentWrapperRef = createRef(); private buttonsRef = createRef(); @@ -137,7 +137,7 @@ export default class LegacyCallView extends React.Component { document.removeEventListener("keydown", this.onNativeKeyDown); this.updateCallListeners(this.props.call, null); - dis.unregister(this.dispatcherRef); + if (this.dispatcherRef) dis.unregister(this.dispatcherRef); } public static getDerivedStateFromProps(props: IProps): Partial { diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index c02154936f4..87c0eeb7f0c 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -52,7 +52,7 @@ interface IState { } export default class VideoFeed extends React.PureComponent { - private element: HTMLVideoElement; + private element?: HTMLVideoElement; public constructor(props: IProps) { super(props); diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 8d95ae523ba..3aed32d91fd 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -32,7 +32,7 @@ export type GetAutocompleterComponent = () => Autocomplete | null; export type UpdateQuery = (test: string) => Promise; export default class AutocompleteWrapperModel { - private partIndex: number; + private partIndex?: number; public constructor( private updateCallback: UpdateCallback, diff --git a/src/integrations/IntegrationManagers.ts b/src/integrations/IntegrationManagers.ts index da1bb62ba98..95d57a33bb0 100644 --- a/src/integrations/IntegrationManagers.ts +++ b/src/integrations/IntegrationManagers.ts @@ -39,8 +39,8 @@ export class IntegrationManagers { private static instance?: IntegrationManagers; private managers: IntegrationManagerInstance[] = []; - private client: MatrixClient; - private primaryManager: IntegrationManagerInstance | null; + private client?: MatrixClient; + private primaryManager: IntegrationManagerInstance | null = null; public static sharedInstance(): IntegrationManagers { if (!IntegrationManagers.instance) { diff --git a/src/models/LocalRoom.ts b/src/models/LocalRoom.ts index c0bb3a75deb..8c8d812d66d 100644 --- a/src/models/LocalRoom.ts +++ b/src/models/LocalRoom.ts @@ -35,7 +35,7 @@ export class LocalRoom extends Room { /** Whether the actual room should be encrypted. */ public encrypted = false; /** If the actual room has been created, this holds its ID. */ - public actualRoomId: string; + public actualRoomId?: string; /** DM chat partner */ public targets: Member[] = []; /** Callbacks that should be invoked after the actual room has been created. */ diff --git a/src/models/RoomUpload.ts b/src/models/RoomUpload.ts index c86151d13e1..feda356a295 100644 --- a/src/models/RoomUpload.ts +++ b/src/models/RoomUpload.ts @@ -20,7 +20,7 @@ import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; export class RoomUpload { public readonly abortController = new AbortController(); - public promise: Promise<{ url?: string; file?: IEncryptedFile }>; + public promise?: Promise<{ url?: string; file?: IEncryptedFile }>; private uploaded = 0; public constructor( diff --git a/src/stores/ActiveWidgetStore.ts b/src/stores/ActiveWidgetStore.ts index c09586b0ded..42e8d739ac0 100644 --- a/src/stores/ActiveWidgetStore.ts +++ b/src/stores/ActiveWidgetStore.ts @@ -39,8 +39,8 @@ export enum ActiveWidgetStoreEvent { */ export default class ActiveWidgetStore extends EventEmitter { private static internalInstance: ActiveWidgetStore; - private persistentWidgetId: string | null; - private persistentRoomId: string | null; + private persistentWidgetId: string | null = null; + private persistentRoomId: string | null = null; private dockedWidgetsByUid = new Map(); public static get instance(): ActiveWidgetStore { diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index a3563380534..db8960307bf 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -43,7 +43,7 @@ export class OwnProfileStore extends AsyncStoreWithClient { return instance; })(); - private monitoredUser: User | null; + private monitoredUser: User | null = null; private constructor() { // seed from localstorage because otherwise we won't get these values until a whole network diff --git a/src/stores/local-echo/GenericEchoChamber.ts b/src/stores/local-echo/GenericEchoChamber.ts index e1f418fbd95..2470c583da3 100644 --- a/src/stores/local-echo/GenericEchoChamber.ts +++ b/src/stores/local-echo/GenericEchoChamber.ts @@ -28,7 +28,7 @@ export const PROPERTY_UPDATED = "property_updated"; export abstract class GenericEchoChamber extends EventEmitter { private cache = new Map(); - protected matrixClient: MatrixClient | null; + protected matrixClient: MatrixClient | null = null; protected constructor(public readonly context: C, private lookupFn: (key: K) => V) { super(); diff --git a/src/utils/LazyValue.ts b/src/utils/LazyValue.ts index b9de7e5ad74..2497a771d74 100644 --- a/src/utils/LazyValue.ts +++ b/src/utils/LazyValue.ts @@ -18,8 +18,8 @@ limitations under the License. * Utility class for lazily getting a variable. */ export class LazyValue { - private val: T; - private prom: Promise; + private val?: T; + private prom?: Promise; private done = false; public constructor(private getFn: () => Promise) {} @@ -36,7 +36,7 @@ export class LazyValue { * Gets the value without invoking a get. May be undefined until the * value is fetched properly. */ - public get cachedValue(): T { + public get cachedValue(): T | undefined { return this.val; } diff --git a/src/utils/ValidatedServerConfig.ts b/src/utils/ValidatedServerConfig.ts index 7d2325d5105..bac271eef6a 100644 --- a/src/utils/ValidatedServerConfig.ts +++ b/src/utils/ValidatedServerConfig.ts @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class ValidatedServerConfig { - public hsUrl: string; - public hsName: string; - public hsNameIsDifferent: boolean; +export interface ValidatedServerConfig { + hsUrl: string; + hsName: string; + hsNameIsDifferent: boolean; - public isUrl: string; + isUrl: string; - public isDefault: boolean; + isDefault: boolean; // when the server config is based on static URLs the hsName is not resolvable and things may wish to use hsUrl - public isNameResolvable: boolean; + isNameResolvable: boolean; - public warning: string | Error; + warning: string | Error; } diff --git a/src/widgets/Jitsi.ts b/src/widgets/Jitsi.ts index 6dca3b3de7b..af250e91bfd 100644 --- a/src/widgets/Jitsi.ts +++ b/src/widgets/Jitsi.ts @@ -31,7 +31,7 @@ export interface JitsiWidgetData { export class Jitsi { private static instance: Jitsi; - private domain: string; + private domain?: string; public get preferredDomain(): string { return this.domain || "meet.element.io"; diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 7feef7d6a1a..d6bc9620827 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -76,8 +76,7 @@ describe("", () => { client = stubClient(); mocked(createClient).mockReturnValue(client); - serverConfig = new ValidatedServerConfig(); - serverConfig.hsName = "example.com"; + serverConfig = { hsName: "example.com" } as ValidatedServerConfig; onComplete = jest.fn(); onLoginClick = jest.fn(); From 1f4d857283cef97c756788ee68fa7d6e6d2cf62b Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 5 May 2023 20:13:50 +1200 Subject: [PATCH 027/253] Apply `strictNullChecks` to `src/components/views/settings` (#10724) --- src/components/views/elements/Tag.tsx | 7 +- .../views/settings/JoinRuleSettings.tsx | 16 +- .../views/settings/Notifications.tsx | 56 ++-- .../views/settings/ProfileSettings.tsx | 4 +- .../views/settings/SecureBackupPanel.tsx | 16 +- .../tabs/user/VoiceUserSettingsTab.tsx | 2 +- .../views/settings/JoinRuleSettings-test.tsx | 250 ++++++++++++++++++ .../views/settings/Notifications-test.tsx | 100 ++++++- .../views/settings/SecureBackupPanel-test.tsx | 187 +++++++++++++ .../SecureBackupPanel-test.tsx.snap | 116 ++++++++ .../tabs/user/VoiceUserSettingsTab-test.tsx | 66 ++++- 11 files changed, 772 insertions(+), 48 deletions(-) create mode 100644 test/components/views/settings/JoinRuleSettings-test.tsx create mode 100644 test/components/views/settings/SecureBackupPanel-test.tsx create mode 100644 test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap diff --git a/src/components/views/elements/Tag.tsx b/src/components/views/elements/Tag.tsx index f6d90cede4c..d7d46fe7e11 100644 --- a/src/components/views/elements/Tag.tsx +++ b/src/components/views/elements/Tag.tsx @@ -32,7 +32,12 @@ export const Tag: React.FC = ({ icon, label, onDeleteClick, disabled = f {icon?.()} {label} {onDeleteClick && ( - + )} diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index d478639dc0a..4aee5d74ca6 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -36,7 +36,7 @@ import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions"; -interface IProps { +export interface JoinRuleSettingsProps { room: Room; promptUpgrade?: boolean; closeSettingsFn(): void; @@ -45,7 +45,7 @@ interface IProps { aliasWarning?: ReactNode; } -const JoinRuleSettings: React.FC = ({ +const JoinRuleSettings: React.FC = ({ room, promptUpgrade, aliasWarning, @@ -287,7 +287,10 @@ const JoinRuleSettings: React.FC = ({ fn(_t("Upgrading room"), 0, total); } else if (!progress.roomSynced) { fn(_t("Loading new room"), 1, total); - } else if (progress.inviteUsersProgress < progress.inviteUsersTotal) { + } else if ( + progress.inviteUsersProgress !== undefined && + progress.inviteUsersProgress < progress.inviteUsersTotal + ) { fn( _t("Sending invites... (%(progress)s out of %(count)s)", { progress: progress.inviteUsersProgress, @@ -296,13 +299,16 @@ const JoinRuleSettings: React.FC = ({ 2 + progress.inviteUsersProgress, total, ); - } else if (progress.updateSpacesProgress < progress.updateSpacesTotal) { + } else if ( + progress.updateSpacesProgress !== undefined && + progress.updateSpacesProgress < progress.updateSpacesTotal + ) { fn( _t("Updating spaces... (%(progress)s out of %(count)s)", { progress: progress.updateSpacesProgress, count: progress.updateSpacesTotal, }), - 2 + progress.inviteUsersProgress + progress.updateSpacesProgress, + 2 + (progress.inviteUsersProgress ?? 0) + progress.updateSpacesProgress, total, ); } diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 3e833b315fe..6c6c38c0b4f 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -167,7 +167,7 @@ const maximumVectorState = ( if (!definition.syncedRuleIds?.length) { return undefined; } - const vectorState = definition.syncedRuleIds.reduce((maxVectorState, ruleId) => { + const vectorState = definition.syncedRuleIds.reduce((maxVectorState, ruleId) => { // already set to maximum if (maxVectorState === VectorState.Loud) { return maxVectorState; @@ -177,12 +177,15 @@ const maximumVectorState = ( const syncedRuleVectorState = definition.ruleToVectorState(syncedRule); // if syncedRule is 'louder' than current maximum // set maximum to louder vectorState - if (OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)) { + if ( + syncedRuleVectorState && + OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState) + ) { return syncedRuleVectorState; } } return maxVectorState; - }, definition.ruleToVectorState(rule)); + }, definition.ruleToVectorState(rule)!); return vectorState; }; @@ -281,7 +284,7 @@ export default class Notifications extends React.PureComponent { } private async refreshRules(): Promise> { - const ruleSets = await MatrixClientPeg.get().getPushRules(); + const ruleSets = await MatrixClientPeg.get().getPushRules()!; const categories: Record = { [RuleId.Master]: RuleClass.Master, @@ -316,7 +319,7 @@ export default class Notifications extends React.PureComponent { // noinspection JSUnfilteredForInLoop const kind = k as PushRuleKind; - for (const r of ruleSets.global[kind]) { + for (const r of ruleSets.global[kind]!) { const rule: IAnnotatedPushRule = Object.assign(r, { kind }); const category = categories[rule.rule_id] ?? RuleClass.Other; @@ -344,7 +347,7 @@ export default class Notifications extends React.PureComponent { preparedNewState.vectorPushRules[category] = []; for (const rule of defaultRules[category]) { const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.rule_id]; - const vectorState = definition.ruleToVectorState(rule); + const vectorState = definition.ruleToVectorState(rule)!; preparedNewState.vectorPushRules[category]!.push({ ruleId: rule.rule_id, rule, @@ -441,8 +444,7 @@ export default class Notifications extends React.PureComponent { } else { const pusher = this.state.pushers?.find((p) => p.kind === "email" && p.pushkey === email); if (pusher) { - pusher.kind = null; // flag for delete - await MatrixClientPeg.get().setPusher(pusher); + await MatrixClientPeg.get().removePusher(pusher.pushkey, pusher.app_id); } } @@ -539,17 +541,20 @@ export default class Notifications extends React.PureComponent { } }; - private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]): Promise { + private async setKeywords( + unsafeKeywords: (string | undefined)[], + originalRules: IAnnotatedPushRule[], + ): Promise { try { // De-duplicate and remove empties - keywords = filterBoolean(Array.from(new Set(keywords))); - const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern)))); + const keywords = filterBoolean(Array.from(new Set(unsafeKeywords))); + const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern)))); // Note: Technically because of the UI interaction (at the time of writing), the diff // will only ever be +/-1 so we don't really have to worry about efficiently handling // tons of keyword changes. - const diff = arrayDiff(oldKeywords, keywords); + const diff = arrayDiff(oldKeywords, keywords); for (const word of diff.removed) { for (const rule of originalRules.filter((r) => r.pattern === word)) { @@ -557,16 +562,16 @@ export default class Notifications extends React.PureComponent { } } - let ruleVectorState = this.state.vectorKeywordRuleInfo?.vectorState; + let ruleVectorState = this.state.vectorKeywordRuleInfo!.vectorState; if (ruleVectorState === VectorState.Off) { // When the current global keywords rule is OFF, we need to look at // the flavor of existing rules to apply the same actions // when creating the new rule. - if (originalRules.length) { - ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) ?? undefined; - } else { - ruleVectorState = VectorState.On; // default - } + const existingRuleVectorState = originalRules.length + ? PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) + : undefined; + // set to same state as existing rule, or default to On + ruleVectorState = existingRuleVectorState ?? VectorState.On; //default } const kind = PushRuleKind.ContentSpecific; for (const word of diff.added) { @@ -588,6 +593,10 @@ export default class Notifications extends React.PureComponent { } private onKeywordAdd = (keyword: string): void => { + // should not encounter this + if (!this.state.vectorKeywordRuleInfo) { + throw new Error("Notification data is incomplete."); + } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We add the keyword immediately as a sort of local echo effect @@ -606,7 +615,7 @@ export default class Notifications extends React.PureComponent { }, async (): Promise => { await this.setKeywords( - this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern), + this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, @@ -614,6 +623,10 @@ export default class Notifications extends React.PureComponent { }; private onKeywordRemove = (keyword: string): void => { + // should not encounter this + if (!this.state.vectorKeywordRuleInfo) { + throw new Error("Notification data is incomplete."); + } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We remove the keyword immediately as a sort of local echo effect @@ -627,7 +640,7 @@ export default class Notifications extends React.PureComponent { }, async (): Promise => { await this.setKeywords( - this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern), + this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, @@ -749,9 +762,10 @@ export default class Notifications extends React.PureComponent { let keywordComposer: JSX.Element | undefined; if (category === RuleClass.VectorMentions) { + const tags = filterBoolean(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []); keywordComposer = ( r.pattern)} + tags={tags} onAdd={this.onKeywordAdd} onRemove={this.onKeywordRemove} disabled={this.state.phase === Phase.Persisting} diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index d7fdd9c143f..d8ef9928358 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -65,7 +65,9 @@ export default class ProfileSettings extends React.Component<{}, IState> { private removeAvatar = (): void => { // clear file upload field so same file can be selected - this.avatarUpload.current.value = ""; + if (this.avatarUpload.current) { + this.avatarUpload.current.value = ""; + } this.setState({ avatarUrl: undefined, avatarFile: null, diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 1df87008c78..4d249c8df8f 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -99,12 +99,12 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private async checkKeyBackupStatus(): Promise { this.getUpdatedDiagnostics(); try { - const { backupInfo, trustInfo } = await MatrixClientPeg.get().checkKeyBackup(); + const keyBackupResult = await MatrixClientPeg.get().checkKeyBackup(); this.setState({ loading: false, error: null, - backupInfo, - backupSigStatus: trustInfo, + backupInfo: keyBackupResult?.backupInfo ?? null, + backupSigStatus: keyBackupResult?.trustInfo ?? null, }); } catch (e) { logger.log("Unable to fetch check backup status", e); @@ -123,7 +123,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { this.getUpdatedDiagnostics(); try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo!); + const backupSigStatus = backupInfo ? await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) : null; if (this.unmounted) return; this.setState({ loading: false, @@ -192,7 +192,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { if (!proceed) return; this.setState({ loading: true }); MatrixClientPeg.get() - .deleteKeyBackupVersion(this.state.backupInfo.version) + .deleteKeyBackupVersion(this.state.backupInfo!.version!) .then(() => { this.loadBackupStatus(); }); @@ -285,7 +285,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { ); } - let backupSigStatuses: React.ReactNode = backupSigStatus?.sigs.map((sig, i) => { + let backupSigStatuses: React.ReactNode | undefined = backupSigStatus?.sigs?.map((sig, i) => { const deviceName = sig.device ? sig.device.getDisplayName() || sig.device.deviceId : null; const validity = (sub: string): JSX.Element => ( @@ -354,7 +354,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { {}, { validity, verify, device }, ); - } else if (sig.valid && !sig.deviceTrust.isVerified()) { + } else if (sig.valid && !sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + "unverified session ", @@ -368,7 +368,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { {}, { validity, verify, device }, ); - } else if (!sig.valid && !sig.deviceTrust.isVerified()) { + } else if (!sig.valid && !sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has an invalid signature from " + "unverified session ", diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index 71d33171df0..ada24bdf271 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -83,7 +83,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> { private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => { MediaDeviceHandler.instance.setDevice(deviceId, kind); - this.setState({ [kind]: deviceId }); + this.setState({ [kind]: deviceId }); }; private changeWebRtcMethod = (p2p: boolean): void => { diff --git a/test/components/views/settings/JoinRuleSettings-test.tsx b/test/components/views/settings/JoinRuleSettings-test.tsx new file mode 100644 index 00000000000..6cd3696a124 --- /dev/null +++ b/test/components/views/settings/JoinRuleSettings-test.tsx @@ -0,0 +1,250 @@ +/* +Copyright 2023 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 { fireEvent, render, screen, within } from "@testing-library/react"; +import { + EventType, + GuestAccess, + HistoryVisibility, + JoinRule, + MatrixEvent, + Room, + ClientEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +import { + clearAllModals, + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsUser, +} from "../../../test-utils"; +import { filterBoolean } from "../../../../src/utils/arrays"; +import JoinRuleSettings, { JoinRuleSettingsProps } from "../../../../src/components/views/settings/JoinRuleSettings"; +import { PreferredRoomVersions } from "../../../../src/utils/PreferredRoomVersions"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getRoom: jest.fn(), + getLocalAliases: jest.fn().mockReturnValue([]), + sendStateEvent: jest.fn(), + upgradeRoom: jest.fn(), + getProfileInfo: jest.fn(), + invite: jest.fn().mockResolvedValue(undefined), + isRoomEncrypted: jest.fn().mockReturnValue(false), + }); + const roomId = "!room:server.org"; + const newRoomId = "!roomUpgraded:server.org"; + + const defaultProps = { + room: new Room(roomId, client, userId), + closeSettingsFn: jest.fn(), + onError: jest.fn(), + }; + const getComponent = (props: Partial = {}) => + render(); + + const setRoomStateEvents = ( + room: Room, + version = "9", + joinRule?: JoinRule, + guestAccess?: GuestAccess, + history?: HistoryVisibility, + ): void => { + const events = filterBoolean([ + new MatrixEvent({ + type: EventType.RoomCreate, + content: { version }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + guestAccess && + new MatrixEvent({ + type: EventType.RoomGuestAccess, + content: { guest_access: guestAccess }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + history && + new MatrixEvent({ + type: EventType.RoomHistoryVisibility, + content: { history_visibility: history }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + joinRule && + new MatrixEvent({ + type: EventType.RoomJoinRules, + content: { join_rule: joinRule }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + ]); + + room.currentState.setStateEvents(events); + }; + + beforeEach(() => { + client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" }); + client.isRoomEncrypted.mockReturnValue(false); + client.upgradeRoom.mockResolvedValue({ replacement_room: newRoomId }); + client.getRoom.mockReturnValue(null); + }); + + describe("Restricted rooms", () => { + afterEach(async () => { + await clearAllModals(); + }); + describe("When room does not support restricted rooms", () => { + it("should not show restricted room join rule when upgrade not enabled", () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + + getComponent({ room: v8Room, promptUpgrade: false }); + + expect(screen.queryByText("Space members")).not.toBeInTheDocument(); + }); + + it("should show restricted room join rule when upgrade is enabled", () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + + getComponent({ room: v8Room, promptUpgrade: true }); + + expect(screen.getByText("Space members")).toBeInTheDocument(); + expect(screen.getByText("Upgrade required")).toBeInTheDocument(); + }); + + it("upgrades room when changing join rule to restricted", async () => { + const deferredInvites: IDeferred[] = []; + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + const parentSpace = new Room("!parentSpace:server.org", client, userId); + jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parentSpace.roomId])); + setRoomStateEvents(v8Room, "8"); + const memberAlice = new RoomMember(roomId, "@alice:server.org"); + const memberBob = new RoomMember(roomId, "@bob:server.org"); + const memberCharlie = new RoomMember(roomId, "@charlie:server.org"); + jest.spyOn(v8Room, "getMembersWithMembership").mockImplementation((membership) => + membership === "join" ? [memberAlice, memberBob] : [memberCharlie], + ); + const upgradedRoom = new Room(newRoomId, client, userId); + setRoomStateEvents(upgradedRoom); + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (parentSpace.roomId === id) return parentSpace; + return null; + }); + + // resolve invites by hand + // flushPromises is too blunt to test reliably + client.invite.mockImplementation(() => { + const p = defer<{}>(); + deferredInvites.push(p); + return p.promise; + }); + + getComponent({ room: v8Room, promptUpgrade: true }); + + fireEvent.click(screen.getByText("Space members")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("Upgrade")); + + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms); + + expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument(); + + await flushPromises(); + + expect(within(dialog).getByText("Loading new room")).toBeInTheDocument(); + + // "create" our new room, have it come thru sync + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (newRoomId === id) return upgradedRoom; + if (parentSpace.roomId === id) return parentSpace; + return null; + }); + client.emit(ClientEvent.Room, upgradedRoom); + + // invite users + expect(await screen.findByText("Sending invites... (0 out of 2)")).toBeInTheDocument(); + deferredInvites.pop()!.resolve({}); + expect(await screen.findByText("Sending invites... (1 out of 2)")).toBeInTheDocument(); + deferredInvites.pop()!.resolve({}); + + // update spaces + expect(await screen.findByText("Updating space...")).toBeInTheDocument(); + + await flushPromises(); + + // done, modal closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("upgrades room with no parent spaces or members when changing join rule to restricted", async () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + const upgradedRoom = new Room(newRoomId, client, userId); + setRoomStateEvents(upgradedRoom); + + getComponent({ room: v8Room, promptUpgrade: true }); + + fireEvent.click(screen.getByText("Space members")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("Upgrade")); + + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms); + + expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument(); + + await flushPromises(); + + expect(within(dialog).getByText("Loading new room")).toBeInTheDocument(); + + // "create" our new room, have it come thru sync + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (newRoomId === id) return upgradedRoom; + return null; + }); + client.emit(ClientEvent.Room, upgradedRoom); + + await flushPromises(); + await flushPromises(); + + // done, modal closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index 535239b2127..523e89443cb 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022, 2023 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. @@ -26,16 +26,18 @@ import { TweakName, ConditionKind, IPushRuleCondition, + PushRuleKind, } from "matrix-js-sdk/src/matrix"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import { act, fireEvent, getByTestId, render, screen, waitFor, within } from "@testing-library/react"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import Notifications from "../../../../src/components/views/settings/Notifications"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { StandardActions } from "../../../../src/notifications/StandardActions"; -import { getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; +import { clearAllModals, getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; // don't pollute test output with error logs from mock rejections jest.mock("matrix-js-sdk/src/logger"); @@ -257,6 +259,7 @@ describe("", () => { getPushers: jest.fn(), getThreePids: jest.fn(), setPusher: jest.fn(), + removePusher: jest.fn(), setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), getRooms: jest.fn().mockReturnValue([]), @@ -274,10 +277,12 @@ describe("", () => { sendReadReceipt: jest.fn(), supportsThreads: jest.fn().mockReturnValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(false), + addPushRule: jest.fn().mockResolvedValue({}), + deletePushRule: jest.fn().mockResolvedValue({}), }); mockClient.getPushRules.mockResolvedValue(pushRules); - beforeEach(() => { + beforeEach(async () => { let i = 0; mocked(randomString).mockImplementation(() => { return "testid_" + i++; @@ -286,9 +291,17 @@ describe("", () => { mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] }); mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] }); - mockClient.setPusher.mockClear().mockResolvedValue({}); + mockClient.setPusher.mockReset().mockResolvedValue({}); + mockClient.removePusher.mockClear().mockResolvedValue({}); mockClient.setPushRuleActions.mockReset().mockResolvedValue({}); mockClient.pushRules = pushRules; + mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); + mockClient.addPushRule.mockClear(); + mockClient.deletePushRule.mockClear(); + + userEvent.setup(); + + await clearAllModals(); }); it("renders spinner while loading", async () => { @@ -392,21 +405,30 @@ describe("", () => { // force render await flushPromises(); + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText("An error occurred whilst saving your notification preferences."), + ).toBeInTheDocument(); + + // dismiss the dialog + fireEvent.click(within(dialog).getByText("OK")); expect(screen.getByTestId("error-message")).toBeInTheDocument(); }); it("enables email notification when toggling off", async () => { - const testPusher = { kind: "email", pushkey: "tester@test.com" } as unknown as IPusher; + const testPusher = { + kind: "email", + pushkey: "tester@test.com", + app_id: "testtest", + } as unknown as IPusher; mockClient.getPushers.mockResolvedValue({ pushers: [testPusher] }); await getComponentAndWait(); const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!; fireEvent.click(emailToggle); - expect(mockClient.setPusher).toHaveBeenCalledWith({ - ...testPusher, - kind: null, - }); + expect(mockClient.removePusher).toHaveBeenCalledWith(testPusher.pushkey, testPusher.app_id); }); }); @@ -809,6 +831,66 @@ describe("", () => { ), ).toBeInTheDocument(); }); + + it("adds a new keyword", async () => { + await getComponentAndWait(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + expect(screen.getByLabelText("Keyword")).toHaveValue("jest"); + + fireEvent.click(screen.getByText("Add")); + + expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", { + actions: [PushRuleActionName.Notify, { set_tweak: "highlight", value: false }], + pattern: "jest", + }); + }); + + it("adds a new keyword with same actions as existing rules when keywords rule is off", async () => { + const offContentRule = { + ...bananaRule, + enabled: false, + actions: [PushRuleActionName.Notify], + }; + const pushRulesWithContentOff = { + global: { + ...pushRules.global, + content: [offContentRule], + }, + }; + mockClient.pushRules = pushRulesWithContentOff; + mockClient.getPushRules.mockClear().mockResolvedValue(pushRulesWithContentOff); + + await getComponentAndWait(); + + const keywords = screen.getByTestId("vector_mentions_keywords"); + + expect(within(keywords).getByLabelText("Off")).toBeChecked(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + expect(screen.getByLabelText("Keyword")).toHaveValue("jest"); + + fireEvent.click(screen.getByText("Add")); + + expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", { + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }], + pattern: "jest", + }); + }); + + it("removes keyword", async () => { + await getComponentAndWait(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + + const keyword = screen.getByText("banana"); + + fireEvent.click(within(keyword.parentElement!).getByLabelText("Remove")); + + expect(mockClient.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "banana"); + + await flushPromises(); + }); }); describe("clear all notifications", () => { diff --git a/test/components/views/settings/SecureBackupPanel-test.tsx b/test/components/views/settings/SecureBackupPanel-test.tsx new file mode 100644 index 00000000000..c8ad4790f1b --- /dev/null +++ b/test/components/views/settings/SecureBackupPanel-test.tsx @@ -0,0 +1,187 @@ +/* +Copyright 2023 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 { fireEvent, render, screen, within } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import SecureBackupPanel from "../../../../src/components/views/settings/SecureBackupPanel"; +import { accessSecretStorage } from "../../../../src/SecurityManager"; + +jest.mock("../../../../src/SecurityManager", () => ({ + accessSecretStorage: jest.fn(), +})); + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + checkKeyBackup: jest.fn(), + isKeyBackupKeyStored: jest.fn(), + isSecretStorageReady: jest.fn(), + getKeyBackupEnabled: jest.fn(), + getKeyBackupVersion: jest.fn().mockReturnValue("1"), + isKeyBackupTrusted: jest.fn().mockResolvedValue(true), + getClientWellKnown: jest.fn(), + deleteKeyBackupVersion: jest.fn(), + }); + // @ts-ignore allow it + client.crypto = { + secretStorage: { hasKey: jest.fn() }, + getSessionBackupPrivateKey: jest.fn(), + } as unknown as Crypto; + + const getComponent = () => render(); + + beforeEach(() => { + client.checkKeyBackup.mockResolvedValue({ + backupInfo: { + version: "1", + algorithm: "test", + auth_data: { + public_key: "1234", + }, + }, + trustInfo: { + usable: false, + sigs: [], + }, + }); + + mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(false); + client.deleteKeyBackupVersion.mockClear().mockResolvedValue(); + client.getKeyBackupVersion.mockClear(); + client.isKeyBackupTrusted.mockClear(); + + mocked(accessSecretStorage).mockClear().mockResolvedValue(); + }); + + it("displays a loader while checking keybackup", async () => { + getComponent(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + await flushPromises(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("handles null backup info", async () => { + // checkKeyBackup can fail and return null for various reasons + client.checkKeyBackup.mockResolvedValue(null); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + // no backup info + expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument(); + }); + + it("suggests connecting session to key backup when backup exists", async () => { + const { container } = getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + expect(container).toMatchSnapshot(); + }); + + it("displays when session is connected to key backup", async () => { + client.getKeyBackupEnabled.mockReturnValue(true); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + expect(screen.getByText("✅ This session is backing up your keys.")).toBeInTheDocument(); + }); + + it("asks for confirmation before deleting a backup", async () => { + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + fireEvent.click(screen.getByText("Delete Backup")); + + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText( + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", + ), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByText("Cancel")); + + expect(client.deleteKeyBackupVersion).not.toHaveBeenCalled(); + }); + + it("deletes backup after confirmation", async () => { + client.checkKeyBackup + .mockResolvedValueOnce({ + backupInfo: { + version: "1", + algorithm: "test", + auth_data: { + public_key: "1234", + }, + }, + trustInfo: { + usable: false, + sigs: [], + }, + }) + .mockResolvedValue(null); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + fireEvent.click(screen.getByText("Delete Backup")); + + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText( + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", + ), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByTestId("dialog-primary-button")); + + expect(client.deleteKeyBackupVersion).toHaveBeenCalledWith("1"); + + // delete request + await flushPromises(); + // refresh backup info + await flushPromises(); + }); + + it("resets secret storage", async () => { + mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(true); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + client.getKeyBackupVersion.mockClear(); + client.isKeyBackupTrusted.mockClear(); + + fireEvent.click(screen.getByText("Reset")); + + // enter loading state + expect(accessSecretStorage).toHaveBeenCalled(); + await flushPromises(); + + // backup status refreshed + expect(client.getKeyBackupVersion).toHaveBeenCalled(); + expect(client.isKeyBackupTrusted).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap new file mode 100644 index 00000000000..e17dfd0064c --- /dev/null +++ b/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` suggests connecting session to key backup when backup exists 1`] = ` +
      +
      +

      + Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key. +

      +

      + + This session is + + not backing up your keys + + , but you do have an existing backup you can restore from and add to going forward. + +

      +

      + Connect this session to key backup before signing out to avoid losing any keys that may only be on this session. +

      +
      + + Advanced + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + Backup key stored: + + not stored +
      + Backup key cached: + + not found locally + +
      + Secret storage public key: + + not found +
      + Secret storage: + + not ready +
      + Backup version: + + 1 +
      + Algorithm: + + test +
      + +
      + Backup is not signed by any of your sessions +
      +
      +
      +
      +
      + Connect this session to Key Backup +
      +
      + Delete Backup +
      +
      +
      +
      +`; diff --git a/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx index 9bc8b205171..1771b9c8ab1 100644 --- a/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx @@ -16,10 +16,11 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import VoiceUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/VoiceUserSettingsTab"; -import MediaDeviceHandler from "../../../../../../src/MediaDeviceHandler"; +import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../../src/MediaDeviceHandler"; +import { flushPromises } from "../../../../../test-utils"; jest.mock("../../../../../../src/MediaDeviceHandler"); const MediaDeviceHandlerMock = mocked(MediaDeviceHandler); @@ -27,8 +28,69 @@ const MediaDeviceHandlerMock = mocked(MediaDeviceHandler); describe("", () => { const getComponent = (): React.ReactElement => ; + const audioIn1 = { + deviceId: "1", + groupId: "g1", + kind: MediaDeviceKindEnum.AudioInput, + label: "Audio input test 1", + }; + const videoIn1 = { + deviceId: "2", + groupId: "g1", + kind: MediaDeviceKindEnum.VideoInput, + label: "Video input test 1", + }; + const videoIn2 = { + deviceId: "3", + groupId: "g1", + kind: MediaDeviceKindEnum.VideoInput, + label: "Video input test 2", + }; + const defaultMediaDevices = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [audioIn1], + [MediaDeviceKindEnum.VideoInput]: [videoIn1, videoIn2], + } as unknown as IMediaDevices; + beforeEach(() => { jest.clearAllMocks(); + MediaDeviceHandlerMock.hasAnyLabeledDevices.mockResolvedValue(true); + MediaDeviceHandlerMock.getDevices.mockResolvedValue(defaultMediaDevices); + + // @ts-ignore bad mocking + MediaDeviceHandlerMock.instance = { setDevice: jest.fn() }; + }); + + describe("devices", () => { + it("renders dropdowns for input devices", async () => { + render(getComponent()); + await flushPromises(); + + expect(screen.getByLabelText("Microphone")).toHaveDisplayValue(audioIn1.label); + expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn1.label); + }); + + it("updates device", async () => { + render(getComponent()); + await flushPromises(); + + fireEvent.change(screen.getByLabelText("Camera"), { target: { value: videoIn2.deviceId } }); + + expect(MediaDeviceHandlerMock.instance.setDevice).toHaveBeenCalledWith( + videoIn2.deviceId, + MediaDeviceKindEnum.VideoInput, + ); + + expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn2.label); + }); + + it("does not render dropdown when no devices exist for type", async () => { + render(getComponent()); + await flushPromises(); + + expect(screen.getByText("No Audio Outputs detected")).toBeInTheDocument(); + expect(screen.queryByLabelText("Audio Output")).not.toBeInTheDocument(); + }); }); it("renders audio processing settings", () => { From 4dd214506b1dbf2c70a3d402704b929ad386b7fb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 5 May 2023 08:45:14 +0100 Subject: [PATCH 028/253] Move reaction message previews out of labs (#10601) * Update reaction message previews to match designs * Delabs reaction message previews * tsc strict * Iterate * Add test * Iterate --- .../context_menus/MessageContextMenu.tsx | 4 +- src/i18n/strings/en_EN.json | 6 +- src/settings/Settings.tsx | 18 --- .../previews/ReactionEventPreview.ts | 38 ++--- src/stores/room-list/previews/utils.ts | 4 +- .../tabs/user/LabsUserSettingsTab-test.tsx | 2 +- .../previews/PollStartEventPreview-test.ts | 1 + .../previews/ReactionEventPreview-test.ts | 139 ++++++++++++++++++ 8 files changed, 167 insertions(+), 45 deletions(-) create mode 100644 test/stores/room-list/previews/ReactionEventPreview-test.ts diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 3ffe63a235d..a4cc689349a 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -114,7 +114,7 @@ interface IProps extends MenuProps { // True if the menu is being used as a right click menu rightClick?: boolean; // The Relations model from the JS SDK for reactions to `mxEvent` - reactions?: Relations | null | undefined; + reactions?: Relations | null; // A permalink to this event or an href of an anchor element the user has clicked link?: string; @@ -556,7 +556,7 @@ export default class MessageContextMenu extends React.Component } let jumpToRelatedEventButton: JSX.Element | undefined; - const relatedEventId = mxEvent.getWireContent()?.["m.relates_to"]?.event_id; + const relatedEventId = mxEvent.relationEventId; if (relatedEventId && SettingsStore.getValue("developerMode")) { jumpToRelatedEventButton = ( = { [LabGroup.VoiceAndVideo]: _td("Voice & Video"), [LabGroup.Moderation]: _td("Moderation"), [LabGroup.Analytics]: _td("Analytics"), - [LabGroup.MessagePreviews]: _td("Message Previews"), [LabGroup.Themes]: _td("Themes"), [LabGroup.Encryption]: _td("Encryption"), [LabGroup.Experimental]: _td("Experimental"), @@ -298,22 +296,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_roomlist_preview_reactions_dms": { - isFeature: true, - labsGroup: LabGroup.MessagePreviews, - displayName: _td("Show message previews for reactions in DMs"), - supportedLevels: LEVELS_FEATURE, - default: false, - // this option is a subset of `feature_roomlist_preview_reactions_all` so disable it when that one is enabled - controller: new IncompatibleController("feature_roomlist_preview_reactions_all"), - }, - "feature_roomlist_preview_reactions_all": { - isFeature: true, - labsGroup: LabGroup.MessagePreviews, - displayName: _td("Show message previews for reactions in all rooms"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_dehydration": { isFeature: true, labsGroup: LabGroup.Encryption, diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts index 6af7ebab702..a9f06581415 100644 --- a/src/stores/room-list/previews/ReactionEventPreview.ts +++ b/src/stores/room-list/previews/ReactionEventPreview.ts @@ -18,37 +18,39 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IPreview } from "./IPreview"; import { TagID } from "../models"; -import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { getSenderName, isSelf } from "./utils"; import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import DMRoomMap from "../../../utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { MessagePreviewStore } from "../MessagePreviewStore"; export class ReactionEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { - const showDms = SettingsStore.getValue("feature_roomlist_preview_reactions_dms"); - const showAll = SettingsStore.getValue("feature_roomlist_preview_reactions_all"); - const roomId = event.getRoomId(); if (!roomId) return null; // not a room event - // If we're not showing all reactions, see if we're showing DMs instead - if (!showAll) { - // If we're not showing reactions on DMs, or we are and the room isn't a DM, skip - if (!(showDms && DMRoomMap.shared().getUserIdForRoomId(roomId))) { - return null; - } - } - const relation = event.getRelation(); if (!relation) return null; // invalid reaction (probably redacted) const reaction = relation.key; if (!reaction) return null; // invalid reaction (unknown format) - if (isThread || isSelf(event) || !shouldPrefixMessagesIn(roomId, tagId)) { - return reaction; - } else { - return _t("%(senderName)s: %(reaction)s", { senderName: getSenderName(event), reaction }); + const cli = MatrixClientPeg.get(); + const room = cli?.getRoom(roomId); + const relatedEvent = relation.event_id ? room?.findEventById(relation.event_id) : null; + if (!relatedEvent) return null; + + const message = MessagePreviewStore.instance.generatePreviewForEvent(relatedEvent); + if (isSelf(event)) { + return _t("You reacted %(reaction)s to %(message)s", { + reaction, + message, + }); } + + return _t("%(sender)s reacted %(reaction)s to %(message)s", { + sender: getSenderName(event), + reaction, + message, + }); } } diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts index 2dfa75966e8..ec8ddb28e3b 100644 --- a/src/stores/room-list/previews/utils.ts +++ b/src/stores/room-list/previews/utils.ts @@ -20,7 +20,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { DefaultTagID, TagID } from "../models"; export function isSelf(event: MatrixEvent): boolean { - const selfUserId = MatrixClientPeg.get().getUserId(); + const selfUserId = MatrixClientPeg.get().getSafeUserId(); if (event.getType() === "m.room.member") { return event.getStateKey() === selfUserId; } @@ -37,5 +37,5 @@ export function shouldPrefixMessagesIn(roomId: string, tagId?: TagID): boolean { } export function getSenderName(event: MatrixEvent): string { - return event.sender ? event.sender.name : event.getSender() || ""; + return event.sender?.name ?? event.getSender() ?? ""; } diff --git a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx index ddf6105274c..a8001481f64 100644 --- a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx @@ -70,7 +70,7 @@ describe("", () => { const { container } = render(getComponent()); const labsSections = container.getElementsByClassName("mx_SettingsTab_section"); - expect(labsSections).toHaveLength(12); + expect(labsSections).toHaveLength(11); }); it("allow setting a labs flag which requires unstable support once support is confirmed", async () => { diff --git a/test/stores/room-list/previews/PollStartEventPreview-test.ts b/test/stores/room-list/previews/PollStartEventPreview-test.ts index 324f42d7b68..dc9bcabfe3f 100644 --- a/test/stores/room-list/previews/PollStartEventPreview-test.ts +++ b/test/stores/room-list/previews/PollStartEventPreview-test.ts @@ -22,6 +22,7 @@ import { makePollStartEvent } from "../../../test-utils"; jest.spyOn(MatrixClientPeg, "get").mockReturnValue({ getUserId: () => "@me:example.com", + getSafeUserId: () => "@me:example.com", } as unknown as MatrixClient); describe("PollStartEventPreview", () => { diff --git a/test/stores/room-list/previews/ReactionEventPreview-test.ts b/test/stores/room-list/previews/ReactionEventPreview-test.ts new file mode 100644 index 00000000000..e49dba342c3 --- /dev/null +++ b/test/stores/room-list/previews/ReactionEventPreview-test.ts @@ -0,0 +1,139 @@ +/* +Copyright 2023 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 { RelationType, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { mkEvent, stubClient } from "../../../test-utils"; +import { ReactionEventPreview } from "../../../../src/stores/room-list/previews/ReactionEventPreview"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +describe("ReactionEventPreview", () => { + const preview = new ReactionEventPreview(); + const userId = "@user:example.com"; + const roomId = "!room:example.com"; + + beforeAll(() => { + stubClient(); + }); + + describe("getTextFor", () => { + it("should return null for non-relations", () => { + const event = mkEvent({ + event: true, + content: {}, + user: userId, + type: "m.room.message", + room: roomId, + }); + expect(preview.getTextFor(event)).toBeNull(); + }); + + it("should return null for non-reactions", () => { + const event = mkEvent({ + event: true, + content: { + "body": "", + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: "$foo:bar", + }, + }, + user: userId, + type: "m.room.message", + room: roomId, + }); + expect(preview.getTextFor(event)).toBeNull(); + }); + + it("should use 'You' for your own reactions", () => { + const cli = MatrixClientPeg.get(); + const room = new Room(roomId, cli, userId); + mocked(cli.getRoom).mockReturnValue(room); + + const message = mkEvent({ + event: true, + content: { + "body": "duck duck goose", + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: "$foo:bar", + }, + }, + user: userId, + type: "m.room.message", + room: roomId, + }); + + room.getUnfilteredTimelineSet().addLiveEvent(message, {}); + + const event = mkEvent({ + event: true, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + key: "🪿", + event_id: message.getId(), + }, + }, + user: cli.getSafeUserId(), + type: "m.reaction", + room: roomId, + }); + expect(preview.getTextFor(event)).toMatchInlineSnapshot(`"You reacted 🪿 to duck duck goose"`); + }); + + it("should use display name for your others' reactions", () => { + const cli = MatrixClientPeg.get(); + const room = new Room(roomId, cli, userId); + mocked(cli.getRoom).mockReturnValue(room); + + const message = mkEvent({ + event: true, + content: { + "body": "duck duck goose", + "m.relates_to": { + rel_type: RelationType.Thread, + event_id: "$foo:bar", + }, + }, + user: userId, + type: "m.room.message", + room: roomId, + }); + + room.getUnfilteredTimelineSet().addLiveEvent(message, {}); + + const event = mkEvent({ + event: true, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + key: "🪿", + event_id: message.getId(), + }, + }, + user: userId, + type: "m.reaction", + room: roomId, + }); + event.sender = new RoomMember(roomId, userId); + event.sender.name = "Bob"; + + expect(preview.getTextFor(event)).toMatchInlineSnapshot(`"Bob reacted 🪿 to duck duck goose"`); + }); + }); +}); From 8e962f6897ed849020332dfe96aa1ff48c7adfa3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 5 May 2023 09:13:21 +0100 Subject: [PATCH 029/253] Update static_analysis.yaml (#10725) --- .github/workflows/static_analysis.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 360fa2d8bb9..3714516b937 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -49,12 +49,6 @@ jobs: permissions: pull-requests: read checks: write - strategy: - fail-fast: false - matrix: - args: - - "--strict --noImplicitAny" - - "--noImplicitAny" steps: - uses: actions/checkout@v3 with: @@ -82,7 +76,7 @@ jobs: use-check: false check-fail-mode: added output-behaviour: annotate - ts-extra-args: ${{ matrix.args }} + ts-extra-args: "--strict --noImplicitAny" files-changed: ${{ steps.files.outputs.files_updated }} files-added: ${{ steps.files.outputs.files_created }} files-deleted: ${{ steps.files.outputs.files_deleted }} From 99ac9e502987da8927b131f13d586445cf046831 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 5 May 2023 09:26:11 +0100 Subject: [PATCH 030/253] Ensure tooltip contents is linked via aria to the target element (#10729) * Ensure tooltip contents is linked via aria to the target element * Iterate * Fix tests * Fix tests * Update snapshot * Fix missing aria labels for more tooltips * Iterate * Update snapshots --- res/css/views/right_panel/_UserInfo.pcss | 2 ++ .../auth/forgot-password/CheckEmail.tsx | 11 ++++-- .../auth/forgot-password/VerifyEmailModal.tsx | 11 ++++-- .../views/dialogs/UntrustedDeviceDialog.tsx | 6 ++-- .../views/elements/LinkWithTooltip.tsx | 4 ++- src/components/views/elements/Pill.tsx | 13 +++++-- .../views/elements/TextWithTooltip.tsx | 4 +++ src/components/views/elements/Tooltip.tsx | 2 +- .../views/messages/ReactionsRowButton.tsx | 2 +- .../messages/ReactionsRowButtonTooltip.tsx | 4 +-- src/components/views/right_panel/UserInfo.tsx | 8 ++--- src/components/views/rooms/E2EIcon.tsx | 36 ++++++++++++------- src/components/views/rooms/EventTile.tsx | 12 +++++-- .../views/rooms/MessageComposer.tsx | 17 ++++++--- .../__snapshots__/RoomView-test.tsx.snap | 1 + test/components/views/elements/Pill-test.tsx | 6 ++++ .../elements/__snapshots__/Pill-test.tsx.snap | 10 ++++++ .../__snapshots__/TooltipTarget-test.tsx.snap | 7 ++++ .../views/messages/TextualBody-test.tsx | 6 ++-- .../__snapshots__/TextualBody-test.tsx.snap | 7 ++++ .../views/right_panel/UserInfo-test.tsx | 6 ++-- .../RoomSummaryCard-test.tsx.snap | 1 + 22 files changed, 133 insertions(+), 43 deletions(-) diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index fd017d8a07c..1287e74f067 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -163,6 +163,8 @@ limitations under the License. line-height: $font-25px; flex: 1; justify-content: center; + // We reverse things here so for accessible technologies the name comes before the e2e shield + flex-direction: row-reverse; span { /* limit to 2 lines, show an ellipsis if it overflows */ diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index be976e4e58f..7e4679a1dcf 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ReactNode, useRef } from "react"; import AccessibleButton from "../../../views/elements/AccessibleButton"; import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; @@ -42,6 +42,7 @@ export const CheckEmail: React.FC = ({ onSubmitForm, onResendClick, }) => { + const tooltipId = useRef(`mx_CheckEmail_${Math.random()}`).current; const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); const onResendClickFn = async (): Promise => { @@ -68,10 +69,16 @@ export const CheckEmail: React.FC = ({
      {_t("Did not receive it?")} - + {_t("Resend")} = ({ onReEnterEmailClick, onResendClick, }) => { + const tooltipId = useRef(`mx_VerifyEmailModal_${Math.random()}`).current; const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); const onResendClickFn = async (): Promise => { @@ -66,10 +67,16 @@ export const VerifyEmailModal: React.FC = ({
      {_t("Did not receive it?")} - + {_t("Resend")} = ({ device, user, onFinished }) => { - let askToVerifyText; - let newSessionText; + let askToVerifyText: string; + let newSessionText: string; if (MatrixClientPeg.get().getUserId() === user.userId) { newSessionText = _t("You signed in to a new session without verifying it:"); @@ -51,7 +51,7 @@ const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) = className="mx_UntrustedDeviceDialog" title={ <> - + {_t("Not Trusted")} } diff --git a/src/components/views/elements/LinkWithTooltip.tsx b/src/components/views/elements/LinkWithTooltip.tsx index 88d29e4a861..854f01e0e57 100644 --- a/src/components/views/elements/LinkWithTooltip.tsx +++ b/src/components/views/elements/LinkWithTooltip.tsx @@ -18,7 +18,9 @@ import React from "react"; import TextWithTooltip from "./TextWithTooltip"; -interface IProps extends Omit, "tabIndex" | "onClick"> {} +interface IProps extends Omit, "tabIndex" | "onClick" | "tooltip"> { + tooltip: string; +} export default class LinkWithTooltip extends React.Component { public constructor(props: IProps) { diff --git a/src/components/views/elements/Pill.tsx b/src/components/views/elements/Pill.tsx index 965205e71fe..49c6c1bfd2d 100644 --- a/src/components/views/elements/Pill.tsx +++ b/src/components/views/elements/Pill.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, useState } from "react"; +import React, { ReactElement, useRef, useState } from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/matrix"; @@ -89,6 +89,7 @@ export interface PillProps { } export const Pill: React.FC = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => { + const tooltipId = useRef(`mx_Pill_${Math.random()}`).current; const [hover, setHover] = useState(false); const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({ room, @@ -117,7 +118,7 @@ export const Pill: React.FC = ({ type: propType, url, inMessage, room setHover(false); }; - const tip = hover && resourceId ? : null; + const tip = hover && resourceId ? : null; let avatar: ReactElement | null = null; let pillText: string | null = text; @@ -165,13 +166,19 @@ export const Pill: React.FC = ({ type: propType, url, inMessage, room onClick={onClick} onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} + aria-describedby={tooltipId} > {avatar} {pillText} {tip} ) : ( - + {avatar} {pillText} {tip} diff --git a/src/components/views/elements/TextWithTooltip.tsx b/src/components/views/elements/TextWithTooltip.tsx index 3ebdac55113..a72cd82faa2 100644 --- a/src/components/views/elements/TextWithTooltip.tsx +++ b/src/components/views/elements/TextWithTooltip.tsx @@ -35,6 +35,10 @@ export default class TextWithTooltip extends React.Component { public render(): React.ReactNode { const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props; + if (typeof tooltip === "string") { + props["aria-label"] = tooltip; + } + return ( { style.display = this.props.visible ? "block" : "none"; const tooltip = ( -
      +
      {this.props.label}
      diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 3b165b9a61f..8dd144eff68 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -92,7 +92,7 @@ export default class ReactionsRowButton extends React.PureComponent; } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index b87c8ab76c1..99498c1fe9d 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1560,9 +1560,9 @@ export const UserInfoHeader: React.FC<{
      ); - let presenceState; - let presenceLastActiveAgo; - let presenceCurrentlyActive; + let presenceState: string | undefined; + let presenceLastActiveAgo: number | undefined; + let presenceCurrentlyActive: boolean | undefined; if (member instanceof RoomMember && member.user) { presenceState = member.user.presence; presenceLastActiveAgo = member.user.lastActiveAgo; @@ -1597,10 +1597,10 @@ export const UserInfoHeader: React.FC<{

      - {e2eIcon} {displayName} + {e2eIcon}

      diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 0103c308ddd..01a6b8a4985 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -22,6 +22,7 @@ import { _t, _td } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import Tooltip, { Alignment } from "../elements/Tooltip"; import { E2EStatus } from "../../../utils/ShieldUtils"; +import { XOR } from "../../../@types/common"; export enum E2EState { Verified = "verified", @@ -42,9 +43,7 @@ const crossSigningRoomTitles: { [key in E2EState]?: string } = { [E2EState.Verified]: _td("Everyone in this room is verified"), }; -interface IProps { - isUser?: boolean; - status?: E2EState | E2EStatus; +interface Props { className?: string; size?: number; onClick?: () => void; @@ -53,7 +52,17 @@ interface IProps { bordered?: boolean; } -const E2EIcon: React.FC = ({ +interface UserProps extends Props { + isUser: true; + status: E2EState | E2EStatus; +} + +interface RoomProps extends Props { + isUser?: false; + status: E2EStatus; +} + +const E2EIcon: React.FC> = ({ isUser, status, className, @@ -77,12 +86,10 @@ const E2EIcon: React.FC = ({ ); let e2eTitle: string | undefined; - if (status) { - if (isUser) { - e2eTitle = crossSigningUserTitles[status]; - } else { - e2eTitle = crossSigningRoomTitles[status]; - } + if (isUser) { + e2eTitle = crossSigningUserTitles[status]; + } else { + e2eTitle = crossSigningRoomTitles[status]; } let style: CSSProperties | undefined; @@ -93,9 +100,11 @@ const E2EIcon: React.FC = ({ const onMouseOver = (): void => setHover(true); const onMouseLeave = (): void => setHover(false); + const label = e2eTitle ? _t(e2eTitle) : ""; + let tip: JSX.Element | undefined; - if (hover && !hideTooltip) { - tip = ; + if (hover && !hideTooltip && label) { + tip = ; } if (onClick) { @@ -106,6 +115,7 @@ const E2EIcon: React.FC = ({ onMouseLeave={onMouseLeave} className={classes} style={style} + aria-label={label} > {tip} @@ -113,7 +123,7 @@ const E2EIcon: React.FC = ({ } return ( -
      +
      {tip}
      ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 34b10dc0956..df30602351a 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, forwardRef, MouseEvent, ReactNode, RefObject } from "react"; +import React, { createRef, forwardRef, MouseEvent, ReactNode, RefObject, useRef } from "react"; import classNames from "classnames"; import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { EventStatus, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -1513,7 +1513,12 @@ class E2ePadlock extends React.Component { const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`; return ( -
      +
      {tooltip}
      ); @@ -1525,6 +1530,7 @@ interface ISentReceiptProps { } function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { + const tooltipId = useRef(`mx_SentReceipt_${Math.random()}`).current; const isSent = !messageState || messageState === "sent"; const isFailed = messageState === "not_sent"; const receiptClasses = classNames({ @@ -1546,6 +1552,7 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { label = _t("Failed to send"); } const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ + id: tooltipId, label: label, alignment: Alignment.TopRight, }); @@ -1559,6 +1566,7 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { onMouseLeave={hideTooltip} onFocus={showTooltip} onBlur={hideTooltip} + aria-describedby={tooltipId} > {nonCssBadge} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index c7d824c9163..4025954f64f 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -107,6 +107,7 @@ interface IState { } export class MessageComposer extends React.Component { + private tooltipId = `mx_MessageComposer_${Math.random()}`; private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); @@ -470,7 +471,7 @@ export class MessageComposer extends React.Component { public render(): React.ReactNode { const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus); const e2eIcon = hasE2EIcon && ( - + ); const controls: ReactNode[] = []; @@ -561,11 +562,15 @@ export class MessageComposer extends React.Component { ); } - let recordingTooltip; + let recordingTooltip: JSX.Element | undefined; if (this.state.recordingTimeLeftSeconds) { const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds); recordingTooltip = ( - + ); } @@ -593,7 +598,11 @@ export class MessageComposer extends React.Component { }); return ( -
      +
      {recordingTooltip}
      ", () => { jest.spyOn(dis, "dispatch"); pillParentClickHandler = jest.fn(); + + jest.spyOn(global.Math, "random").mockReturnValue(0.123456); + }); + + afterEach(() => { + jest.spyOn(global.Math, "random").mockRestore(); }); describe("when rendering a pill for a room", () => { diff --git a/test/components/views/elements/__snapshots__/Pill-test.tsx.snap b/test/components/views/elements/__snapshots__/Pill-test.tsx.snap index 8b945b0c159..6644fea7cb0 100644 --- a/test/components/views/elements/__snapshots__/Pill-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/Pill-test.tsx.snap @@ -11,6 +11,7 @@ exports[` should not render an avatar or link when called with inMessage =
      should render the expected pill for @room 1`] = `
      should render the expected pill for a known user not in the room
      @@ -108,6 +111,7 @@ exports[` should render the expected pill for a message in another room 1`
      @@ -148,6 +152,7 @@ exports[` should render the expected pill for a message in the same room 1
      @@ -188,6 +193,7 @@ exports[` should render the expected pill for a room alias 1`] = `
      @@ -228,6 +234,7 @@ exports[` should render the expected pill for a space 1`] = `
      @@ -268,6 +275,7 @@ exports[` should render the expected pill for an uknown user not in the ro
      @@ -290,6 +298,7 @@ exports[` when rendering a pill for a room should render the expected pill
      @@ -330,6 +339,7 @@ exports[` when rendering a pill for a user in the room should render as ex
      diff --git a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap index bcfbe5effd0..ad5f3791653 100644 --- a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -3,6 +3,7 @@ exports[` displays Bottom aligned tooltip on mouseover 1`] = `
    • , + ,
    • ", () => { - const userId = "@alice:server.org"; - - // the local device - const ownDevice = { device_id: "device_1" }; - - // a device which we have verified via cross-signing - const verifiedDevice = { device_id: "device_2" }; - - // a device which we have *not* verified via cross-signing - const unverifiedDevice = { device_id: "device_3" }; - - // a device which is returned by `getDevices` but getDeviceVerificationStatus returns `null` for - // (as it would for a device with no E2E keys). - const nonCryptoDevice = { device_id: "non_crypto" }; - - const mockCrypto = { - getDeviceVerificationStatus: jest.fn().mockImplementation((_userId, deviceId) => { - if (_userId !== userId) { - throw new Error(`bad user id ${_userId}`); - } - if (deviceId === ownDevice.device_id || deviceId === verifiedDevice.device_id) { - return { crossSigningVerified: true }; - } else if (deviceId === unverifiedDevice.device_id) { - return { - crossSigningVerified: false, - }; - } else { - return null; - } - }), - }; - const mockClient = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(userId), - getDevices: jest.fn(), - getDeviceId: jest.fn().mockReturnValue(ownDevice.device_id), - deleteMultipleDevices: jest.fn(), - getStoredDevice: jest.fn().mockReturnValue(new DeviceInfo("id")), - generateClientSecret: jest.fn(), - getPushers: jest.fn(), - setPusher: jest.fn(), - getCrypto: jest.fn().mockReturnValue(mockCrypto), - }); - - const getComponent = () => ( - - - - ); - - beforeEach(() => { - jest.clearAllMocks(); - - mockClient.getDevices - .mockReset() - .mockResolvedValue({ devices: [ownDevice, verifiedDevice, unverifiedDevice, nonCryptoDevice] }); - - mockClient.getPushers.mockReset().mockResolvedValue({ - pushers: [ - mkPusher({ - [PUSHER_DEVICE_ID.name]: ownDevice.device_id, - [PUSHER_ENABLED.name]: true, - }), - ], - }); - }); - - it("renders device panel with devices", async () => { - const { container } = render(getComponent()); - await flushPromises(); - expect(container).toMatchSnapshot(); - }); - - describe("device deletion", () => { - const interactiveAuthError = { httpStatus: 401, data: { flows: [{ stages: ["m.login.password"] }] } }; - - const toggleDeviceSelection = (container: HTMLElement, deviceId: string) => - act(() => { - const checkbox = container.querySelector(`#device-tile-checkbox-${deviceId}`)!; - fireEvent.click(checkbox); - }); - - beforeEach(() => { - mockClient.deleteMultipleDevices.mockReset(); - }); - - it("deletes selected devices when interactive auth is not required", async () => { - mockClient.deleteMultipleDevices.mockResolvedValue({}); - mockClient.getDevices - .mockResolvedValueOnce({ devices: [ownDevice, verifiedDevice, unverifiedDevice] }) - // pretend it was really deleted on refresh - .mockResolvedValueOnce({ devices: [ownDevice, unverifiedDevice] }); - - const { container, getByTestId } = render(getComponent()); - await flushPromises(); - - expect(container.getElementsByClassName("mx_DevicesPanel_device").length).toEqual(3); - - toggleDeviceSelection(container, verifiedDevice.device_id); - - mockClient.getDevices.mockClear(); - - act(() => { - fireEvent.click(getByTestId("sign-out-devices-btn")); - }); - - expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); - expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([verifiedDevice.device_id], undefined); - - await flushPromises(); - - // devices refreshed - expect(mockClient.getDevices).toHaveBeenCalled(); - // and rerendered - expect(container.getElementsByClassName("mx_DevicesPanel_device").length).toEqual(2); - }); - - it("deletes selected devices when interactive auth is required", async () => { - mockClient.deleteMultipleDevices - // require auth - .mockRejectedValueOnce(interactiveAuthError) - // then succeed - .mockResolvedValueOnce({}); - - mockClient.getDevices - .mockResolvedValueOnce({ devices: [ownDevice, verifiedDevice, unverifiedDevice] }) - // pretend it was really deleted on refresh - .mockResolvedValueOnce({ devices: [ownDevice, unverifiedDevice] }); - - const { container, getByTestId, getByLabelText } = render(getComponent()); - - await flushPromises(); - - // reset mock count after initial load - mockClient.getDevices.mockClear(); - - toggleDeviceSelection(container, verifiedDevice.device_id); - - act(() => { - fireEvent.click(getByTestId("sign-out-devices-btn")); - }); - - await flushPromises(); - // modal rendering has some weird sleeps - await sleep(100); - - expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([verifiedDevice.device_id], undefined); - - const modal = document.getElementsByClassName("mx_Dialog"); - expect(modal).toMatchSnapshot(); - - // fill password and submit for interactive auth - act(() => { - fireEvent.change(getByLabelText("Password"), { target: { value: "topsecret" } }); - fireEvent.submit(getByLabelText("Password")); - }); - - await flushPromises(); - - // called again with auth - expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([verifiedDevice.device_id], { - identifier: { - type: "m.id.user", - user: userId, - }, - password: "", - type: "m.login.password", - user: userId, - }); - // devices refreshed - expect(mockClient.getDevices).toHaveBeenCalled(); - // and rerendered - expect(container.getElementsByClassName("mx_DevicesPanel_device").length).toEqual(2); - }); - - it("clears loading state when interactive auth fail is cancelled", async () => { - mockClient.deleteMultipleDevices - // require auth - .mockRejectedValueOnce(interactiveAuthError) - // then succeed - .mockResolvedValueOnce({}); - - mockClient.getDevices - .mockResolvedValueOnce({ devices: [ownDevice, verifiedDevice, unverifiedDevice] }) - // pretend it was really deleted on refresh - .mockResolvedValueOnce({ devices: [ownDevice, unverifiedDevice] }); - - const { container, getByTestId } = render(getComponent()); - - await flushPromises(); - - // reset mock count after initial load - mockClient.getDevices.mockClear(); - - toggleDeviceSelection(container, verifiedDevice.device_id); - - act(() => { - fireEvent.click(getByTestId("sign-out-devices-btn")); - }); - - expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); - - await flushPromises(); - // modal rendering has some weird sleeps - await sleep(20); - - // close the modal without submission - act(() => { - const modalCloseButton = document.querySelector('[aria-label="Close dialog"]')!; - fireEvent.click(modalCloseButton); - }); - - await flushPromises(); - - // not refreshed - expect(mockClient.getDevices).not.toHaveBeenCalled(); - // spinner removed - expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy(); - }); - }); -}); diff --git a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap deleted file mode 100644 index ad12a99c7c5..00000000000 --- a/test/components/views/settings/__snapshots__/DevicesPanel-test.tsx.snap +++ /dev/null @@ -1,506 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` device deletion deletes selected devices when interactive auth is required 1`] = ` -HTMLCollection [ -
      -
      -