mirror of
https://github.com/element-hq/element-web.git
synced 2025-09-17 11:04:05 +02:00
Compare commits
32 Commits
hs/remove-
...
dbkr/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bf70195b | ||
|
|
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 = {
|
||||
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: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
|
||||
49
.github/workflows/shared-component-visual-tests-netlify.yaml
vendored
Normal file
49
.github/workflows/shared-component-visual-tests-netlify.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
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: "tree -L 1 --noreport -H '-.' -o received-images/index.html received-images"
|
||||
|
||||
- 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
|
||||
prefix: "diffs-"
|
||||
70
.github/workflows/shared-component-visual-tests.yaml
vendored
Normal file
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
4
.github/workflows/triage-stale.yml
vendored
@@ -15,12 +15,14 @@ jobs:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
with:
|
||||
operations-per-run: 100
|
||||
|
||||
# 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-close: 0
|
||||
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"
|
||||
|
||||
# Stale PR closing
|
||||
days-before-pr-stale: 180
|
||||
days-before-pr-close: 0
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ electron/pub
|
||||
/index.html
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
28
.storybook/ElementTheme.ts
Normal file
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
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
37
.storybook/main.ts
Normal file
37
.storybook/main.ts
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 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"] })],
|
||||
});
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
18
.storybook/manager.js
Normal file
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
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);
|
||||
}
|
||||
90
.storybook/preview.tsx
Normal file
90
.storybook/preview.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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/i18n";
|
||||
|
||||
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 preview: Preview = {
|
||||
tags: ["autodocs"],
|
||||
decorators: [withThemeProvider, withLanguageProvider],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
37
.storybook/test-runner.js
Normal file
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)
|
||||
Small update for tarball deployment
|
||||
|
||||
* Alexander (https://github.com/ioalexander)
|
||||
Save image on CTRL + S shortcut
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,3 +1,21 @@
|
||||
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)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
8
declaration.d.ts
vendored
Normal file
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,7 +17,7 @@ const config: Config = {
|
||||
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
|
||||
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",
|
||||
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.105",
|
||||
"version": "1.11.106",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -65,7 +65,11 @@
|
||||
"coverage": "yarn test --coverage",
|
||||
"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",
|
||||
"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\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
|
||||
},
|
||||
"resolutions": {
|
||||
"**/pretty-format/react-is": "19.1.0",
|
||||
@@ -187,6 +191,11 @@
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@rrweb/types": "^2.0.0-alpha.18",
|
||||
"@sentry/webpack-plugin": "^3.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",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@@ -246,6 +255,7 @@
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-storybook": "^9.0.12",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"express": "^5.0.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
@@ -257,6 +267,7 @@
|
||||
"jest": "^29.6.2",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-image-snapshot": "^6.5.1",
|
||||
"jest-mock": "^29.6.2",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -285,6 +296,7 @@
|
||||
"rimraf": "^6.0.0",
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"storybook": "^9.0.12",
|
||||
"stylelint": "^16.13.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
@@ -294,6 +306,8 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"vite": "^7.0.1",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
|
||||
46
patches/@types+mdx+2.0.13.patch
Normal file
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]>;
|
||||
}
|
||||
& {
|
||||
/**
|
||||
@@ -168,6 +168,7 @@ test.describe("Cryptography", function () {
|
||||
|
||||
// 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_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -48,31 +48,38 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
|
||||
return promiseVerificationRequest;
|
||||
}
|
||||
|
||||
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
|
||||
await logIntoElement(page, credentials);
|
||||
test(
|
||||
"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
|
||||
const verificationRequest = await initiateAliceVerificationRequest(page);
|
||||
// Launch the verification request between alice and the bot
|
||||
const verificationRequest = await initiateAliceVerificationRequest(page);
|
||||
|
||||
// Handle emoji SAS verification
|
||||
const infoDialog = page.locator(".mx_InfoDialog");
|
||||
// the bot chooses to do an emoji verification
|
||||
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
|
||||
// Handle emoji SAS verification
|
||||
const infoDialog = page.locator(".mx_InfoDialog");
|
||||
// the bot chooses to do an emoji verification
|
||||
const verifier = await verificationRequest.evaluateHandle((request) =>
|
||||
request.startVerification("m.sas.v1"),
|
||||
);
|
||||
|
||||
// Handle emoji request and check that emojis are matching
|
||||
await doTwoWaySasVerification(page, verifier);
|
||||
// Handle emoji request and check that emojis are matching
|
||||
await doTwoWaySasVerification(page, verifier);
|
||||
|
||||
await infoDialog.getByRole("button", { name: "They match" }).click();
|
||||
await infoDialog.getByRole("button", { name: "Got it" }).click();
|
||||
await infoDialog.getByRole("button", { name: "They match" }).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
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
// Check that our device is now cross-signed
|
||||
await checkDeviceIsCrossSigned(app);
|
||||
|
||||
// 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,
|
||||
// as we need to wait for the secret gossiping to happen.
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
||||
});
|
||||
// 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,
|
||||
// as we need to wait for the secret gossiping to happen.
|
||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
|
||||
},
|
||||
);
|
||||
|
||||
// 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 }) => {
|
||||
|
||||
@@ -49,8 +49,7 @@ test.describe("Room list", () => {
|
||||
// Put focus on the room list
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||
// Scroll to the end of the room list
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
||||
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
|
||||
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
|
||||
});
|
||||
|
||||
@@ -120,10 +119,8 @@ test.describe("Room list", () => {
|
||||
// Put focus on the room list
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
|
||||
|
||||
while (!(await roomItem.isVisible())) {
|
||||
// Scroll to the end of the room list
|
||||
await page.mouse.wheel(0, 1000);
|
||||
}
|
||||
// Scroll to the end of the room list
|
||||
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
|
||||
|
||||
// The room decoration should have the muted icon
|
||||
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
|
||||
@@ -144,7 +141,7 @@ test.describe("Room list", () => {
|
||||
// Put focus on the room list
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||
// 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 roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
|
||||
|
||||
@@ -213,4 +213,26 @@ export class ElementAppPage {
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 367 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.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";
|
||||
|
||||
const TAG = "develop@sha256:aea1d8f371268aed7a5863fa5dde960fb4f9f578cd0a5952cc4da92537f95cfa";
|
||||
const TAG = "develop@sha256:b38e55f06543f83f5a13f1d843489eb7aeaf7370a5c17a51897b462eeca315f5";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
9
res/css/shared.pcss
Normal file
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");
|
||||
@@ -28,12 +28,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
--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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -246,3 +240,10 @@ 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;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
@@ -52,17 +52,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_E2EIcon_normal::after {
|
||||
mask-image: url("$(res)/img/e2e/normal.svg");
|
||||
background-color: var(--cpd-color-icon-tertiary);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.mx_E2EIcon_verified::after {
|
||||
mask-image: url("$(res)/img/e2e/verified.svg");
|
||||
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.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.cpd.exclusions=src/i18n/strings/*.json
|
||||
|
||||
1
src/@types/global.d.ts
vendored
1
src/@types/global.d.ts
vendored
@@ -135,6 +135,7 @@ declare global {
|
||||
initialise(): Promise<{
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
supportsBadgeOverlay: boolean;
|
||||
config: IConfigOptions;
|
||||
supportedSettings: Record<string, boolean>;
|
||||
}>;
|
||||
|
||||
@@ -494,15 +494,12 @@ export default abstract class BasePlatform {
|
||||
}
|
||||
|
||||
private updateFavicon(): void {
|
||||
let bgColor = "#d00";
|
||||
let notif: string | number = this.notificationCount;
|
||||
const notif: string | number = this.notificationCount;
|
||||
|
||||
if (this.errorDidOccur) {
|
||||
notif = notif || "×";
|
||||
bgColor = "#f00";
|
||||
this.favicon.badge(notif || "×", { bgColor: "#f00" });
|
||||
}
|
||||
|
||||
this.favicon.badge(notif, { bgColor });
|
||||
this.favicon.badge(notif);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type SyncState,
|
||||
ClientStoppedError,
|
||||
} 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 { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
@@ -213,6 +213,7 @@ export default class DeviceListener {
|
||||
};
|
||||
|
||||
private onKeyBackupStatusChanged = (): void => {
|
||||
logger.info("Backup status changed");
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.recheck();
|
||||
};
|
||||
@@ -313,6 +314,7 @@ export default class DeviceListener {
|
||||
private async doRecheck(): Promise<void> {
|
||||
if (!this.running || !this.client) return; // we have been stopped
|
||||
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
|
||||
logSpan.debug("starting recheck...");
|
||||
|
||||
const cli = this.client;
|
||||
|
||||
@@ -355,7 +357,7 @@ export default class DeviceListener {
|
||||
(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);
|
||||
|
||||
// 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).
|
||||
*/
|
||||
private checkKeyBackupStatus = async (): Promise<void> => {
|
||||
if (!(await this.isKeyBackupUploadActive())) {
|
||||
if (!(await this.isKeyBackupUploadActive(logger))) {
|
||||
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.
|
||||
*/
|
||||
private isKeyBackupUploadActive = async (): Promise<boolean> => {
|
||||
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
|
||||
if (!this.client) {
|
||||
// To preserve existing behaviour, if there is no client, we
|
||||
// pretend key backup upload is on.
|
||||
@@ -611,6 +613,7 @@ export default class DeviceListener {
|
||||
// Fetch the answer and cache it
|
||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
|
||||
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { _td, type TranslationKey } from "../languageHandler";
|
||||
// Import i18n.tsx instead of languageHandler to avoid circular deps
|
||||
import { _td, type TranslationKey } from "../shared-components/i18n";
|
||||
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
|
||||
import { type IBaseSetting } from "../settings/Settings";
|
||||
import { type KeyCombo } from "../KeyBindingsManager";
|
||||
@@ -145,6 +146,7 @@ export enum KeyBindingAction {
|
||||
ArrowDown = "KeyBinding.arrowDown",
|
||||
Tab = "KeyBinding.tab",
|
||||
Comma = "KeyBinding.comma",
|
||||
Save = "KeyBinding.save",
|
||||
|
||||
/** Toggle visibility of hidden events */
|
||||
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
|
||||
@@ -268,6 +270,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
|
||||
KeyBindingAction.ArrowRight,
|
||||
KeyBindingAction.ArrowDown,
|
||||
KeyBindingAction.Comma,
|
||||
KeyBindingAction.Save,
|
||||
],
|
||||
},
|
||||
[CategoryName.NAVIGATION]: {
|
||||
@@ -620,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
|
||||
},
|
||||
displayName: _td("keyboard|composer_redo"),
|
||||
},
|
||||
[KeyBindingAction.Save]: {
|
||||
default: {
|
||||
key: Key.S,
|
||||
ctrlOrCmdKey: true,
|
||||
},
|
||||
displayName: _td("keyboard|save"),
|
||||
},
|
||||
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
|
||||
default: {
|
||||
metaKey: IS_MAC,
|
||||
|
||||
@@ -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 => {
|
||||
for (const reaction of this.getUnsentReactions()) {
|
||||
Resend.resend(MatrixClientPeg.safeGet(), reaction);
|
||||
@@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
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 => {
|
||||
editEvent(
|
||||
MatrixClientPeg.safeGet(),
|
||||
@@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedText = getSelectedText();
|
||||
|
||||
let copyButton: JSX.Element | undefined;
|
||||
if (rightClick && getSelectedText()) {
|
||||
if (rightClick && selectedText) {
|
||||
copyButton = (
|
||||
<IconizedContextMenuOption
|
||||
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;
|
||||
if (rightClick && canEditContent(cli, mxEvent)) {
|
||||
editButton = (
|
||||
@@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
}
|
||||
|
||||
let nativeItemsList: JSX.Element | undefined;
|
||||
if (copyButton || copyLinkButton) {
|
||||
if (copyButton || quoteButton || copyLinkButton) {
|
||||
nativeItemsList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{copyButton}
|
||||
{quoteButton}
|
||||
{copyLinkButton}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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 { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
@@ -31,11 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
@@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
private imageWrapper = createRef<HTMLDivElement>();
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private downloadFunction?: () => Promise<void>;
|
||||
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private previousX = 0;
|
||||
@@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
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 => {
|
||||
// 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.
|
||||
@@ -552,7 +560,12 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
title={_t("lightbox|rotate_right")}
|
||||
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}
|
||||
<AccessibleButton
|
||||
className="mx_ImageView_button mx_ImageView_button_close"
|
||||
@@ -585,97 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadButton({
|
||||
url,
|
||||
fileName,
|
||||
mxEvent,
|
||||
}: {
|
||||
interface DownloadButtonProps {
|
||||
url: string;
|
||||
fileName?: string;
|
||||
mxEvent?: MatrixEvent;
|
||||
}): JSX.Element | null {
|
||||
const downloader = useRef(new FileDownloader()).current;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDownload, setCanDownload] = useState<boolean>(false);
|
||||
const blobRef = useRef<Blob>(undefined);
|
||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
||||
onDownloadReady?: (download: () => Promise<void>) => void;
|
||||
}
|
||||
|
||||
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
|
||||
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mxEvent) {
|
||||
// If we have no event, we assume this is safe to download.
|
||||
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]);
|
||||
if (onDownloadReady) onDownloadReady(download);
|
||||
}, [download, onDownloadReady]);
|
||||
|
||||
function showError(e: unknown): void {
|
||||
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;
|
||||
}
|
||||
if (!canDownload) return null;
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
||||
onClick={onDownloadClick}
|
||||
onClick={download}
|
||||
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 React, { type JSX } from "react";
|
||||
import React, { type ReactElement, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
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 { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -30,121 +26,32 @@ interface IProps {
|
||||
mediaEventHelperGet: () => MediaEventHelper | undefined;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
canDownload: null | boolean;
|
||||
loading: boolean;
|
||||
blob?: Blob;
|
||||
tooltip: TranslationKey;
|
||||
}
|
||||
|
||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||
private downloader = new FileDownloader();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
|
||||
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 = {
|
||||
loading: false,
|
||||
tooltip: _td("timeline|download_action_downloading"),
|
||||
...downloadState,
|
||||
};
|
||||
}
|
||||
|
||||
private onDownloadClick = async (): Promise<void> => {
|
||||
try {
|
||||
await this.doDownload();
|
||||
} catch (e) {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
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> {
|
||||
const mediaEventHelper = this.props.mediaEventHelperGet();
|
||||
if (this.state.loading || !mediaEventHelper) return;
|
||||
|
||||
if (mediaEventHelper.media.isEncrypted) {
|
||||
this.setState({ tooltip: _td("timeline|download_action_decrypting") });
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
if (this.state.blob) {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
|
||||
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
|
||||
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
|
||||
const fileName = mediaEventHelper?.fileName;
|
||||
|
||||
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
|
||||
|
||||
if (!canDownload) return null;
|
||||
|
||||
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
|
||||
const classes = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_downloadButton: true,
|
||||
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
||||
});
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
||||
onClick={download}
|
||||
disabled={loading}
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { copyPlaintext, getSelectedText } from "../../../utils/strings";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||
import RedactedBody from "../messages/RedactedBody";
|
||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
@@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
// Electron layer (webcontents-handler.ts)
|
||||
if (clickTarget instanceof HTMLImageElement) return;
|
||||
|
||||
// Return if we're in a browser and click either an a tag or we have
|
||||
// selected text, as in those cases we want to use the native browser
|
||||
// menu
|
||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
|
||||
// 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
|
||||
if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
|
||||
|
||||
// We don't want to show the menu when editing a message
|
||||
if (this.props.editState) return;
|
||||
@@ -1237,22 +1235,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
TimelineRenderingType.Thread,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(TimelineRenderingType.Thread, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{timestamp}
|
||||
@@ -1383,22 +1378,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
</a>,
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{renderTile(
|
||||
TimelineRenderingType.File,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(TimelineRenderingType.File, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
</div>,
|
||||
],
|
||||
);
|
||||
@@ -1433,23 +1425,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
{groupTimestamp}
|
||||
{groupPadlock}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
this.context.timelineRenderingType,
|
||||
{
|
||||
...this.props,
|
||||
{renderTile(this.context.timelineRenderingType, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp: bubbleTimestamp,
|
||||
// overrides
|
||||
ref: this.tile,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp: bubbleTimestamp,
|
||||
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
// appease TS
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
{this.props.layout === Layout.IRC && (
|
||||
<>
|
||||
|
||||
@@ -95,7 +95,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
|
||||
className="mx_MemberListView_container"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
<Form.Root>
|
||||
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
||||
<MemberListHeaderView vm={vm} />
|
||||
</Form.Root>
|
||||
<AutoSizer>
|
||||
|
||||
@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: false,
|
||||
},
|
||||
false /* showHiddenEvents shouldn't be relevant */,
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import MessageEvent from "../components/views/messages/MessageEvent";
|
||||
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
|
||||
import { CallEvent } from "../components/views/messages/CallEvent";
|
||||
import TextualEvent from "../components/views/messages/TextualEvent";
|
||||
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
|
||||
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
|
||||
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
|
||||
@@ -44,6 +43,8 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||
import { ElementCall } from "../models/Call";
|
||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||
import ModuleApi from "../modules/Api";
|
||||
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
|
||||
import { TextualEvent } from "../shared-components/event-tiles/TextualEvent";
|
||||
|
||||
// Subset of EventTile's IProps plus some mixins
|
||||
export interface EventTileTypeProps
|
||||
@@ -67,6 +68,7 @@ export interface EventTileTypeProps
|
||||
maxImageHeight?: number; // pixels
|
||||
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
|
||||
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
|
||||
showHiddenEvents: boolean;
|
||||
}
|
||||
|
||||
type FactoryProps = Omit<EventTileTypeProps, "ref">;
|
||||
@@ -77,7 +79,10 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
|
||||
<LegacyCallEvent ref={ref} {...props} />
|
||||
);
|
||||
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
|
||||
export const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
|
||||
export const TextualEventFactory: Factory = (ref, props) => {
|
||||
const vm = new TextualEventViewModel(props);
|
||||
return <TextualEvent vm={vm} />;
|
||||
};
|
||||
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
||||
|
||||
@@ -252,12 +257,11 @@ export function pickFactory(
|
||||
export function renderTile(
|
||||
renderType: TimelineRenderingType,
|
||||
props: EventTileTypeProps,
|
||||
showHiddenEvents: boolean,
|
||||
cli?: MatrixClient,
|
||||
): Optional<JSX.Element> {
|
||||
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
||||
|
||||
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
|
||||
const factory = pickFactory(props.mxEvent, cli, props.showHiddenEvents);
|
||||
if (!factory) {
|
||||
// If we don't have a factory for this event, attempt
|
||||
// to find a custom component that can render it.
|
||||
@@ -286,6 +290,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
} = props;
|
||||
|
||||
switch (renderType) {
|
||||
@@ -309,6 +314,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
default:
|
||||
@@ -332,6 +338,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -394,6 +401,7 @@ export function renderReplyTile(
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
159
src/favicon.ts
159
src/favicon.ts
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2020-2024 New Vector Ltd.
|
||||
Copyright 2020-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.
|
||||
@@ -28,56 +28,19 @@ const defaults: IParams = {
|
||||
isLeft: false,
|
||||
};
|
||||
|
||||
// Allows dynamic rendering of a circular badge atop the loaded favicon
|
||||
// supports colour, font and basic positioning parameters.
|
||||
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
|
||||
export default class Favicon {
|
||||
private readonly browser = {
|
||||
ff: typeof window.InstallTrigger !== "undefined",
|
||||
opera: !!window.opera || navigator.userAgent.includes("Opera"),
|
||||
};
|
||||
|
||||
private readonly params: IParams;
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly baseImage: HTMLImageElement;
|
||||
private context!: CanvasRenderingContext2D;
|
||||
private icons: HTMLLinkElement[];
|
||||
|
||||
private isReady = false;
|
||||
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
|
||||
private readyCb?: () => void;
|
||||
|
||||
public constructor(params: Partial<IParams> = {}) {
|
||||
this.params = { ...defaults, ...params };
|
||||
|
||||
this.icons = Favicon.getIcons();
|
||||
// create work canvas
|
||||
abstract class IconRenderer {
|
||||
protected readonly canvas: HTMLCanvasElement;
|
||||
protected readonly context: CanvasRenderingContext2D;
|
||||
public constructor(
|
||||
protected readonly params: IParams = defaults,
|
||||
protected readonly baseImage?: HTMLImageElement,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
// create clone of favicon as a base
|
||||
this.baseImage = document.createElement("img");
|
||||
|
||||
const lastIcon = this.icons[this.icons.length - 1];
|
||||
if (lastIcon.hasAttribute("href")) {
|
||||
this.baseImage.setAttribute("crossOrigin", "anonymous");
|
||||
this.baseImage.onload = (): void => {
|
||||
// get height and width of the favicon
|
||||
this.canvas.height = this.baseImage.height > 0 ? this.baseImage.height : 32;
|
||||
this.canvas.width = this.baseImage.width > 0 ? this.baseImage.width : 32;
|
||||
this.context = this.canvas.getContext("2d")!;
|
||||
this.ready();
|
||||
};
|
||||
this.baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
|
||||
} else {
|
||||
this.canvas.height = this.baseImage.height = 32;
|
||||
this.canvas.width = this.baseImage.width = 32;
|
||||
this.context = this.canvas.getContext("2d")!;
|
||||
this.ready();
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw Error("Could not get canvas context");
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private options(
|
||||
@@ -125,11 +88,23 @@ export default class Favicon {
|
||||
return opt;
|
||||
}
|
||||
|
||||
private circle(n: number | string, opts?: Partial<IParams>): void {
|
||||
/**
|
||||
* Draws a circualr status icon, usually over the top of the application icon.
|
||||
* @param n The content of the circle. Should be a number or a single character.
|
||||
* @param opts Options to adjust.
|
||||
*/
|
||||
protected circle(n: number | string, opts?: Partial<IParams>): void {
|
||||
const params = { ...this.params, ...opts };
|
||||
const opt = this.options(n, params);
|
||||
|
||||
let more = false;
|
||||
if (!this.baseImage) {
|
||||
// If we omit the background, assume the entire canvas is our target.
|
||||
opt.x = 0;
|
||||
opt.y = 0;
|
||||
opt.w = this.canvas.width;
|
||||
opt.h = this.canvas.height;
|
||||
}
|
||||
if (opt.len === 2) {
|
||||
opt.x = opt.x - opt.w * 0.4;
|
||||
opt.w = opt.w * 1.4;
|
||||
@@ -141,7 +116,9 @@ export default class Favicon {
|
||||
}
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.baseImage) {
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
this.context.beginPath();
|
||||
const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px";
|
||||
this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`;
|
||||
@@ -177,6 +154,86 @@ export default class Favicon {
|
||||
|
||||
this.context.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
export class BadgeOverlayRenderer extends IconRenderer {
|
||||
public constructor() {
|
||||
super();
|
||||
// Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
|
||||
this.canvas.width = 16;
|
||||
this.canvas.height = 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an overlay badge without the application icon, and export
|
||||
* as an ArrayBuffer
|
||||
* @param contents The content of the circle. Should be a number or a single character.
|
||||
* @param bgColor Optional alternative background colo.r
|
||||
* @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn.
|
||||
*/
|
||||
public async render(contents: number | string, bgColor?: string): Promise<ArrayBuffer | null> {
|
||||
if (contents === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.circle(contents, { ...(bgColor ? { bgColor } : undefined) });
|
||||
return new Promise((resolve, reject) => {
|
||||
this.canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob.arrayBuffer());
|
||||
}
|
||||
reject(new Error("Could not render badge overlay as blob"));
|
||||
},
|
||||
"image/png",
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Allows dynamic rendering of a circular badge atop the loaded favicon
|
||||
// supports colour, font and basic positioning parameters.
|
||||
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
|
||||
export default class Favicon extends IconRenderer {
|
||||
private readonly browser = {
|
||||
ff: typeof window.InstallTrigger !== "undefined",
|
||||
opera: !!window.opera || navigator.userAgent.includes("Opera"),
|
||||
};
|
||||
|
||||
private icons: HTMLLinkElement[];
|
||||
|
||||
private isReady = false;
|
||||
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
|
||||
private readyCb?: () => void;
|
||||
|
||||
public constructor() {
|
||||
const baseImage = document.createElement("img");
|
||||
super(defaults, baseImage);
|
||||
|
||||
this.icons = Favicon.getIcons();
|
||||
|
||||
const lastIcon = this.icons[this.icons.length - 1];
|
||||
if (lastIcon.hasAttribute("href")) {
|
||||
baseImage.setAttribute("crossOrigin", "anonymous");
|
||||
baseImage.onload = (): void => {
|
||||
// get height and width of the favicon
|
||||
this.canvas.height = baseImage.height > 0 ? baseImage.height : 32;
|
||||
this.canvas.width = baseImage.width > 0 ? baseImage.width : 32;
|
||||
this.ready();
|
||||
};
|
||||
baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
|
||||
} else {
|
||||
this.canvas.height = baseImage.height = 32;
|
||||
this.canvas.width = baseImage.width = 32;
|
||||
this.ready();
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
private ready(): void {
|
||||
if (this.isReady) return;
|
||||
|
||||
93
src/hooks/useDownloadMedia.ts
Normal file
93
src/hooks/useDownloadMedia.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2024 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 { parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { useRef, useState, useMemo, useEffect } from "react";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
||||
import { _t } from "../languageHandler";
|
||||
import Modal from "../Modal";
|
||||
import { FileDownloader } from "../utils/FileDownloader";
|
||||
import { MediaEventHelper } from "../utils/MediaEventHelper";
|
||||
import ModuleApi from "../modules/Api";
|
||||
|
||||
export interface UseDownloadMediaReturn {
|
||||
download: () => Promise<void>;
|
||||
loading: boolean;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
export function useDownloadMedia(url: string, fileName?: string, mxEvent?: MatrixEvent): UseDownloadMediaReturn {
|
||||
const downloader = useRef(new FileDownloader()).current;
|
||||
const blobRef = useRef<Blob>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDownload, setCanDownload] = useState<boolean>(true);
|
||||
|
||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mxEvent) return;
|
||||
|
||||
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
|
||||
if (hints?.allowDownloadingMedia) {
|
||||
setCanDownload(false);
|
||||
hints
|
||||
.allowDownloadingMedia()
|
||||
.then(setCanDownload)
|
||||
.catch((err: any) => {
|
||||
logger.error(`Failed to check media download permission for ${mxEvent.event.event_id}`, err);
|
||||
|
||||
setCanDownload(false);
|
||||
});
|
||||
} else {
|
||||
setCanDownload(true);
|
||||
}
|
||||
}, [mxEvent]);
|
||||
|
||||
const download = async (): Promise<void> => {
|
||||
if (loading) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (blobRef.current) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBlob = async (blob: Blob): Promise<void> => {
|
||||
await downloader.download({
|
||||
blob,
|
||||
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const showError = (e: unknown): void => {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return { download, loading, canDownload };
|
||||
}
|
||||
@@ -1938,8 +1938,32 @@
|
||||
"active_heading": "Arolygon gweithredol",
|
||||
"empty_active": "Nid oes unrhyw arolygon gweithredol yn yr ystafell hon",
|
||||
"empty_active_load_more": "Nid oes unrhyw arolygon gweithredol. Llwythwch fwy o arolygon barn y misoedd blaenorol",
|
||||
"empty_active_load_more_n_days": {
|
||||
"zero": "Does dim polau gweithredol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"one": "Does dim polau gweithredol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"two": "Does dim polau gweithredol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"few": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"many": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"other": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
|
||||
},
|
||||
"empty_past": "Nid oes arolygon o'r gorffennol yn yr ystafell hon",
|
||||
"empty_past_load_more": "Nid oes unrhyw arolygon o'r gorffennol. Llwythwch fwy o arolygon barn ar gyfer y misoedd blaenorol",
|
||||
"empty_past_load_more_n_days": {
|
||||
"zero": "Does dim polau'r gorffennol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"one": "Does dim polau'r gorffennol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"two": "Does dim polau'r gorffennol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"few": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"many": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
|
||||
"other": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
|
||||
},
|
||||
"final_result": {
|
||||
"zero": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidleisiau",
|
||||
"one": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
|
||||
"two": "Canlyniadau terfynol yn seiliedig ar %(count)s bleidlais",
|
||||
"few": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
|
||||
"many": "Canlyniadau terfynol yn seiliedig ar %(count)s phleidlais",
|
||||
"other": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais"
|
||||
},
|
||||
"load_more": "Llwytho mwy o arolygon barn",
|
||||
"loading": "Wrthi'n llwytho arolygon",
|
||||
"past_heading": "Arolygon y gorffennol",
|
||||
|
||||
@@ -1450,6 +1450,7 @@
|
||||
"room_list_navigate_down": "Navigate down in the room list",
|
||||
"room_list_navigate_up": "Navigate up in the room list",
|
||||
"room_list_select_room": "Select room from the room list",
|
||||
"save": "Save",
|
||||
"scroll_down_timeline": "Scroll down in the timeline",
|
||||
"scroll_up_timeline": "Scroll up in the timeline",
|
||||
"search": "Search (must be enabled)",
|
||||
@@ -3370,7 +3371,6 @@
|
||||
"unable_to_decrypt": "Unable to decrypt message"
|
||||
},
|
||||
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
|
||||
"download_action_decrypting": "Decrypting",
|
||||
"download_action_downloading": "Downloading",
|
||||
"download_failed": "Download failed",
|
||||
"download_failed_description": "An error occurred while downloading this file",
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2017 MTRNord and Cooperative EITA
|
||||
Copyright 2017 Vector Creations 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.
|
||||
*/
|
||||
* 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 counterpart from "counterpart";
|
||||
import React from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
import { MapWithDefault } from "matrix-js-sdk/src/utils";
|
||||
import { normalizeLanguageKey, type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
|
||||
import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
|
||||
import _ from "lodash";
|
||||
|
||||
import type Translations from "./i18n/strings/en_EN.json";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import { retry } from "./utils/promise";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||
import {
|
||||
_t,
|
||||
normalizeLanguageKey,
|
||||
type TranslationKey,
|
||||
type IVariables,
|
||||
KEY_SEPARATOR,
|
||||
getLangsJson,
|
||||
} from "./shared-components/i18n";
|
||||
|
||||
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
|
||||
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
|
||||
|
||||
export { normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
|
||||
export {
|
||||
_t,
|
||||
type IVariables,
|
||||
type Tags,
|
||||
type TranslationKey,
|
||||
type TranslatedString,
|
||||
_td,
|
||||
_tDom,
|
||||
lookupString,
|
||||
sanitizeForTranslation,
|
||||
normalizeLanguageKey,
|
||||
getNormalizedLanguageKeys,
|
||||
substitute,
|
||||
} from "./shared-components/i18n";
|
||||
|
||||
const i18nFolder = "i18n/";
|
||||
|
||||
// Control whether to also return original, untranslated strings
|
||||
// Useful for debugging and testing
|
||||
const ANNOTATE_STRINGS = false;
|
||||
|
||||
// We use english strings as keys, some of which contain full stops
|
||||
counterpart.setSeparator(KEY_SEPARATOR);
|
||||
|
||||
// see `translateWithFallback` for an explanation of fallback handling
|
||||
const FALLBACK_LOCALE = "en";
|
||||
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
||||
|
||||
export interface ErrorOptions {
|
||||
// Because we're mixing the substitution variables and `cause` into the same object
|
||||
// below, we want them to always explicitly say whether there is an underlying error
|
||||
@@ -96,353 +96,6 @@ export function getUserLanguage(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
|
||||
* @example `common|error` to access `error` within the `common` sub-object.
|
||||
* {
|
||||
* "common": {
|
||||
* "error": "Error"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export type TranslationKey = _TranslationKey<typeof Translations>;
|
||||
|
||||
// Function which only purpose is to mark that a string is translatable
|
||||
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
|
||||
export function _td(s: TranslationKey): TranslationKey {
|
||||
return s;
|
||||
}
|
||||
|
||||
function isValidTranslation(translated: string): boolean {
|
||||
return typeof translated === "string" && !translated.startsWith("missing translation:");
|
||||
}
|
||||
|
||||
/**
|
||||
* to improve screen reader experience translations that are not in the main page language
|
||||
* eg a translation that fell back to english from another language
|
||||
* should be wrapped with an appropriate `lang='en'` attribute
|
||||
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
|
||||
* is in the target locale or a fallback locale
|
||||
* for this reason, force fallbackLocale === locale in the first call to translate
|
||||
* and fallback 'manually' so we can mark fallback strings appropriately
|
||||
* */
|
||||
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
|
||||
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
|
||||
if (isValidTranslation(translated)) {
|
||||
return { translated };
|
||||
}
|
||||
|
||||
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
|
||||
if (isValidTranslation(fallbackTranslated)) {
|
||||
return { translated: fallbackTranslated, isFallback: true };
|
||||
}
|
||||
|
||||
// Even the translation via FALLBACK_LOCALE failed; this can happen if
|
||||
//
|
||||
// 1. The string isn't in the translations dictionary, usually because you're in develop
|
||||
// and haven't run yarn i18n
|
||||
// 2. Loading the translation resources over the network failed, which can happen due to
|
||||
// to network or if the client tried to load a translation that's been removed from the
|
||||
// server.
|
||||
//
|
||||
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
|
||||
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
|
||||
return { translated: text, isFallback: true };
|
||||
};
|
||||
|
||||
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
|
||||
// Takes the same arguments as counterpart.translate()
|
||||
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
|
||||
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
|
||||
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
|
||||
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
|
||||
const options: IVariables & {
|
||||
interpolate: boolean;
|
||||
} = { ...variables, interpolate: false };
|
||||
|
||||
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
|
||||
// The interpolation library that counterpart uses does not support undefined/null
|
||||
// values and instead will throw an error. This is a problem since everywhere else
|
||||
// in JS land passing undefined/null will simply stringify instead, and when converting
|
||||
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
|
||||
// if there are no existing null guards. To avoid this making the app completely inoperable,
|
||||
// we'll check all the values for undefined/null and stringify them here.
|
||||
if (options && typeof options === "object") {
|
||||
Object.keys(options).forEach((k) => {
|
||||
if (options[k] === undefined) {
|
||||
logger.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
|
||||
options[k] = "undefined";
|
||||
}
|
||||
if (options[k] === null) {
|
||||
logger.warn("safeCounterpartTranslate called with null interpolation name: " + k);
|
||||
options[k] = "null";
|
||||
}
|
||||
});
|
||||
}
|
||||
return translateWithFallback(text, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a variable or tag can take for a translation interpolation.
|
||||
*/
|
||||
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
|
||||
|
||||
export interface IVariables {
|
||||
count?: number;
|
||||
[key: string]: SubstitutionValue;
|
||||
}
|
||||
|
||||
export type Tags = Record<string, SubstitutionValue>;
|
||||
|
||||
export type TranslatedString = string | React.ReactNode;
|
||||
|
||||
// For development/testing purposes it is useful to also output the original string
|
||||
// Don't do that for release versions
|
||||
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
|
||||
if (!ANNOTATE_STRINGS) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return `@@${translationKey}##${result}@@`;
|
||||
} else {
|
||||
return (
|
||||
<span className="translated-string" data-orig-string={translationKey}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* In both variables and tags, the values to substitute with can be either simple strings, React components,
|
||||
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
|
||||
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
|
||||
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
|
||||
* substitution to insert React components, but you can't use it to translate text between tags.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
// eslint-next-line @typescript-eslint/naming-convention
|
||||
export function _t(text: TranslationKey, variables?: IVariables): string;
|
||||
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
|
||||
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
return annotateStrings(substituted, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to look up a string by its translation key without resolving variables & tags
|
||||
* @param key - the translation key to return the value for
|
||||
*/
|
||||
export function lookupString(key: TranslationKey): string {
|
||||
return safeCounterpartTranslate(key, {}).translated;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
|
||||
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions
|
||||
* or translation used a fallback locale, otherwise a string
|
||||
*/
|
||||
// eslint-next-line @typescript-eslint/naming-convention
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
|
||||
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
// wrap en fallback translation with lang attribute for screen readers
|
||||
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
|
||||
|
||||
return annotateStrings(result, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
|
||||
* replaceable by the translation functions.
|
||||
* @param {string} text The text to sanitize.
|
||||
* @returns {string} The sanitized text.
|
||||
*/
|
||||
export function sanitizeForTranslation(text: string): string {
|
||||
// Add a non-breaking space so the regex doesn't trigger when translating.
|
||||
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
|
||||
}
|
||||
|
||||
/*
|
||||
* Similar to _t(), except only does substitutions, and no translation
|
||||
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* The values to substitute with can be either simple strings, or functions that return the value to use in
|
||||
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
|
||||
* the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function substitute(text: string, variables?: IVariables): string;
|
||||
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
|
||||
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
|
||||
let result: React.ReactNode | string = text;
|
||||
|
||||
if (variables !== undefined) {
|
||||
const regexpMapping: IVariables = {};
|
||||
for (const variable in variables) {
|
||||
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
if (tags !== undefined) {
|
||||
const regexpMapping: Tags = {};
|
||||
for (const tag in tags) {
|
||||
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parts of a text using regular expressions
|
||||
* @param text - The text on which to perform substitutions
|
||||
* @param mapping - A mapping from regular expressions in string form to replacement string or a
|
||||
* function which will receive as the argument the capture groups defined in the regexp. E.g.
|
||||
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function replaceByRegexes(text: string, mapping: IVariables): string;
|
||||
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
|
||||
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
|
||||
// We initially store our output as an array of strings and objects (e.g. React components).
|
||||
// This will then be converted to a string or a <span> at the end
|
||||
const output: SubstitutionValue[] = [text];
|
||||
|
||||
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
|
||||
let shouldWrapInSpan = false;
|
||||
|
||||
for (const regexpString in mapping) {
|
||||
// TODO: Cache regexps
|
||||
const regexp = new RegExp(regexpString, "g");
|
||||
|
||||
// Loop over what output we have so far and perform replacements
|
||||
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
|
||||
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
|
||||
// Otherwise there would be no need for the splitting and we could do simple replacement.
|
||||
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
|
||||
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
|
||||
const inputText = output[outputIndex];
|
||||
if (typeof inputText !== "string") {
|
||||
// We might have inserted objects earlier, don't try to replace them
|
||||
continue;
|
||||
}
|
||||
|
||||
// process every match in the string
|
||||
// starting with the first
|
||||
let match = regexp.exec(inputText);
|
||||
|
||||
if (!match) continue;
|
||||
matchFoundSomewhere = true;
|
||||
|
||||
// The textual part before the first match
|
||||
const head = inputText.slice(0, match.index);
|
||||
|
||||
const parts: SubstitutionValue[] = [];
|
||||
// keep track of prevMatch
|
||||
let prevMatch;
|
||||
while (match) {
|
||||
// store prevMatch
|
||||
prevMatch = match;
|
||||
const capturedGroups = match.slice(2);
|
||||
|
||||
let replaced: SubstitutionValue;
|
||||
// If substitution is a function, call it
|
||||
if (mapping[regexpString] instanceof Function) {
|
||||
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
|
||||
} else {
|
||||
replaced = mapping[regexpString];
|
||||
}
|
||||
|
||||
if (typeof replaced === "object") {
|
||||
shouldWrapInSpan = true;
|
||||
}
|
||||
|
||||
// Here we also need to check that it actually is a string before comparing against one
|
||||
// The head and tail are always strings
|
||||
if (typeof replaced !== "string" || replaced !== "") {
|
||||
parts.push(replaced);
|
||||
}
|
||||
|
||||
// try the next match
|
||||
match = regexp.exec(inputText);
|
||||
|
||||
// add the text between prevMatch and this one
|
||||
// or the end of the string if prevMatch is the last match
|
||||
let tail;
|
||||
if (match) {
|
||||
const startIndex = prevMatch.index + prevMatch[0].length;
|
||||
tail = inputText.slice(startIndex, match.index);
|
||||
} else {
|
||||
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
|
||||
}
|
||||
if (tail) {
|
||||
parts.push(tail);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert in reverse order as splice does insert-before and this way we get the final order correct
|
||||
// remove the old element at the same time
|
||||
output.splice(outputIndex, 1, ...parts);
|
||||
|
||||
if (head !== "") {
|
||||
// Don't push empty nodes, they are of no use
|
||||
output.splice(outputIndex, 0, head);
|
||||
}
|
||||
}
|
||||
if (!matchFoundSomewhere) {
|
||||
if (
|
||||
// The current regexp did not match anything in the input. Missing
|
||||
// matches is entirely possible because you might choose to show some
|
||||
// variables only in the case of e.g. plurals. It's still a bit
|
||||
// suspicious, and could be due to an error, so log it. However, not
|
||||
// showing count is so common that it's not worth logging. And other
|
||||
// commonly unused variables here, if there are any.
|
||||
regexpString !== "%\\(count\\)s" &&
|
||||
// Ignore the `locale` option which can be used to override the locale
|
||||
// in counterpart
|
||||
regexpString !== "%\\(locale\\)s"
|
||||
) {
|
||||
logger.log(`Could not find ${regexp} in ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWrapInSpan) {
|
||||
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return output.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Allow overriding the text displayed when no translation exists
|
||||
// Currently only used in unit tests to avoid having to load
|
||||
// the translations in element-web
|
||||
@@ -450,10 +103,6 @@ export function setMissingEntryGenerator(f: (value: string) => void): void {
|
||||
counterpart.setMissingEntryGenerator(f);
|
||||
}
|
||||
|
||||
type Languages = {
|
||||
[lang: string]: string;
|
||||
};
|
||||
|
||||
export async function setLanguage(...preferredLangs: string[]): Promise<void> {
|
||||
PlatformPeg.get()?.setLanguage(preferredLangs);
|
||||
|
||||
@@ -554,24 +203,6 @@ export function pickBestLanguage(langs: string[]): string {
|
||||
return langs[0];
|
||||
}
|
||||
|
||||
async function getLangsJson(): Promise<Languages> {
|
||||
let url: string;
|
||||
if (typeof webpackLangJsonUrl === "string") {
|
||||
// in Jest this 'url' isn't a URL, so just fall through
|
||||
url = webpackLangJsonUrl;
|
||||
} else {
|
||||
url = i18nFolder + "languages.json";
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${url}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface ICounterpartTranslation {
|
||||
[key: string]:
|
||||
| string
|
||||
|
||||
@@ -11,7 +11,8 @@ import React, { type ReactNode } from "react";
|
||||
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
|
||||
import { _t, _td, type TranslationKey } from "../languageHandler";
|
||||
// Import i18n.tsx instead of languageHandler to avoid circular deps
|
||||
import { _t, _td, type TranslationKey } from "../shared-components/i18n";
|
||||
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
|
||||
import {
|
||||
NotificationBodyEnabledController,
|
||||
|
||||
23
src/shared-components/MockViewModel.ts
Normal file
23
src/shared-components/MockViewModel.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
|
||||
*/
|
||||
export class MockViewModel<T> implements ViewModel<T> {
|
||||
public constructor(private snapshot: T) {}
|
||||
|
||||
public getSnapshot = (): T => {
|
||||
return this.snapshot;
|
||||
};
|
||||
|
||||
public subscribe(listener: () => void): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
23
src/shared-components/ViewModel.ts
Normal file
23
src/shared-components/ViewModel.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The interface for a generic View Model passed to the shared components.
|
||||
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
|
||||
*/
|
||||
export interface ViewModel<T> {
|
||||
/**
|
||||
* The current snapshot of the view model.
|
||||
*/
|
||||
getSnapshot: () => T;
|
||||
|
||||
/**
|
||||
* Subscribes to changes in the view model.
|
||||
* The listener will be called whenever the snapshot changes.
|
||||
*/
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { type Meta, type StoryFn } from "@storybook/react-vite";
|
||||
|
||||
import { TextualEvent as TextualEventComponent } from "./TextualEvent";
|
||||
import { MockViewModel } from "../../MockViewModel";
|
||||
|
||||
export default {
|
||||
title: "Event/TextualEvent",
|
||||
component: TextualEventComponent,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
vm: new MockViewModel("Not dummy textual event text"),
|
||||
},
|
||||
} as Meta<typeof TextualEventComponent>;
|
||||
|
||||
const Template: StoryFn<typeof TextualEventComponent> = (args) => <TextualEventComponent {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
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 { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./TextualEvent.stories.tsx";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("TextualEvent", () => {
|
||||
it("renders a textual event", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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 ReactNode, type JSX } from "react";
|
||||
|
||||
import { type ViewModel } from "../../ViewModel";
|
||||
import { useViewModel } from "../../useViewModel";
|
||||
|
||||
export type TextualEventViewSnapshot = string | ReactNode;
|
||||
|
||||
export interface Props {
|
||||
vm: ViewModel<TextualEventViewSnapshot>;
|
||||
}
|
||||
|
||||
export function TextualEvent({ vm }: Props): JSX.Element {
|
||||
const contents = useViewModel(vm);
|
||||
|
||||
return <div className="mx_TextualEvent">{contents}</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TextualEvent renders a textual event 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_TextualEvent"
|
||||
>
|
||||
Dummy textual event text
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
8
src/shared-components/event-tiles/TextualEvent/index.ts
Normal file
8
src/shared-components/event-tiles/TextualEvent/index.ts
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.
|
||||
*/
|
||||
|
||||
export { TextualEvent } from "./TextualEvent";
|
||||
432
src/shared-components/i18n.tsx
Normal file
432
src/shared-components/i18n.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* In both variables and tags, the values to substitute with can be either simple strings, React components,
|
||||
* or functions that return the value to use in the substitution (e.g. return a React component). In case of
|
||||
* a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* Use tag substitutions if you need to translate text between tags (e.g. "<a>Click here!</a>"), otherwise
|
||||
* you will end up with literal "<a>" in your output, rather than HTML. Note that you can also use variable
|
||||
* substitution to insert React components, but you can't use it to translate text between tags.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
import React from "react";
|
||||
import { type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
|
||||
import counterpart from "counterpart";
|
||||
|
||||
import type Translations from "../i18n/strings/en_EN.json";
|
||||
|
||||
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
|
||||
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
|
||||
|
||||
export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
|
||||
|
||||
const i18nFolder = "i18n/";
|
||||
|
||||
// Control whether to also return original, untranslated strings
|
||||
// Useful for debugging and testing
|
||||
const ANNOTATE_STRINGS = false;
|
||||
|
||||
// We use english strings as keys, some of which contain full stops
|
||||
counterpart.setSeparator(KEY_SEPARATOR);
|
||||
|
||||
// see `translateWithFallback` for an explanation of fallback handling
|
||||
const FALLBACK_LOCALE = "en";
|
||||
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
||||
|
||||
/**
|
||||
* A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
|
||||
* @example `common|error` to access `error` within the `common` sub-object.
|
||||
* {
|
||||
* "common": {
|
||||
* "error": "Error"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export type TranslationKey = _TranslationKey<typeof Translations>;
|
||||
|
||||
// Function which only purpose is to mark that a string is translatable
|
||||
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
|
||||
export function _td(s: TranslationKey): TranslationKey {
|
||||
return s;
|
||||
}
|
||||
|
||||
function isValidTranslation(translated: string): boolean {
|
||||
return typeof translated === "string" && !translated.startsWith("missing translation:");
|
||||
}
|
||||
|
||||
/**
|
||||
* to improve screen reader experience translations that are not in the main page language
|
||||
* eg a translation that fell back to english from another language
|
||||
* should be wrapped with an appropriate `lang='en'` attribute
|
||||
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
|
||||
* is in the target locale or a fallback locale
|
||||
* for this reason, force fallbackLocale === locale in the first call to translate
|
||||
* and fallback 'manually' so we can mark fallback strings appropriately
|
||||
* */
|
||||
const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
|
||||
const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
|
||||
if (isValidTranslation(translated)) {
|
||||
return { translated };
|
||||
}
|
||||
|
||||
const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
|
||||
if (isValidTranslation(fallbackTranslated)) {
|
||||
return { translated: fallbackTranslated, isFallback: true };
|
||||
}
|
||||
|
||||
// Even the translation via FALLBACK_LOCALE failed; this can happen if
|
||||
//
|
||||
// 1. The string isn't in the translations dictionary, usually because you're in develop
|
||||
// and haven't run yarn i18n
|
||||
// 2. Loading the translation resources over the network failed, which can happen due to
|
||||
// to network or if the client tried to load a translation that's been removed from the
|
||||
// server.
|
||||
//
|
||||
// At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
|
||||
// so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
|
||||
return { translated: text, isFallback: true };
|
||||
};
|
||||
|
||||
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
|
||||
// Takes the same arguments as counterpart.translate()
|
||||
function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
|
||||
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
|
||||
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
|
||||
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
|
||||
const options: IVariables & {
|
||||
interpolate: boolean;
|
||||
} = { ...variables, interpolate: false };
|
||||
|
||||
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
|
||||
// The interpolation library that counterpart uses does not support undefined/null
|
||||
// values and instead will throw an error. This is a problem since everywhere else
|
||||
// in JS land passing undefined/null will simply stringify instead, and when converting
|
||||
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
|
||||
// if there are no existing null guards. To avoid this making the app completely inoperable,
|
||||
// we'll check all the values for undefined/null and stringify them here.
|
||||
if (options && typeof options === "object") {
|
||||
Object.keys(options).forEach((k) => {
|
||||
if (options[k] === undefined) {
|
||||
console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
|
||||
options[k] = "undefined";
|
||||
}
|
||||
if (options[k] === null) {
|
||||
console.warn("safeCounterpartTranslate called with null interpolation name: " + k);
|
||||
options[k] = "null";
|
||||
}
|
||||
});
|
||||
}
|
||||
return translateWithFallback(text, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a variable or tag can take for a translation interpolation.
|
||||
*/
|
||||
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
|
||||
|
||||
export interface IVariables {
|
||||
count?: number;
|
||||
[key: string]: SubstitutionValue;
|
||||
}
|
||||
|
||||
export type Tags = Record<string, SubstitutionValue>;
|
||||
|
||||
export type TranslatedString = string | React.ReactNode;
|
||||
|
||||
// For development/testing purposes it is useful to also output the original string
|
||||
// Don't do that for release versions
|
||||
const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
|
||||
if (!ANNOTATE_STRINGS) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return `@@${translationKey}##${result}@@`;
|
||||
} else {
|
||||
return (
|
||||
<span className="translated-string" data-orig-string={translationKey}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function _t(text: TranslationKey, variables?: IVariables): string;
|
||||
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
|
||||
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
return annotateStrings(substituted, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to look up a string by its translation key without resolving variables & tags
|
||||
* @param key - the translation key to return the value for
|
||||
*/
|
||||
export function lookupString(key: TranslationKey): string {
|
||||
return safeCounterpartTranslate(key, {}).translated;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
|
||||
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
|
||||
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions
|
||||
* or translation used a fallback locale, otherwise a string
|
||||
*/
|
||||
// eslint-next-line @typescript-eslint/naming-convention
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
|
||||
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
|
||||
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
|
||||
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
|
||||
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
|
||||
const substituted = substitute(translated, variables, tags);
|
||||
|
||||
// wrap en fallback translation with lang attribute for screen readers
|
||||
const result = isFallback ? <span lang="en">{substituted}</span> : substituted;
|
||||
|
||||
return annotateStrings(result, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
|
||||
* replaceable by the translation functions.
|
||||
* @param {string} text The text to sanitize.
|
||||
* @returns {string} The sanitized text.
|
||||
*/
|
||||
export function sanitizeForTranslation(text: string): string {
|
||||
// Add a non-breaking space so the regex doesn't trigger when translating.
|
||||
return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
|
||||
}
|
||||
|
||||
/*
|
||||
* Similar to _t(), except only does substitutions, and no translation
|
||||
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".
|
||||
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
|
||||
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
|
||||
*
|
||||
* The values to substitute with can be either simple strings, or functions that return the value to use in
|
||||
* the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
|
||||
* the argument the text inside the element corresponding to the tag.
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function substitute(text: string, variables?: IVariables): string;
|
||||
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
|
||||
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
|
||||
let result: React.ReactNode | string = text;
|
||||
|
||||
if (variables !== undefined) {
|
||||
const regexpMapping: IVariables = {};
|
||||
for (const variable in variables) {
|
||||
regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
if (tags !== undefined) {
|
||||
const regexpMapping: Tags = {};
|
||||
for (const tag in tags) {
|
||||
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
|
||||
}
|
||||
result = replaceByRegexes(result as string, regexpMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parts of a text using regular expressions
|
||||
* @param text - The text on which to perform substitutions
|
||||
* @param mapping - A mapping from regular expressions in string form to replacement string or a
|
||||
* function which will receive as the argument the capture groups defined in the regexp. E.g.
|
||||
* { 'Hello (.?) World': (sub) => sub.toUpperCase() }
|
||||
*
|
||||
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
|
||||
*/
|
||||
export function replaceByRegexes(text: string, mapping: IVariables): string;
|
||||
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
|
||||
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
|
||||
// We initially store our output as an array of strings and objects (e.g. React components).
|
||||
// This will then be converted to a string or a <span> at the end
|
||||
const output: SubstitutionValue[] = [text];
|
||||
|
||||
// If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
|
||||
let shouldWrapInSpan = false;
|
||||
|
||||
for (const regexpString in mapping) {
|
||||
// TODO: Cache regexps
|
||||
const regexp = new RegExp(regexpString, "g");
|
||||
|
||||
// Loop over what output we have so far and perform replacements
|
||||
// We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
|
||||
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
|
||||
// Otherwise there would be no need for the splitting and we could do simple replacement.
|
||||
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
|
||||
for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
|
||||
const inputText = output[outputIndex];
|
||||
if (typeof inputText !== "string") {
|
||||
// We might have inserted objects earlier, don't try to replace them
|
||||
continue;
|
||||
}
|
||||
|
||||
// process every match in the string
|
||||
// starting with the first
|
||||
let match = regexp.exec(inputText);
|
||||
|
||||
if (!match) continue;
|
||||
matchFoundSomewhere = true;
|
||||
|
||||
// The textual part before the first match
|
||||
const head = inputText.slice(0, match.index);
|
||||
|
||||
const parts: SubstitutionValue[] = [];
|
||||
// keep track of prevMatch
|
||||
let prevMatch;
|
||||
while (match) {
|
||||
// store prevMatch
|
||||
prevMatch = match;
|
||||
const capturedGroups = match.slice(2);
|
||||
|
||||
let replaced: SubstitutionValue;
|
||||
// If substitution is a function, call it
|
||||
if (mapping[regexpString] instanceof Function) {
|
||||
replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
|
||||
} else {
|
||||
replaced = mapping[regexpString];
|
||||
}
|
||||
|
||||
if (typeof replaced === "object") {
|
||||
shouldWrapInSpan = true;
|
||||
}
|
||||
|
||||
// Here we also need to check that it actually is a string before comparing against one
|
||||
// The head and tail are always strings
|
||||
if (typeof replaced !== "string" || replaced !== "") {
|
||||
parts.push(replaced);
|
||||
}
|
||||
|
||||
// try the next match
|
||||
match = regexp.exec(inputText);
|
||||
|
||||
// add the text between prevMatch and this one
|
||||
// or the end of the string if prevMatch is the last match
|
||||
let tail;
|
||||
if (match) {
|
||||
const startIndex = prevMatch.index + prevMatch[0].length;
|
||||
tail = inputText.slice(startIndex, match.index);
|
||||
} else {
|
||||
tail = inputText.slice(prevMatch.index + prevMatch[0].length);
|
||||
}
|
||||
if (tail) {
|
||||
parts.push(tail);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert in reverse order as splice does insert-before and this way we get the final order correct
|
||||
// remove the old element at the same time
|
||||
output.splice(outputIndex, 1, ...parts);
|
||||
|
||||
if (head !== "") {
|
||||
// Don't push empty nodes, they are of no use
|
||||
output.splice(outputIndex, 0, head);
|
||||
}
|
||||
}
|
||||
if (!matchFoundSomewhere) {
|
||||
if (
|
||||
// The current regexp did not match anything in the input. Missing
|
||||
// matches is entirely possible because you might choose to show some
|
||||
// variables only in the case of e.g. plurals. It's still a bit
|
||||
// suspicious, and could be due to an error, so log it. However, not
|
||||
// showing count is so common that it's not worth logging. And other
|
||||
// commonly unused variables here, if there are any.
|
||||
regexpString !== "%\\(count\\)s" &&
|
||||
// Ignore the `locale` option which can be used to override the locale
|
||||
// in counterpart
|
||||
regexpString !== "%\\(locale\\)s"
|
||||
) {
|
||||
console.log(`Could not find ${regexp} in ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWrapInSpan) {
|
||||
return React.createElement("span", null, ...(output as Array<number | string | React.ReactNode>));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return output.join("");
|
||||
}
|
||||
}
|
||||
|
||||
type Languages = {
|
||||
[lang: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the language for the application.
|
||||
* In Element web,`languageHandler.setLanguage` should be used instead.
|
||||
* @param language
|
||||
*/
|
||||
export async function setLanguage(language: string): Promise<void> {
|
||||
const availableLanguages = await getLangsJson();
|
||||
const chosenLanguage = language in availableLanguages ? language : "en";
|
||||
|
||||
const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]);
|
||||
|
||||
counterpart.registerTranslations(chosenLanguage, languageData);
|
||||
counterpart.setLocale(chosenLanguage);
|
||||
}
|
||||
|
||||
interface ICounterpartTranslation {
|
||||
[key: string]:
|
||||
| string
|
||||
| {
|
||||
[pluralisation: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
|
||||
console.log("Loading language from", langPath);
|
||||
const res = await fetch(langPath, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${langPath}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getLangsJson(): Promise<Languages> {
|
||||
let url: string;
|
||||
if (typeof webpackLangJsonUrl === "string") {
|
||||
// in Jest this 'url' isn't a URL, so just fall through
|
||||
url = webpackLangJsonUrl;
|
||||
} else {
|
||||
url = i18nFolder + "languages.json";
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load ${url}, got ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
21
src/shared-components/useViewModel.ts
Normal file
21
src/shared-components/useViewModel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
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 { useSyncExternalStore } from "react";
|
||||
|
||||
import { type ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
|
||||
* @param vm The view model to use
|
||||
* @returns The current snapshot
|
||||
*/
|
||||
export function useViewModel<T>(vm: ViewModel<T>): T {
|
||||
// We need to pass the same getSnapshot function as getServerSnapshot as this
|
||||
// is used when making the HTML chat export.
|
||||
return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type MatrixClient,
|
||||
ClientEvent,
|
||||
RoomStateEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
@@ -360,7 +361,7 @@ export class StopGapWidget extends EventEmitter {
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.client.on(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
|
||||
|
||||
this.messaging.on(
|
||||
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
|
||||
@@ -493,7 +494,7 @@ export class StopGapWidget extends EventEmitter {
|
||||
this.client.off(ClientEvent.Event, this.onEvent);
|
||||
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.client.off(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
|
||||
}
|
||||
|
||||
private onEvent = (ev: MatrixEvent): void => {
|
||||
@@ -513,10 +514,10 @@ export class StopGapWidget extends EventEmitter {
|
||||
});
|
||||
};
|
||||
|
||||
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||||
private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise<void> => {
|
||||
const { message, encryptionInfo } = payload;
|
||||
// TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent
|
||||
await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
Copyright 2018-2021 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
@@ -42,6 +42,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { SeshatIndexManager } from "./SeshatIndexManager";
|
||||
import { IPCManager } from "./IPCManager";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { BadgeOverlayRenderer } from "../../favicon";
|
||||
|
||||
interface SquirrelUpdate {
|
||||
releaseNotes: string;
|
||||
@@ -87,10 +88,11 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
|
||||
export default class ElectronPlatform extends BasePlatform {
|
||||
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
|
||||
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
|
||||
private readonly initialised: Promise<void>;
|
||||
public readonly initialised: Promise<void>;
|
||||
private readonly electron: Electron;
|
||||
private protocol!: string;
|
||||
private sessionId!: string;
|
||||
private badgeOverlayRenderer?: BadgeOverlayRenderer;
|
||||
private config!: IConfigOptions;
|
||||
private supportedSettings?: Record<string, boolean>;
|
||||
|
||||
@@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform {
|
||||
}
|
||||
|
||||
private async initialise(): Promise<void> {
|
||||
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
|
||||
const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } =
|
||||
await this.electron.initialise();
|
||||
this.protocol = protocol;
|
||||
this.sessionId = sessionId;
|
||||
this.config = config;
|
||||
this.supportedSettings = supportedSettings;
|
||||
if (supportsBadgeOverlay) {
|
||||
this.badgeOverlayRenderer = new BadgeOverlayRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
public async getConfig(): Promise<IConfigOptions | undefined> {
|
||||
@@ -249,8 +255,42 @@ export default class ElectronPlatform extends BasePlatform {
|
||||
public setNotificationCount(count: number): void {
|
||||
if (this.notificationCount === count) return;
|
||||
super.setNotificationCount(count);
|
||||
if (this.badgeOverlayRenderer) {
|
||||
this.badgeOverlayRenderer
|
||||
.render(count)
|
||||
.then((buffer) => {
|
||||
this.electron.send("setBadgeCount", count, buffer);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.warn("Unable to generate badge overlay", ex);
|
||||
});
|
||||
} else {
|
||||
this.electron.send("setBadgeCount", count);
|
||||
}
|
||||
}
|
||||
|
||||
this.electron.send("setBadgeCount", count);
|
||||
public setErrorStatus(errorDidOccur: boolean): void {
|
||||
if (!this.badgeOverlayRenderer) {
|
||||
super.setErrorStatus(errorDidOccur);
|
||||
return;
|
||||
}
|
||||
// Check before calling super so we don't override the previous state.
|
||||
if (this.errorDidOccur !== errorDidOccur) {
|
||||
super.setErrorStatus(errorDidOccur);
|
||||
let promise: Promise<ArrayBuffer | null>;
|
||||
if (errorDidOccur) {
|
||||
promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00");
|
||||
} else {
|
||||
promise = this.badgeOverlayRenderer.render(this.notificationCount);
|
||||
}
|
||||
promise
|
||||
.then((buffer) => {
|
||||
this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.warn("Unable to generate badge overlay", ex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public supportsNotifications(): boolean {
|
||||
|
||||
56
src/viewmodels/SubscriptionViewModel.ts
Normal file
56
src/viewmodels/SubscriptionViewModel.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
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 ViewModel } from "../shared-components/ViewModel";
|
||||
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
|
||||
|
||||
export abstract class SubscriptionViewModel<T> implements ViewModel<T> {
|
||||
protected subs: ViewModelSubscriptions;
|
||||
|
||||
protected constructor() {
|
||||
this.subs = new ViewModelSubscriptions(
|
||||
this.addDownstreamSubscriptionWrapper,
|
||||
this.removeDownstreamSubscriptionWrapper,
|
||||
);
|
||||
}
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
return this.subs.add(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
|
||||
* has a bound `this` context.
|
||||
*/
|
||||
private addDownstreamSubscriptionWrapper = (): void => {
|
||||
this.addDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
|
||||
* in the constructor.
|
||||
*/
|
||||
private removeDownstreamSubscriptionWrapper = (): void => {
|
||||
this.removeDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
|
||||
* to call this.subs.emit() when the snapshot changes.
|
||||
*/
|
||||
protected abstract addDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
|
||||
*/
|
||||
protected abstract removeDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Returns the current snapshot of the view model.
|
||||
*/
|
||||
public abstract getSnapshot: () => T;
|
||||
}
|
||||
50
src/viewmodels/ViewModelSubscriptions.ts
Normal file
50
src/viewmodels/ViewModelSubscriptions.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility class for view models to manage suscriptions to their updates
|
||||
*/
|
||||
export class ViewModelSubscriptions {
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* @param subscribeCallback Called when the first listener subscribes.
|
||||
* @param unsubscribeCallback Called when the last listener unsubscribes.
|
||||
*/
|
||||
public constructor(
|
||||
private subscribeCallback: () => void,
|
||||
private unsubscribeCallback: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the view model.
|
||||
* @param listener Will be called whenever the snapshot changes.
|
||||
* @returns A function to unsubscribe from the view model updates.
|
||||
*/
|
||||
public add = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
if (this.listeners.size === 1) {
|
||||
this.subscribeCallback();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
if (this.listeners.size === 0) {
|
||||
this.unsubscribeCallback();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit an update to all subscribed listeners.
|
||||
*/
|
||||
public emit = (): void => {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
}
|
||||
38
src/viewmodels/event-tiles/TextualEventViewModel.ts
Normal file
38
src/viewmodels/event-tiles/TextualEventViewModel.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
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 { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type EventTileTypeProps } from "../../events/EventTileFactory";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { textForEvent } from "../../TextForEvent";
|
||||
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
|
||||
import { SubscriptionViewModel } from "../SubscriptionViewModel";
|
||||
|
||||
export class TextualEventViewModel extends SubscriptionViewModel<TextualEventViewSnapshot> {
|
||||
public constructor(private eventTileProps: EventTileTypeProps) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected addDownstreamSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
||||
};
|
||||
|
||||
protected removeDownstreamSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
||||
};
|
||||
|
||||
public getSnapshot = (): TextualEventViewSnapshot => {
|
||||
const text = textForEvent(
|
||||
this.eventTileProps.mxEvent,
|
||||
MatrixClientPeg.safeGet(),
|
||||
true,
|
||||
this.eventTileProps.showHiddenEvents,
|
||||
);
|
||||
return text;
|
||||
};
|
||||
}
|
||||
@@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quote button", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => {
|
||||
mocked(getSelectedText).mockReturnValue("quoted text");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeTruthy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when getSelectedText returns empty", () => {
|
||||
mocked(getSelectedText).mockReturnValue("");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when selection is not inside one MTextBody", () => {
|
||||
mocked(getSelectedText).mockReturnValue("quoted text");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(false);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("dispatches ComposerInsert with quoted text when quote button is clicked", () => {
|
||||
mocked(getSelectedText).mockReturnValue("line1\nline2");
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]')!;
|
||||
fireEvent.mouseDown(quoteButton);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ComposerInsert,
|
||||
text: "\n> line1\n> line2\n\n ",
|
||||
}),
|
||||
);
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
|
||||
it("does not show quote button when getSelectedText returns only whitespace", () => {
|
||||
mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only
|
||||
const isSelectionWithinSingleTextBody = jest
|
||||
.spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
|
||||
.mockReturnValue(true);
|
||||
|
||||
createRightClickMenuWithContent(createMessageEventContent("hello"));
|
||||
const quoteButton = document.querySelector('li[aria-label="Quote"]');
|
||||
expect(quoteButton).toBeFalsy();
|
||||
|
||||
isSelectionWithinSingleTextBody.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelectionWithinSingleTextBody", () => {
|
||||
let mockGetSelection: jest.SpyInstance;
|
||||
let contextMenuInstance: MessageContextMenu;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockGetSelection = jest.spyOn(window, "getSelection");
|
||||
|
||||
const eventContent = createMessageEventContent("hello");
|
||||
const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
|
||||
|
||||
contextMenuInstance = new MessageContextMenu({
|
||||
mxEvent,
|
||||
onFinished: jest.fn(),
|
||||
rightClick: true,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetSelection.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false when there is no selection", () => {
|
||||
mockGetSelection.mockReturnValue(null);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when selection has no ranges", () => {
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 0,
|
||||
getRangeAt: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when selection is within a single mx_MTextBody element", () => {
|
||||
// Create a mock MTextBody element
|
||||
const textBodyElement = document.createElement("div");
|
||||
textBodyElement.classList.add("mx_MTextBody");
|
||||
|
||||
// Create mock text nodes within the MTextBody
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
textBodyElement.appendChild(startTextNode);
|
||||
textBodyElement.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range with the text nodes
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when selection spans multiple mx_MTextBody elements", () => {
|
||||
// Create two different MTextBody elements
|
||||
const textBody1 = document.createElement("div");
|
||||
textBody1.classList.add("mx_MTextBody");
|
||||
const textBody2 = document.createElement("div");
|
||||
textBody2.classList.add("mx_MTextBody");
|
||||
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
textBody1.appendChild(startTextNode);
|
||||
textBody2.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range spanning different MTextBody elements
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when selection is outside any mx_MTextBody element", () => {
|
||||
// Create regular div elements without mx_MTextBody class
|
||||
const regularDiv1 = document.createElement("div");
|
||||
const regularDiv2 = document.createElement("div");
|
||||
|
||||
const startTextNode = document.createTextNode("start");
|
||||
const endTextNode = document.createTextNode("end");
|
||||
regularDiv1.appendChild(startTextNode);
|
||||
regularDiv2.appendChild(endTextNode);
|
||||
|
||||
// Create a mock range outside MTextBody elements
|
||||
const mockRange = {
|
||||
startContainer: startTextNode,
|
||||
endContainer: endTextNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when start and end are the same mx_MTextBody element", () => {
|
||||
const textBodyElement = document.createElement("div");
|
||||
textBodyElement.classList.add("mx_MTextBody");
|
||||
|
||||
const textNode = document.createTextNode("same text");
|
||||
textBodyElement.appendChild(textNode);
|
||||
|
||||
// Create a mock range within the same MTextBody element
|
||||
const mockRange = {
|
||||
startContainer: textNode,
|
||||
endContainer: textNode,
|
||||
} as unknown as Range;
|
||||
|
||||
mockGetSelection.mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: jest.fn().mockReturnValue(mockRange),
|
||||
} as any);
|
||||
|
||||
const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("right click", () => {
|
||||
it("copy button does work as expected", () => {
|
||||
const text = "hello";
|
||||
|
||||
@@ -44,6 +44,28 @@ describe("<ImageView />", () => {
|
||||
expect(fetchMock).toHaveFetched("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("should start download on Ctrl+S", async () => {
|
||||
fetchMock.get("https://example.com/image.png", "TESTFILE");
|
||||
|
||||
const { container } = render(
|
||||
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
|
||||
);
|
||||
|
||||
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
|
||||
dialog?.focus();
|
||||
|
||||
fireEvent.keyDown(dialog!, { key: "s", code: "KeyS", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({
|
||||
blob: expect.anything(),
|
||||
name: "filename.png",
|
||||
});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveFetched("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("should handle download errors", async () => {
|
||||
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||
fetchMock.get("https://example.com/image.png", { status: 500 });
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act } from "react";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
import { waitFor, fireEvent } from "jest-matrix-react";
|
||||
import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type JSX } from "react";
|
||||
|
||||
@@ -154,6 +154,16 @@ describe("MemberListView and MemberlistHeaderView", () => {
|
||||
expect(root.container.querySelector(".mx_PresenceIconView_unavailable")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent default form submission", async () => {
|
||||
const { root } = rendered;
|
||||
const form = root.container.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
|
||||
const preventDefaultSpy = jest.spyOn(submitEvent, "preventDefault");
|
||||
fireEvent(form!, submitEvent);
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([true, false])("does order members correctly (presence %s)", (enablePresence) => {
|
||||
|
||||
@@ -742,6 +742,26 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
|
||||
</kbd>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="mx_KeyboardShortcut_shortcutRow"
|
||||
>
|
||||
Save
|
||||
<div
|
||||
class="mx_KeyboardShortcut"
|
||||
>
|
||||
<kbd>
|
||||
|
||||
Ctrl
|
||||
|
||||
</kbd>
|
||||
+
|
||||
<kbd>
|
||||
|
||||
s
|
||||
|
||||
</kbd>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,16 +83,42 @@ describe("StopGapWidget", () => {
|
||||
});
|
||||
|
||||
it("feeds incoming to-device messages to the widget", async () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "org.example.foo",
|
||||
user: "@alice:example.org",
|
||||
content: { hello: "world" },
|
||||
});
|
||||
const receivedToDevice = {
|
||||
message: {
|
||||
type: "org.example.foo",
|
||||
sender: "@alice:example.org",
|
||||
content: {
|
||||
hello: "world",
|
||||
},
|
||||
},
|
||||
encryptionInfo: null,
|
||||
};
|
||||
|
||||
client.emit(ClientEvent.ToDeviceEvent, event);
|
||||
client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice);
|
||||
await Promise.resolve(); // flush promises
|
||||
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
|
||||
expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, false);
|
||||
});
|
||||
|
||||
it("feeds incoming encrypted to-device messages to the widget", async () => {
|
||||
const receivedToDevice = {
|
||||
message: {
|
||||
type: "org.example.foo",
|
||||
sender: "@alice:example.org",
|
||||
content: {
|
||||
hello: "world",
|
||||
},
|
||||
},
|
||||
encryptionInfo: {
|
||||
senderVerified: false,
|
||||
sender: "@alice:example.org",
|
||||
senderCurve25519KeyBase64: "",
|
||||
senderDevice: "ABCDEFGHI",
|
||||
},
|
||||
};
|
||||
|
||||
client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice);
|
||||
await Promise.resolve(); // flush promises
|
||||
expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true);
|
||||
});
|
||||
|
||||
it("feeds incoming state updates to the widget", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
@@ -26,18 +27,20 @@ jest.mock("../../../../src/rageshake/rageshake", () => ({
|
||||
}));
|
||||
|
||||
describe("ElectronPlatform", () => {
|
||||
const initialiseValues = jest.fn().mockReturnValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportedSettings: { setting1: false, setting2: true },
|
||||
supportsBadgeOverlay: false,
|
||||
});
|
||||
const defaultUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36";
|
||||
const mockElectron = {
|
||||
on: jest.fn(),
|
||||
send: jest.fn(),
|
||||
initialise: jest.fn().mockResolvedValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportedSettings: { setting1: false, setting2: true },
|
||||
}),
|
||||
initialise: initialiseValues,
|
||||
setSettingValue: jest.fn().mockResolvedValue(undefined),
|
||||
getSettingValue: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MockedObject<Electron>;
|
||||
@@ -405,4 +408,101 @@ describe("ElectronPlatform", () => {
|
||||
state: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notification overlay badges", () => {
|
||||
beforeEach(() => {
|
||||
initialiseValues.mockReturnValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportsBadgeOverlay: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should send a badge with a notification count", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const ipcMessage = mockElectron.send.mock.lastCall;
|
||||
expect(ipcMessage?.[1]).toEqual(1);
|
||||
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update badge and skip duplicates", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
platform.setNotificationCount(1); // Test that duplicates do not fire.
|
||||
platform.setNotificationCount(2);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageA, ipcMessageB] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(1);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(2);
|
||||
expect(ipcMessageB?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
});
|
||||
});
|
||||
it("should remove badge when notification count zeros", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
platform.setNotificationCount(0); // Test that duplicates do not fire.
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(1);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(0);
|
||||
expect(ipcMessageB?.[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
it("should show an error badge when the application errors", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setErrorStatus(true);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount");
|
||||
|
||||
expect(ipcMessage?.[1]).toEqual(0);
|
||||
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
expect(ipcMessage?.[3]).toEqual(true);
|
||||
});
|
||||
});
|
||||
it("should restore after error is resolved", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setErrorStatus(true);
|
||||
platform.setErrorStatus(false);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(0);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
expect(ipcMessageA?.[3]).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(0);
|
||||
expect(ipcMessageB?.[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
29
test/viewmodels/event-tiles/TextualEventViewModel-test.ts
Normal file
29
test/viewmodels/event-tiles/TextualEventViewModel-test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
|
||||
|
||||
describe("TextualEventViewModel", () => {
|
||||
it("should update when the sentinel updates", () => {
|
||||
const fakeEvent = new MatrixEvent({});
|
||||
|
||||
const vm = new TextualEventViewModel({
|
||||
showHiddenEvents: false,
|
||||
mxEvent: fakeEvent,
|
||||
});
|
||||
|
||||
const cb = jest.fn();
|
||||
|
||||
vm.subscribe(cb);
|
||||
|
||||
fakeEvent.emit(MatrixEventEvent.SentinelUpdated);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@
|
||||
"lib": ["es2022", "es2024.promise", "dom", "dom.iterable"],
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"jest-matrix-react": ["./test/test-utils/jest-matrix-react"]
|
||||
"jest-matrix-react": ["./test/test-utils/jest-matrix-react"],
|
||||
"rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -26,7 +27,8 @@
|
||||
"./src/**/*.tsx",
|
||||
"./test/**/*.ts",
|
||||
"./test/**/*.tsx",
|
||||
"./scripts/*.ts"
|
||||
"./scripts/*.ts",
|
||||
"./declaration.d.ts"
|
||||
],
|
||||
"ts-node": {
|
||||
"files": true,
|
||||
|
||||
Reference in New Issue
Block a user