diff --git a/.github/workflows/build_desktop_and_deploy.yaml b/.github/workflows/build_desktop_and_deploy.yaml index 1bddf5f635f..e7cd8494328 100644 --- a/.github/workflows/build_desktop_and_deploy.yaml +++ b/.github/workflows/build_desktop_and_deploy.yaml @@ -289,7 +289,7 @@ jobs: id-token: write # This is required for requesting the JWT steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6 + uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 # v6 with: role-to-assume: arn:aws:iam::264135176173:role/Push-ElementDesktop-MSI role-session-name: githubaction-run-${{ github.run_id }} diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 087db24a7f4..bbc1d28bd1b 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -127,15 +127,14 @@ jobs: - name: Deploy to Cloudflare Pages id: cfp - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CF_PAGES_TOKEN }} accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} - projectName: element-web-develop - directory: _deploy + command: pages deploy _deploy --project-name element-web-develop gitHubToken: ${{ secrets.GITHUB_TOKEN }} - run: | echo "Deployed to ${STEPS_CFP_OUTPUTS_URL}" >> $GITHUB_STEP_SUMMARY env: - STEPS_CFP_OUTPUTS_URL: ${{ steps.cfp.outputs.url }} + STEPS_CFP_OUTPUTS_URL: ${{ steps.cfp.outputs.deployment-url }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 238b881286e..162c67e2ab7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -91,11 +91,9 @@ jobs: check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages).)*$ - name: Deploy to Cloudflare Pages - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CF_PAGES_TOKEN }} accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} - projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} - directory: _deploy + command: pages deploy _deploy --project-name ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} --branch main gitHubToken: ${{ secrets.GITHUB_TOKEN }} - branch: main diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index e934f05ad12..00000000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: SonarQube -on: - # Privilege escalation necessary to call upon SonarCloud - # 🚨 We must not execute any checked out code here. - workflow_run: # zizmor: ignore[dangerous-triggers] - workflows: ["Tests"] - types: - - completed -concurrency: - group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true -permissions: {} -jobs: - sonarqube: - name: 🩻 SonarQube - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group' - uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop # zizmor: ignore[unpinned-uses] - permissions: - actions: read - statuses: write - id-token: write # sonar - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - with: - sharded: true - version-pkg-json-dir: ./apps/web diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e60b9b01f2..dedf36333cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,8 +80,6 @@ jobs: --shard "$SHARD" \ --cacheDirectory /tmp/jest_cache env: - JEST_SONAR_UNIQUE_OUTPUT_NAME: true - # tell jest to use coloured output FORCE_COLOR: true MAX_WORKERS: ${{ steps.cpu-cores.outputs.count }} @@ -103,28 +101,6 @@ jobs: apps/web/coverage !apps/web/coverage/lcov-report - complete: - name: jest-tests - needs: [jest_ew, vitest] - if: always() - runs-on: ubuntu-24.04 - permissions: - statuses: write - steps: - - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 - - - name: Skip SonarCloud in merge queue - if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - state: success - description: SonarCloud skipped - context: SonarCloud Code Analysis - sha: ${{ github.sha }} - target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - vitest: name: Vitest strategy: @@ -191,3 +167,61 @@ jobs: path: | ${{ matrix.path }}/coverage !${{ matrix.path }}/coverage/lcov-report + + complete: + name: Tests + needs: + - jest_ew + - vitest + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read + checks: write + statuses: write + steps: + - name: Skip SonarCloud in merge queue + if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' + uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09 + with: + authToken: ${{ secrets.GITHUB_TOKEN }} + state: success + description: SonarCloud skipped + context: SonarCloud Code Analysis + sha: ${{ github.sha }} + target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + with: + persist-credentials: false + fetch-depth: 0 # Full history, fastest for diff-cover + + - name: Download coverage artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + with: + pattern: coverage-* + path: coverage + + - name: Diff Coverage + id: coverage + if: needs.test.result == 'success' && env.ENABLE_COVERAGE == 'true' + uses: Affanmir/diff-cover-action@0d8c98f613bbd2428df50b3109b1e3b1d5ab59d3 # v2.1.0 + with: + compare-branch: origin/${{ github.base_ref || 'develop' }} + mode: coverage + coverage-files: coverage/*/*lcov.info + ignore-whitespace: true + show-uncovered: true + post-comment: false + create-annotations: true + annotation-type: warning + fail-on-threshold: ${{ contains(github.event.pull_request.labels.*.name, 'Z-Skip-Coverage') && 'false' || 'true' }} + fail-under: 80 + + - name: Check status of tests + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 615e4969385..8e5b3de55ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,6 +140,10 @@ These are located in `/spec/` in `matrix-js-sdk` or `/test/` in `element-web`. When writing unit tests, please aim for a high level of test coverage for new code - 80% or greater. If you cannot achieve that, please document why it's not possible in your PR. +CI will validate that the coverage reached on your change is sufficient, +you can also assert this locally by installing https://github.com/Bachmann1234/diff_cover, +running the entire test suite in coverage mode `pnpm coverage` then running +`pnpm coverage:diff` to see the coverage of the diff between your HEAD and `develop`. Some sections of code are not sensible to add coverage for, such as those which explicitly inhibit noisy logging for tests. Which can be hidden using diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4665edd2a1a..df8723051e4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,6 +54,8 @@ "test:playwright": "nx test:playwright --", "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "sane-postinstall": "electron-builder install-app-deps" }, "dependencies": { @@ -106,8 +108,7 @@ "rimraf": "^6.0.0", "tar": "^7.5.8", "typescript": "6.0.3", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "hakDependencies": { "matrix-seshat": "4.3.0" diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 7139bd2f342..75f72bfd077 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -68,7 +68,6 @@ if (env["GITHUB_ACTIONS"] !== undefined) { config.reporters ??= []; config.reporters.push(["github-actions", { silent: false }]); config.reporters.push("summary"); - config.reporters.push("@casualbot/jest-sonar-reporter"); // if we're running against the develop branch, also enable the slow test reporter if (env["GITHUB_REF"] == "refs/heads/develop") { diff --git a/apps/web/package.json b/apps/web/package.json index f19096f20bd..cf6ab7af4b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "test:playwright:open": "nx test:playwright -- --ui", "test:playwright:screenshots": "nx test:playwright:screenshots --", "coverage": "pnpm test --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp" }, "dependencies": { @@ -125,7 +126,6 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", - "@casualbot/jest-sonar-reporter": "2.7.1", "@element-hq/element-call-embedded": "0.19.4", "@element-hq/element-web-playwright-common": "workspace:*", "@fetch-mock/jest": "^0.2.20", @@ -236,11 +236,6 @@ "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, - "@casualbot/jest-sonar-reporter": { - "outputDirectory": "coverage", - "outputName": "jest-sonar-report.xml", - "relativePaths": true - }, "engines": { "node": ">=22.18" }, diff --git a/apps/web/playwright/testcontainers/mas.ts b/apps/web/playwright/testcontainers/mas.ts index 28b55e18470..b4446c371c2 100644 --- a/apps/web/playwright/testcontainers/mas.ts +++ b/apps/web/playwright/testcontainers/mas.ts @@ -11,7 +11,7 @@ import { } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; const DOCKER_IMAGE = - "ghcr.io/element-hq/matrix-authentication-service:main@sha256:07510c0e76c174c54509c7a54109ff950a82b9b3fc085c63d989f66c7f542285"; + "ghcr.io/element-hq/matrix-authentication-service:main@sha256:be4a4844608ab44a17d1c625dfbc3b8e5d4d11b4353ecee7498bf9ed6fd2b608"; /** * MatrixAuthenticationServiceContainer which freezes the docker digest to diff --git a/apps/web/playwright/testcontainers/synapse.ts b/apps/web/playwright/testcontainers/synapse.ts index 0260b0a4316..d81dc034a78 100644 --- a/apps/web/playwright/testcontainers/synapse.ts +++ b/apps/web/playwright/testcontainers/synapse.ts @@ -8,7 +8,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/index.js"; const DOCKER_IMAGE = - "ghcr.io/element-hq/synapse:develop@sha256:9be022fcbee02b3e5d526a25bf73b787c86348c0428ecac482b95ad7424a156e"; + "ghcr.io/element-hq/synapse:develop@sha256:be8a99ec2e06494fee6525152778bf263cd71a364c138a641cb0d3ee4a735d6b"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 907612b0ce4..2644edc7103 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -9,18 +9,13 @@ Please see LICENSE files in the repository root for full details. import React, { createRef, - useCallback, - useContext, useEffect, - useMemo, - useState, type JSX, type Ref, type FocusEvent, type MouseEvent, type ReactNode, } from "react"; -import classNames from "classnames"; import { type EventStatus, EventType, @@ -28,7 +23,6 @@ import { MatrixEventEvent, type Relations, type Room, - RelationsEvent, RoomEvent, type RoomMember, type Thread, @@ -36,22 +30,9 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; -import { Tooltip } from "@vector-im/compound-web"; -import { uniqueId, uniqBy } from "lodash"; -import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { - useCreateAutoDisposedViewModel, - ActionBarView, - E2eMessageSharedIconView, - MessageTimestampView, - PinnedMessageBadge, - ReactionsRowButtonView, - ReactionsRowView, - ThreadMessagePreviewView, - ThreadSummaryView, - TileErrorView, - useViewModel, -} from "@element-hq/web-shared-components"; +import { uniqueId } from "lodash"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -60,22 +41,16 @@ import { Layout } from "../../../settings/enums/Layout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu"; +import { aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "./NotificationBadge"; import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper"; import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; import PlatformPeg from "../../../PlatformPeg"; -import MemberAvatar from "../avatars/MemberAvatar"; -import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -import ReactionPicker from "../emojipicker/ReactionPicker"; import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; -import { isContentActionable } from "../../../utils/EventUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { copyPlaintext } from "../../../utils/strings"; @@ -83,22 +58,25 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; -import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; -import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; +import { ActionBarAdapter } from "./EventTile/ActionBarAdapter"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; -import SettingsStore from "../../../settings/SettingsStore"; -import { CardContext } from "../right_panel/context"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; +import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; +import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; +import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; +import { ReceiptAdapter } from "./EventTile/ReceiptAdapter"; +import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter"; +import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; +import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; +import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; import { EventTileViewModel, type EventTileViewModelProps, } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; -import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; import { getEventTileReceiptState, type EventTileReceiptState, @@ -121,28 +99,13 @@ import { initialEventTileInteractionState, type EventTileInteractionState, } from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState"; -import { - type MessageTimestampViewModel, - type MessageTimestampViewModelProps, -} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { - ThreadMessagePreviewViewModel, - ThreadSummaryViewModel, -} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx"; -import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; -import { - MAX_ITEMS_WHEN_LIMITED, - ReactionsRowViewModel, -} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; +import { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; import { getEventTileReactionRelations, isEventTileReactionRelation, type GetRelationsForEvent, } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; -import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { type ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; @@ -513,7 +476,7 @@ export class UnwrappedEventTile extends React.Component
{threadState.thread.length} - +
); } @@ -521,7 +484,8 @@ export class UnwrappedEventTile extends React.Component private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - return null; case "messageShared": return ( - @@ -1041,34 +1006,20 @@ export class UnwrappedEventTile extends React.Component // Local echos have a send "status". const scrollToken = eventTileRenderState.root.scrollToken; - let avatar: JSX.Element | null = null; - let sender: JSX.Element | null = null; - const { avatarSize } = eventTileSnapshot.sender.profileState; - - if (this.props.mxEvent.sender && avatarSize !== null) { - avatar = ( -
- -
- ); - } - - const senderProfileMode = eventTileSnapshot.sender.profileMode; - if (senderProfileMode === "clickable") { - sender = ; - } else if (senderProfileMode === "tooltip") { - sender = ; - } else if (senderProfileMode === "default") { - sender = ; - } + const avatar = ( + + ); + const sender = ( + + ); const actionBar = eventTileSnapshot.actionBar.show ? ( - ) : null; const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( ) : ( @@ -1101,7 +1053,8 @@ export class UnwrappedEventTile extends React.Component ); const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( ) : ( @@ -1116,7 +1069,8 @@ export class UnwrappedEventTile extends React.Component let reactionsRow: JSX.Element | undefined; if (hasReactionsRow) { reactionsRow = ( - const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock(); const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock(); - const receiptState = this.receiptState; - let msgOption: JSX.Element | undefined; - if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { - msgOption = ; - } else if (this.props.showReadReceipts) { - msgOption = ( - - ); - } + const msgOption = ( + + ); const replyChainState = getEventTileReplyChainState({ mxEvent: this.props.mxEvent, @@ -1315,10 +1266,7 @@ export class UnwrappedEventTile extends React.Component {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( { ); }; export default SafeEventTile; - -interface ISentReceiptProps { - messageState: EventStatus | undefined; -} - -function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { - const isSent = !messageState || messageState === "sent"; - const isFailed = messageState === "not_sent"; - - let icon: JSX.Element | undefined; - let label: string | undefined; - if (messageState === "encrypting") { - icon = ; - label = _t("timeline|send_state_encrypting"); - } else if (isSent) { - icon = ; - label = _t("timeline|send_state_sent"); - } else if (isFailed) { - icon = ; - label = _t("timeline|send_state_failed"); - } else { - icon = ; - label = _t("timeline|send_state_sending"); - } - - return ( -
-
- -
- {icon} -
-
-
-
- ); -} - -interface MessageTimestampAdapterProps { - vm: MessageTimestampViewModel; - timestampProps: MessageTimestampViewModelProps; -} - -function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { - useEffect(() => { - vm.setProps(timestampProps); - }, [vm, timestampProps]); - - return ( - <> - {timestampProps.receivedTs ? ( - - ) : undefined} - - - ); -} - -interface ThreadMessagePreviewWrapperProps { - thread: Thread; - showDisplayName?: boolean; -} - -function ThreadMessagePreviewWrapper({ - thread, - showDisplayName = false, -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadMessagePreviewViewModel({ - cli, - thread, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - showDisplayName, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setThread(thread); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - vm.setShowDisplayName(showDisplayName); - }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); - - return ; -} - -interface ThreadSummaryWrapperProps extends Omit, "aria-label" | "onClick"> { - mxEvent: MatrixEvent; - thread: Thread; -} - -function ThreadSummaryWrapper({ - mxEvent, - thread, - className, - ...props -}: Readonly): JSX.Element { - const cli = useMatrixClientContext(); - const { isCard } = useContext(CardContext); - const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( - "narrow", - "room", - "timelineRenderingType", - "lowBandwidth", - ); - const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadSummaryViewModel({ - cli, - mxEvent, - thread, - narrow, - isCard, - room, - timelineRenderingType, - lowBandwidth, - useOnlyCurrentProfiles, - avatarClassName: "mx_BaseAvatar", - }), - ); - - useEffect(() => { - vm.setClient(cli); - vm.setRootEvent(mxEvent); - vm.setThread(thread); - vm.setNarrow(narrow); - vm.setIsCard(isCard); - vm.setRoom(room); - vm.setTimelineRenderingType(timelineRenderingType); - vm.setLowBandwidth(lowBandwidth); - vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); - }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); - - return ; -} - -interface ThreadListActionBarAdapterProps { - vm: ThreadListActionBarViewModel; - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; - className?: string; -} - -function ThreadListActionBarAdapter({ - vm, - onViewInRoomClick, - onCopyLinkClick, - className, -}: Readonly): JSX.Element { - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - -interface ReactionsRowButtonItemProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -function ReactionsRowButtonItem(props: Readonly): JSX.Element { - const client = useMatrixClientContext(); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowButtonViewModel({ - client, - mxEvent: props.mxEvent, - content: props.content, - count: props.count, - reactionEvents: props.reactionEvents, - myReactionEvent: props.myReactionEvent, - disabled: props.disabled, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }), - ); - - useEffect(() => { - vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); - }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); - - useEffect(() => { - vm.setCount(props.count); - }, [props.count, vm]); - - useEffect(() => { - vm.setMyReactionEvent(props.myReactionEvent); - }, [props.myReactionEvent, vm]); - - useEffect(() => { - vm.setDisabled(props.disabled); - }, [props.disabled, vm]); - - return ; -} - -interface ReactionGroup { - content: string; - events: MatrixEvent[]; -} - -const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => - reactions - ?.getSortedAnnotationsByKey() - ?.map(([content, events]) => ({ - content, - events: [...events], - })) - .filter(({ events }) => events.length > 0) ?? []; - -const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { - if (!reactions || !userId) { - return null; - } - - const myReactions = reactions.getAnnotationsBySender()?.[userId]; - if (!myReactions) { - return null; - } - - return [...myReactions.values()]; -}; - -interface ReactionsRowWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; -} - -function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element | null { - const roomContext = useContext(RoomContext); - const userId = roomContext.room?.client.getUserId() ?? undefined; - const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); - const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); - const [menuDisplayed, setMenuDisplayed] = useState(false); - const [menuAnchorRect, setMenuAnchorRect] = useState(null); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); - - const openReactionMenu = useCallback((event: React.MouseEvent): void => { - setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); - setMenuDisplayed(true); - }, []); - - const closeReactionMenu = useCallback((): void => { - setMenuDisplayed(false); - }, []); - - const updateReactionsState = useCallback((): void => { - const nextReactionGroups = getReactionGroups(reactions); - setReactionGroups(nextReactionGroups); - setMyReactions(getMyReactions(reactions, userId)); - vm.setReactionGroupCount(nextReactionGroups.length); - }, [reactions, userId, vm]); - - useEffect(() => { - vm.setActionable(isContentActionable(mxEvent)); - }, [mxEvent, vm]); - - useEffect(() => { - vm.setCanReact(roomContext.canReact); - if (!roomContext.canReact && menuDisplayed) { - setMenuDisplayed(false); - } - }, [roomContext.canReact, menuDisplayed, vm]); - - useEffect(() => { - vm.setAddReactionHandlers({ - onAddReactionClick: openReactionMenu, - onAddReactionContextMenu: openReactionMenu, - }); - }, [openReactionMenu, vm]); - - useEffect(() => { - vm.setAddReactionButtonActive(menuDisplayed); - }, [menuDisplayed, vm]); - - useEffect(() => { - updateReactionsState(); - }, [updateReactionsState]); - - useEffect(() => { - if (!reactions) return; - - reactions.on(RelationsEvent.Add, updateReactionsState); - reactions.on(RelationsEvent.Remove, updateReactionsState); - reactions.on(RelationsEvent.Redaction, updateReactionsState); - - return () => { - reactions.off(RelationsEvent.Add, updateReactionsState); - reactions.off(RelationsEvent.Remove, updateReactionsState); - reactions.off(RelationsEvent.Redaction, updateReactionsState); - }; - }, [reactions, updateReactionsState]); - - useEffect(() => { - const onDecrypted = (): void => { - vm.setActionable(isContentActionable(mxEvent)); - }; - - if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { - mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); - } - - return () => { - mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); - }; - }, [mxEvent, vm]); - - const snapshot = useViewModel(vm); - const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); - const items = useMemo((): JSX.Element[] | undefined => { - const mappedItems = reactionGroups.map(({ content, events }) => { - // Deduplicate reaction events by sender per Matrix spec. - const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); - const myReactionEvent = myReactions?.find((reactionEvent) => { - if (reactionEvent.isRedacted()) { - return false; - } - return reactionEvent.getRelation()?.key === content; - }); - - return ( - - ); - }); - - if (!mappedItems.length) { - return undefined; - } - - return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; - }, [ - reactionGroups, - myReactions, - mxEvent, - customReactionImagesEnabled, - roomContext.canReact, - roomContext.canSelfRedact, - snapshot.showAllButtonVisible, - ]); - - if (!snapshot.isVisible || !items?.length) { - return null; - } - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { - contextMenu = ( - - - - ); - } - - return ( - <> - - {items} - - {contextMenu} - - ); -} - -interface ActionBarWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; - permalinkCreator?: RoomPermalinkCreator; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - onFocusChange?: (focused: boolean) => void; - isQuoteExpanded?: boolean; - toggleThreadExpanded: () => void; - getRelationsForEvent?: GetRelationsForEvent; -} - -function ActionBarWrapper({ - mxEvent, - reactions, - permalinkCreator, - getTile, - getReplyChain, - onFocusChange, - isQuoteExpanded, - toggleThreadExpanded, - getRelationsForEvent, -}: Readonly): JSX.Element { - const roomContext = useContext(RoomContext); - const { isCard } = useContext(CardContext); - const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); - const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); - const isSearch = Boolean(roomContext.search); - const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { - setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { - setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const vm = useCreateAutoDisposedViewModel( - () => - new EventTileActionBarViewModel({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - getRelationsForEvent, - }), - ); - - useEffect(() => { - vm.setProps({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - }); - }, [ - vm, - mxEvent, - roomContext.timelineRenderingType, - roomContext.canSendMessages, - roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - handleOptionsClick, - handleReactionsClick, - toggleThreadExpanded, - ]); - - useEffect(() => { - onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); - }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); - - useEffect(() => { - setOptionsMenuAnchorRect(null); - setReactionsMenuAnchorRect(null); - }, [mxEvent]); - - const closeOptionsMenu = useCallback((): void => { - setOptionsMenuAnchorRect(null); - }, []); - - const closeReactionsMenu = useCallback((): void => { - setReactionsMenuAnchorRect(null); - }, []); - - const tile = getTile(); - const replyChain = getReplyChain(); - const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; - const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; - - return ( - <> - - {optionsMenuAnchorRect ? ( - - ) : null} - {reactionsMenuAnchorRect ? ( - - - - ) : null} - - ); -} - -interface E2eMessageSharedIconWrapperProps { - /** - * The ID of the room containing the event whose keys were shared. - */ - roomId: string; - /** - * The ID of the user who shared the keys. - */ - keyForwardingUserId: string; -} - -function E2eMessageSharedIconWrapper({ - roomId, - keyForwardingUserId, -}: Readonly): JSX.Element { - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new E2eMessageSharedIconViewModel({ - client, - roomId, - keyForwardingUserId, - }), - ); - - useEffect(() => { - vm.setRoomId(roomId); - }, [roomId, vm]); - - useEffect(() => { - vm.setKeyForwardingUserId(keyForwardingUserId); - }, [keyForwardingUserId, vm]); - - return ( - - ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx new file mode 100644 index 00000000000..ef4ca843b91 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx @@ -0,0 +1,188 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useCallback, useContext, useEffect, useState, type JSX } from "react"; +import { type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix"; +import { ActionBarView } from "@element-hq/web-shared-components"; + +import type ReplyChain from "../../elements/ReplyChain"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import MessageContextMenu from "../../context_menus/MessageContextMenu"; +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import RoomContext from "../../../../contexts/RoomContext"; +import { CardContext } from "../../right_panel/context"; +import { type RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { type GetRelationsForEvent } from "../../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; + +/** + * Operations exposed by the event tile for action bar interactions. + */ +interface ActionBarEventTileOps { + /** Returns whether the widget is currently hidden. */ + isWidgetHidden(): boolean; + /** Unhides the widget if it was previously hidden. */ + unhideWidget(): void; +} + +/** + * Event tile handle used by the action bar to query tile-level operations. + */ +interface ActionBarEventTile { + /** Returns the available tile operations, if any. */ + getEventTileOps?(): ActionBarEventTileOps; +} + +/** + * Props for the {@link ActionBarAdapter} component. + */ +interface ActionBarAdapterProps { + /** View model backing the event tile action bar. */ + eventTileViewModel: EventTileViewModel; + /** Matrix event rendered by this tile. */ + mxEvent: MatrixEvent; + /** Reaction relation state for the event, if available. */ + reactions?: Relations | null; + /** Creates permalinks for the room, when link actions are shown. */ + permalinkCreator?: RoomPermalinkCreator; + /** Returns the current tile instance for event tile operations. */ + getTile: () => ActionBarEventTile | null; + /** Returns the current reply chain for the tile, if any. */ + getReplyChain: () => ReplyChain | null; + /** Notifies the parent when the action bar gains or loses focus. */ + onFocusChange?: (focused: boolean) => void; + /** Indicates whether the event quote is currently expanded. */ + isQuoteExpanded?: boolean; + /** Toggles the thread expansion state for this tile. */ + toggleThreadExpanded: () => void; + /** Looks up relation data for the current event. */ + getRelationsForEvent?: GetRelationsForEvent; +} + +/** + * Renders the event tile action bar and its context menus. + */ +export function ActionBarAdapter({ + eventTileViewModel, + mxEvent, + reactions, + permalinkCreator, + getTile, + getReplyChain, + onFocusChange, + isQuoteExpanded, + toggleThreadExpanded, + getRelationsForEvent, +}: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const { isCard } = useContext(CardContext); + const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); + const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); + const isSearch = Boolean(roomContext.search); + const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { + setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { + setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const vm = eventTileViewModel.getActionBarViewModel({ + mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + onToggleThreadExpanded: toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + getRelationsForEvent, + }); + + useEffect(() => { + // This child VM owns Matrix and settings listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseActionBarViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setProps({ + mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + getRelationsForEvent, + onToggleThreadExpanded: toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + }); + }, [ + vm, + mxEvent, + roomContext.timelineRenderingType, + roomContext.canSendMessages, + roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded, + getRelationsForEvent, + handleOptionsClick, + handleReactionsClick, + toggleThreadExpanded, + ]); + + useEffect(() => { + onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); + }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); + + useEffect(() => { + setOptionsMenuAnchorRect(null); + setReactionsMenuAnchorRect(null); + }, [mxEvent]); + + const closeOptionsMenu = useCallback((): void => { + setOptionsMenuAnchorRect(null); + }, []); + + const closeReactionsMenu = useCallback((): void => { + setReactionsMenuAnchorRect(null); + }, []); + + const tile = getTile(); + const replyChain = getReplyChain(); + const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; + const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; + + return ( + <> + + {optionsMenuAnchorRect ? ( + + ) : null} + {reactionsMenuAnchorRect ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx new file mode 100644 index 00000000000..b470cf9ce3c --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx @@ -0,0 +1,67 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useEffect, type JSX } from "react"; +import { E2eMessageSharedIconView } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link E2eMessageSharedIconAdapter} component. + */ +interface E2eMessageSharedIconAdapterProps { + /** View model backing the shared-key indicator. */ + eventTileViewModel: EventTileViewModel; + /** The ID of the room containing the event whose keys were shared. */ + roomId: string; + /** The ID of the user who shared the keys. */ + keyForwardingUserId: string; +} + +/** + * Renders the end-to-end encryption key-sharing indicator. + */ +export function E2eMessageSharedIconAdapter({ + eventTileViewModel, + roomId, + keyForwardingUserId, +}: Readonly): JSX.Element { + const client = useMatrixClientContext(); + const vm = eventTileViewModel.getE2eMessageSharedIconViewModel({ + client, + roomId, + keyForwardingUserId, + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseE2eMessageSharedIconViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(client); + }, [client, vm]); + + useEffect(() => { + vm.setRoomId(roomId); + }, [roomId, vm]); + + useEffect(() => { + vm.setKeyForwardingUserId(keyForwardingUserId); + }, [keyForwardingUserId, vm]); + + return ( + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx index ade790e2db5..131c88d6c56 100644 --- a/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx +++ b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx @@ -8,11 +8,19 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components"; +/** + * Props for the {@link E2eStandardPadlockIcon} component. + */ interface E2eStandardPadlockIconProps { + /** Icon variant to render. */ icon: E2ePadlockIcon; + /** Accessible title for the icon. */ title: string; } +/** + * Renders the standard end-to-end encryption padlock icon. + */ export function E2eStandardPadlockIcon({ icon, title }: Readonly): JSX.Element { return ( ): JSX.Element { + const vm = + kind === "linked" + ? eventTileViewModel.getLinkedMessageTimestampViewModel(timestampProps) + : eventTileViewModel.getMessageTimestampViewModel(timestampProps); + + useEffect(() => { + vm.setProps(timestampProps); + }, [vm, timestampProps]); + + return ( + <> + {timestampProps.receivedTs ? ( + + ) : undefined} + + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx new file mode 100644 index 00000000000..f832a5604ae --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx @@ -0,0 +1,289 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useCallback, useContext, useEffect, useMemo, useState, type JSX } from "react"; +import { MatrixEventEvent, type MatrixEvent, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; +import { uniqBy } from "lodash"; +import { + ReactionsRowButtonView, + ReactionsRowView, + useCreateAutoDisposedViewModel, + useViewModel, +} from "@element-hq/web-shared-components"; + +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import RoomContext from "../../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { isContentActionable } from "../../../../utils/EventUtils"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { ReactionsRowButtonViewModel } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; +import { MAX_ITEMS_WHEN_LIMITED } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; + +/** + * Props for the {@link ReactionsRowButtonAdapter} component. + */ +interface ReactionsRowButtonAdapterProps { + /** Matrix event whose reaction button is being rendered. */ + mxEvent: MatrixEvent; + /** Reaction emoji or custom content key for this button. */ + content: string; + /** Total number of reactions in this group. */ + count: number; + /** Reaction events belonging to this group. */ + reactionEvents: MatrixEvent[]; + /** The current user's reaction event, if present. */ + myReactionEvent?: MatrixEvent; + /** Disables interaction when true. */ + disabled?: boolean; + /** Enables rendering custom reaction images. */ + customReactionImagesEnabled?: boolean; +} + +/** + * Renders a single reaction button within the event tile reaction row. + */ +function ReactionsRowButtonAdapter(props: Readonly): JSX.Element { + const client = useMatrixClientContext(); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowButtonViewModel({ + client, + mxEvent: props.mxEvent, + content: props.content, + count: props.count, + reactionEvents: props.reactionEvents, + myReactionEvent: props.myReactionEvent, + disabled: props.disabled, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }), + ); + + useEffect(() => { + vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); + }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); + + useEffect(() => { + vm.setCount(props.count); + }, [props.count, vm]); + + useEffect(() => { + vm.setMyReactionEvent(props.myReactionEvent); + }, [props.myReactionEvent, vm]); + + useEffect(() => { + vm.setDisabled(props.disabled); + }, [props.disabled, vm]); + + return ; +} + +interface ReactionGroup { + content: string; + events: MatrixEvent[]; +} + +const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => + reactions + ?.getSortedAnnotationsByKey() + ?.map(([content, events]) => ({ + content, + events: [...events], + })) + .filter(({ events }) => events.length > 0) ?? []; + +const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { + if (!reactions || !userId) { + return null; + } + + const myReactions = reactions.getAnnotationsBySender()?.[userId]; + if (!myReactions) { + return null; + } + + return [...myReactions.values()]; +}; + +/** + * Props for the {@link ReactionsRowAdapter} component. + */ +interface ReactionsRowAdapterProps { + /** View model backing the event tile reaction row. */ + eventTileViewModel: EventTileViewModel; + /** Matrix event whose reactions are being rendered. */ + mxEvent: MatrixEvent; + /** Current reaction relations for the event, if available. */ + reactions?: Relations | null; +} + +/** + * Renders the reaction row and reaction picker for an event tile. + */ +export function ReactionsRowAdapter({ + eventTileViewModel, + mxEvent, + reactions, +}: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + + const vm = eventTileViewModel.getReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }); + + useEffect(() => { + // This child VM is owned by EventTileViewModel, but scoped to this rendered adapter surface. + return () => eventTileViewModel.releaseReactionsRowViewModel(); + }, [eventTileViewModel]); + + const openReactionMenu = useCallback((event: React.MouseEvent): void => { + setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); + setMenuDisplayed(true); + }, []); + + const closeReactionMenu = useCallback((): void => { + setMenuDisplayed(false); + }, []); + + const updateReactionsState = useCallback((): void => { + const nextReactionGroups = getReactionGroups(reactions); + setReactionGroups(nextReactionGroups); + setMyReactions(getMyReactions(reactions, userId)); + vm.setReactionGroupCount(nextReactionGroups.length); + }, [reactions, userId, vm]); + + useEffect(() => { + vm.setActionable(isContentActionable(mxEvent)); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setCanReact(roomContext.canReact); + if (!roomContext.canReact && menuDisplayed) { + setMenuDisplayed(false); + } + }, [roomContext.canReact, menuDisplayed, vm]); + + useEffect(() => { + vm.setAddReactionHandlers({ + onAddReactionClick: openReactionMenu, + onAddReactionContextMenu: openReactionMenu, + }); + }, [openReactionMenu, vm]); + + useEffect(() => { + vm.setAddReactionButtonActive(menuDisplayed); + }, [menuDisplayed, vm]); + + useEffect(() => { + updateReactionsState(); + }, [updateReactionsState]); + + useEffect(() => { + if (!reactions) return; + + reactions.on(RelationsEvent.Add, updateReactionsState); + reactions.on(RelationsEvent.Remove, updateReactionsState); + reactions.on(RelationsEvent.Redaction, updateReactionsState); + + return () => { + reactions.off(RelationsEvent.Add, updateReactionsState); + reactions.off(RelationsEvent.Remove, updateReactionsState); + reactions.off(RelationsEvent.Redaction, updateReactionsState); + }; + }, [reactions, updateReactionsState]); + + useEffect(() => { + const onDecrypted = (): void => { + vm.setActionable(isContentActionable(mxEvent)); + }; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); + } + + return () => { + mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mxEvent, vm]); + + const snapshot = useViewModel(vm); + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); + const items = useMemo((): JSX.Element[] | undefined => { + const mappedItems = reactionGroups.map(({ content, events }) => { + // Deduplicate reaction events by sender per Matrix spec. + const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); + const myReactionEvent = myReactions?.find((reactionEvent) => { + if (reactionEvent.isRedacted()) { + return false; + } + return reactionEvent.getRelation()?.key === content; + }); + + return ( + + ); + }); + + if (!mappedItems.length) { + return undefined; + } + + return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; + }, [ + reactionGroups, + myReactions, + mxEvent, + customReactionImagesEnabled, + roomContext.canReact, + roomContext.canSelfRedact, + snapshot.showAllButtonVisible, + ]); + + if (!snapshot.isVisible || !items?.length) { + return null; + } + + let contextMenu: JSX.Element | undefined; + if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { + contextMenu = ( + + + + ); + } + + return ( + <> + + {items} + + {contextMenu} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx new file mode 100644 index 00000000000..58f8ccbecb5 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx @@ -0,0 +1,121 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { type JSX } from "react"; +import { type EventStatus, type RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; +import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../NotificationBadge"; +import { ReadReceiptGroup } from "../ReadReceiptGroup"; +import { type IReadReceiptPosition } from "../ReadReceiptMarker"; +import { type EventTileReceiptState } from "../../../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; + +/** + * A single read receipt entry displayed in the event tile receipt row. + */ +interface ReadReceiptProps { + /** The user ID associated with the receipt. */ + userId: string; + /** The room member metadata for the user, if available. */ + roomMember: RoomMember | null; + /** Timestamp of the receipt in milliseconds since the Unix epoch. */ + ts: number; +} + +/** + * Props for the {@link ReceiptAdapter} component. + */ +interface ReceiptAdapterProps { + /** Current receipt state for the event tile. */ + receiptState: EventTileReceiptState; + /** Send status used when rendering the sent-state badge. */ + eventSendStatus?: EventStatus; + /** Whether read receipts should be shown. */ + showReadReceipts?: boolean; + /** Read receipts to render when the group is visible. */ + readReceipts?: ReadReceiptProps[]; + /** Read receipt positions keyed by user ID. */ + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; + /** Checks whether the receipt row is unmounting. */ + checkUnmounting?: () => boolean; + /** Suppresses receipt animation when true. */ + suppressAnimation: boolean; + /** Whether timestamps should use a 12-hour clock. */ + isTwelveHour?: boolean; +} + +/** + * Renders the send-state or read-receipt indicator for an event tile. + */ +export function ReceiptAdapter({ + receiptState, + eventSendStatus, + showReadReceipts, + readReceipts, + readReceiptMap, + checkUnmounting, + suppressAnimation, + isTwelveHour, +}: Readonly): JSX.Element | null { + if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { + return ; + } + + if (!showReadReceipts) { + return null; + } + + return ( + + ); +} + +interface SentReceiptProps { + messageState: EventStatus | undefined; +} + +function SentReceipt({ messageState }: Readonly): JSX.Element { + const isSent = !messageState || messageState === "sent"; + const isFailed = messageState === "not_sent"; + + let icon: JSX.Element | undefined; + let label: string | undefined; + if (messageState === "encrypting") { + icon = ; + label = _t("timeline|send_state_encrypting"); + } else if (isSent) { + icon = ; + label = _t("timeline|send_state_sent"); + } else if (isFailed) { + icon = ; + label = _t("timeline|send_state_failed"); + } else { + icon = ; + label = _t("timeline|send_state_sending"); + } + + return ( +
+
+ +
+ {icon} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx new file mode 100644 index 00000000000..eb60ac2952b --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx @@ -0,0 +1,80 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { type JSX } from "react"; +import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MemberAvatar from "../../avatars/MemberAvatar"; +import SenderProfile from "../../messages/SenderProfile"; +import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link EventTileAvatarAdapter} component. + */ +interface EventTileAvatarAdapterProps { + /** Matrix event whose sender avatar is being rendered. */ + mxEvent: MatrixEvent; + /** Snapshot of the sender identity state for this tile. */ + senderSnapshot: EventTileSenderSnapshot; +} + +/** + * Renders the sender avatar for an event tile. + */ +export function EventTileAvatarAdapter({ + mxEvent, + senderSnapshot, +}: Readonly): JSX.Element | null { + const { avatarSize } = senderSnapshot.profileState; + + if (!mxEvent.sender || avatarSize === null) { + return null; + } + + return ( +
+ +
+ ); +} + +/** + * Props for the {@link EventTileSenderAdapter} component. + */ +interface EventTileSenderAdapterProps { + /** Matrix event whose sender identity is being rendered. */ + mxEvent: MatrixEvent; + /** Snapshot of the sender identity state for this tile. */ + senderSnapshot: EventTileSenderSnapshot; + /** Invoked when the sender profile is clicked. */ + onSenderProfileClick: () => void; +} + +/** + * Renders the sender identity display for an event tile. + */ +export function EventTileSenderAdapter({ + mxEvent, + senderSnapshot, + onSenderProfileClick, +}: Readonly): JSX.Element | null { + switch (senderSnapshot.profileMode) { + case "clickable": + return ; + case "tooltip": + return ; + case "default": + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx new file mode 100644 index 00000000000..9d7c23225ef --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useEffect, type JSX } from "react"; +import { ActionBarView } from "@element-hq/web-shared-components"; + +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadListActionBarAdapter} component. + */ +interface ThreadListActionBarAdapterProps { + /** View model backing the thread list action bar. */ + eventTileViewModel: EventTileViewModel; + /** Opens the event in the room timeline. */ + onViewInRoomClick: (anchor: HTMLElement | null) => void; + /** Copies the event permalink to the clipboard. */ + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; + /** Optional class name applied to the action bar. */ + className?: string; +} + +/** + * Renders the action bar used by the thread list view. + */ +export function ThreadListActionBarAdapter({ + eventTileViewModel, + onViewInRoomClick, + onCopyLinkClick, + className, +}: Readonly): JSX.Element { + const vm = eventTileViewModel.getThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx new file mode 100644 index 00000000000..33de8868322 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useEffect, type JSX } from "react"; +import { type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadMessagePreviewView } from "@element-hq/web-shared-components"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadMessagePreviewAdapter} component. + */ +interface ThreadMessagePreviewAdapterProps { + /** View model backing the thread preview. */ + eventTileViewModel: EventTileViewModel; + /** Thread whose message preview is rendered. */ + thread: Thread; + /** Whether the sender display name should be shown. */ + showDisplayName?: boolean; +} + +/** + * Renders the preview shown for a thread message. + */ +export function ThreadMessagePreviewAdapter({ + eventTileViewModel, + thread, + showDisplayName = false, +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = eventTileViewModel.getThreadMessagePreviewViewModel({ + cli, + thread, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + showDisplayName, + avatarClassName: "mx_BaseAvatar", + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadMessagePreviewViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(cli); + vm.setThread(thread); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + vm.setShowDisplayName(showDisplayName); + }, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx new file mode 100644 index 00000000000..4f12d4c44f1 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -0,0 +1,81 @@ +/* +Copyright 2026 Element 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. +*/ + +import React, { useContext, useEffect, type JSX } from "react"; +import classNames from "classnames"; +import { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix"; +import { ThreadSummaryView } from "@element-hq/web-shared-components"; + +import { CardContext } from "../../right_panel/context"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +/** + * Props for the {@link ThreadSummaryAdapter} component. + */ +interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { + /** View model backing the thread summary tile. */ + eventTileViewModel: EventTileViewModel; + /** Root event for the thread summary. */ + mxEvent: MatrixEvent; + /** Thread represented by the summary tile. */ + thread: Thread; +} + +/** + * Renders the thread summary tile for an event. + */ +export function ThreadSummaryAdapter({ + eventTileViewModel, + mxEvent, + thread, + className, + ...props +}: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const { isCard } = useContext(CardContext); + const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext( + "narrow", + "room", + "timelineRenderingType", + "lowBandwidth", + ); + const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); + const vm = eventTileViewModel.getThreadSummaryViewModel({ + cli, + mxEvent, + thread, + narrow, + isCard, + room, + timelineRenderingType, + lowBandwidth, + useOnlyCurrentProfiles, + avatarClassName: "mx_BaseAvatar", + }); + + useEffect(() => { + // This child VM owns Matrix listeners, so release it when the view using it leaves the tree. + return () => eventTileViewModel.releaseThreadSummaryViewModel(); + }, [eventTileViewModel]); + + useEffect(() => { + vm.setClient(cli); + vm.setRootEvent(mxEvent); + vm.setThread(thread); + vm.setNarrow(narrow); + vm.setIsCard(isCard); + vm.setRoom(room); + vm.setTimelineRenderingType(timelineRenderingType); + vm.setLowBandwidth(lowBandwidth); + vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles); + }, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]); + + return ; +} diff --git a/apps/web/src/utils/exportUtils/Exporter.ts b/apps/web/src/utils/exportUtils/Exporter.ts index 185507e8cc0..1c535db5d48 100644 --- a/apps/web/src/utils/exportUtils/Exporter.ts +++ b/apps/web/src/utils/exportUtils/Exporter.ts @@ -56,7 +56,7 @@ export default abstract class Exporter { } public get destinationFileName(): string { - return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip"; + return this.makeFileNameNoExtension() + ".zip"; } protected onBeforeUnload(this: void, e: BeforeUnloadEvent): string { @@ -77,12 +77,12 @@ export default abstract class Exporter { this.files.push(file); } - protected makeFileNameNoExtension(brand = "matrix"): string { + protected makeFileNameNoExtension(): string { // First try to use the real name of the room, then a translated copy of a generic name, // then finally hardcoded default to guarantee we'll have a name. const safeRoomName = sanitizeFilename(this.room.name ?? _t("common|unnamed_room")).trim() || "Unnamed Room"; const safeDate = formatFullDateNoDayISO(new Date()).replace(/:/g, "-"); // ISO format automatically removes a lot of stuff for us - const safeBrand = sanitizeFilename(brand); + const safeBrand = sanitizeFilename(SdkConfig.get().brand); return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`; } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts index c992b4f54be..70e5464c0d4 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts @@ -33,6 +33,14 @@ export class E2eMessageSharedIconViewModel this.disposables.track(() => this.teardownRoomStateListener()); } + public setClient(client: MatrixClient): void { + if (this.props.client === client) return; + + this.props = { ...this.props, client }; + this.setupRoomStateListener(); + this.updateSnapshotFromProps(); + } + public setRoomId(roomId: string): void { if (this.props.roomId === roomId) return; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index ac6800bee97..f707582036b 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -34,10 +34,22 @@ import { import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import { type Layout } from "../../../../settings/enums/Layout"; import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel"; +import { + ThreadMessagePreviewViewModel, + type ThreadMessagePreviewViewModelProps, + ThreadSummaryViewModel, + type ThreadSummaryViewModelProps, +} from "./ThreadSummaryViewModel.tsx"; +import { + E2eMessageSharedIconViewModel, + type E2eMessageSharedIconViewModelProps, +} from "./E2eMessageSharedIconViewModel"; import { ThreadListActionBarViewModel, type ThreadListActionBarViewModelProps, } from "../../ThreadListActionBarViewModel"; +import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel"; +import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reactions/ReactionsRowViewModel"; /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { @@ -285,7 +297,12 @@ export interface EventTileRenderState { export class EventTileViewModel extends BaseViewModel { private messageTimestampViewModel?: MessageTimestampViewModel; private linkedMessageTimestampViewModel?: MessageTimestampViewModel; + private threadMessagePreviewViewModel?: ThreadMessagePreviewViewModel; + private threadSummaryViewModel?: ThreadSummaryViewModel; private threadListActionBarViewModel?: ThreadListActionBarViewModel; + private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel; + private actionBarViewModel?: EventTileActionBarViewModel; + private reactionsRowViewModel?: ReactionsRowViewModel; public constructor(props: EventTileViewModelProps) { const initialRenderState = EventTileViewModel.createRenderState(props); @@ -302,7 +319,12 @@ export class EventTileViewModel extends BaseViewModel { jest.setSystemTime(REPEATABLE_DATE); }); - it("should have a Matrix-branded destination file name", () => { + it("should have an Element-branded destination file name", () => { const roomName = "My / Test / Room: Welcome"; const client = createTestClient(); const stubOptions: IExportOptions = { diff --git a/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts b/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts index 006e86b3bf6..355e73c30a1 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts +++ b/apps/web/test/unit-tests/utils/exportUtils/PlainTextExport-test.ts @@ -34,7 +34,7 @@ describe("PlainTextExport", () => { stubRoom = mkStubRoom("!myroom:example.org", roomName, client); }); - it("should have a Matrix-branded destination file name", () => { + it("should have an Element-branded destination file name", () => { const exporter = new PlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {}); expect(exporter.destinationFileName).toMatchSnapshot(); diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap index b8d2c3ce181..70410b2b248 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/JSONExport-test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`JSONExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`; +exports[`JSONExport should have an Element-branded destination file name 1`] = `"Element - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`; diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap index de3cff0d551..6c9f5ab29db 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/PlainTextExport-test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`PlainTextExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`; +exports[`PlainTextExport should have an Element-branded destination file name 1`] = `"Element - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`; diff --git a/diff-cover.toml b/diff-cover.toml new file mode 100644 index 00000000000..fcb7783d75e --- /dev/null +++ b/diff-cover.toml @@ -0,0 +1,2 @@ +[tool.diff_cover] +compare_branch = "origin/develop" diff --git a/knip.ts b/knip.ts index 9e890e74947..81d6bd8771e 100644 --- a/knip.ts +++ b/knip.ts @@ -1,6 +1,6 @@ import { KnipConfig } from "knip"; -// Specify this as knip loads config files which may conditionally add reporters, e.g. `@casualbot/jest-sonar-reporter' +// Specify this as knip loads config files which may conditionally load plugins process.env.GITHUB_ACTIONS = "1"; export default { @@ -67,6 +67,10 @@ export default { "events", ], ignoreExportsUsedInFile: true, + ignoreBinaries: [ + // Optional for coverage:diff development script + "diff-cover", + ], compilers: { pcss: (text: string) => [...text.matchAll(/@import\s+(?:url\()?["']([^"']+)["']\)?[^;]*;/g)] diff --git a/package.json b/package.json index 291ccb287de..968c5d0d156 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "postinstall": "node scripts/pnpm-link.ts && pnpm run -r sane-postinstall", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs" + "docs:preview": "vitepress preview docs", + "coverage:diff": "diff-cover --config-file diff-cover.toml apps/*/coverage/lcov.info packages/*/coverage/lcov.info" }, "devDependencies": { "@action-validator/cli": "^0.6.0", diff --git a/packages/module-api/package.json b/packages/module-api/package.json index 344ce5bfff1..c43ac4fb3a2 100644 --- a/packages/module-api/package.json +++ b/packages/module-api/package.json @@ -27,7 +27,9 @@ "scripts": { "prepack": "nx build", "lint:types": "nx lint:types", - "test:unit": "vitest" + "test:unit": "vitest", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info" }, "devDependencies": { "@element-hq/vite-common": "workspace:*", @@ -44,8 +46,7 @@ "typescript": "^6.0.0", "unplugin-dts": "1.0.1", "vite": "catalog:", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "peerDependencies": { "@matrix-org/react-sdk-module-api": "*", diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 38e2fec1fd8..a67845feae0 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -51,6 +51,8 @@ "test:unit": "nx test:unit", "test:storybook": "nx test:storybook", "test:storybook:update": "nx test:storybook:update", + "coverage": "pnpm test:unit --coverage", + "coverage:diff": "diff-cover --config-file ../../diff-cover.toml coverage/lcov.info", "build": "nx build", "prepack": "pnpm run build", "storybook": "storybook dev -p 6007", @@ -132,8 +134,7 @@ "unplugin-dts": "1.0.1", "vite": "catalog:", "vite-plugin-node-polyfills": "^0.28.0", - "vitest": "catalog:", - "vitest-sonar-reporter": "catalog:" + "vitest": "catalog:" }, "engines": { "node": ">=20.0.0" diff --git a/packages/vite-common/package.json b/packages/vite-common/package.json index 4a335ac0e72..c36fd6683af 100644 --- a/packages/vite-common/package.json +++ b/packages/vite-common/package.json @@ -13,7 +13,6 @@ "typescript": "catalog:" }, "peerDependencies": { - "@vitest/coverage-v8": "catalog:", - "vitest-sonar-reporter": "catalog:" + "@vitest/coverage-v8": "catalog:" } } diff --git a/packages/vite-common/vite.config.ts b/packages/vite-common/vite.config.ts index 93cb0b90f13..fc7d7152675 100644 --- a/packages/vite-common/vite.config.ts +++ b/packages/vite-common/vite.config.ts @@ -31,13 +31,6 @@ const slowTestReporter: Reporter = { // if we're running under GHA, enable the GHA & Sonar reporters if (env["GITHUB_ACTIONS"] !== undefined) { reporters.push(["github-actions", { silent: false }]); - reporters.push([ - "vitest-sonar-reporter", - { - outputFile: "coverage/sonar-report.xml", - onWritePath: (path): string => `${process.cwd()}/${path}`, - }, - ]); // if we're running against the develop branch, also enable the slow test reporter if (env["GITHUB_REF"] == "refs/heads/develop") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfcca418b81..c099c4f326b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,9 +241,6 @@ catalogs: vitest: specifier: 4.1.7 version: 4.1.7 - vitest-sonar-reporter: - specifier: 3.0.0 - version: 3.0.0 overrides: pretty-format@30>react-is: 19.2.6 @@ -496,9 +493,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) apps/web: dependencies: @@ -761,9 +755,6 @@ importers: '@babel/preset-typescript': specifier: ^7.12.7 version: 7.28.5(@babel/core@7.29.0) - '@casualbot/jest-sonar-reporter': - specifier: 2.7.1 - version: 2.7.1 '@element-hq/element-call-embedded': specifier: 0.19.4 version: 0.19.4 @@ -1146,9 +1137,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) packages/playwright-common: dependencies: @@ -1412,9 +1400,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) packages/vite-common: dependencies: @@ -1424,9 +1409,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest-sonar-reporter: - specifier: 'catalog:' - version: 3.0.0(vitest@4.1.7) devDependencies: typescript: specifier: 'catalog:' @@ -2254,10 +2236,6 @@ packages: '@cacheable/utils@2.4.1': resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} - '@casualbot/jest-sonar-reporter@2.7.1': - resolution: {integrity: sha512-C/lzwYEXnHueUufmvSLRKSz8ANVCrFv7HjtGsLoPDUYwugn6CYB1E7gEiCUlWaDeq+DUib2xMUX0nMYpwxUYzA==} - engines: {node: '>=14.0.0'} - '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -10544,11 +10522,6 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -13447,12 +13420,6 @@ packages: '@vitest/browser-webdriverio': optional: true - vitest-sonar-reporter@3.0.0: - resolution: {integrity: sha512-QRT5m9Z/3Kt0WVDVcFHm5LC9Ba89yDP4twlX2QUAJ9PdqfJBkLAh/kXj7m6U3i0k6GxnIEiwCc295bmAYokmZg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - vitest: '>=3' - vitest@4.1.7: resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -13763,9 +13730,6 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xml@1.0.1: - resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -14916,11 +14880,6 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@casualbot/jest-sonar-reporter@2.7.1': - dependencies: - mkdirp: 1.0.4 - xml: 1.0.1 - '@chevrotain/types@11.1.2': {} '@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -24270,8 +24229,6 @@ snapshots: dependencies: minimist: 1.2.8 - mkdirp@1.0.4: {} - mkdirp@3.0.1: {} mlly@1.8.2: @@ -27835,10 +27792,6 @@ snapshots: - babel-plugin-macros - typescript - vitest-sonar-reporter@3.0.0(vitest@4.1.7): - dependencies: - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)) - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.7)(@vitest/coverage-v8@4.1.7)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.13(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.7 @@ -28275,8 +28228,6 @@ snapshots: xml-name-validator@5.0.0: {} - xml@1.0.1: {} - xmlbuilder@15.1.1: {} xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00ca79adfca..6f025839364 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ linkWorkspacePackages: true cleanupUnusedCatalogs: true ignorePatchFailures: false +ignoreWorkspaceRootCheck: true packages: - "apps/*" - "packages/*" @@ -26,7 +27,6 @@ catalog: # vite vite: 8.0.13 vitest: 4.1.7 - vitest-sonar-reporter: 3.0.0 "@vitest/coverage-v8": 4.1.7 packageExtensions: @@ -59,6 +59,9 @@ allowBuilds: protobufjs: false # Builds optional crypto binding, which we do not need ssh2: false + # The following are to make wrangler-action happy - https://github.com/cloudflare/wrangler-action/issues/436 + sharp: true + workerd: true patchedDependencies: "@vector-im/matrix-wysiwyg": patches/@vector-im__matrix-wysiwyg.patch diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 7a25cf4571c..00000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,68 +0,0 @@ -sonar.projectKey=element-web -sonar.organization=element-hq - -# Encoding of the source code. Default is default system encoding -#sonar.sourceEncoding=UTF-8 - -sonar.sources=. -sonar.tests=apps/web/test,apps/web/playwright,apps/desktop,packages -sonar.test.inclusions=\ - apps/web/test/*,\ - apps/web/playwright/*,\ - apps/desktop/playwright/*,\ - apps/desktop/src/**/*.test.ts,\ - packages/*/src/**/*.test.*,\ - packages/*/src/test/**/* -sonar.exclusions=\ - apps/web/__mocks__,\ - docs,\ - apps/web/element.io,\ - apps/web/nginx,\ - apps/web/src/vector/modernizr.cjs,\ - **/*.webm,\ - **/*.ogg,\ - **/*.mp3,\ - **/*.woff2,\ - **/*.ttf,\ - **/*.webp,\ - **/*.jpg,\ - **/*.apng,\ - **/*.ico,\ - **/*.png,\ - **/*.gif - -# Exclude translations and tests from the duplication check -sonar.cpd.exclusions=\ - **/src/i18n/strings/*.json,\ - apps/**/test/**,\ - apps/**/playwright/**,\ - packages/**/test/**,\ - packages/shared-components/src/**/*.stories.tsx,\ - packages/playwright-common/** - -sonar.javascript.lcov.reportPaths=apps/web/coverage/lcov.info,packages/shared-components/coverage/lcov.info -sonar.coverage.exclusions=\ - apps/web/test/**/*,\ - apps/web/playwright/**/*,\ - apps/web/res/**/*,\ - apps/web/scripts/**/*,\ - apps/web/__mocks__/**/*,\ - apps/web/I18nWebpackPlugin.ts,\ - apps/web/recorder-worklet-loader.cjs,\ - apps/web/src/components/views/dialogs/devtools/**/*,\ - apps/web/src/utils/SessionLock.ts,\ - apps/web/src/**/*.d.ts,\ - apps/web/src/vector/mobile_guide/**/*,\ - apps/desktop/electron-builder.ts,\ - apps/desktop/scripts/**/*,\ - apps/desktop/playwright/**/*,\ - apps/desktop/hak/**/*,\ - packages/shared-components/src/test/**/*,\ - packages/shared-components/src/**/*.stories.tsx,\ - packages/shared-components/scripts/**/*,\ - packages/playwright-common/**/*,\ - scripts/**/*,\ - docs/**/*,\ - **/*.config.*,\ - knip.ts -sonar.testExecutionReportPaths=apps/web/coverage/jest-sonar-report.xml