Compare commits
108 Commits
hs/remove-
...
rav/refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21aee80387 | ||
|
|
d3cc6399c6 | ||
|
|
09cd22cbd6 | ||
|
|
86d8354012 | ||
|
|
6e3543b2ab | ||
|
|
8d1db9440f | ||
|
|
6a88f0dea0 | ||
|
|
814f4a85df | ||
|
|
475504d33b | ||
|
|
7faee3d1b7 | ||
|
|
30e7567064 | ||
|
|
2250f5e6a2 | ||
|
|
e43b696461 | ||
|
|
bf98ede4fa | ||
|
|
cc0ece9837 | ||
|
|
ab6ef2fa85 | ||
|
|
c79c8c836b | ||
|
|
3f0dcaa64c | ||
|
|
652e891663 | ||
|
|
7eb5a29cf0 | ||
|
|
1b38624fd8 | ||
|
|
d98533025a | ||
|
|
c3e5367e45 | ||
|
|
1e15a322a5 | ||
|
|
452996eacf | ||
|
|
ee120f2fa9 | ||
|
|
94aa51dc57 | ||
|
|
e19d3dcd44 | ||
|
|
5a4b5418cc | ||
|
|
d1f62317ba | ||
|
|
9232a220dc | ||
|
|
45a2fd9d63 | ||
|
|
7e40e3697f | ||
|
|
beaabd5b44 | ||
|
|
db5c69e228 | ||
|
|
a23a2c03d3 | ||
|
|
c2c040dd42 | ||
|
|
c98358cb26 | ||
|
|
d384a9b71b | ||
|
|
fc04ad26ce | ||
|
|
b5160c47b3 | ||
|
|
3af8273d6b | ||
|
|
81edfece6a | ||
|
|
ab26004c4c | ||
|
|
ffedca3954 | ||
|
|
f7ef948cf0 | ||
|
|
ba828b2194 | ||
|
|
16ef503174 | ||
|
|
7bfb9818f6 | ||
|
|
dcbba5ea9d | ||
|
|
6b40da5779 | ||
|
|
941835ccf2 | ||
|
|
4ec10a9b4d | ||
|
|
6a48183a35 | ||
|
|
62b080a50e | ||
|
|
0dc7fcc64a | ||
|
|
354867baa7 | ||
|
|
4c1e3c82e4 | ||
|
|
1e689ac098 | ||
|
|
16ab7ffbc7 | ||
|
|
b35e2a8c45 | ||
|
|
a07d5b82b3 | ||
|
|
ca1420e604 | ||
|
|
8e59ebb754 | ||
|
|
cc2ee5ea78 | ||
|
|
774e0e8f7b | ||
|
|
acb3d31a07 | ||
|
|
9136332f42 | ||
|
|
e0f5f48eef | ||
|
|
e7a772472e | ||
|
|
0a97cbaada | ||
|
|
8a879c7fca | ||
|
|
5b659fe2e5 | ||
|
|
42c718666c | ||
|
|
f3a181a792 | ||
|
|
148d7fc0a9 | ||
|
|
e42fcb797f | ||
|
|
31fb23a170 | ||
|
|
69c2afe8e4 | ||
|
|
bc1effd2a2 | ||
|
|
3b0c04c2e9 | ||
|
|
77cb4b3157 | ||
|
|
3e11a62a3f | ||
|
|
084f447c6e | ||
|
|
55c8256900 | ||
|
|
b64e9ed675 | ||
|
|
dc2060fc7b | ||
|
|
0e37fea9f5 | ||
|
|
7bb526b83a | ||
|
|
2885fc2443 | ||
|
|
d05806b9e9 | ||
|
|
3f2f463bc3 | ||
|
|
557293af31 | ||
|
|
114ad1df0d | ||
|
|
0fe275fbd2 | ||
|
|
93f04f7aaa | ||
|
|
4bbcb8bb5d | ||
|
|
361d36272e | ||
|
|
8bb1b22d46 | ||
|
|
1090c52410 | ||
|
|
e528f95b2e | ||
|
|
f3058c9597 | ||
|
|
a05ca97409 | ||
|
|
2d92b73e5f | ||
|
|
366eeb7d61 | ||
|
|
26d71530f5 | ||
|
|
3a01a00d51 | ||
|
|
33f3ee15fe |
@@ -1,6 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
|
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
|
||||||
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
|
extends: [
|
||||||
|
"plugin:matrix-org/babel",
|
||||||
|
"plugin:matrix-org/react",
|
||||||
|
"plugin:matrix-org/a11y",
|
||||||
|
"plugin:storybook/recommended",
|
||||||
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ["./tsconfig.json"],
|
project: ["./tsconfig.json"],
|
||||||
},
|
},
|
||||||
|
|||||||
13
.github/workflows/docker.yaml
vendored
@@ -139,3 +139,16 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
repository: vectorim/element-web
|
repository: vectorim/element-web
|
||||||
|
|
||||||
|
- name: Repository Dispatch
|
||||||
|
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
repository: element-hq/element-web-pro
|
||||||
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
event-type: image-built
|
||||||
|
# Stable way to determine the :version
|
||||||
|
client-payload: |-
|
||||||
|
{
|
||||||
|
"base-ref": "${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}"
|
||||||
|
}
|
||||||
|
|||||||
51
.github/workflows/shared-component-visual-tests-netlify.yaml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Triggers after the shared component tests have finished,
|
||||||
|
# It uploads the received images and diffs to netlify, printing the URLs to the console
|
||||||
|
name: Upload Shared Component Visual Test Diffs
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Shared Component Visual Tests"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||||
|
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
report:
|
||||||
|
if: github.event.workflow_run.conclusion == 'failure'
|
||||||
|
name: Upload Diffs
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
environment: Netlify
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
deployments: write
|
||||||
|
steps:
|
||||||
|
- name: Install tree
|
||||||
|
run: "sudo apt-get install -y tree"
|
||||||
|
|
||||||
|
- name: Download Diffs
|
||||||
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
name: received-images
|
||||||
|
path: received-images
|
||||||
|
|
||||||
|
- name: Generate Index
|
||||||
|
run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ."
|
||||||
|
|
||||||
|
- name: 📤 Deploy to Netlify
|
||||||
|
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
|
||||||
|
with:
|
||||||
|
path: received-images
|
||||||
|
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||||
|
branch: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
revision: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
site_id: ${{ vars.NETLIFY_SITE_ID }}
|
||||||
|
desc: Shared Component Visual Diffs
|
||||||
|
deployment_env: SharedComponentDiffs
|
||||||
|
prefix: "diffs-"
|
||||||
70
.github/workflows/shared-component-visual-tests.yaml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Shared Component Visual Tests
|
||||||
|
on:
|
||||||
|
pull_request: {}
|
||||||
|
merge_group:
|
||||||
|
types: [checks_requested]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions: {} # No permissions required
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
testStorybook:
|
||||||
|
name: "Run Visual Tests"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
issues: read
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
repository: element-hq/element-web
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
cache: "yarn"
|
||||||
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Get installed Playwright version
|
||||||
|
id: playwright
|
||||||
|
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cache playwright binaries
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: "yarn playwright install --with-deps --only-shell"
|
||||||
|
|
||||||
|
- name: Build Element Web resources
|
||||||
|
# Needed to prepare language files
|
||||||
|
run: "yarn build:res"
|
||||||
|
|
||||||
|
- name: Build storybook dependencies
|
||||||
|
# When the first test is ran, it will fail because the dependencies are not yet built.
|
||||||
|
# This step is to ensure that the dependencies are built before running the tests.
|
||||||
|
run: "yarn test:storybook:ci"
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run Visual tests
|
||||||
|
run: "yarn test:storybook:ci"
|
||||||
|
|
||||||
|
- name: Upload received images & diffs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: received-images
|
||||||
|
path: playwright/shared-component-received
|
||||||
4
.github/workflows/triage-stale.yml
vendored
@@ -15,12 +15,14 @@ jobs:
|
|||||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||||
with:
|
with:
|
||||||
operations-per-run: 100
|
operations-per-run: 100
|
||||||
|
|
||||||
# Flaky test issue closing
|
# Flaky test issue closing
|
||||||
only-issue-labels: "Z-Flaky-Test"
|
any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit"
|
||||||
days-before-issue-stale: 14
|
days-before-issue-stale: 14
|
||||||
days-before-issue-close: 0
|
days-before-issue-close: 0
|
||||||
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
|
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
|
||||||
exempt-issue-labels: "Z-Flaky-Test-Disabled"
|
exempt-issue-labels: "Z-Flaky-Test-Disabled"
|
||||||
|
|
||||||
# Stale PR closing
|
# Stale PR closing
|
||||||
days-before-pr-stale: 180
|
days-before-pr-stale: 180
|
||||||
days-before-pr-close: 0
|
days-before-pr-close: 0
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -31,3 +31,6 @@ electron/pub
|
|||||||
/index.html
|
/index.html
|
||||||
# version file and tarball created by `npm pack` / `yarn pack`
|
# version file and tarball created by `npm pack` / `yarn pack`
|
||||||
/git-revision.txt
|
/git-revision.txt
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
28
.storybook/ElementTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from "storybook/theming";
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
base: "light",
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
textColor: "#1b1d22",
|
||||||
|
colorSecondary: "#111111",
|
||||||
|
|
||||||
|
// UI
|
||||||
|
appBg: "#ffffff",
|
||||||
|
appContentBg: "#ffffff",
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
barBg: "#ffffff",
|
||||||
|
|
||||||
|
brandTitle: "Element Web",
|
||||||
|
brandUrl: "https://github.com/element-hq/element-web",
|
||||||
|
brandImage: "https://element.io/images/logo-ele-secondary.svg",
|
||||||
|
brandTarget: "_self",
|
||||||
|
});
|
||||||
61
.storybook/languageAddon.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Addon, types, useGlobals } from "storybook/manager-api";
|
||||||
|
import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
|
||||||
|
import React from "react";
|
||||||
|
import { GlobeIcon } from "@storybook/icons";
|
||||||
|
|
||||||
|
// We can't import `shared/i18n.tsx` directly here.
|
||||||
|
// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
|
||||||
|
import json from "../webapp/i18n/languages.json";
|
||||||
|
const languages = Object.keys(json).filter((lang) => lang !== "default");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the title of a language in the user's locale.
|
||||||
|
*/
|
||||||
|
function languageTitle(language: string): string {
|
||||||
|
return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || language;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageAddon: Addon = {
|
||||||
|
title: "Language Selector",
|
||||||
|
type: types.TOOL,
|
||||||
|
render: ({ active }) => {
|
||||||
|
const [globals, updateGlobals] = useGlobals();
|
||||||
|
const selectedLanguage = globals.language || "en";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WithTooltip
|
||||||
|
placement="top"
|
||||||
|
trigger="click"
|
||||||
|
closeOnOutsideClick
|
||||||
|
tooltip={({ onHide }) => {
|
||||||
|
return (
|
||||||
|
<TooltipLinkList
|
||||||
|
links={languages.map((language) => ({
|
||||||
|
id: language,
|
||||||
|
title: languageTitle(language),
|
||||||
|
active: selectedLanguage === language,
|
||||||
|
onClick: async () => {
|
||||||
|
// Update the global state with the selected language
|
||||||
|
updateGlobals({ language });
|
||||||
|
onHide();
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton title="Language">
|
||||||
|
<GlobeIcon />
|
||||||
|
{languageTitle(selectedLanguage)}
|
||||||
|
</IconButton>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
40
.storybook/main.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
import path from "node:path";
|
||||||
|
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||||
|
import { mergeConfig } from "vite";
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
|
staticDirs: ["../webapp"],
|
||||||
|
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
|
||||||
|
framework: "@storybook/react-vite",
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
reactDocgen: "react-docgen-typescript",
|
||||||
|
},
|
||||||
|
async viteFinal(config) {
|
||||||
|
return mergeConfig(config, {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Alias used by i18n.tsx
|
||||||
|
$webapp: path.resolve("webapp"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Needed for counterpart to work
|
||||||
|
plugins: [nodePolyfills({ include: ["process", "util"] })],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ["localhost", ".docker.internal"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
18
.storybook/manager.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { addons } from "storybook/manager-api";
|
||||||
|
import ElementTheme from "./ElementTheme";
|
||||||
|
import { languageAddon } from "./languageAddon";
|
||||||
|
|
||||||
|
addons.setConfig({
|
||||||
|
theme: ElementTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
addons.register("elementhq/language", () => addons.add("language", languageAddon));
|
||||||
10
.storybook/preview.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.docs-story {
|
||||||
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
|
}
|
||||||
106
.storybook/preview.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
|
||||||
|
import { addons } from "storybook/preview-api";
|
||||||
|
|
||||||
|
import "../res/css/shared.pcss";
|
||||||
|
import "./preview.css";
|
||||||
|
import React, { useLayoutEffect } from "react";
|
||||||
|
import { FORCE_RE_RENDER } from "storybook/internal/core-events";
|
||||||
|
import { setLanguage } from "../src/shared-components/utils/i18n";
|
||||||
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
export const globalTypes = {
|
||||||
|
theme: {
|
||||||
|
name: "Theme",
|
||||||
|
description: "Global theme for components",
|
||||||
|
toolbar: {
|
||||||
|
icon: "circlehollow",
|
||||||
|
title: "Theme",
|
||||||
|
items: [
|
||||||
|
{ title: "System", value: "system", icon: "browser" },
|
||||||
|
{ title: "Light", value: "light", icon: "sun" },
|
||||||
|
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
|
||||||
|
{ title: "Dark", value: "dark", icon: "moon" },
|
||||||
|
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
name: "Language",
|
||||||
|
description: "Global language for components",
|
||||||
|
},
|
||||||
|
initialGlobals: {
|
||||||
|
theme: "system",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
} satisfies ArgTypes;
|
||||||
|
|
||||||
|
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
|
||||||
|
|
||||||
|
const ThemeSwitcher: React.FC<{
|
||||||
|
theme: string;
|
||||||
|
}> = ({ theme }) => {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
document.documentElement.classList.remove(...allThemesClasses);
|
||||||
|
if (theme !== "system") {
|
||||||
|
document.documentElement.classList.add(`cpd-theme-${theme}`);
|
||||||
|
}
|
||||||
|
return () => document.documentElement.classList.remove(...allThemesClasses);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withThemeProvider: Decorator = (Story, context) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ThemeSwitcher theme={context.globals.theme} />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LanguageSwitcher: React.FC<{
|
||||||
|
language: string;
|
||||||
|
}> = ({ language }) => {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const changeLanguage = async (language: string) => {
|
||||||
|
await setLanguage(language);
|
||||||
|
// Force the component to re-render to apply the new language
|
||||||
|
addons.getChannel().emit(FORCE_RE_RENDER);
|
||||||
|
};
|
||||||
|
changeLanguage(language);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withLanguageProvider: Decorator = (Story, context) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher language={context.globals.language} />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTooltipProvider: Decorator = (Story) => {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Story />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
tags: ["autodocs"],
|
||||||
|
decorators: [withThemeProvider, withLanguageProvider, withTooltipProvider],
|
||||||
|
parameters: {
|
||||||
|
options: {
|
||||||
|
storySort: {
|
||||||
|
method: "alphabetical",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
37
.storybook/test-runner.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { waitForPageReady } from "@storybook/test-runner";
|
||||||
|
import { toMatchImageSnapshot } from "jest-image-snapshot";
|
||||||
|
|
||||||
|
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
|
||||||
|
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('@storybook/test-runner').TestRunnerConfig}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
setup(page) {
|
||||||
|
expect.extend({ toMatchImageSnapshot });
|
||||||
|
},
|
||||||
|
async postVisit(page, context) {
|
||||||
|
await waitForPageReady(page);
|
||||||
|
|
||||||
|
// If you want to take screenshot of multiple browsers, use
|
||||||
|
// page.context().browser().browserType().name() to get the browser name to prefix the file name
|
||||||
|
const image = await page.screenshot();
|
||||||
|
expect(image).toMatchImageSnapshot({
|
||||||
|
customSnapshotsDir,
|
||||||
|
customSnapshotIdentifier: `${context.id}-${process.platform}`,
|
||||||
|
storeReceivedOnFailure: true,
|
||||||
|
customReceivedDir,
|
||||||
|
customDiffDir: customReceivedDir,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -19,3 +19,6 @@ include:
|
|||||||
|
|
||||||
* Thom Cleary (https://github.com/thomcatdotrocks)
|
* Thom Cleary (https://github.com/thomcatdotrocks)
|
||||||
Small update for tarball deployment
|
Small update for tarball deployment
|
||||||
|
|
||||||
|
* Alexander (https://github.com/ioalexander)
|
||||||
|
Save image on CTRL + S shortcut
|
||||||
|
|||||||
44
CHANGELOG.md
@@ -1,3 +1,47 @@
|
|||||||
|
Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30)
|
||||||
|
====================================================================================================
|
||||||
|
## 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot.
|
||||||
|
|
||||||
|
|
||||||
|
Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29)
|
||||||
|
====================================================================================================
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
* Message preview should show tooltip with the full message on hover ([#30265](https://github.com/element-hq/element-web/pull/30265)). Contributed by @MidhunSureshR.
|
||||||
|
* Support rendering notification badges on platforms that do their own icon overlays ([#30315](https://github.com/element-hq/element-web/pull/30315)). Contributed by @Half-Shot.
|
||||||
|
* Add SubscriptionViewModel base class ([#30297](https://github.com/element-hq/element-web/pull/30297)). Contributed by @dbkr.
|
||||||
|
* Enhancement: Save image on CTRL+S ([#30330](https://github.com/element-hq/element-web/pull/30330)). Contributed by @ioalexander.
|
||||||
|
* Add quote functionality to MessageContextMenu (#29893) ([#30323](https://github.com/element-hq/element-web/pull/30323)). Contributed by @AlirezaMrtz.
|
||||||
|
* Initial structure for shared component views ([#30216](https://github.com/element-hq/element-web/pull/30216)). Contributed by @dbkr.
|
||||||
|
|
||||||
|
## 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* [Backport staging] Fix e2e shield being invisible in white mode for encrypted room ([#30411](https://github.com/element-hq/element-web/pull/30411)). Contributed by @RiotRobot.
|
||||||
|
* Force ED titlebar color for new room list ([#30332](https://github.com/element-hq/element-web/pull/30332)). Contributed by @florianduros.
|
||||||
|
* Add a background color to left panel for macos titlebar in element desktop ([#30328](https://github.com/element-hq/element-web/pull/30328)). Contributed by @florianduros.
|
||||||
|
* Fix: Prevent page refresh on Enter key in right panel member search ([#30312](https://github.com/element-hq/element-web/pull/30312)). Contributed by @AlirezaMrtz.
|
||||||
|
|
||||||
|
|
||||||
|
Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
|
||||||
|
====================================================================================================
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot.
|
||||||
|
* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot.
|
||||||
|
* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave.
|
||||||
|
* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot.
|
||||||
|
* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown.
|
||||||
|
|
||||||
|
## 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot.
|
||||||
|
* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii.
|
||||||
|
* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg.
|
||||||
|
* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy.
|
||||||
|
|
||||||
|
|
||||||
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
|
Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
|
||||||
====================================================================================================
|
====================================================================================================
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
|
# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
|
||||||
|
|
||||||
# Builder
|
# Builder
|
||||||
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder
|
FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:a80324457a2c8d09c83ff9edf2bdf71f378d3288de920e68a358bd3c484b8c4a AS builder
|
||||||
|
|
||||||
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
|
||||||
ARG USE_CUSTOM_SDKS=false
|
ARG USE_CUSTOM_SDKS=false
|
||||||
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
|
|||||||
RUN cp /src/config.sample.json /src/webapp/config.json
|
RUN cp /src/config.sample.json /src/webapp/config.json
|
||||||
|
|
||||||
# App
|
# App
|
||||||
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00
|
FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:86df552d36eb24c45d3f5becf6423bd056a3fd235d7085fe3d5ea28ba89a8232
|
||||||
|
|
||||||
# Need root user to install packages & manipulate the usr directory
|
# Need root user to install packages & manipulate the usr directory
|
||||||
USER root
|
USER root
|
||||||
|
|||||||
8
declaration.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare module "*.module.css";
|
||||||
@@ -17,11 +17,13 @@ const config: Config = {
|
|||||||
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
|
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
|
||||||
customExportConditions: ["browser", "node"],
|
customExportConditions: ["browser", "node"],
|
||||||
},
|
},
|
||||||
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"],
|
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
|
||||||
globalSetup: "<rootDir>/test/globalSetup.ts",
|
globalSetup: "<rootDir>/test/globalSetup.ts",
|
||||||
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
|
// Support CSS module
|
||||||
|
"\\.(module.css)$": "identity-obj-proxy",
|
||||||
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
|
"\\.(css|scss|pcss)$": "<rootDir>/__mocks__/cssMock.js",
|
||||||
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
"\\.(gif|png|ttf|woff2)$": "<rootDir>/__mocks__/imageMock.js",
|
||||||
"\\.svg$": "<rootDir>/__mocks__/svg.js",
|
"\\.svg$": "<rootDir>/__mocks__/svg.js",
|
||||||
|
|||||||
39
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "element-web",
|
"name": "element-web",
|
||||||
"version": "1.11.105",
|
"version": "1.11.108",
|
||||||
"description": "Element: the future of secure communication",
|
"description": "Element: the future of secure communication",
|
||||||
"author": "New Vector Ltd.",
|
"author": "New Vector Ltd.",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -65,11 +65,16 @@
|
|||||||
"coverage": "yarn test --coverage",
|
"coverage": "yarn test --coverage",
|
||||||
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
|
||||||
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
|
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package",
|
||||||
|
"storybook": "storybook dev -p 6007",
|
||||||
|
"build-storybook": "storybook build",
|
||||||
|
"test:storybook": "test-storybook --url http://localhost:6007/",
|
||||||
|
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"",
|
||||||
|
"test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"**/pretty-format/react-is": "19.1.0",
|
"**/pretty-format/react-is": "19.1.0",
|
||||||
"@playwright/test": "1.53.2",
|
"@playwright/test": "1.54.1",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"oidc-client-ts": "3.3.0",
|
"oidc-client-ts": "3.3.0",
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
"@types/react-virtualized": "^9.21.30",
|
"@types/react-virtualized": "^9.21.30",
|
||||||
"@vector-im/compound-design-tokens": "^5.0.0",
|
"@vector-im/compound-design-tokens": "^5.0.0",
|
||||||
"@vector-im/compound-web": "^8.1.2",
|
"@vector-im/compound-web": "^8.1.2",
|
||||||
"@vector-im/matrix-wysiwyg": "2.38.4",
|
"@vector-im/matrix-wysiwyg": "2.39.0",
|
||||||
"@zxcvbn-ts/core": "^3.0.4",
|
"@zxcvbn-ts/core": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||||
@@ -112,7 +117,7 @@
|
|||||||
"emojibase-regex": "15.3.2",
|
"emojibase-regex": "15.3.2",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"filesize": "10.1.6",
|
"filesize": "11.0.2",
|
||||||
"github-markdown-css": "^5.5.1",
|
"github-markdown-css": "^5.5.1",
|
||||||
"glob-to-regexp": "^0.4.1",
|
"glob-to-regexp": "^0.4.1",
|
||||||
"highlight.js": "^11.3.1",
|
"highlight.js": "^11.3.1",
|
||||||
@@ -123,9 +128,9 @@
|
|||||||
"jsrsasign": "^11.0.0",
|
"jsrsasign": "^11.0.0",
|
||||||
"jszip": "^3.7.0",
|
"jszip": "^3.7.0",
|
||||||
"katex": "^0.16.0",
|
"katex": "^0.16.0",
|
||||||
"linkify-react": "4.3.1",
|
"linkify-react": "4.3.2",
|
||||||
"linkify-string": "4.3.1",
|
"linkify-string": "4.3.2",
|
||||||
"linkifyjs": "4.3.1",
|
"linkifyjs": "4.3.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"maplibre-gl": "^5.0.0",
|
"maplibre-gl": "^5.0.0",
|
||||||
"matrix-encrypt-attachment": "^1.0.3",
|
"matrix-encrypt-attachment": "^1.0.3",
|
||||||
@@ -138,7 +143,7 @@
|
|||||||
"opus-recorder": "^8.0.3",
|
"opus-recorder": "^8.0.3",
|
||||||
"pako": "^2.0.3",
|
"pako": "^2.0.3",
|
||||||
"png-chunks-extract": "^1.0.0",
|
"png-chunks-extract": "^1.0.0",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.257.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"re-resizable": "6.11.2",
|
"re-resizable": "6.11.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -149,6 +154,7 @@
|
|||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"react-transition-group": "^4.4.1",
|
"react-transition-group": "^4.4.1",
|
||||||
"react-virtualized": "^9.22.5",
|
"react-virtualized": "^9.22.5",
|
||||||
|
"react-virtuoso": "^4.12.6",
|
||||||
"rfc4648": "^1.4.0",
|
"rfc4648": "^1.4.0",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sanitize-html": "2.17.0",
|
"sanitize-html": "2.17.0",
|
||||||
@@ -181,12 +187,17 @@
|
|||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||||
"@element-hq/element-call-embedded": "0.13.1",
|
"@element-hq/element-call-embedded": "0.13.1",
|
||||||
"@element-hq/element-web-playwright-common": "^1.4.2",
|
"@element-hq/element-web-playwright-common": "^1.4.4",
|
||||||
"@peculiar/webcrypto": "^1.4.3",
|
"@peculiar/webcrypto": "^1.4.3",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||||
"@rrweb/types": "^2.0.0-alpha.18",
|
"@rrweb/types": "^2.0.0-alpha.18",
|
||||||
"@sentry/webpack-plugin": "^3.0.0",
|
"@sentry/webpack-plugin": "^4.0.0",
|
||||||
|
"@storybook/addon-designs": "^10.0.1",
|
||||||
|
"@storybook/addon-docs": "^9.0.12",
|
||||||
|
"@storybook/icons": "^1.4.0",
|
||||||
|
"@storybook/react-vite": "^9.0.15",
|
||||||
|
"@storybook/test-runner": "^0.23.0",
|
||||||
"@stylistic/eslint-plugin": "^5.0.0",
|
"@stylistic/eslint-plugin": "^5.0.0",
|
||||||
"@svgr/webpack": "^8.0.0",
|
"@svgr/webpack": "^8.0.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
@@ -246,6 +257,7 @@
|
|||||||
"eslint-plugin-react": "^7.28.0",
|
"eslint-plugin-react": "^7.28.0",
|
||||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-storybook": "^9.0.12",
|
||||||
"eslint-plugin-unicorn": "^56.0.0",
|
"eslint-plugin-unicorn": "^56.0.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"fake-indexeddb": "^6.0.0",
|
"fake-indexeddb": "^6.0.0",
|
||||||
@@ -254,9 +266,11 @@
|
|||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
"html-webpack-plugin": "^5.5.3",
|
"html-webpack-plugin": "^5.5.3",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^29.6.2",
|
"jest": "^29.6.2",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-image-snapshot": "^6.5.1",
|
||||||
"jest-mock": "^29.6.2",
|
"jest-mock": "^29.6.2",
|
||||||
"jest-raw-loader": "^1.0.1",
|
"jest-raw-loader": "^1.0.1",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
@@ -285,6 +299,7 @@
|
|||||||
"rimraf": "^6.0.0",
|
"rimraf": "^6.0.0",
|
||||||
"semver": "^7.5.2",
|
"semver": "^7.5.2",
|
||||||
"source-map-loader": "^5.0.0",
|
"source-map-loader": "^5.0.0",
|
||||||
|
"storybook": "^9.0.12",
|
||||||
"stylelint": "^16.13.0",
|
"stylelint": "^16.13.0",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
"stylelint-scss": "^6.0.0",
|
"stylelint-scss": "^6.0.0",
|
||||||
@@ -294,6 +309,8 @@
|
|||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
|
"vite": "^7.0.1",
|
||||||
|
"vite-plugin-node-polyfills": "^0.24.0",
|
||||||
"web-streams-polyfill": "^4.0.0",
|
"web-streams-polyfill": "^4.0.0",
|
||||||
"webpack": "^5.89.0",
|
"webpack": "^5.89.0",
|
||||||
"webpack-bundle-analyzer": "^4.8.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
|
|||||||
46
patches/@types+mdx+2.0.13.patch
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
|
||||||
|
index 498bb69..4e89216 100644
|
||||||
|
--- a/node_modules/@types/mdx/types.d.ts
|
||||||
|
+++ b/node_modules/@types/mdx/types.d.ts
|
||||||
|
@@ -5,7 +5,7 @@
|
||||||
|
*/
|
||||||
|
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
|
||||||
|
// defined or not.
|
||||||
|
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
|
||||||
|
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This matches any function component types that ar part of `ElementType`.
|
||||||
|
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
|
||||||
|
/**
|
||||||
|
* A valid JSX string component.
|
||||||
|
*/
|
||||||
|
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
|
||||||
|
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A JSX element returned by MDX content.
|
||||||
|
*/
|
||||||
|
-export type Element = JSX.Element;
|
||||||
|
+export type Element = React.JSX.Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid JSX function component.
|
||||||
|
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
|
||||||
|
*/
|
||||||
|
type ClassComponent<Props> = ElementType extends never
|
||||||
|
// If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass
|
||||||
|
- ? new(props: Props) => JSX.ElementClass
|
||||||
|
+ ? new(props: Props) => React.JSX.ElementClass
|
||||||
|
: ClassElementType extends never
|
||||||
|
// If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed.
|
||||||
|
? never
|
||||||
|
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
|
||||||
|
export type MDXComponents =
|
||||||
|
& NestedMDXComponents
|
||||||
|
& {
|
||||||
|
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
|
||||||
|
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
|
||||||
|
}
|
||||||
|
& {
|
||||||
|
/**
|
||||||
@@ -158,6 +158,8 @@ test.describe("Cryptography", function () {
|
|||||||
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
|
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
|
||||||
await checkDMRoom(page);
|
await checkDMRoom(page);
|
||||||
const bobRoomId = await bobJoin(page, bob);
|
const bobRoomId = await bobJoin(page, bob);
|
||||||
|
await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon-normal.png");
|
||||||
|
|
||||||
await testMessages(page, bob, bobRoomId);
|
await testMessages(page, bob, bobRoomId);
|
||||||
await verify(app, bob);
|
await verify(app, bob);
|
||||||
|
|
||||||
@@ -168,6 +170,7 @@ test.describe("Cryptography", function () {
|
|||||||
|
|
||||||
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
|
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
|
||||||
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
|
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
|
||||||
|
await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,31 +48,38 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
|||||||
return promiseVerificationRequest;
|
return promiseVerificationRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
|
test(
|
||||||
await logIntoElement(page, credentials);
|
"Verify device with SAS during login",
|
||||||
|
{ tag: "@screenshot" },
|
||||||
|
async ({ page, app, credentials, homeserver }) => {
|
||||||
|
await logIntoElement(page, credentials);
|
||||||
|
|
||||||
// Launch the verification request between alice and the bot
|
// Launch the verification request between alice and the bot
|
||||||
const verificationRequest = await initiateAliceVerificationRequest(page);
|
const verificationRequest = await initiateAliceVerificationRequest(page);
|
||||||
|
|
||||||
// Handle emoji SAS verification
|
// Handle emoji SAS verification
|
||||||
const infoDialog = page.locator(".mx_InfoDialog");
|
const infoDialog = page.locator(".mx_InfoDialog");
|
||||||
// the bot chooses to do an emoji verification
|
// the bot chooses to do an emoji verification
|
||||||
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
|
const verifier = await verificationRequest.evaluateHandle((request) =>
|
||||||
|
request.startVerification("m.sas.v1"),
|
||||||
|
);
|
||||||
|
|
||||||
// Handle emoji request and check that emojis are matching
|
// Handle emoji request and check that emojis are matching
|
||||||
await doTwoWaySasVerification(page, verifier);
|
await doTwoWaySasVerification(page, verifier);
|
||||||
|
|
||||||
await infoDialog.getByRole("button", { name: "They match" }).click();
|
await infoDialog.getByRole("button", { name: "They match" }).click();
|
||||||
await infoDialog.getByRole("button", { name: "Got it" }).click();
|
await expect(page.locator(".mx_E2EIcon_verified")).toMatchScreenshot("device-verified-e2eIcon.png");
|
||||||
|
await infoDialog.getByRole("button", { name: "Got it" }).click();
|
||||||
|
|
||||||
// Check that our device is now cross-signed
|
// Check that our device is now cross-signed
|
||||||
await checkDeviceIsCrossSigned(app);
|
await checkDeviceIsCrossSigned(app);
|
||||||
|
|
||||||
// Check that the current device is connected to key backup
|
// Check that the current device is connected to key backup
|
||||||
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
||||||
// as we need to wait for the secret gossiping to happen.
|
// as we need to wait for the secret gossiping to happen.
|
||||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Regression test for https://github.com/element-hq/element-web/issues/29110
|
// Regression test for https://github.com/element-hq/element-web/issues/29110
|
||||||
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
|
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
|
||||||
@@ -117,6 +124,10 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
|||||||
const toasts = new Toasts(page);
|
const toasts = new Toasts(page);
|
||||||
await toasts.rejectToast("Notifications");
|
await toasts.rejectToast("Notifications");
|
||||||
await toasts.assertNoToasts();
|
await toasts.assertNoToasts();
|
||||||
|
|
||||||
|
// There may still be a `/sendToDevice/m.secret.request` in flight, which will later throw an error and cause
|
||||||
|
// a *subsequent* test to fail. Tell playwright to ignore any errors resulting from in-flight routes.
|
||||||
|
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
|
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
|
||||||
|
// The charlies were running off the bottom of the screen.
|
||||||
|
// We no longer overscan the member list so the result is they are not in the dom.
|
||||||
|
// Increase the viewport size to ensure they are.
|
||||||
|
await page.setViewportSize({ width: 1000, height: 1000 });
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
const displayName = `Charly #${i}`;
|
const displayName = `Charly #${i}`;
|
||||||
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ test.describe("Header section of the room list", () => {
|
|||||||
|
|
||||||
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
|
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
|
||||||
|
|
||||||
// New message should open the direct messages dialog
|
// Start chat should open the direct messages dialog
|
||||||
await page.getByRole("menuitem", { name: "New message" }).click();
|
await page.getByRole("menuitem", { name: "Start chat" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
|
||||||
await app.closeDialog();
|
await app.closeDialog();
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ test.describe("Room list", () => {
|
|||||||
// Put focus on the room list
|
// Put focus on the room list
|
||||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||||
// Scroll to the end of the room list
|
// Scroll to the end of the room list
|
||||||
await page.mouse.wheel(0, 1000);
|
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
|
||||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
|
||||||
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
|
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,10 +119,8 @@ test.describe("Room list", () => {
|
|||||||
// Put focus on the room list
|
// Put focus on the room list
|
||||||
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
|
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
|
||||||
|
|
||||||
while (!(await roomItem.isVisible())) {
|
// Scroll to the end of the room list
|
||||||
// Scroll to the end of the room list
|
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
|
||||||
await page.mouse.wheel(0, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The room decoration should have the muted icon
|
// The room decoration should have the muted icon
|
||||||
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
|
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
|
||||||
@@ -144,7 +141,7 @@ test.describe("Room list", () => {
|
|||||||
// Put focus on the room list
|
// Put focus on the room list
|
||||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||||
// Scroll to the end of the room list
|
// Scroll to the end of the room list
|
||||||
await page.mouse.wheel(0, 1000);
|
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
|
||||||
|
|
||||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
||||||
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
|
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
|
||||||
|
|||||||
@@ -11,6 +11,32 @@ import { Bot } from "../../pages/bot";
|
|||||||
const ROOM_NAME = "Test room";
|
const ROOM_NAME = "Test room";
|
||||||
const NAME = "Alice";
|
const NAME = "Alice";
|
||||||
|
|
||||||
|
async function setupRoomWithMembers(
|
||||||
|
app: any,
|
||||||
|
page: any,
|
||||||
|
homeserver: any,
|
||||||
|
roomName: string,
|
||||||
|
memberNames: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public);
|
||||||
|
const id = await app.client.createRoom({ name: roomName, visibility });
|
||||||
|
const bots: Bot[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < memberNames.length; i++) {
|
||||||
|
const displayName = memberNames[i];
|
||||||
|
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
|
||||||
|
if (displayName === "Susan") {
|
||||||
|
await bot.prepareClient();
|
||||||
|
await app.client.inviteUser(id, bot.credentials?.userId);
|
||||||
|
} else {
|
||||||
|
await bot.joinRoom(id);
|
||||||
|
}
|
||||||
|
bots.push(bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
test.use({
|
test.use({
|
||||||
synapseConfig: {
|
synapseConfig: {
|
||||||
presence: {
|
presence: {
|
||||||
@@ -25,17 +51,8 @@ test.use({
|
|||||||
test.describe("Memberlist", () => {
|
test.describe("Memberlist", () => {
|
||||||
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
|
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
|
||||||
testInfo.setTimeout(testInfo.timeout + 30_000);
|
testInfo.setTimeout(testInfo.timeout + 30_000);
|
||||||
const id = await app.client.createRoom({ name: ROOM_NAME });
|
|
||||||
const newBots: Bot[] = [];
|
|
||||||
const names = ["Bob", "Bob", "Susan"];
|
const names = ["Bob", "Bob", "Susan"];
|
||||||
for (let i = 0; i < 3; i++) {
|
await setupRoomWithMembers(app, page, homeserver, ROOM_NAME, names);
|
||||||
const displayName = names[i];
|
|
||||||
const autoAcceptInvites = displayName !== "Susan";
|
|
||||||
const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites });
|
|
||||||
await bot.prepareClient();
|
|
||||||
await app.client.inviteUser(id, bot.credentials?.userId);
|
|
||||||
newBots.push(bot);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => {
|
test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||||
@@ -45,4 +62,37 @@ test.describe("Memberlist", () => {
|
|||||||
await expect(memberlist.getByText("Invited")).toHaveCount(1);
|
await expect(memberlist.getByText("Invited")).toHaveCount(1);
|
||||||
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
|
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should handle scroll and click to view member profile", async ({ page, app, homeserver }) => {
|
||||||
|
// Create a room with many members to enable scrolling
|
||||||
|
const memberNames = Array.from({ length: 15 }, (_, i) => `Member${i.toString()}`);
|
||||||
|
await setupRoomWithMembers(app, page, homeserver, "Large Room", memberNames);
|
||||||
|
|
||||||
|
// Navigate to the room and open member list
|
||||||
|
await app.viewRoomByName("Large Room");
|
||||||
|
|
||||||
|
const memberlist = await app.toggleMemberlistPanel();
|
||||||
|
|
||||||
|
// Get the scrollable container
|
||||||
|
const memberListContainer = memberlist.locator(".mx_AutoHideScrollbar");
|
||||||
|
|
||||||
|
// Scroll down to the bottom of the member list
|
||||||
|
await app.scrollListToBottom(memberListContainer);
|
||||||
|
|
||||||
|
// Wait for the target member to be visible after scrolling
|
||||||
|
const targetName = "Member14";
|
||||||
|
const targetMember = memberlist.locator(".mx_MemberTileView_name").filter({ hasText: targetName });
|
||||||
|
await targetMember.waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
// Verify Alice is not visible at this point
|
||||||
|
await expect(memberlist.locator(".mx_MemberTileView_name").filter({ hasText: "Alice" })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Click on a member near the bottom of the list
|
||||||
|
await expect(targetMember).toBeVisible();
|
||||||
|
await targetMember.click();
|
||||||
|
|
||||||
|
// Verify that the user info screen is shown and hasn't scrolled back to top
|
||||||
|
await expect(page.locator(".mx_UserInfo")).toBeVisible();
|
||||||
|
await expect(page.locator(".mx_UserInfo_profile").getByText(targetName)).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test.describe("Share dialog", () => {
|
|||||||
|
|
||||||
const rightPanel = await app.toggleRoomInfoPanel();
|
const rightPanel = await app.toggleRoomInfoPanel();
|
||||||
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
await rightPanel.getByRole("menuitem", { name: "People" }).click();
|
||||||
await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click();
|
await rightPanel.getByRole("option", { name: user.displayName }).click();
|
||||||
await rightPanel.getByRole("button", { name: "Share profile" }).click();
|
await rightPanel.getByRole("button", { name: "Share profile" }).click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog", { name: "Share User" });
|
const dialog = page.getByRole("dialog", { name: "Share User" });
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function openSpaceContextMenu(page: Page, app: ElementAppPage, spaceName:
|
|||||||
return page.locator(".mx_SpacePanel_contextMenu");
|
return page.locator(".mx_SpacePanel_contextMenu");
|
||||||
}
|
}
|
||||||
|
|
||||||
function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts {
|
function spaceCreateOptions(serverName: string, spaceName: string, roomIds: string[] = []): ICreateRoomOpts {
|
||||||
return {
|
return {
|
||||||
creation_content: {
|
creation_content: {
|
||||||
type: "m.space",
|
type: "m.space",
|
||||||
@@ -35,17 +35,21 @@ function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateR
|
|||||||
name: spaceName,
|
name: spaceName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...roomIds.map((r) => spaceChildInitialState(r)),
|
...roomIds.map((r) => spaceChildInitialState(serverName, r)),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function spaceChildInitialState(roomId: string, order?: string): ICreateRoomOpts["initial_state"]["0"] {
|
function spaceChildInitialState(
|
||||||
|
serverName: string,
|
||||||
|
roomId: string,
|
||||||
|
order?: string,
|
||||||
|
): ICreateRoomOpts["initial_state"]["0"] {
|
||||||
return {
|
return {
|
||||||
type: "m.space.child",
|
type: "m.space.child",
|
||||||
state_key: roomId,
|
state_key: roomId,
|
||||||
content: {
|
content: {
|
||||||
via: [roomId.split(":")[1]],
|
via: [serverName],
|
||||||
order,
|
order,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -240,7 +244,7 @@ test.describe("Spaces", () => {
|
|||||||
});
|
});
|
||||||
await expect(await app.getSpacePanelButton("My Space")).toBeVisible();
|
await expect(await app.getSpacePanelButton("My Space")).toBeVisible();
|
||||||
|
|
||||||
const roomId = await bot.createRoom(spaceCreateOptions("Space Space"));
|
const roomId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Space Space"));
|
||||||
await bot.inviteUser(roomId, user.userId);
|
await bot.inviteUser(roomId, user.userId);
|
||||||
|
|
||||||
// Assert that `Space Space` is above `My Space` due to it being an invite
|
// Assert that `Space Space` is above `My Space` due to it being an invite
|
||||||
@@ -260,7 +264,10 @@ test.describe("Spaces", () => {
|
|||||||
const spaceName = "Spacey Mc. Space Space";
|
const spaceName = "Spacey Mc. Space Space";
|
||||||
await app.client.createSpace({
|
await app.client.createSpace({
|
||||||
name: spaceName,
|
name: spaceName,
|
||||||
initial_state: [spaceChildInitialState(roomId1), spaceChildInitialState(roomId2)],
|
initial_state: [
|
||||||
|
spaceChildInitialState(user.homeServer, roomId1),
|
||||||
|
spaceChildInitialState(user.homeServer, roomId2),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.viewSpaceHomeByName(spaceName);
|
await app.viewSpaceHomeByName(spaceName);
|
||||||
@@ -287,7 +294,7 @@ test.describe("Spaces", () => {
|
|||||||
});
|
});
|
||||||
await app.client.createSpace({
|
await app.client.createSpace({
|
||||||
name: "Root Space",
|
name: "Root Space",
|
||||||
initial_state: [spaceChildInitialState(childSpaceId)],
|
initial_state: [spaceChildInitialState(user.homeServer, childSpaceId)],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find collapsed Space panel
|
// Find collapsed Space panel
|
||||||
@@ -323,7 +330,7 @@ test.describe("Spaces", () => {
|
|||||||
name: "Test Room",
|
name: "Test Room",
|
||||||
topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link",
|
topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link",
|
||||||
});
|
});
|
||||||
const spaceId = await bot.createRoom(spaceCreateOptions("Test Space", [roomId]));
|
const spaceId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Test Space", [roomId]));
|
||||||
await bot.inviteUser(spaceId, user.userId);
|
await bot.inviteUser(spaceId, user.userId);
|
||||||
|
|
||||||
await expect(await app.getSpacePanelButton("Test Space")).toBeVisible();
|
await expect(await app.getSpacePanelButton("Test Space")).toBeVisible();
|
||||||
@@ -361,9 +368,9 @@ test.describe("Spaces", () => {
|
|||||||
await app.client.createSpace({
|
await app.client.createSpace({
|
||||||
name: "Root Space",
|
name: "Root Space",
|
||||||
initial_state: [
|
initial_state: [
|
||||||
spaceChildInitialState(childSpaceId1, "a"),
|
spaceChildInitialState(user.homeServer, childSpaceId1, "a"),
|
||||||
spaceChildInitialState(childSpaceId2, "b"),
|
spaceChildInitialState(user.homeServer, childSpaceId2, "b"),
|
||||||
spaceChildInitialState(childSpaceId3, "c"),
|
spaceChildInitialState(user.homeServer, childSpaceId3, "c"),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await app.viewSpaceByName("Root Space");
|
await app.viewSpaceByName("Root Space");
|
||||||
|
|||||||
@@ -213,4 +213,26 @@ export class ElementAppPage {
|
|||||||
.getByRole("button", { name: "Dismiss" })
|
.getByRole("button", { name: "Dismiss" })
|
||||||
.click();
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll an infinite list to the bottom.
|
||||||
|
* @param list The element to scroll
|
||||||
|
*/
|
||||||
|
public async scrollListToBottom(list: Locator): Promise<void> {
|
||||||
|
// First hover the mouse over the element that we want to scroll
|
||||||
|
await list.hover();
|
||||||
|
|
||||||
|
const needsScroll = async () => {
|
||||||
|
// From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
|
||||||
|
const fullyScrolled = await list.evaluate(
|
||||||
|
(e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
|
||||||
|
);
|
||||||
|
return !fullyScrolled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll the element until we detect that it is fully scrolled
|
||||||
|
do {
|
||||||
|
await this.page.mouse.wheel(0, 1000);
|
||||||
|
} while (await needsScroll());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 367 B |
|
After Width: | Height: | Size: 268 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||||
|
|
||||||
const TAG = "develop@sha256:aea1d8f371268aed7a5863fa5dde960fb4f9f578cd0a5952cc4da92537f95cfa";
|
const TAG = "develop@sha256:8e478cf4f135467287c17687e80fd859f70db23e1d6cd35a853369ff423c9773";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||||
|
|||||||
@@ -53,8 +53,6 @@
|
|||||||
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
|
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
|
||||||
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
|
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
|
||||||
@import "./components/views/typography/_Caption.pcss";
|
@import "./components/views/typography/_Caption.pcss";
|
||||||
@import "./components/views/utils/_Box.pcss";
|
|
||||||
@import "./components/views/utils/_Flex.pcss";
|
|
||||||
@import "./compound/_Icon.pcss";
|
@import "./compound/_Icon.pcss";
|
||||||
@import "./compound/_SuccessDialog.pcss";
|
@import "./compound/_SuccessDialog.pcss";
|
||||||
@import "./structures/_AutoHideScrollbar.pcss";
|
@import "./structures/_AutoHideScrollbar.pcss";
|
||||||
|
|||||||
9
res/css/shared.pcss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
|
||||||
|
@import url("@vector-im/compound-web/dist/style.css");
|
||||||
@@ -50,7 +50,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
color: var(--cpd-color-text-secondary);
|
color: var(--cpd-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_Flex {
|
.mx_ErrorView_flexContainer {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: max-content;
|
max-width: max-content;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
--collapsedWidth: 68px;
|
--collapsedWidth: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_LeftPanel_newRoomList {
|
|
||||||
/* Thew new rooms list is not designed to be collapsed to just icons. */
|
|
||||||
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
|
|
||||||
--collapsedWidth: 224px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_LeftPanel_wrapper {
|
.mx_LeftPanel_wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -246,3 +240,11 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel_newRoomList {
|
||||||
|
/* Thew new rooms list is not designed to be collapsed to just icons. */
|
||||||
|
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
|
||||||
|
--collapsedWidth: 224px;
|
||||||
|
/* Important to force the color on ED titlebar until we remove the old room list */
|
||||||
|
background-color: var(--cpd-color-bg-canvas-default) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: var(--cpd-color-text-secondary);
|
color: var(--cpd-color-text-secondary);
|
||||||
|
|
||||||
.mx_Box {
|
.mx_RoomSummaryCard_topic_box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,11 +108,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: $font-20px;
|
font-size: $font-20px;
|
||||||
line-height: $font-25px;
|
line-height: $font-25px;
|
||||||
|
|
||||||
/* E2E icon wrapper */
|
|
||||||
.mx_Flex > span {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_UserInfo_profile_name {
|
.mx_UserInfo_profile_name {
|
||||||
|
|||||||
@@ -12,10 +12,6 @@
|
|||||||
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
|
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
|
||||||
padding: 0 var(--cpd-space-3x);
|
padding: 0 var(--cpd-space-3x);
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--cpd-color-icon-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomListSearch_search {
|
.mx_RoomListSearch_search {
|
||||||
/* The search button should take all the remaining space */
|
/* The search button should take all the remaining space */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -23,6 +19,10 @@
|
|||||||
color: var(--cpd-color-text-secondary);
|
color: var(--cpd-color-text-secondary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--cpd-color-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
@@ -42,10 +42,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomListSearch_button:hover {
|
|
||||||
svg {
|
|
||||||
fill: var(--cpd-color-icon-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,14 +55,13 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
background-color: var(--cpd-color-icon-tertiary);
|
background-color: var(--cpd-color-icon-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_E2EIcon_verified {
|
||||||
|
.mx_E2EIcon_normal::after {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_E2EIcon_verified::after {
|
.mx_E2EIcon_verified::after {
|
||||||
mask-image: url("$(res)/img/e2e/verified.svg");
|
mask-image: url("$(res)/img/e2e/verified.svg");
|
||||||
background-color: $e2e-verified-color;
|
background-color: $e2e-verified-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When using the "normal" icon as a background for verified or warning icon,
|
|
||||||
// it should be slightly smaller than the foreground icon
|
|
||||||
.mx_E2EIcon_verified, .mx_E2EIcon_warning .mx_E2EIcon_normal::after {
|
|
||||||
mask-size: 90%;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ sonar.organization=element-hq
|
|||||||
#sonar.sourceEncoding=UTF-8
|
#sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
sonar.sources=src,res
|
sonar.sources=src,res
|
||||||
sonar.tests=test,playwright
|
sonar.tests=test,playwright,src
|
||||||
|
sonar.test.inclusions=test/*,playwright/*,src/**/*.test.tsx
|
||||||
sonar.exclusions=__mocks__,docs,element.io,nginx
|
sonar.exclusions=__mocks__,docs,element.io,nginx
|
||||||
|
|
||||||
sonar.cpd.exclusions=src/i18n/strings/*.json
|
sonar.cpd.exclusions=src/i18n/strings/*.json
|
||||||
|
|||||||
1
src/@types/global.d.ts
vendored
@@ -135,6 +135,7 @@ declare global {
|
|||||||
initialise(): Promise<{
|
initialise(): Promise<{
|
||||||
protocol: string;
|
protocol: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
supportsBadgeOverlay: boolean;
|
||||||
config: IConfigOptions;
|
config: IConfigOptions;
|
||||||
supportedSettings: Record<string, boolean>;
|
supportedSettings: Record<string, boolean>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -494,15 +494,12 @@ export default abstract class BasePlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateFavicon(): void {
|
private updateFavicon(): void {
|
||||||
let bgColor = "#d00";
|
const notif: string | number = this.notificationCount;
|
||||||
let notif: string | number = this.notificationCount;
|
|
||||||
|
|
||||||
if (this.errorDidOccur) {
|
if (this.errorDidOccur) {
|
||||||
notif = notif || "×";
|
this.favicon.badge(notif || "×", { bgColor: "#f00" });
|
||||||
bgColor = "#f00";
|
|
||||||
}
|
}
|
||||||
|
this.favicon.badge(notif);
|
||||||
this.favicon.badge(notif, { bgColor });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type SyncState,
|
type SyncState,
|
||||||
ClientStoppedError,
|
ClientStoppedError,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
|
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
|
||||||
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||||
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||||
@@ -213,6 +213,7 @@ export default class DeviceListener {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onKeyBackupStatusChanged = (): void => {
|
private onKeyBackupStatusChanged = (): void => {
|
||||||
|
logger.info("Backup status changed");
|
||||||
this.cachedKeyBackupUploadActive = undefined;
|
this.cachedKeyBackupUploadActive = undefined;
|
||||||
this.recheck();
|
this.recheck();
|
||||||
};
|
};
|
||||||
@@ -313,6 +314,7 @@ export default class DeviceListener {
|
|||||||
private async doRecheck(): Promise<void> {
|
private async doRecheck(): Promise<void> {
|
||||||
if (!this.running || !this.client) return; // we have been stopped
|
if (!this.running || !this.client) return; // we have been stopped
|
||||||
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
|
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
|
||||||
|
logSpan.debug("starting recheck...");
|
||||||
|
|
||||||
const cli = this.client;
|
const cli = this.client;
|
||||||
|
|
||||||
@@ -355,7 +357,7 @@ export default class DeviceListener {
|
|||||||
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyBackupUploadActive = await this.isKeyBackupUploadActive();
|
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
|
||||||
const backupDisabled = await this.recheckBackupDisabled(cli);
|
const backupDisabled = await this.recheckBackupDisabled(cli);
|
||||||
|
|
||||||
// We warn if key backup upload is turned off and we have not explicitly
|
// We warn if key backup upload is turned off and we have not explicitly
|
||||||
@@ -579,7 +581,7 @@ export default class DeviceListener {
|
|||||||
* trigger an auto-rageshake).
|
* trigger an auto-rageshake).
|
||||||
*/
|
*/
|
||||||
private checkKeyBackupStatus = async (): Promise<void> => {
|
private checkKeyBackupStatus = async (): Promise<void> => {
|
||||||
if (!(await this.isKeyBackupUploadActive())) {
|
if (!(await this.isKeyBackupUploadActive(logger))) {
|
||||||
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
|
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -587,7 +589,7 @@ export default class DeviceListener {
|
|||||||
/**
|
/**
|
||||||
* Is key backup enabled? Use a cached answer if we have one.
|
* Is key backup enabled? Use a cached answer if we have one.
|
||||||
*/
|
*/
|
||||||
private isKeyBackupUploadActive = async (): Promise<boolean> => {
|
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
// To preserve existing behaviour, if there is no client, we
|
// To preserve existing behaviour, if there is no client, we
|
||||||
// pretend key backup upload is on.
|
// pretend key backup upload is on.
|
||||||
@@ -611,6 +613,7 @@ export default class DeviceListener {
|
|||||||
// Fetch the answer and cache it
|
// Fetch the answer and cache it
|
||||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||||
|
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
|
||||||
|
|
||||||
return this.cachedKeyBackupUploadActive;
|
return this.cachedKeyBackupUploadActive;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -621,6 +621,9 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean }
|
|||||||
await getStoredSessionVars();
|
await getStoredSessionVars();
|
||||||
|
|
||||||
if (hasAccessToken && !accessToken) {
|
if (hasAccessToken && !accessToken) {
|
||||||
|
logger.warn(
|
||||||
|
"restoreSessionFromStorage: storage indicates we should have an access token, but we do not. Displaying StorageEvictedDialog",
|
||||||
|
);
|
||||||
await abortLogin();
|
await abortLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +826,7 @@ async function doSetLoggedIn(
|
|||||||
// crypto store, we'll be generally confused when handling encrypted data.
|
// crypto store, we'll be generally confused when handling encrypted data.
|
||||||
// Show a modal recommending a full reset of storage.
|
// Show a modal recommending a full reset of storage.
|
||||||
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
|
if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
|
||||||
|
logger.warn("doSetLoggedIn: StorageManager consistency check failed; displaying StorageEvictedDialog.");
|
||||||
await abortLogin();
|
await abortLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export interface IInviteResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invites multiple addresses to a room
|
* Invites multiple addresses to a room.
|
||||||
* Simpler interface to utils/MultiInviter but with
|
*
|
||||||
* no option to cancel.
|
* Simpler interface to {@link MultiInviter}.
|
||||||
*
|
*
|
||||||
* @param {string} roomId The ID of the room to invite to
|
* @param {string} roomId The ID of the room to invite to
|
||||||
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { _td, type TranslationKey } from "../languageHandler";
|
// Import i18n.tsx instead of languageHandler to avoid circular deps
|
||||||
|
import { _td, type TranslationKey } from "../shared-components/utils/i18n";
|
||||||
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
|
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
|
||||||
import { type IBaseSetting } from "../settings/Settings";
|
import { type IBaseSetting } from "../settings/Settings";
|
||||||
import { type KeyCombo } from "../KeyBindingsManager";
|
import { type KeyCombo } from "../KeyBindingsManager";
|
||||||
@@ -145,6 +146,7 @@ export enum KeyBindingAction {
|
|||||||
ArrowDown = "KeyBinding.arrowDown",
|
ArrowDown = "KeyBinding.arrowDown",
|
||||||
Tab = "KeyBinding.tab",
|
Tab = "KeyBinding.tab",
|
||||||
Comma = "KeyBinding.comma",
|
Comma = "KeyBinding.comma",
|
||||||
|
Save = "KeyBinding.save",
|
||||||
|
|
||||||
/** Toggle visibility of hidden events */
|
/** Toggle visibility of hidden events */
|
||||||
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
|
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
|
||||||
@@ -268,6 +270,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
|
|||||||
KeyBindingAction.ArrowRight,
|
KeyBindingAction.ArrowRight,
|
||||||
KeyBindingAction.ArrowDown,
|
KeyBindingAction.ArrowDown,
|
||||||
KeyBindingAction.Comma,
|
KeyBindingAction.Comma,
|
||||||
|
KeyBindingAction.Save,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[CategoryName.NAVIGATION]: {
|
[CategoryName.NAVIGATION]: {
|
||||||
@@ -620,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
|
|||||||
},
|
},
|
||||||
displayName: _td("keyboard|composer_redo"),
|
displayName: _td("keyboard|composer_redo"),
|
||||||
},
|
},
|
||||||
|
[KeyBindingAction.Save]: {
|
||||||
|
default: {
|
||||||
|
key: Key.S,
|
||||||
|
ctrlOrCmdKey: true,
|
||||||
|
},
|
||||||
|
displayName: _td("keyboard|save"),
|
||||||
|
},
|
||||||
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
|
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
|
||||||
default: {
|
default: {
|
||||||
metaKey: IS_MAC,
|
metaKey: IS_MAC,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Text, Heading, Button, Separator } from "@vector-im/compound-web";
|
|||||||
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
|
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
|
||||||
|
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import { Flex } from "../../components/utils/Flex";
|
import { Flex } from "../../shared-components/utils/Flex";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg";
|
import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg";
|
||||||
import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg";
|
import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg";
|
||||||
@@ -58,7 +58,7 @@ const MobileAppLinks: React.FC<{
|
|||||||
googlePlayUrl?: string;
|
googlePlayUrl?: string;
|
||||||
fdroidUrl?: string;
|
fdroidUrl?: string;
|
||||||
}> = ({ appleAppStoreUrl, googlePlayUrl, fdroidUrl }) => (
|
}> = ({ appleAppStoreUrl, googlePlayUrl, fdroidUrl }) => (
|
||||||
<Flex gap="var(--cpd-space-6x)">
|
<Flex gap="var(--cpd-space-6x)" className="mx_ErrorView_flexContainer">
|
||||||
{appleAppStoreUrl && (
|
{appleAppStoreUrl && (
|
||||||
<a href={appleAppStoreUrl} target="_blank" rel="noreferrer noopener">
|
<a href={appleAppStoreUrl} target="_blank" rel="noreferrer noopener">
|
||||||
<img height="64" src="themes/element/img/download/apple.svg" alt="Apple App Store" />
|
<img height="64" src="themes/element/img/download/apple.svg" alt="Apple App Store" />
|
||||||
@@ -84,7 +84,7 @@ const DesktopAppLinks: React.FC<{
|
|||||||
linuxUrl?: string;
|
linuxUrl?: string;
|
||||||
}> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => {
|
}> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => {
|
||||||
return (
|
return (
|
||||||
<Flex gap="var(--cpd-space-4x)">
|
<Flex gap="var(--cpd-space-4x)" className="mx_ErrorView_flexContainer">
|
||||||
{macOsUrl && (
|
{macOsUrl && (
|
||||||
<Button as="a" href={macOsUrl} kind="secondary" Icon={AppleIcon}>
|
<Button as="a" href={macOsUrl} kind="secondary" Icon={AppleIcon}>
|
||||||
{_t("incompatible_browser|macos")}
|
{_t("incompatible_browser|macos")}
|
||||||
@@ -193,7 +193,7 @@ export const UnsupportedBrowserView: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Flex gap="var(--cpd-space-4x)" className="mx_ErrorView_buttons">
|
<Flex gap="var(--cpd-space-4x)" className="mx_ErrorView_flexContainer mx_ErrorView_buttons">
|
||||||
<Button Icon={PopOutIcon} kind="secondary" size="sm">
|
<Button Icon={PopOutIcon} kind="secondary" size="sm">
|
||||||
{_t("incompatible_browser|learn_more")}
|
{_t("incompatible_browser|learn_more")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
291
src/components/utils/ListView.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
|
||||||
|
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context object passed to each list item containing the currently focused key
|
||||||
|
* and any additional context data from the parent component.
|
||||||
|
*/
|
||||||
|
export type ListContext<Context> = {
|
||||||
|
/** The key of item that should have tabIndex == 0 */
|
||||||
|
tabIndexKey?: string;
|
||||||
|
/** Whether an item in the list is currently focused */
|
||||||
|
focused: boolean;
|
||||||
|
/** Additional context data passed from the parent component */
|
||||||
|
context: Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IListViewProps<Item, Context>
|
||||||
|
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "data" | "itemContent" | "context"> {
|
||||||
|
/**
|
||||||
|
* The array of items to display in the virtualized list.
|
||||||
|
* Each item will be passed to getItemComponent for rendering.
|
||||||
|
*/
|
||||||
|
items: Item[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function called when an item is selected (via Enter/Space key).
|
||||||
|
* @param item - The selected item from the items array
|
||||||
|
*/
|
||||||
|
onSelectItem: (item: Item) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function that renders each list item as a JSX element.
|
||||||
|
* @param index - The index of the item in the list
|
||||||
|
* @param item - The data item to render
|
||||||
|
* @param context - The context object containing the focused key and any additional data
|
||||||
|
* @returns JSX element representing the rendered item
|
||||||
|
*/
|
||||||
|
getItemComponent: (
|
||||||
|
index: number,
|
||||||
|
item: Item,
|
||||||
|
context: ListContext<Context>,
|
||||||
|
onFocus: (e: React.FocusEvent) => void,
|
||||||
|
) => JSX.Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional additional context data to pass to each rendered item.
|
||||||
|
* This will be available in the ListContext passed to getItemComponent.
|
||||||
|
*/
|
||||||
|
context?: Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to determine if an item can receive focus during keyboard navigation.
|
||||||
|
* @param item - The item to check for focusability
|
||||||
|
* @returns true if the item can be focused, false otherwise
|
||||||
|
*/
|
||||||
|
isItemFocusable: (item: Item) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to get the key to use for focusing an item.
|
||||||
|
* @param item - The item to get the key for
|
||||||
|
* @return The key to use for focusing the item
|
||||||
|
*/
|
||||||
|
getItemKey: (item: Item) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic virtualized list component built on top of react-virtuoso.
|
||||||
|
* Provides keyboard navigation and virtualized rendering for performance with large lists.
|
||||||
|
*
|
||||||
|
* @template Item - The type of data items in the list
|
||||||
|
* @template Context - The type of additional context data passed to items
|
||||||
|
*/
|
||||||
|
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
|
||||||
|
// Extract our custom props to avoid conflicts with Virtuoso props
|
||||||
|
const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
|
||||||
|
/** Reference to the Virtuoso component for programmatic scrolling */
|
||||||
|
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
|
||||||
|
/** Reference to the DOM element containing the virtualized list */
|
||||||
|
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
|
||||||
|
/** Key of the item that should have tabIndex == 0 */
|
||||||
|
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
|
||||||
|
props.items[0] ? getItemKey(props.items[0]) : undefined,
|
||||||
|
);
|
||||||
|
/** Range of currently visible items in the viewport */
|
||||||
|
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||||
|
/** Map from item keys to their indices in the items array */
|
||||||
|
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
||||||
|
/** Whether the list is currently scrolling to an item */
|
||||||
|
const isScrollingToItem = useRef<boolean>(false);
|
||||||
|
/** Whether the list is currently focused */
|
||||||
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Update the key-to-index mapping whenever items change
|
||||||
|
useEffect(() => {
|
||||||
|
const newKeyToIndexMap = new Map<string, number>();
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const key = getItemKey(item);
|
||||||
|
newKeyToIndexMap.set(key, index);
|
||||||
|
});
|
||||||
|
setKeyToIndexMap(newKeyToIndexMap);
|
||||||
|
}, [items, getItemKey]);
|
||||||
|
|
||||||
|
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
|
||||||
|
setTabIndexKey(getItemKey(items[0]));
|
||||||
|
}
|
||||||
|
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to a specific item index and sets it as focused.
|
||||||
|
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
|
||||||
|
*/
|
||||||
|
const scrollToIndex = useCallback(
|
||||||
|
(index: number, align?: "center" | "end" | "start"): void => {
|
||||||
|
// Ensure index is within bounds
|
||||||
|
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||||
|
if (isScrollingToItem.current) {
|
||||||
|
// If already scrolling to an item drop this request. Adding further requests
|
||||||
|
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items[clampedIndex]) {
|
||||||
|
const key = getItemKey(items[clampedIndex]);
|
||||||
|
setTabIndexKey(key);
|
||||||
|
isScrollingToItem.current = true;
|
||||||
|
virtuosoHandleRef?.current?.scrollIntoView({
|
||||||
|
index: clampedIndex,
|
||||||
|
align: align,
|
||||||
|
behavior: "auto",
|
||||||
|
done: () => {
|
||||||
|
isScrollingToItem.current = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[items, getItemKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to an item, skipping over non-focusable items if necessary.
|
||||||
|
* This is used for keyboard navigation to ensure focus lands on valid items.
|
||||||
|
*/
|
||||||
|
const scrollToItem = useCallback(
|
||||||
|
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
|
||||||
|
const totalRows = items.length;
|
||||||
|
let nextIndex: number | undefined;
|
||||||
|
|
||||||
|
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
|
||||||
|
if (isItemFocusable(items[i])) {
|
||||||
|
nextIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToIndex(nextIndex, align);
|
||||||
|
},
|
||||||
|
[scrollToIndex, items, isItemFocusable],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles keyboard navigation for the list.
|
||||||
|
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
|
||||||
|
*/
|
||||||
|
const keyDownCallback = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!e) return; // Guard against null/undefined events
|
||||||
|
|
||||||
|
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
|
||||||
|
|
||||||
|
let handled = false;
|
||||||
|
if (e.code === "ArrowUp" && currentIndex !== undefined) {
|
||||||
|
scrollToItem(currentIndex - 1, false);
|
||||||
|
handled = true;
|
||||||
|
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
|
||||||
|
scrollToItem(currentIndex + 1, true);
|
||||||
|
handled = true;
|
||||||
|
} else if ((e.code === "Enter" || e.code === "Space") && currentIndex !== undefined) {
|
||||||
|
const item = items[currentIndex];
|
||||||
|
onSelectItem(item);
|
||||||
|
handled = true;
|
||||||
|
} else if (e.code === "Home") {
|
||||||
|
scrollToIndex(0);
|
||||||
|
handled = true;
|
||||||
|
} else if (e.code === "End") {
|
||||||
|
scrollToIndex(items.length - 1);
|
||||||
|
handled = true;
|
||||||
|
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) {
|
||||||
|
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||||
|
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
|
||||||
|
handled = true;
|
||||||
|
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) {
|
||||||
|
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||||
|
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback ref for the Virtuoso scroller element.
|
||||||
|
* Stores the reference for use in focus management.
|
||||||
|
*/
|
||||||
|
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
|
||||||
|
virtuosoDomRef.current = element;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemComponentInternal = useCallback(
|
||||||
|
(index: number, item: Item, context: ListContext<Context>): JSX.Element => {
|
||||||
|
const onFocus = (e: React.FocusEvent): void => {
|
||||||
|
// If one of the item components has been focused directly, set the focused and tabIndex state
|
||||||
|
// and stop propagation so the ListViews onFocus doesn't also handle it.
|
||||||
|
const key = getItemKey(item);
|
||||||
|
setIsFocused(true);
|
||||||
|
setTabIndexKey(key);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
return getItemComponent(index, item, context, onFocus);
|
||||||
|
},
|
||||||
|
[getItemComponent, getItemKey],
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Handles focus events on the list.
|
||||||
|
* Sets the focused state and scrolls to the focused item if it is not currently visible.
|
||||||
|
*/
|
||||||
|
const onFocus = useCallback(
|
||||||
|
(e?: React.FocusEvent): void => {
|
||||||
|
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFocused(true);
|
||||||
|
const index = keyToIndexMap.get(tabIndexKey);
|
||||||
|
if (
|
||||||
|
index !== undefined &&
|
||||||
|
visibleRange &&
|
||||||
|
(index < visibleRange.startIndex || index > visibleRange.endIndex)
|
||||||
|
) {
|
||||||
|
scrollToIndex(index);
|
||||||
|
}
|
||||||
|
e?.stopPropagation();
|
||||||
|
e?.preventDefault();
|
||||||
|
},
|
||||||
|
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBlur = useCallback((): void => {
|
||||||
|
setIsFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const listContext: ListContext<Context> = {
|
||||||
|
tabIndexKey: tabIndexKey,
|
||||||
|
focused: isFocused,
|
||||||
|
context: props.context || ({} as Context),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtuoso
|
||||||
|
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
|
||||||
|
scrollerRef={scrollerRef}
|
||||||
|
ref={virtuosoHandleRef}
|
||||||
|
onKeyDown={keyDownCallback}
|
||||||
|
context={listContext}
|
||||||
|
rangeChanged={setVisibleRange}
|
||||||
|
// virtuoso errors internally if you pass undefined.
|
||||||
|
overscan={props.overscan || 0}
|
||||||
|
data={props.items}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
itemContent={getItemComponentInternal}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite";
|
|||||||
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
|
||||||
import { type XOR } from "../../../@types/common";
|
import { type XOR } from "../../../@types/common";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
|
||||||
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ export interface MemberListViewState {
|
|||||||
shouldShowSearch: boolean;
|
shouldShowSearch: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canInvite: boolean;
|
canInvite: boolean;
|
||||||
|
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
|
||||||
onInviteButtonClick: (ev: ButtonEvent) => void;
|
onInviteButtonClick: (ev: ButtonEvent) => void;
|
||||||
}
|
}
|
||||||
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
export function useMemberListViewModel(roomId: string): MemberListViewState {
|
||||||
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
|||||||
*/
|
*/
|
||||||
const [memberCount, setMemberCount] = useState(0);
|
const [memberCount, setMemberCount] = useState(0);
|
||||||
|
|
||||||
|
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ViewUser,
|
||||||
|
member: member,
|
||||||
|
push: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadMembers = useMemo(
|
const loadMembers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
throttle(
|
throttle(
|
||||||
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
|
|||||||
isPresenceEnabled,
|
isPresenceEnabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInviteButtonClick,
|
onInviteButtonClick,
|
||||||
|
onClickMember,
|
||||||
shouldShowSearch: totalMemberCount >= 20,
|
shouldShowSearch: totalMemberCount >= 20,
|
||||||
canInvite,
|
canInvite,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays";
|
|||||||
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
|
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
|
||||||
import { type RoomMember } from "../../../../models/rooms/RoomMember";
|
import { type RoomMember } from "../../../../models/rooms/RoomMember";
|
||||||
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
|
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
|
||||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
|
||||||
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
import { E2EStatus } from "../../../../utils/ShieldUtils";
|
||||||
|
|
||||||
interface MemberTileViewModelProps {
|
interface MemberTileViewModelProps {
|
||||||
@@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
|
|||||||
e2eStatus?: E2EStatus;
|
e2eStatus?: E2EStatus;
|
||||||
name: string;
|
name: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title?: string;
|
|
||||||
userLabel?: string;
|
userLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +128,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = useMemo(() => {
|
|
||||||
return _t("member_list|power_label", {
|
|
||||||
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
|
|
||||||
roomId: member.roomId,
|
|
||||||
}),
|
|
||||||
powerLevelNumber: member.powerLevel,
|
|
||||||
}).trim();
|
|
||||||
}, [member.powerLevel, member.roomId, member.userId]);
|
|
||||||
|
|
||||||
let userLabel;
|
let userLabel;
|
||||||
const powerStatus = powerStatusMap.get(powerLevel);
|
const powerStatus = powerStatusMap.get(powerLevel);
|
||||||
if (powerStatus) {
|
if (powerStatus) {
|
||||||
@@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
|
||||||
member,
|
member,
|
||||||
name,
|
name,
|
||||||
onClick,
|
onClick,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient, type RoomMember, type User } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
|
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { type IDevice } from "../../../views/right_panel/UserInfo";
|
||||||
|
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||||
|
import { verifyUser } from "../../../../verification";
|
||||||
|
|
||||||
|
export interface UserInfoVerificationSectionState {
|
||||||
|
/**
|
||||||
|
* variables used to check if we can verify the user and display the verify button
|
||||||
|
*/
|
||||||
|
canVerify: boolean;
|
||||||
|
hasCrossSigningKeys: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* used to display correct badge value
|
||||||
|
*/
|
||||||
|
isUserVerified: boolean;
|
||||||
|
/**
|
||||||
|
* callback function when verifyUser button is clicked
|
||||||
|
*/
|
||||||
|
verifySelectedUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
|
||||||
|
return useAsyncMemo<boolean>(
|
||||||
|
async () => {
|
||||||
|
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||||
|
},
|
||||||
|
[cli],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => {
|
||||||
|
return useAsyncMemo(async () => {
|
||||||
|
if (!canVerify) return undefined;
|
||||||
|
return cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
|
||||||
|
}, [cli, member, canVerify]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View model for the userInfoVerificationHeaderView
|
||||||
|
* @see {@link UserInfoVerificationSectionState} for more information about what this view model returns.
|
||||||
|
*/
|
||||||
|
export const useUserInfoVerificationViewModel = (
|
||||||
|
member: User | RoomMember,
|
||||||
|
devices: IDevice[],
|
||||||
|
): UserInfoVerificationSectionState => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
|
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
|
||||||
|
|
||||||
|
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
|
||||||
|
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
|
||||||
|
[member.userId],
|
||||||
|
// the user verification status is not initialized
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const hasUserVerificationStatus = Boolean(userTrust);
|
||||||
|
const isUserVerified = Boolean(userTrust?.isVerified());
|
||||||
|
const isMe = member.userId === cli.getUserId();
|
||||||
|
const canVerify =
|
||||||
|
hasUserVerificationStatus &&
|
||||||
|
homeserverSupportsCrossSigning &&
|
||||||
|
!isUserVerified &&
|
||||||
|
!isMe &&
|
||||||
|
devices &&
|
||||||
|
devices.length > 0;
|
||||||
|
|
||||||
|
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
|
||||||
|
const verifySelectedUser = (): Promise<void> => verifyUser(cli, member as User);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canVerify,
|
||||||
|
hasCrossSigningKeys,
|
||||||
|
isUserVerified,
|
||||||
|
verifySelectedUser,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RoomMember, type User } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { useCallback, useContext } from "react";
|
||||||
|
|
||||||
|
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||||
|
import Modal from "../../../../Modal";
|
||||||
|
import ImageView from "../../../views/elements/ImageView";
|
||||||
|
import SdkConfig from "../../../../SdkConfig";
|
||||||
|
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { type Member } from "../../../views/right_panel/UserInfo";
|
||||||
|
import { useUserTimezone } from "../../../../hooks/useUserTimezone";
|
||||||
|
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
||||||
|
|
||||||
|
export interface PresenceInfo {
|
||||||
|
lastActiveAgo: number | undefined;
|
||||||
|
currentlyActive: boolean | undefined;
|
||||||
|
state: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimezoneInfo {
|
||||||
|
timezone: string;
|
||||||
|
friendly: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfoHeaderState {
|
||||||
|
/**
|
||||||
|
* callback function when selected user avatar is clicked in user info
|
||||||
|
*/
|
||||||
|
onMemberAvatarClick: () => void;
|
||||||
|
/**
|
||||||
|
* Object containing information about the precense of the selected user
|
||||||
|
*/
|
||||||
|
precenseInfo: PresenceInfo;
|
||||||
|
/**
|
||||||
|
* Boolean that show or hide the precense information
|
||||||
|
*/
|
||||||
|
showPresence: boolean;
|
||||||
|
/**
|
||||||
|
* Timezone object
|
||||||
|
*/
|
||||||
|
timezoneInfo: TimezoneInfo | null;
|
||||||
|
/**
|
||||||
|
* Displayed identifier for the selected user
|
||||||
|
*/
|
||||||
|
userIdentifier: string | null;
|
||||||
|
}
|
||||||
|
interface UserInfoHeaderViewModelProps {
|
||||||
|
member: Member;
|
||||||
|
roomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View model for the userInfoHeaderView
|
||||||
|
* props
|
||||||
|
* @see {@link UserInfoHeaderState} for more information about what this view model returns.
|
||||||
|
*/
|
||||||
|
export function useUserfoHeaderViewModel({ member, roomId }: UserInfoHeaderViewModelProps): UserInfoHeaderState {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
|
let showPresence = true;
|
||||||
|
|
||||||
|
const precenseInfo: PresenceInfo = {
|
||||||
|
lastActiveAgo: undefined,
|
||||||
|
currentlyActive: undefined,
|
||||||
|
state: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
|
||||||
|
|
||||||
|
const timezoneInfo = useUserTimezone(cli, member.userId);
|
||||||
|
|
||||||
|
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
|
||||||
|
roomId,
|
||||||
|
withDisplayName: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMemberAvatarClick = useCallback(() => {
|
||||||
|
const avatarUrl = (member as RoomMember).getMxcAvatarUrl
|
||||||
|
? (member as RoomMember).getMxcAvatarUrl()
|
||||||
|
: (member as User).avatarUrl;
|
||||||
|
|
||||||
|
const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
|
||||||
|
if (!httpUrl) return;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
src: httpUrl,
|
||||||
|
name: (member as RoomMember).name || (member as User).displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||||
|
}, [member]);
|
||||||
|
|
||||||
|
if (member instanceof RoomMember && member.user) {
|
||||||
|
precenseInfo.state = member.user.presence;
|
||||||
|
precenseInfo.lastActiveAgo = member.user.lastActiveAgo;
|
||||||
|
precenseInfo.currentlyActive = member.user.currentlyActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
|
||||||
|
showPresence = enablePresenceByHsUrl[cli.baseUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMemberAvatarClick,
|
||||||
|
showPresence,
|
||||||
|
precenseInfo,
|
||||||
|
timezoneInfo,
|
||||||
|
userIdentifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
|||||||
data-testid="back-button"
|
data-testid="back-button"
|
||||||
className="mx_LoginWithQR_BackButton"
|
className="mx_LoginWithQR_BackButton"
|
||||||
onClick={this.handleClick(Click.Back)}
|
onClick={this.handleClick(Click.Back)}
|
||||||
title="Back"
|
title={_t("action|back")}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/we
|
|||||||
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
|
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
|
||||||
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
|
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import RoomAvatar from "./RoomAvatar";
|
import RoomAvatar from "./RoomAvatar";
|
||||||
import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||||
@@ -37,6 +38,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
|||||||
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
|
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
|
||||||
|
|
||||||
const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence);
|
const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence);
|
||||||
|
const label = getDecorationLabel(vm.badgeDecoration, vm.presence);
|
||||||
|
|
||||||
// Presence indicator and video/public icons don't have the same size
|
// Presence indicator and video/public icons don't have the same size
|
||||||
// We use different masks
|
// We use different masks
|
||||||
@@ -48,22 +50,15 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="mx_RoomAvatarView">
|
<div className="mx_RoomAvatarView">
|
||||||
<RoomAvatar className={classNames("mx_RoomAvatarView_RoomAvatar", maskClass)} size="32px" room={room} />
|
<RoomAvatar className={classNames("mx_RoomAvatarView_RoomAvatar", maskClass)} size="32px" room={room} />
|
||||||
{icon}
|
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PresenceDecorationProps = {
|
|
||||||
/**
|
|
||||||
* The presence of the user in the DM room.
|
|
||||||
*/
|
|
||||||
presence: NonNullable<Presence>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to display the presence of a user in a DM room.
|
* Get the decoration for the avatar based on the presence.
|
||||||
*/
|
*/
|
||||||
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
|
function getPresenceDecoration(presence: Presence): JSX.Element {
|
||||||
switch (presence) {
|
switch (presence) {
|
||||||
case Presence.Online:
|
case Presence.Online:
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +67,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
|||||||
height="8px"
|
height="8px"
|
||||||
className="mx_RoomAvatarView_PresenceDecoration"
|
className="mx_RoomAvatarView_PresenceDecoration"
|
||||||
color="var(--cpd-color-icon-accent-primary)"
|
color="var(--cpd-color-icon-accent-primary)"
|
||||||
aria-label={_t("presence|online")}
|
aria-label={getPresenceLabel(presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case Presence.Away:
|
case Presence.Away:
|
||||||
@@ -82,7 +77,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
|||||||
height="8px"
|
height="8px"
|
||||||
className="mx_RoomAvatarView_PresenceDecoration"
|
className="mx_RoomAvatarView_PresenceDecoration"
|
||||||
color="var(--cpd-color-icon-quaternary)"
|
color="var(--cpd-color-icon-quaternary)"
|
||||||
aria-label={_t("presence|away")}
|
aria-label={getPresenceLabel(presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case Presence.Offline:
|
case Presence.Offline:
|
||||||
@@ -92,7 +87,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
|||||||
height="8px"
|
height="8px"
|
||||||
className="mx_RoomAvatarView_PresenceDecoration"
|
className="mx_RoomAvatarView_PresenceDecoration"
|
||||||
color="var(--cpd-color-icon-tertiary)"
|
color="var(--cpd-color-icon-tertiary)"
|
||||||
aria-label={_t("presence|offline")}
|
aria-label={getPresenceLabel(presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case Presence.Busy:
|
case Presence.Busy:
|
||||||
@@ -102,7 +97,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
|||||||
height="8px"
|
height="8px"
|
||||||
className="mx_RoomAvatarView_PresenceDecoration"
|
className="mx_RoomAvatarView_PresenceDecoration"
|
||||||
color="var(--cpd-color-icon-tertiary)"
|
color="var(--cpd-color-icon-tertiary)"
|
||||||
aria-label={_t("presence|busy")}
|
aria-label={getPresenceLabel(presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +111,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
|
|||||||
height="16px"
|
height="16px"
|
||||||
className="mx_RoomAvatarView_icon"
|
className="mx_RoomAvatarView_icon"
|
||||||
color="var(--cpd-color-icon-tertiary)"
|
color="var(--cpd-color-icon-tertiary)"
|
||||||
aria-label={_t("room|room_is_low_priority")}
|
aria-label={getDecorationLabel(decoration, presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (decoration === AvatarBadgeDecoration.VideoRoom) {
|
} else if (decoration === AvatarBadgeDecoration.VideoRoom) {
|
||||||
@@ -126,7 +121,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
|
|||||||
height="16px"
|
height="16px"
|
||||||
className="mx_RoomAvatarView_icon"
|
className="mx_RoomAvatarView_icon"
|
||||||
color="var(--cpd-color-icon-tertiary)"
|
color="var(--cpd-color-icon-tertiary)"
|
||||||
aria-label={_t("room|video_room")}
|
aria-label={getDecorationLabel(decoration, presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (decoration === AvatarBadgeDecoration.PublicRoom) {
|
} else if (decoration === AvatarBadgeDecoration.PublicRoom) {
|
||||||
@@ -135,11 +130,45 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
|
|||||||
width="16px"
|
width="16px"
|
||||||
height="16px"
|
height="16px"
|
||||||
className="mx_RoomAvatarView_icon"
|
className="mx_RoomAvatarView_icon"
|
||||||
color="var(--cpd-color-icon-tertiary)"
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
aria-label={_t("room|header|room_is_public")}
|
aria-label={getDecorationLabel(decoration, presence)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (decoration === AvatarBadgeDecoration.Presence) {
|
} else if (decoration === AvatarBadgeDecoration.Presence) {
|
||||||
return <PresenceDecoration presence={presence!} />;
|
return getPresenceDecoration(presence!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for the avatar decoration.
|
||||||
|
* This is used for the tooltip and a11y label.
|
||||||
|
*/
|
||||||
|
function getDecorationLabel(decoration: AvatarBadgeDecoration, presence: Presence | null): string | undefined {
|
||||||
|
switch (decoration) {
|
||||||
|
case AvatarBadgeDecoration.LowPriority:
|
||||||
|
return _t("room|room_is_low_priority");
|
||||||
|
case AvatarBadgeDecoration.VideoRoom:
|
||||||
|
return _t("room|video_room");
|
||||||
|
case AvatarBadgeDecoration.PublicRoom:
|
||||||
|
return _t("room|header|room_is_public");
|
||||||
|
case AvatarBadgeDecoration.Presence:
|
||||||
|
return getPresenceLabel(presence!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the label for the presence.
|
||||||
|
* This is used for the tooltip and a11y label.
|
||||||
|
*/
|
||||||
|
function getPresenceLabel(presence: Presence): string {
|
||||||
|
switch (presence) {
|
||||||
|
case Presence.Online:
|
||||||
|
return _t("presence|online");
|
||||||
|
case Presence.Away:
|
||||||
|
return _t("presence|away");
|
||||||
|
case Presence.Offline:
|
||||||
|
return _t("presence|offline");
|
||||||
|
case Presence.Busy:
|
||||||
|
return _t("presence|busy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the current selection is entirely within a single "mx_MTextBody" element.
|
||||||
|
*/
|
||||||
|
private isSelectionWithinSingleTextBody(): boolean {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return false;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
function getParentByClass(node: Node | null, className: string): HTMLElement | null {
|
||||||
|
while (node) {
|
||||||
|
if (node instanceof HTMLElement && node.classList.contains(className)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody");
|
||||||
|
const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody");
|
||||||
|
|
||||||
|
return !!startTextBody && startTextBody === endTextBody;
|
||||||
|
}
|
||||||
|
|
||||||
private onResendReactionsClick = (): void => {
|
private onResendReactionsClick = (): void => {
|
||||||
for (const reaction of this.getUnsentReactions()) {
|
for (const reaction of this.getUnsentReactions()) {
|
||||||
Resend.resend(MatrixClientPeg.safeGet(), reaction);
|
Resend.resend(MatrixClientPeg.safeGet(), reaction);
|
||||||
@@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onQuoteClick = (): void => {
|
||||||
|
const selectedText = getSelectedText();
|
||||||
|
if (selectedText) {
|
||||||
|
// Format as markdown quote
|
||||||
|
const quotedText = selectedText
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "\n" + quotedText + "\n\n ",
|
||||||
|
timelineRenderingType: this.context.timelineRenderingType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.closeMenu();
|
||||||
|
};
|
||||||
|
|
||||||
private onEditClick = (): void => {
|
private onEditClick = (): void => {
|
||||||
editEvent(
|
editEvent(
|
||||||
MatrixClientPeg.safeGet(),
|
MatrixClientPeg.safeGet(),
|
||||||
@@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedText = getSelectedText();
|
||||||
|
|
||||||
let copyButton: JSX.Element | undefined;
|
let copyButton: JSX.Element | undefined;
|
||||||
if (rightClick && getSelectedText()) {
|
if (rightClick && selectedText) {
|
||||||
copyButton = (
|
copyButton = (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
iconClassName="mx_MessageContextMenu_iconCopy"
|
iconClassName="mx_MessageContextMenu_iconCopy"
|
||||||
@@ -561,6 +605,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let quoteButton: JSX.Element | undefined;
|
||||||
|
if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
|
||||||
|
quoteButton = (
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_MessageContextMenu_iconQuote"
|
||||||
|
label={_t("action|quote")}
|
||||||
|
triggerOnMouseDown={true}
|
||||||
|
onClick={this.onQuoteClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let editButton: JSX.Element | undefined;
|
let editButton: JSX.Element | undefined;
|
||||||
if (rightClick && canEditContent(cli, mxEvent)) {
|
if (rightClick && canEditContent(cli, mxEvent)) {
|
||||||
editButton = (
|
editButton = (
|
||||||
@@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nativeItemsList: JSX.Element | undefined;
|
let nativeItemsList: JSX.Element | undefined;
|
||||||
if (copyButton || copyLinkButton) {
|
if (copyButton || quoteButton || copyLinkButton) {
|
||||||
nativeItemsList = (
|
nativeItemsList = (
|
||||||
<IconizedContextMenuOptionList>
|
<IconizedContextMenuOptionList>
|
||||||
{copyButton}
|
{copyButton}
|
||||||
|
{quoteButton}
|
||||||
{copyLinkButton}
|
{copyLinkButton}
|
||||||
</IconizedContextMenuOptionList>
|
</IconizedContextMenuOptionList>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import FilteredList from "./FilteredList";
|
|||||||
import Spinner from "../../elements/Spinner";
|
import Spinner from "../../elements/Spinner";
|
||||||
import SyntaxHighlight from "../../elements/SyntaxHighlight";
|
import SyntaxHighlight from "../../elements/SyntaxHighlight";
|
||||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||||
|
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
|
||||||
|
|
||||||
export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
|
export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
|
||||||
const context = useContext(DevtoolsContext);
|
const context = useContext(DevtoolsContext);
|
||||||
@@ -114,6 +115,7 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [event, setEvent] = useState<MatrixEvent | null>(null);
|
const [event, setEvent] = useState<MatrixEvent | null>(null);
|
||||||
const [history, setHistory] = useState(false);
|
const [history, setHistory] = useState(false);
|
||||||
|
const [showEmptyState, setShowEmptyState] = useState(true);
|
||||||
|
|
||||||
const events = context.room.currentState.events.get(eventType)!;
|
const events = context.room.currentState.events.get(eventType)!;
|
||||||
|
|
||||||
@@ -149,10 +151,17 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
|
|||||||
return (
|
return (
|
||||||
<BaseTool onBack={onBack}>
|
<BaseTool onBack={onBack}>
|
||||||
<FilteredList query={query} onChange={setQuery}>
|
<FilteredList query={query} onChange={setQuery}>
|
||||||
{Array.from(events.entries()).map(([stateKey, ev]) => (
|
{Array.from(events.entries())
|
||||||
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
|
.filter(([_, ev]) => showEmptyState || Object.keys(ev.getContent()).length > 0)
|
||||||
))}
|
.map(([stateKey, ev]) => (
|
||||||
|
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
|
||||||
|
))}
|
||||||
</FilteredList>
|
</FilteredList>
|
||||||
|
<LabelledToggleSwitch
|
||||||
|
label={_t("devtools|show_empty_content_events")}
|
||||||
|
onChange={setShowEmptyState}
|
||||||
|
value={showEmptyState}
|
||||||
|
/>
|
||||||
</BaseTool>
|
</BaseTool>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import React, { type ChangeEvent, type FormEvent } from "react";
|
|||||||
import { type SecretStorage } from "matrix-js-sdk/src/matrix";
|
import { type SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import Field from "../../elements/Field";
|
import Field from "../../elements/Field";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { EncryptionCard } from "../../settings/encryption/EncryptionCard";
|
import { EncryptionCard } from "../../settings/encryption/EncryptionCard";
|
||||||
import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons";
|
import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons";
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
|
import React, { type JSX, createRef, type CSSProperties, useEffect } from "react";
|
||||||
import FocusLock from "react-focus-lock";
|
import FocusLock from "react-focus-lock";
|
||||||
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
@@ -31,11 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
|||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||||
import AccessibleButton from "./AccessibleButton";
|
import AccessibleButton from "./AccessibleButton";
|
||||||
import Modal from "../../../Modal";
|
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
|
||||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
|
|
||||||
import ModuleApi from "../../../modules/Api";
|
|
||||||
|
|
||||||
// Max scale to keep gaps around the image
|
// Max scale to keep gaps around the image
|
||||||
const MAX_SCALE = 0.95;
|
const MAX_SCALE = 0.95;
|
||||||
@@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||||||
private imageWrapper = createRef<HTMLDivElement>();
|
private imageWrapper = createRef<HTMLDivElement>();
|
||||||
private image = createRef<HTMLImageElement>();
|
private image = createRef<HTMLImageElement>();
|
||||||
|
|
||||||
|
private downloadFunction?: () => Promise<void>;
|
||||||
|
|
||||||
private initX = 0;
|
private initX = 0;
|
||||||
private initY = 0;
|
private initY = 0;
|
||||||
private previousX = 0;
|
private previousX = 0;
|
||||||
@@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
break;
|
break;
|
||||||
|
case KeyBindingAction.Save:
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (this.downloadFunction) {
|
||||||
|
this.downloadFunction();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,6 +331,10 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onDownloadFunctionReady = (download: () => Promise<void>): void => {
|
||||||
|
this.downloadFunction = download;
|
||||||
|
};
|
||||||
|
|
||||||
private onPermalinkClicked = (ev: React.MouseEvent): void => {
|
private onPermalinkClicked = (ev: React.MouseEvent): void => {
|
||||||
// This allows the permalink to be opened in a new tab/window or copied as
|
// This allows the permalink to be opened in a new tab/window or copied as
|
||||||
// matrix.to, but also for it to enable routing within Element when clicked.
|
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||||
@@ -552,7 +560,12 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||||||
title={_t("lightbox|rotate_right")}
|
title={_t("lightbox|rotate_right")}
|
||||||
onClick={this.onRotateClockwiseClick}
|
onClick={this.onRotateClockwiseClick}
|
||||||
/>
|
/>
|
||||||
<DownloadButton url={this.props.src} fileName={this.props.name} mxEvent={this.props.mxEvent} />
|
<DownloadButton
|
||||||
|
url={this.props.src}
|
||||||
|
fileName={this.props.name}
|
||||||
|
mxEvent={this.props.mxEvent}
|
||||||
|
onDownloadReady={this.onDownloadFunctionReady}
|
||||||
|
/>
|
||||||
{contextMenuButton}
|
{contextMenuButton}
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_ImageView_button mx_ImageView_button_close"
|
className="mx_ImageView_button mx_ImageView_button_close"
|
||||||
@@ -585,97 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownloadButton({
|
interface DownloadButtonProps {
|
||||||
url,
|
|
||||||
fileName,
|
|
||||||
mxEvent,
|
|
||||||
}: {
|
|
||||||
url: string;
|
url: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
mxEvent?: MatrixEvent;
|
mxEvent?: MatrixEvent;
|
||||||
}): JSX.Element | null {
|
onDownloadReady?: (download: () => Promise<void>) => void;
|
||||||
const downloader = useRef(new FileDownloader()).current;
|
}
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [canDownload, setCanDownload] = useState<boolean>(false);
|
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
|
||||||
const blobRef = useRef<Blob>(undefined);
|
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
|
||||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mxEvent) {
|
if (onDownloadReady) onDownloadReady(download);
|
||||||
// If we have no event, we assume this is safe to download.
|
}, [download, onDownloadReady]);
|
||||||
setCanDownload(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
|
|
||||||
if (hints?.allowDownloadingMedia) {
|
|
||||||
// Disable downloading as soon as we know there is a hint.
|
|
||||||
setCanDownload(false);
|
|
||||||
hints
|
|
||||||
.allowDownloadingMedia()
|
|
||||||
.then((downloadable) => {
|
|
||||||
setCanDownload(downloadable);
|
|
||||||
})
|
|
||||||
.catch((ex) => {
|
|
||||||
logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex);
|
|
||||||
// Err on the side of safety.
|
|
||||||
setCanDownload(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [mxEvent]);
|
|
||||||
|
|
||||||
function showError(e: unknown): void {
|
if (!canDownload) return null;
|
||||||
Modal.createDialog(ErrorDialog, {
|
|
||||||
title: _t("timeline|download_failed"),
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<div>{_t("timeline|download_failed_description")}</div>
|
|
||||||
<div>{e instanceof Error ? e.toString() : ""}</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDownloadClick = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (loading) return;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (blobRef.current) {
|
|
||||||
// Cheat and trigger a download, again.
|
|
||||||
return downloadBlob(blobRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw parseErrorResponse(res, await res.text());
|
|
||||||
}
|
|
||||||
const blob = await res.blob();
|
|
||||||
blobRef.current = blob;
|
|
||||||
await downloadBlob(blob);
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function downloadBlob(blob: Blob): Promise<void> {
|
|
||||||
await downloader.download({
|
|
||||||
blob,
|
|
||||||
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canDownload) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_ImageView_button mx_ImageView_button_download"
|
className="mx_ImageView_button mx_ImageView_button_download"
|
||||||
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
||||||
onClick={onDownloadClick}
|
onClick={download}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import React, { type JSX } from "react";
|
import React, { type ReactElement, useMemo } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
|
|
||||||
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
|
||||||
import Modal from "../../../Modal";
|
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
|
||||||
import ModuleApi from "../../../modules/Api";
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
@@ -30,121 +26,40 @@ interface IProps {
|
|||||||
mediaEventHelperGet: () => MediaEventHelper | undefined;
|
mediaEventHelperGet: () => MediaEventHelper | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
function useButtonTitle(loading: boolean, isEncrypted: boolean): string {
|
||||||
canDownload: null | boolean;
|
if (!loading) return _t("action|download");
|
||||||
loading: boolean;
|
|
||||||
blob?: Blob;
|
return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading");
|
||||||
tooltip: TranslationKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
|
||||||
private downloader = new FileDownloader();
|
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
|
||||||
|
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
|
||||||
|
const fileName = mediaEventHelper?.fileName;
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
|
||||||
super(props);
|
|
||||||
|
|
||||||
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
|
const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false);
|
||||||
const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
|
|
||||||
if (moduleHints?.allowDownloadingMedia) {
|
|
||||||
downloadState.canDownload = null;
|
|
||||||
moduleHints
|
|
||||||
.allowDownloadingMedia()
|
|
||||||
.then((canDownload) => {
|
|
||||||
this.setState({
|
|
||||||
canDownload: canDownload,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((ex) => {
|
|
||||||
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
|
|
||||||
this.setState({
|
|
||||||
canDownload: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = {
|
if (!canDownload) return null;
|
||||||
loading: false,
|
|
||||||
tooltip: _td("timeline|download_action_downloading"),
|
|
||||||
...downloadState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private onDownloadClick = async (): Promise<void> => {
|
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
|
||||||
try {
|
const classes = classNames({
|
||||||
await this.doDownload();
|
mx_MessageActionBar_iconButton: true,
|
||||||
} catch (e) {
|
mx_MessageActionBar_downloadButton: true,
|
||||||
Modal.createDialog(ErrorDialog, {
|
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
||||||
title: _t("timeline|download_failed"),
|
});
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<div>{_t("timeline|download_failed_description")}</div>
|
|
||||||
<div>{e instanceof Error ? e.toString() : ""}</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
this.setState({ loading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private async doDownload(): Promise<void> {
|
return (
|
||||||
const mediaEventHelper = this.props.mediaEventHelperGet();
|
<RovingAccessibleButton
|
||||||
if (this.state.loading || !mediaEventHelper) return;
|
className={classes}
|
||||||
|
title={buttonTitle}
|
||||||
if (mediaEventHelper.media.isEncrypted) {
|
onClick={download}
|
||||||
this.setState({ tooltip: _td("timeline|download_action_decrypting") });
|
disabled={loading}
|
||||||
}
|
placement="left"
|
||||||
|
>
|
||||||
this.setState({ loading: true });
|
<DownloadIcon />
|
||||||
|
{spinner}
|
||||||
if (this.state.blob) {
|
</RovingAccessibleButton>
|
||||||
// Cheat and trigger a download, again.
|
);
|
||||||
return this.downloadBlob(this.state.blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await mediaEventHelper.sourceBlob.value;
|
|
||||||
this.setState({ blob });
|
|
||||||
await this.downloadBlob(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async downloadBlob(blob: Blob): Promise<void> {
|
|
||||||
await this.downloader.download({
|
|
||||||
blob,
|
|
||||||
name: this.props.mediaEventHelperGet()!.fileName,
|
|
||||||
});
|
|
||||||
this.setState({ loading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
let spinner: JSX.Element | undefined;
|
|
||||||
if (this.state.loading) {
|
|
||||||
spinner = <Spinner w={18} h={18} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.canDownload === null) {
|
|
||||||
spinner = <Spinner w={18} h={18} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.canDownload === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const classes = classNames({
|
|
||||||
mx_MessageActionBar_iconButton: true,
|
|
||||||
mx_MessageActionBar_downloadButton: true,
|
|
||||||
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RovingAccessibleButton
|
|
||||||
className={classes}
|
|
||||||
title={spinner ? _t(this.state.tooltip) : _t("action|download")}
|
|
||||||
onClick={this.onDownloadClick}
|
|
||||||
disabled={!!spinner}
|
|
||||||
placement="left"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
{spinner}
|
|
||||||
</RovingAccessibleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
|
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
|
||||||
import * as TextForEvent from "../../../TextForEvent";
|
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class TextualEvent extends React.Component<IProps> {
|
|
||||||
public static contextType = RoomContext;
|
|
||||||
declare public context: React.ContextType<typeof RoomContext>;
|
|
||||||
|
|
||||||
public componentDidMount(): void {
|
|
||||||
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
|
||||||
}
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onEventSentinelUpdated = (): void => {
|
|
||||||
// XXX: this is crap, but we don't have a better way to force a re-render
|
|
||||||
// Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated
|
|
||||||
this.forceUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const text = TextForEvent.textForEvent(
|
|
||||||
this.props.mxEvent,
|
|
||||||
MatrixClientPeg.safeGet(),
|
|
||||||
true,
|
|
||||||
this.context?.showHiddenEvents,
|
|
||||||
);
|
|
||||||
if (!text) return null;
|
|
||||||
return <div className="mx_TextualEvent">{text}</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -120,8 +120,8 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({
|
|||||||
value={filter}
|
value={filter}
|
||||||
onFilterChange={onFilterChange}
|
onFilterChange={onFilterChange}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: "ACTIVE", label: "Active polls" },
|
{ id: "ACTIVE", label: _t("right_panel|poll|active_heading") },
|
||||||
{ id: "ENDED", label: "Past polls" },
|
{ id: "ENDED", label: _t("right_panel|poll|past_heading") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{!!pollStartEvents.length && (
|
{!!pollStartEvents.length && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import React, { type ComponentType } from "react";
|
import React, { type ComponentType } from "react";
|
||||||
import { Text } from "@vector-im/compound-web";
|
import { Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { Flex } from "../../utils/Flex";
|
import { Flex } from "../../../shared-components/utils/Flex";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
Icon: ComponentType<React.SVGAttributes<SVGElement>>;
|
Icon: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ import RoomAvatar from "../avatars/RoomAvatar.tsx";
|
|||||||
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
|
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
|
||||||
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts";
|
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts";
|
||||||
import RoomName from "../elements/RoomName.tsx";
|
import RoomName from "../elements/RoomName.tsx";
|
||||||
import { Flex } from "../../utils/Flex.tsx";
|
import { Flex } from "../../../shared-components/utils/Flex";
|
||||||
import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx";
|
import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx";
|
||||||
import { Box } from "../../utils/Box.tsx";
|
import { Box } from "../../../shared-components/utils/Box";
|
||||||
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
|
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
|
||||||
import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx";
|
import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx";
|
||||||
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
|
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
|
||||||
@@ -80,7 +80,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
|||||||
gap="var(--cpd-space-2x)"
|
gap="var(--cpd-space-2x)"
|
||||||
className="mx_RoomSummaryCard_topic"
|
className="mx_RoomSummaryCard_topic"
|
||||||
>
|
>
|
||||||
<Box flex="1">
|
<Box flex="1" className="mx_RoomSummaryCard_topic_box">
|
||||||
<Link kind="primary" onClick={vm.onEditClick}>
|
<Link kind="primary" onClick={vm.onEditClick}>
|
||||||
<Text size="sm" weight="regular">
|
<Text size="sm" weight="regular">
|
||||||
{_t("right_panel|add_topic")}
|
{_t("right_panel|add_topic")}
|
||||||
@@ -103,7 +103,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
|||||||
mx_RoomSummaryCard_topic_collapsed: !vm.expanded,
|
mx_RoomSummaryCard_topic_collapsed: !vm.expanded,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Box flex="1" className="mx_RoomSummaryCard_topic_container">
|
<Box flex="1" className="mx_RoomSummaryCard_topic_container mx_RoomSummaryCard_topic_box">
|
||||||
<Text size="sm" weight="regular" onClick={vm.onTopicLinkClick}>
|
<Text size="sm" weight="regular" onClick={vm.onTopicLinkClick}>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -169,8 +169,8 @@ const RoomSummaryCardView: React.FC<IProps> = ({
|
|||||||
|
|
||||||
<Flex as="section" justify="center" gap="var(--cpd-space-2x)" className="mx_RoomSummaryCard_badges">
|
<Flex as="section" justify="center" gap="var(--cpd-space-2x)" className="mx_RoomSummaryCard_badges">
|
||||||
{!vm.isDirectMessage && vm.roomJoinRule === JoinRule.Public && (
|
{!vm.isDirectMessage && vm.roomJoinRule === JoinRule.Public && (
|
||||||
<Badge kind="grey">
|
<Badge kind="blue">
|
||||||
<PublicIcon width="1em" />
|
<PublicIcon width="1em" color="var(--cpd-color-icon-info-primary)" />
|
||||||
{_t("common|public_room")}
|
{_t("common|public_room")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -183,8 +183,8 @@ const RoomSummaryCardView: React.FC<IProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!vm.isRoomEncrypted && (
|
{!vm.isRoomEncrypted && (
|
||||||
<Badge kind="grey">
|
<Badge kind="blue">
|
||||||
<LockOffIcon width="1em" />
|
<LockOffIcon width="1em" color="var(--cpd-color-icon-info-primary)" />
|
||||||
{_t("common|unencrypted")}
|
{_t("common|unencrypted")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ import {
|
|||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
|
import { MenuItem } from "@vector-im/compound-web";
|
||||||
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
|
|
||||||
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||||
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
||||||
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
|
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
|
||||||
@@ -40,41 +39,32 @@ import Modal from "../../../Modal";
|
|||||||
import { _t, UserFriendlyError } from "../../../languageHandler";
|
import { _t, UserFriendlyError } from "../../../languageHandler";
|
||||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import SdkConfig from "../../../SdkConfig";
|
|
||||||
import MultiInviter from "../../../utils/MultiInviter";
|
import MultiInviter from "../../../utils/MultiInviter";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
import EncryptionPanel from "./EncryptionPanel";
|
import EncryptionPanel from "./EncryptionPanel";
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
import { verifyUser } from "../../../verification";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import ImageView from "../elements/ImageView";
|
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
|
||||||
import PresenceLabel from "../rooms/PresenceLabel";
|
|
||||||
import { ShareDialog } from "../dialogs/ShareDialog";
|
import { ShareDialog } from "../dialogs/ShareDialog";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||||
import { mediaFromMxc } from "../../../customisations/Media";
|
|
||||||
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||||
import { UIComponent } from "../../../settings/UIFeature";
|
import { UIComponent } from "../../../settings/UIFeature";
|
||||||
import { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState";
|
import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState";
|
||||||
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
|
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
|
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
|
||||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||||
import { Flex } from "../../utils/Flex";
|
|
||||||
import CopyableText from "../elements/CopyableText";
|
|
||||||
import { useUserTimezone } from "../../../hooks/useUserTimezone";
|
|
||||||
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
|
||||||
import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
|
import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
|
||||||
|
import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
|
||||||
|
|
||||||
export interface IDevice extends Device {
|
export interface IDevice extends Device {
|
||||||
ambiguous?: boolean;
|
ambiguous?: boolean;
|
||||||
@@ -298,7 +288,7 @@ export const warnSelfDemote = async (isSpace: boolean): Promise<boolean> => {
|
|||||||
return !!confirmed;
|
return !!confirmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Container: React.FC<{
|
export const Container: React.FC<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ children, className }) => {
|
}> = ({ children, className }) => {
|
||||||
@@ -426,16 +416,6 @@ const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
|
|||||||
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
|
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
|
|
||||||
return useAsyncMemo<boolean>(
|
|
||||||
async () => {
|
|
||||||
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
|
||||||
},
|
|
||||||
[cli],
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IRoomPermissions {
|
export interface IRoomPermissions {
|
||||||
modifyLevelMax: number;
|
modifyLevelMax: number;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -567,80 +547,6 @@ export const useDevices = (userId: string): IDevice[] | undefined | null => {
|
|||||||
return devices;
|
return devices;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined {
|
|
||||||
return useAsyncMemo(async () => {
|
|
||||||
if (!canVerify) return undefined;
|
|
||||||
return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
|
|
||||||
}, [cli, member, canVerify]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const VerificationSection: React.FC<{
|
|
||||||
member: User | RoomMember;
|
|
||||||
devices: IDevice[];
|
|
||||||
}> = ({ member, devices }) => {
|
|
||||||
const cli = useContext(MatrixClientContext);
|
|
||||||
let content;
|
|
||||||
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
|
|
||||||
|
|
||||||
const userTrust = useAsyncMemo<UserVerificationStatus | undefined>(
|
|
||||||
async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
|
|
||||||
[member.userId],
|
|
||||||
// the user verification status is not initialized
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const hasUserVerificationStatus = Boolean(userTrust);
|
|
||||||
const isUserVerified = Boolean(userTrust?.isVerified());
|
|
||||||
const isMe = member.userId === cli.getUserId();
|
|
||||||
const canVerify =
|
|
||||||
hasUserVerificationStatus &&
|
|
||||||
homeserverSupportsCrossSigning &&
|
|
||||||
!isUserVerified &&
|
|
||||||
!isMe &&
|
|
||||||
devices &&
|
|
||||||
devices.length > 0;
|
|
||||||
|
|
||||||
const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
|
|
||||||
|
|
||||||
if (isUserVerified) {
|
|
||||||
content = (
|
|
||||||
<Badge kind="green" className="mx_UserInfo_verified_badge">
|
|
||||||
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
|
|
||||||
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
|
|
||||||
{_t("common|verified")}
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
} else if (hasCrossSigningKeys === undefined) {
|
|
||||||
// We are still fetching the cross-signing keys for the user, show spinner.
|
|
||||||
content = <InlineSpinner size={24} />;
|
|
||||||
} else if (canVerify && hasCrossSigningKeys) {
|
|
||||||
content = (
|
|
||||||
<div className="mx_UserInfo_container_verifyButton">
|
|
||||||
<Button
|
|
||||||
className="mx_UserInfo_verify_button"
|
|
||||||
kind="tertiary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => verifyUser(cli, member as User)}
|
|
||||||
>
|
|
||||||
{_t("user_info|verify_button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
content = (
|
|
||||||
<Text className="mx_UserInfo_verification_unavailable" size="sm">
|
|
||||||
({_t("user_info|verification_unavailable")})
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex justify="center" align="center" className="mx_UserInfo_verification">
|
|
||||||
{content}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BasicUserInfo: React.FC<{
|
const BasicUserInfo: React.FC<{
|
||||||
room: Room;
|
room: Room;
|
||||||
member: User | RoomMember;
|
member: User | RoomMember;
|
||||||
@@ -761,114 +667,6 @@ const BasicUserInfo: React.FC<{
|
|||||||
|
|
||||||
export type Member = User | RoomMember;
|
export type Member = User | RoomMember;
|
||||||
|
|
||||||
export const UserInfoHeader: React.FC<{
|
|
||||||
member: Member;
|
|
||||||
devices: IDevice[];
|
|
||||||
roomId?: string;
|
|
||||||
hideVerificationSection?: boolean;
|
|
||||||
}> = ({ member, devices, roomId, hideVerificationSection }) => {
|
|
||||||
const cli = useContext(MatrixClientContext);
|
|
||||||
|
|
||||||
const onMemberAvatarClick = useCallback(() => {
|
|
||||||
const avatarUrl = (member as RoomMember).getMxcAvatarUrl
|
|
||||||
? (member as RoomMember).getMxcAvatarUrl()
|
|
||||||
: (member as User).avatarUrl;
|
|
||||||
|
|
||||||
const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
|
|
||||||
if (!httpUrl) return;
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
src: httpUrl,
|
|
||||||
name: (member as RoomMember).name || (member as User).displayName,
|
|
||||||
};
|
|
||||||
|
|
||||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
|
||||||
}, [member]);
|
|
||||||
|
|
||||||
const avatarUrl = (member as User).avatarUrl;
|
|
||||||
|
|
||||||
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;
|
|
||||||
presenceCurrentlyActive = member.user.currentlyActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
|
|
||||||
let showPresence = true;
|
|
||||||
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
|
|
||||||
showPresence = enablePresenceByHsUrl[cli.baseUrl];
|
|
||||||
}
|
|
||||||
|
|
||||||
let presenceLabel: JSX.Element | undefined;
|
|
||||||
if (showPresence) {
|
|
||||||
presenceLabel = (
|
|
||||||
<PresenceLabel
|
|
||||||
activeAgo={presenceLastActiveAgo}
|
|
||||||
currentlyActive={presenceCurrentlyActive}
|
|
||||||
presenceState={presenceState}
|
|
||||||
className="mx_UserInfo_profileStatus"
|
|
||||||
coloured
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timezoneInfo = useUserTimezone(cli, member.userId);
|
|
||||||
|
|
||||||
const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
|
|
||||||
roomId,
|
|
||||||
withDisplayName: true,
|
|
||||||
});
|
|
||||||
const displayName = (member as RoomMember).rawDisplayName;
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="mx_UserInfo_avatar">
|
|
||||||
<div className="mx_UserInfo_avatar_transition">
|
|
||||||
<div className="mx_UserInfo_avatar_transition_child">
|
|
||||||
<MemberAvatar
|
|
||||||
key={member.userId} // to instantly blank the avatar when UserInfo changes members
|
|
||||||
member={member as RoomMember}
|
|
||||||
size="120px"
|
|
||||||
resizeMethod="scale"
|
|
||||||
fallbackUserId={member.userId}
|
|
||||||
onClick={onMemberAvatarClick}
|
|
||||||
urls={avatarUrl ? [avatarUrl] : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Container className="mx_UserInfo_header">
|
|
||||||
<Flex direction="column" align="center" className="mx_UserInfo_profile">
|
|
||||||
<Heading size="sm" weight="semibold" as="h1" dir="auto">
|
|
||||||
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
|
|
||||||
{displayName}
|
|
||||||
</Flex>
|
|
||||||
</Heading>
|
|
||||||
{presenceLabel}
|
|
||||||
{timezoneInfo && (
|
|
||||||
<Tooltip label={timezoneInfo?.timezone ?? ""}>
|
|
||||||
<Flex align="center" className="mx_UserInfo_timezone">
|
|
||||||
<Text size="sm" weight="regular">
|
|
||||||
{timezoneInfo?.friendly ?? ""}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
|
|
||||||
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
|
|
||||||
{userIdentifier}
|
|
||||||
</CopyableText>
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
|
|
||||||
</Container>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
user: Member;
|
user: Member;
|
||||||
room?: Room;
|
room?: Room;
|
||||||
@@ -927,7 +725,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
|
|||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<>
|
<>
|
||||||
<UserInfoHeader
|
<UserInfoHeaderView
|
||||||
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
|
hideVerificationSection={phase === RightPanelPhases.EncryptionPanel}
|
||||||
member={member}
|
member={member}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { type User, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { Text, Button, InlineSpinner, Badge } from "@vector-im/compound-web";
|
||||||
|
import { VerifiedIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { useUserInfoVerificationViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
|
||||||
|
import { type IDevice } from "../UserInfo";
|
||||||
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
|
import { _t } from "../../../../languageHandler";
|
||||||
|
|
||||||
|
export const UserInfoHeaderVerificationView: React.FC<{
|
||||||
|
member: User | RoomMember;
|
||||||
|
devices: IDevice[];
|
||||||
|
}> = ({ member, devices }) => {
|
||||||
|
let content;
|
||||||
|
const vm = useUserInfoVerificationViewModel(member, devices);
|
||||||
|
|
||||||
|
if (vm.isUserVerified) {
|
||||||
|
content = (
|
||||||
|
<Badge kind="green" className="mx_UserInfo_verified_badge">
|
||||||
|
<VerifiedIcon className="mx_UserInfo_verified_icon" height="16px" width="16px" />
|
||||||
|
<Text size="sm" weight="medium" className="mx_UserInfo_verified_label">
|
||||||
|
{_t("common|verified")}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
} else if (vm.hasCrossSigningKeys === undefined) {
|
||||||
|
// We are still fetching the cross-signing keys for the user, show spinner.
|
||||||
|
content = <InlineSpinner size={24} />;
|
||||||
|
} else if (vm.canVerify && vm.hasCrossSigningKeys) {
|
||||||
|
content = (
|
||||||
|
<div className="mx_UserInfo_container_verifyButton">
|
||||||
|
<Button
|
||||||
|
className="mx_UserInfo_verify_button"
|
||||||
|
kind="tertiary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => vm.verifySelectedUser()}
|
||||||
|
>
|
||||||
|
{_t("user_info|verify_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<Text className="mx_UserInfo_verification_unavailable" size="sm">
|
||||||
|
({_t("user_info|verification_unavailable")})
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justify="center" align="center" className="mx_UserInfo_verification">
|
||||||
|
{content}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { type User, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { Heading, Tooltip, Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { useUserfoHeaderViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
|
||||||
|
import MemberAvatar from "../../avatars/MemberAvatar";
|
||||||
|
import { Container, type Member, type IDevice } from "../UserInfo";
|
||||||
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
|
import PresenceLabel from "../../rooms/PresenceLabel";
|
||||||
|
import CopyableText from "../../elements/CopyableText";
|
||||||
|
import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView";
|
||||||
|
|
||||||
|
export interface UserInfoHeaderViewProps {
|
||||||
|
member: Member;
|
||||||
|
roomId?: string;
|
||||||
|
devices: IDevice[];
|
||||||
|
hideVerificationSection: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserInfoHeaderView: React.FC<UserInfoHeaderViewProps> = ({
|
||||||
|
member,
|
||||||
|
devices,
|
||||||
|
roomId,
|
||||||
|
hideVerificationSection,
|
||||||
|
}) => {
|
||||||
|
const vm = useUserfoHeaderViewModel({ member, roomId });
|
||||||
|
const avatarUrl = (member as User).avatarUrl;
|
||||||
|
const displayName = (member as RoomMember).rawDisplayName;
|
||||||
|
|
||||||
|
let presenceLabel: JSX.Element | undefined;
|
||||||
|
|
||||||
|
if (vm.showPresence) {
|
||||||
|
presenceLabel = (
|
||||||
|
<PresenceLabel
|
||||||
|
activeAgo={vm.precenseInfo.lastActiveAgo}
|
||||||
|
currentlyActive={vm.precenseInfo.currentlyActive}
|
||||||
|
presenceState={vm.precenseInfo.state}
|
||||||
|
className="mx_UserInfo_profileStatus"
|
||||||
|
coloured
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className="mx_UserInfo_avatar">
|
||||||
|
<div className="mx_UserInfo_avatar_transition">
|
||||||
|
<div className="mx_UserInfo_avatar_transition_child">
|
||||||
|
<MemberAvatar
|
||||||
|
key={member.userId} // to instantly blank the avatar when UserInfo changes members
|
||||||
|
member={member as RoomMember}
|
||||||
|
size="120px"
|
||||||
|
resizeMethod="scale"
|
||||||
|
fallbackUserId={member.userId}
|
||||||
|
onClick={vm.onMemberAvatarClick}
|
||||||
|
urls={avatarUrl ? [avatarUrl] : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container className="mx_UserInfo_header">
|
||||||
|
<Flex direction="column" align="center" className="mx_UserInfo_profile">
|
||||||
|
<Heading size="sm" weight="semibold" as="h1" dir="auto">
|
||||||
|
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
|
||||||
|
{displayName}
|
||||||
|
</Flex>
|
||||||
|
</Heading>
|
||||||
|
{presenceLabel}
|
||||||
|
{vm.timezoneInfo && (
|
||||||
|
<Tooltip label={vm.timezoneInfo?.timezone ?? ""}>
|
||||||
|
<Flex align="center" className="mx_UserInfo_timezone">
|
||||||
|
<Text size="sm" weight="regular">
|
||||||
|
{vm.timezoneInfo?.friendly ?? ""}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
|
||||||
|
<CopyableText getTextToCopy={() => vm.userIdentifier} border={false}>
|
||||||
|
{vm.userIdentifier}
|
||||||
|
</CopyableText>
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
{!hideVerificationSection && <UserInfoHeaderVerificationView member={member} devices={devices} />}
|
||||||
|
</Container>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
|||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import { copyPlaintext, getSelectedText } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||||
import RedactedBody from "../messages/RedactedBody";
|
import RedactedBody from "../messages/RedactedBody";
|
||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
@@ -729,11 +729,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
if (this.state.shieldColour !== EventShieldColour.NONE) {
|
if (this.state.shieldColour !== EventShieldColour.NONE) {
|
||||||
let shieldReasonMessage: string;
|
let shieldReasonMessage: string;
|
||||||
switch (this.state.shieldReason) {
|
switch (this.state.shieldReason) {
|
||||||
case null:
|
|
||||||
case EventShieldReason.UNKNOWN:
|
|
||||||
shieldReasonMessage = _t("error|unknown");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.UNVERIFIED_IDENTITY:
|
case EventShieldReason.UNVERIFIED_IDENTITY:
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity");
|
shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity");
|
||||||
break;
|
break;
|
||||||
@@ -761,6 +756,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
case EventShieldReason.VERIFICATION_VIOLATION:
|
case EventShieldReason.VERIFICATION_VIOLATION:
|
||||||
shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case EventShieldReason.MISMATCHED_SENDER:
|
||||||
|
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
shieldReasonMessage = _t("error|unknown");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.shieldColour === EventShieldColour.GREY) {
|
if (this.state.shieldColour === EventShieldColour.GREY) {
|
||||||
@@ -840,10 +843,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
// Electron layer (webcontents-handler.ts)
|
// Electron layer (webcontents-handler.ts)
|
||||||
if (clickTarget instanceof HTMLImageElement) return;
|
if (clickTarget instanceof HTMLImageElement) return;
|
||||||
|
|
||||||
// Return if we're in a browser and click either an a tag or we have
|
// Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu
|
||||||
// selected text, as in those cases we want to use the native browser
|
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
|
||||||
// menu
|
|
||||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
|
|
||||||
|
|
||||||
// We don't want to show the menu when editing a message
|
// We don't want to show the menu when editing a message
|
||||||
if (this.props.editState) return;
|
if (this.props.editState) return;
|
||||||
@@ -1237,22 +1238,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||||
{this.renderContextMenu()}
|
{this.renderContextMenu()}
|
||||||
{replyChain}
|
{replyChain}
|
||||||
{renderTile(
|
{renderTile(TimelineRenderingType.Thread, {
|
||||||
TimelineRenderingType.Thread,
|
...this.props,
|
||||||
{
|
|
||||||
...this.props,
|
|
||||||
|
|
||||||
// overrides
|
// overrides
|
||||||
ref: this.tile,
|
ref: this.tile,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
|
|
||||||
// appease TS
|
// appease TS
|
||||||
highlights: this.props.highlights,
|
highlights: this.props.highlights,
|
||||||
highlightLink: this.props.highlightLink,
|
highlightLink: this.props.highlightLink,
|
||||||
permalinkCreator: this.props.permalinkCreator!,
|
permalinkCreator: this.props.permalinkCreator!,
|
||||||
},
|
showHiddenEvents: this.context.showHiddenEvents,
|
||||||
this.context.showHiddenEvents,
|
})}
|
||||||
)}
|
|
||||||
{actionBar}
|
{actionBar}
|
||||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||||
{timestamp}
|
{timestamp}
|
||||||
@@ -1383,22 +1381,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
</a>,
|
</a>,
|
||||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||||
{this.renderContextMenu()}
|
{this.renderContextMenu()}
|
||||||
{renderTile(
|
{renderTile(TimelineRenderingType.File, {
|
||||||
TimelineRenderingType.File,
|
...this.props,
|
||||||
{
|
|
||||||
...this.props,
|
|
||||||
|
|
||||||
// overrides
|
// overrides
|
||||||
ref: this.tile,
|
ref: this.tile,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
|
|
||||||
// appease TS
|
// appease TS
|
||||||
highlights: this.props.highlights,
|
highlights: this.props.highlights,
|
||||||
highlightLink: this.props.highlightLink,
|
highlightLink: this.props.highlightLink,
|
||||||
permalinkCreator: this.props.permalinkCreator,
|
permalinkCreator: this.props.permalinkCreator,
|
||||||
},
|
showHiddenEvents: this.context.showHiddenEvents,
|
||||||
this.context.showHiddenEvents,
|
})}
|
||||||
)}
|
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1433,23 +1428,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{groupTimestamp}
|
{groupTimestamp}
|
||||||
{groupPadlock}
|
{groupPadlock}
|
||||||
{replyChain}
|
{replyChain}
|
||||||
{renderTile(
|
{renderTile(this.context.timelineRenderingType, {
|
||||||
this.context.timelineRenderingType,
|
...this.props,
|
||||||
{
|
|
||||||
...this.props,
|
|
||||||
|
|
||||||
// overrides
|
// overrides
|
||||||
ref: this.tile,
|
ref: this.tile,
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
isSeeingThroughMessageHiddenForModeration,
|
||||||
timestamp: bubbleTimestamp,
|
timestamp: bubbleTimestamp,
|
||||||
|
|
||||||
// appease TS
|
// appease TS
|
||||||
highlights: this.props.highlights,
|
highlights: this.props.highlights,
|
||||||
highlightLink: this.props.highlightLink,
|
highlightLink: this.props.highlightLink,
|
||||||
permalinkCreator: this.props.permalinkCreator,
|
permalinkCreator: this.props.permalinkCreator,
|
||||||
},
|
showHiddenEvents: this.context.showHiddenEvents,
|
||||||
this.context.showHiddenEvents,
|
})}
|
||||||
)}
|
|
||||||
{actionBar}
|
{actionBar}
|
||||||
{this.props.layout === Layout.IRC && (
|
{this.props.layout === Layout.IRC && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web";
|
import { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
|
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||||
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { type MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
|
import { type MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
|
|
||||||
@@ -19,10 +19,10 @@ interface TooltipProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
|
||||||
if (canInvite) return children;
|
const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip");
|
||||||
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
|
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
|
||||||
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
|
return <Tooltip description={description}>{children}</Tooltip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,10 +42,10 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
if (shouldShowSearch) {
|
if (shouldShowSearch) {
|
||||||
/// When rendered alongside a search box, the invite button is just an icon.
|
/// When rendered alongside a search box, the invite button is just an icon.
|
||||||
return (
|
return (
|
||||||
<OptionalTooltip canInvite={vm.canInvite}>
|
<InviteTooltip canInvite={vm.canInvite}>
|
||||||
<Button
|
<Button
|
||||||
className="mx_MemberListHeaderView_invite_small"
|
className="mx_MemberListHeaderView_invite_small"
|
||||||
kind="primary"
|
kind="secondary"
|
||||||
onClick={vm.onInviteButtonClick}
|
onClick={vm.onInviteButtonClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
iconOnly={true}
|
iconOnly={true}
|
||||||
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
aria-label={_t("action|invite")}
|
aria-label={_t("action|invite")}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
</OptionalTooltip>
|
</InviteTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without a search box, invite button is a full size button.
|
// Without a search box, invite button is a full size button.
|
||||||
return (
|
return (
|
||||||
<OptionalTooltip canInvite={vm.canInvite}>
|
<InviteTooltip canInvite={vm.canInvite}>
|
||||||
<Button
|
<Button
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
|||||||
>
|
>
|
||||||
{_t("action|invite")}
|
{_t("action|invite")}
|
||||||
</Button>
|
</Button>
|
||||||
</OptionalTooltip>
|
</InviteTooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Form } from "@vector-im/compound-web";
|
import { Form } from "@vector-im/compound-web";
|
||||||
import React, { type JSX } from "react";
|
import React, { type JSX, useCallback } from "react";
|
||||||
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
|
|
||||||
import { AutoSizer } from "react-virtualized";
|
|
||||||
|
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import {
|
import {
|
||||||
type MemberWithSeparator,
|
type MemberWithSeparator,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
@@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
|
|||||||
import { MemberListHeaderView } from "./MemberListHeaderView";
|
import { MemberListHeaderView } from "./MemberListHeaderView";
|
||||||
import BaseCard from "../../right_panel/BaseCard";
|
import BaseCard from "../../right_panel/BaseCard";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
|
import { type ListContext, ListView } from "../../../utils/ListView";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -30,53 +28,74 @@ interface IProps {
|
|||||||
|
|
||||||
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||||
const vm = useMemberListViewModel(props.roomId);
|
const vm = useMemberListViewModel(props.roomId);
|
||||||
|
const { isPresenceEnabled, onClickMember, memberCount } = vm;
|
||||||
|
|
||||||
const totalRows = vm.members.length;
|
const getItemKey = useCallback((item: MemberWithSeparator): string => {
|
||||||
|
|
||||||
const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
|
|
||||||
if (item === SEPARATOR) {
|
if (item === SEPARATOR) {
|
||||||
return <hr className="mx_MemberListView_separator" />;
|
return "separator";
|
||||||
} else if (item.member) {
|
} else if (item.member) {
|
||||||
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
|
return `member-${item.member.userId}`;
|
||||||
} else {
|
} else {
|
||||||
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
|
return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getRowHeight = ({ index }: { index: number }): number => {
|
const getItemComponent = useCallback(
|
||||||
if (vm.members[index] === SEPARATOR) {
|
(
|
||||||
/**
|
index: number,
|
||||||
* This is a separator of 2px height rendered between
|
item: MemberWithSeparator,
|
||||||
* joined and invited members.
|
context: ListContext<any>,
|
||||||
*/
|
onFocus: (e: React.FocusEvent) => void,
|
||||||
return 2;
|
): JSX.Element => {
|
||||||
} else if (totalRows && index === totalRows) {
|
const itemKey = getItemKey(item);
|
||||||
/**
|
const isRovingItem = itemKey === context.tabIndexKey;
|
||||||
* The empty spacer div rendered at the bottom should
|
const focused = isRovingItem && context.focused;
|
||||||
* have a height of 32px.
|
if (item === SEPARATOR) {
|
||||||
*/
|
return <hr className="mx_MemberListView_separator" />;
|
||||||
return 32;
|
} else if (item.member) {
|
||||||
} else {
|
return (
|
||||||
/**
|
<RoomMemberTileView
|
||||||
* The actual member tiles have a height of 56px.
|
member={item.member}
|
||||||
*/
|
showPresence={isPresenceEnabled}
|
||||||
return 56;
|
focused={focused}
|
||||||
}
|
tabIndex={isRovingItem ? 0 : -1}
|
||||||
};
|
index={index}
|
||||||
|
memberCount={memberCount}
|
||||||
|
onFocus={onFocus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<ThreePidInviteTileView
|
||||||
|
threePidInvite={item.threePidInvite}
|
||||||
|
focused={focused}
|
||||||
|
tabIndex={isRovingItem ? 0 : -1}
|
||||||
|
memberIndex={index - 1} // Adjust as invites are below the separator
|
||||||
|
memberCount={memberCount}
|
||||||
|
onFocus={onFocus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPresenceEnabled, getItemKey, memberCount],
|
||||||
|
);
|
||||||
|
|
||||||
const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
|
const handleSelectItem = useCallback(
|
||||||
if (index === totalRows) {
|
(item: MemberWithSeparator): void => {
|
||||||
// We've rendered all the members,
|
if (item !== SEPARATOR) {
|
||||||
// now we render an empty div to add some space to the end of the list.
|
if (item.member) {
|
||||||
return <div key={key} style={style} />;
|
onClickMember(item.member);
|
||||||
}
|
} else {
|
||||||
const item = vm.members[index];
|
onClickMember(item.threePidInvite);
|
||||||
return (
|
}
|
||||||
<div key={key} style={style}>
|
}
|
||||||
{getRowComponent(item)}
|
},
|
||||||
</div>
|
[onClickMember],
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => {
|
||||||
|
return item !== SEPARATOR;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseCard
|
<BaseCard
|
||||||
@@ -87,34 +106,20 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
|||||||
header={_t("common|people")}
|
header={_t("common|people")}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
>
|
>
|
||||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
|
||||||
{({ onKeyDownHandler }) => (
|
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
||||||
<Flex
|
<MemberListHeaderView vm={vm} />
|
||||||
align="stretch"
|
</Form.Root>
|
||||||
direction="column"
|
<ListView
|
||||||
className="mx_MemberListView_container"
|
items={vm.members}
|
||||||
onKeyDown={onKeyDownHandler}
|
onSelectItem={handleSelectItem}
|
||||||
>
|
getItemComponent={getItemComponent}
|
||||||
<Form.Root>
|
getItemKey={getItemKey}
|
||||||
<MemberListHeaderView vm={vm} />
|
isItemFocusable={isItemFocusable}
|
||||||
</Form.Root>
|
role="listbox"
|
||||||
<AutoSizer>
|
aria-label={_t("member_list|list_title")}
|
||||||
{({ height, width }) => (
|
/>
|
||||||
<List
|
</Flex>
|
||||||
rowRenderer={rowRenderer}
|
|
||||||
rowHeight={getRowHeight}
|
|
||||||
// The +1 refers to the additional empty div that we render at the end of the list.
|
|
||||||
rowCount={totalRows + 1}
|
|
||||||
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
|
|
||||||
height={height - 113}
|
|
||||||
width={width}
|
|
||||||
overscanRowCount={15}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AutoSizer>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</RovingTabIndexProvider>
|
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
|||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
member: RoomMember;
|
member: RoomMember;
|
||||||
|
index: number;
|
||||||
|
memberCount: number;
|
||||||
showPresence?: boolean;
|
showPresence?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
onFocus: (e: React.FocusEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomMemberTileView(props: IProps): JSX.Element {
|
export function RoomMemberTileView(props: IProps): JSX.Element {
|
||||||
@@ -36,7 +41,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const name = vm.name;
|
const name = vm.name;
|
||||||
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
|
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
|
||||||
|
|
||||||
const presenceState = member.presenceState;
|
const presenceState = member.presenceState;
|
||||||
let presenceJSX: JSX.Element | undefined;
|
let presenceJSX: JSX.Element | undefined;
|
||||||
@@ -54,13 +59,18 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberTileView
|
<MemberTileView
|
||||||
title={vm.title}
|
|
||||||
onClick={vm.onClick}
|
onClick={vm.onClick}
|
||||||
|
onFocus={props.onFocus}
|
||||||
avatarJsx={av}
|
avatarJsx={av}
|
||||||
presenceJsx={presenceJSX}
|
presenceJsx={presenceJSX}
|
||||||
nameJsx={nameJSX}
|
nameJsx={nameJSX}
|
||||||
userLabel={vm.userLabel}
|
userLabel={vm.userLabel}
|
||||||
|
ariaLabel={name}
|
||||||
iconJsx={iconJsx}
|
iconJsx={iconJsx}
|
||||||
|
focused={props.focused}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
memberIndex={props.index - (member.isInvite ? 1 : 0)} // Adjust as invites are below the seperator
|
||||||
|
memberCount={props.memberCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,32 @@ import { InvitedIconView } from "./common/InvitedIconView";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
threePidInvite: ThreePIDInvite;
|
threePidInvite: ThreePIDInvite;
|
||||||
|
memberIndex: number;
|
||||||
|
memberCount: number;
|
||||||
|
focused?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
onFocus: (e: React.FocusEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
export function ThreePidInviteTileView(props: Props): JSX.Element {
|
||||||
const vm = useThreePidTileViewModel(props);
|
const vm = useThreePidTileViewModel(props);
|
||||||
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
|
||||||
const iconJsx = <InvitedIconView isThreePid={true} />;
|
const iconJsx = <InvitedIconView isThreePid={true} />;
|
||||||
|
const name = vm.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberTileView
|
<MemberTileView
|
||||||
nameJsx={vm.name}
|
nameJsx={name}
|
||||||
avatarJsx={av}
|
avatarJsx={av}
|
||||||
onClick={vm.onClick}
|
onClick={vm.onClick}
|
||||||
|
memberIndex={props.memberIndex}
|
||||||
|
memberCount={props.memberCount}
|
||||||
|
ariaLabel={name}
|
||||||
userLabel={vm.userLabel}
|
userLabel={vm.userLabel}
|
||||||
iconJsx={iconJsx}
|
iconJsx={iconJsx}
|
||||||
|
focused={props.focused}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
onFocus={props.onFocus}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import React, { type JSX } from "react";
|
|||||||
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
||||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
|
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
|
||||||
|
|
||||||
import { Flex } from "../../../../../utils/Flex";
|
import { Flex } from "../../../../../../shared-components/utils/Flex";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isThreePid: boolean;
|
isThreePid: boolean;
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { type JSX } from "react";
|
import React, { useEffect, useRef, type JSX } from "react";
|
||||||
|
|
||||||
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
|
import AccessibleButton from "../../../../elements/AccessibleButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
avatarJsx: JSX.Element;
|
avatarJsx: JSX.Element;
|
||||||
nameJsx: JSX.Element | string;
|
nameJsx: JSX.Element | string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title?: string;
|
onFocus: (e: React.FocusEvent) => void;
|
||||||
|
memberIndex: number;
|
||||||
|
memberCount: number;
|
||||||
|
ariaLabel?: string;
|
||||||
presenceJsx?: JSX.Element;
|
presenceJsx?: JSX.Element;
|
||||||
userLabel?: React.ReactNode;
|
userLabel?: React.ReactNode;
|
||||||
iconJsx?: JSX.Element;
|
iconJsx?: JSX.Element;
|
||||||
|
tabIndex?: number;
|
||||||
|
focused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MemberTileView(props: Props): JSX.Element {
|
export function MemberTileView(props: Props): JSX.Element {
|
||||||
@@ -24,22 +29,37 @@ export function MemberTileView(props: Props): JSX.Element {
|
|||||||
if (props.userLabel) {
|
if (props.userLabel) {
|
||||||
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
|
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
|
||||||
}
|
}
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.focused) {
|
||||||
|
ref.current?.focus({ preventScroll: true, focusVisible: true });
|
||||||
|
}
|
||||||
|
}, [props.focused]);
|
||||||
return (
|
return (
|
||||||
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
// The wrapping div is required to make the magic mouse listener work, for some reason.
|
||||||
<div>
|
<div>
|
||||||
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
|
<AccessibleButton
|
||||||
<div className="mx_MemberTileView_left">
|
ref={ref}
|
||||||
|
className="mx_MemberTileView"
|
||||||
|
onClick={props.onClick}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
aria-label={props?.ariaLabel}
|
||||||
|
tabIndex={props.tabIndex}
|
||||||
|
role="option"
|
||||||
|
aria-posinset={props.memberIndex + 1}
|
||||||
|
aria-setsize={props.memberCount}
|
||||||
|
>
|
||||||
|
<div aria-hidden className="mx_MemberTileView_left">
|
||||||
<div className="mx_MemberTileView_avatar">
|
<div className="mx_MemberTileView_avatar">
|
||||||
{props.avatarJsx} {props.presenceJsx}
|
{props.avatarJsx} {props.presenceJsx}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MemberTileView_right">
|
<div aria-hidden className="mx_MemberTileView_right">
|
||||||
{userLabelJsx}
|
{userLabelJsx}
|
||||||
{props.iconJsx}
|
{props.iconJsx}
|
||||||
</div>
|
</div>
|
||||||
</RovingAccessibleButton>
|
</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/vi
|
|||||||
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
||||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { Flex } from "../../utils/Flex";
|
import { Flex } from "../../../shared-components/utils/Flex";
|
||||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||||||
highlights: this.props.highlights,
|
highlights: this.props.highlights,
|
||||||
highlightLink: this.props.highlightLink,
|
highlightLink: this.props.highlightLink,
|
||||||
permalinkCreator: this.props.permalinkCreator,
|
permalinkCreator: this.props.permalinkCreator,
|
||||||
|
showHiddenEvents: false,
|
||||||
},
|
},
|
||||||
false /* showHiddenEvents shouldn't be relevant */,
|
false /* showHiddenEvents shouldn't be relevant */,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStore
|
|||||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
||||||
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
|
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
|
||||||
import { _t } from "../../../../languageHandler.tsx";
|
import { _t } from "../../../../languageHandler.tsx";
|
||||||
import { Flex } from "../../../utils/Flex.tsx";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { Box } from "../../../utils/Box.tsx";
|
import { Box } from "../../../../shared-components/utils/Box";
|
||||||
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
|
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
|
||||||
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
|
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
|
||||||
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
|
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
|
||||||
@@ -286,7 +286,8 @@ export default function RoomHeader({
|
|||||||
<PublicIcon
|
<PublicIcon
|
||||||
width="16px"
|
width="16px"
|
||||||
height="16px"
|
height="16px"
|
||||||
className="mx_RoomHeader_icon text-secondary"
|
className="mx_RoomHeader_icon"
|
||||||
|
color="var(--cpd-color-icon-info-primary)"
|
||||||
aria-label={_t("common|public_room")}
|
aria-label={_t("common|public_room")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
import React, { type JSX, type PropsWithChildren } from "react";
|
import React, { type JSX, type PropsWithChildren } from "react";
|
||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||||
|
|
||||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||||
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
|
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
|
||||||
@@ -148,8 +148,8 @@ function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
|
|||||||
direction="column"
|
direction="column"
|
||||||
gap="var(--cpd-space-4x)"
|
gap="var(--cpd-space-4x)"
|
||||||
>
|
>
|
||||||
<Button size="sm" kind="secondary" Icon={UserAddIcon} onClick={vm.createChatRoom}>
|
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
|
||||||
{_t("action|new_message")}
|
{_t("action|start_chat")}
|
||||||
</Button>
|
</Button>
|
||||||
{vm.canCreateRoom && (
|
{vm.canCreateRoom && (
|
||||||
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home";
|
|||||||
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
|
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
|
||||||
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
|
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
|
||||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
|
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
|
||||||
|
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import {
|
import {
|
||||||
type RoomListHeaderViewState,
|
type RoomListHeaderViewState,
|
||||||
useRoomListHeaderViewModel,
|
useRoomListHeaderViewModel,
|
||||||
@@ -49,7 +50,7 @@ export function RoomListHeaderView(): JSX.Element {
|
|||||||
{vm.displayComposeMenu ? (
|
{vm.displayComposeMenu ? (
|
||||||
<ComposeMenu vm={vm} />
|
<ComposeMenu vm={vm} />
|
||||||
) : (
|
) : (
|
||||||
<IconButton aria-label={_t("action|new_message")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
|
<IconButton aria-label={_t("action|start_chat")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
|
||||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -143,12 +144,7 @@ function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron={true} />
|
||||||
Icon={UserAddIcon}
|
|
||||||
label={_t("action|new_message")}
|
|
||||||
onSelect={vm.createChatRoom}
|
|
||||||
hideChevron={true}
|
|
||||||
/>
|
|
||||||
{vm.canCreateRoom && (
|
{vm.canCreateRoom && (
|
||||||
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
|
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
|
|||||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import {
|
import {
|
||||||
type RoomListItemMenuViewState,
|
type RoomListItemMenuViewState,
|
||||||
useRoomListItemMenuViewModel,
|
useRoomListItemMenuViewModel,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { type Room } from "matrix-js-sdk/src/matrix";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
|
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
||||||
import { NotificationDecoration } from "../NotificationDecoration";
|
import { NotificationDecoration } from "../NotificationDecoration";
|
||||||
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
|
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
|
||||||
@@ -94,7 +94,11 @@ export const RoomListItemView = memo(function RoomListItemView({
|
|||||||
<div className="mx_RoomListItemView_roomName" title={vm.name}>
|
<div className="mx_RoomListItemView_roomName" title={vm.name}>
|
||||||
{vm.name}
|
{vm.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_RoomListItemView_messagePreview">{vm.messagePreview}</div>
|
{vm.messagePreview && (
|
||||||
|
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
|
||||||
|
{vm.messagePreview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showHoverMenu ? (
|
{showHoverMenu ? (
|
||||||
<RoomListItemMenuView
|
<RoomListItemMenuView
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { UIComponent } from "../../../../settings/UIFeature";
|
|||||||
import { RoomListSearch } from "./RoomListSearch";
|
import { RoomListSearch } from "./RoomListSearch";
|
||||||
import { RoomListHeaderView } from "./RoomListHeaderView";
|
import { RoomListHeaderView } from "./RoomListHeaderView";
|
||||||
import { RoomListView } from "./RoomListView";
|
import { RoomListView } from "./RoomListView";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
|
|
||||||
type RoomListPanelProps = {
|
type RoomListPanelProps = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ChatFilter, IconButton } from "@vector-im/compound-web";
|
|||||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||||
|
|
||||||
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
|
|
||||||
interface RoomListPrimaryFiltersProps {
|
interface RoomListPrimaryFiltersProps {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { MetaSpace } from "../../../../stores/spaces";
|
|||||||
import { Action } from "../../../../dispatcher/actions";
|
import { Action } from "../../../../dispatcher/actions";
|
||||||
import PosthogTrackers from "../../../../PosthogTrackers";
|
import PosthogTrackers from "../../../../PosthogTrackers";
|
||||||
import defaultDispatcher from "../../../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../../../dispatcher/dispatcher";
|
||||||
import { Flex } from "../../../utils/Flex";
|
import { Flex } from "../../../../shared-components/utils/Flex";
|
||||||
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
|
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
|
||||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler";
|
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler";
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Elemen
|
|||||||
</Button>
|
</Button>
|
||||||
{displayDialButton && (
|
{displayDialButton && (
|
||||||
<Button
|
<Button
|
||||||
className="mx_RoomListSearch_button"
|
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
Icon={DialPadIcon}
|
Icon={DialPadIcon}
|
||||||
@@ -74,7 +73,6 @@ export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Elemen
|
|||||||
)}
|
)}
|
||||||
{displayExploreButton && (
|
{displayExploreButton && (
|
||||||
<Button
|
<Button
|
||||||
className="mx_RoomListSearch_button"
|
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
Icon={ExploreIcon}
|
Icon={ExploreIcon}
|
||||||
|
|||||||