diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index b7c02c3f2e9..ee731b3ac38 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -132,9 +132,3 @@ jobs: - name: Run linter run: "yarn run lint:knip" - - - name: Install Deps - run: "scripts/layered.sh" - - - name: Dead Code Analysis - run: "yarn run analyse:unused-exports" diff --git a/knip.ts b/knip.ts index 247f9d97894..17ad531332a 100644 --- a/knip.ts +++ b/knip.ts @@ -10,13 +10,13 @@ export default { "playwright/**", "test/**", "res/decoder-ring/**", - ], - project: ["**/*.{js,ts,jsx,tsx}"], - ignore: [ - "docs/**", "res/jitsi_external_api.min.js", + "docs/**", // Used by jest "__mocks__/maplibre-gl.js", + ], + project: ["**/*.{js,ts,jsx,tsx}"], + ignore: [ // Keep for now "src/hooks/useLocalStorageState.ts", "src/components/views/elements/InfoTooltip.tsx", @@ -37,13 +37,8 @@ export default { // False positive "sw.js", // Used by webpack - "buffer", "process", "util", - // Used by workflows - "ts-prune", - // Required due to bug in bloom-filters https://github.com/Callidon/bloom-filters/issues/75 - "@types/seedrandom", ], ignoreBinaries: [ // Used in scripts & workflows diff --git a/package.json b/package.json index 7cda6fa5ace..2b65fa0834f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "test:playwright:screenshots:build": "docker build playwright -t element-web-playwright", "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome", "coverage": "yarn test --coverage", - "analyse:unused-exports": "ts-node ./scripts/analyse_unused_exports.ts", "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" }, @@ -287,7 +286,6 @@ "terser-webpack-plugin": "^5.3.9", "testcontainers": "^10.16.0", "ts-node": "^10.9.1", - "ts-prune": "^0.10.3", "typescript": "5.7.2", "util": "^0.12.5", "web-streams-polyfill": "^4.0.0", diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index 3a35e72f136..33e1e21d7e8 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -105,6 +105,9 @@ test.use({ ...credentials, displayName, }); + + // Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts + await homeserver.restart(); }, }); diff --git a/playwright/services.ts b/playwright/services.ts index 26ab25c5c6b..8f2ebb51d1e 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -19,6 +19,7 @@ import { OAuthServer } from "./plugins/oauth_server"; export interface TestFixtures { mailhogClient: mailhog.API; + // Set in legacyOAuthHomeserver only oAuthServer?: OAuthServer; } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 2f6c460afed..cdb7d391151 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -110,7 +110,6 @@ dis.register((payload) => { let sessionLockStolen = false; // this is exposed solely for unit tests. -// ts-prune-ignore-next export function setSessionLockNotStolen(): void { sessionLockStolen = false; } diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index c052a83f661..79d89aed0d0 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -9,8 +9,9 @@ Please see LICENSE files in the repository root for full details. */ import React, { ComponentProps, ReactNode } from "react"; -import { MatrixEvent, RoomMember, EventType } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, MatrixEventEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; +import { throttle } from "lodash"; import { _t } from "../../../languageHandler"; import { formatList } from "../../../utils/FormattingUtils"; @@ -22,6 +23,8 @@ import { Layout } from "../../../settings/enums/Layout"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import AccessibleButton from "./AccessibleButton"; import RoomContext from "../../../contexts/RoomContext"; +import { arrayHasDiff } from "../../../utils/arrays.ts"; +import { objectHasDiff } from "../../../utils/objects.ts"; const onPinnedMessagesClick = (): void => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); @@ -69,9 +72,14 @@ enum TransitionType { const SEP = ","; -export default class EventListSummary extends React.Component< - IProps & Required> -> { +type Props = IProps & Required>; + +interface State { + userEvents: Record; + summaryMembers: RoomMember[]; +} + +export default class EventListSummary extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; @@ -82,15 +90,122 @@ export default class EventListSummary extends React.Component< layout: Layout.Group, }; - public shouldComponentUpdate(nextProps: IProps): boolean { + public constructor(props: Props) { + super(props); + + this.state = this.generateState(); + } + + private generateState(): State { + const eventsToRender = this.props.events; + + // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, + // so this works perfectly for us to match event order whilst storing the latest Avatar Member + const latestUserAvatarMember = new Map(); + + // Object mapping user IDs to an array of IUserEvents + const userEvents: Record = {}; + eventsToRender.forEach((e, index) => { + const type = e.getType(); + + let userKey = e.getSender()!; + if (e.isState() && type === EventType.RoomThirdPartyInvite) { + userKey = e.getContent().display_name; + } else if (e.isState() && type === EventType.RoomMember) { + userKey = e.getStateKey()!; + } else if (e.isRedacted() && e.getUnsigned()?.redacted_because) { + userKey = e.getUnsigned().redacted_because!.sender; + } + + // Initialise a user's events + if (!userEvents[userKey]) { + userEvents[userKey] = []; + } + + let displayName = userKey; + if (e.isRedacted()) { + const sender = this.context?.room?.getMember(userKey); + if (sender) { + displayName = sender.name; + latestUserAvatarMember.set(userKey, sender); + } + } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { + displayName = e.target.name; + latestUserAvatarMember.set(userKey, e.target); + } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { + displayName = e.sender.name; + latestUserAvatarMember.set(userKey, e.sender); + } + + userEvents[userKey].push({ + mxEvent: e, + displayName, + index: index, + }); + }); + + return { + userEvents, + summaryMembers: Array.from(latestUserAvatarMember.values()), + }; + } + + public componentDidMount(): void { + this.bindSentinelListeners(this.props.events); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.events !== this.props.events) { + this.unbindSentinelListeners(prevProps.events); + this.bindSentinelListeners(this.props.events); + this.setState(this.generateState()); + } + } + + public componentWillUnmount(): void { + this.unbindSentinelListeners(this.props.events); + } + + private bindSentinelListeners(events: MatrixEvent[]): void { + for (const event of events) { + event.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + } + + private unbindSentinelListeners(events: MatrixEvent[]): void { + for (const event of events) { + event.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated); + } + } + + private onEventSentinelUpdated = throttle( + (): void => { + console.log("@@ SENTINEL UPDATED"); + this.setState(this.generateState()); + }, + 500, + { leading: true, trailing: true }, + ); + + public shouldComponentUpdate(nextProps: Props, nextState: State): boolean { // Update if // - The number of summarised events has changed // - or if the summary is about to toggle to become collapsed // - or if there are fewEvents, meaning the child eventTiles are shown as-is + // - or if the summary members have changed + // - or if the one of IUserEvents within userEvents have changed return ( nextProps.events.length !== this.props.events.length || nextProps.events.length < this.props.threshold || - nextProps.layout !== this.props.layout + nextProps.layout !== this.props.layout || + arrayHasDiff(nextState.summaryMembers, this.state.summaryMembers) || + arrayHasDiff(Object.values(nextState.userEvents), Object.values(this.state.userEvents)) || + Object.keys(nextState.userEvents).length !== Object.keys(this.state.userEvents).length || + Object.keys(nextState.userEvents).some((userId) => + nextState.userEvents[userId].some((event, i) => + objectHasDiff(event, this.state.userEvents[userId]?.[i] ?? {}), + ), + ) ); } @@ -492,54 +607,7 @@ export default class EventListSummary extends React.Component< } public render(): React.ReactNode { - const eventsToRender = this.props.events; - - // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, - // so this works perfectly for us to match event order whilst storing the latest Avatar Member - const latestUserAvatarMember = new Map(); - - // Object mapping user IDs to an array of IUserEvents - const userEvents: Record = {}; - eventsToRender.forEach((e, index) => { - const type = e.getType(); - - let userKey = e.getSender()!; - if (e.isState() && type === EventType.RoomThirdPartyInvite) { - userKey = e.getContent().display_name; - } else if (e.isState() && type === EventType.RoomMember) { - userKey = e.getStateKey()!; - } else if (e.isRedacted() && e.getUnsigned()?.redacted_because) { - userKey = e.getUnsigned().redacted_because!.sender; - } - - // Initialise a user's events - if (!userEvents[userKey]) { - userEvents[userKey] = []; - } - - let displayName = userKey; - if (e.isRedacted()) { - const sender = this.context?.room?.getMember(userKey); - if (sender) { - displayName = sender.name; - latestUserAvatarMember.set(userKey, sender); - } - } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { - displayName = e.target.name; - latestUserAvatarMember.set(userKey, e.target); - } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { - displayName = e.sender.name; - latestUserAvatarMember.set(userKey, e.sender); - } - - userEvents[userKey].push({ - mxEvent: e, - displayName, - index: index, - }); - }); - - const aggregate = this.getAggregate(userEvents); + const aggregate = this.getAggregate(this.state.userEvents); // Sort types by order of lowest event index within sequence const orderedTransitionSequences = Object.keys(aggregate.names).sort( @@ -554,7 +622,7 @@ export default class EventListSummary extends React.Component< onToggle={this.props.onToggle} startExpanded={this.props.startExpanded} children={this.props.children} - summaryMembers={[...latestUserAvatarMember.values()]} + summaryMembers={this.state.summaryMembers} layout={this.props.layout} summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} /> diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx index 8549fc5cab5..41a8fdf1154 100644 --- a/src/components/views/messages/TextualEvent.tsx +++ b/src/components/views/messages/TextualEvent.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; import RoomContext from "../../../contexts/RoomContext"; import * as TextForEvent from "../../../TextForEvent"; @@ -21,6 +21,19 @@ export default class TextualEvent extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; + 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, diff --git a/src/hooks/usePinnedEvents.ts b/src/hooks/usePinnedEvents.ts index bdda4a77013..b065edc83f7 100644 --- a/src/hooks/usePinnedEvents.ts +++ b/src/hooks/usePinnedEvents.ts @@ -154,7 +154,7 @@ async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixCl const senderUserId = event.getSender(); if (senderUserId && PinningUtils.isUnpinnable(event)) { // Inject sender information - event.sender = room.getMember(senderUserId); + event.setMetadata(room.currentState, false); // Also inject any edits we've found if (edit) event.makeReplaced(edit); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 25548ef78f0..b54e0949f21 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -49,7 +49,6 @@ export function arrayFastResample(input: number[], points: number): number[] { * @param {number} points The number of samples to end up with. * @returns {number[]} The resampled array. */ -// ts-prune-ignore-next export function arraySmoothingResample(input: number[], points: number): number[] { if (input.length === points) return input; // short-circuit a complicated call @@ -92,7 +91,6 @@ export function arraySmoothingResample(input: number[], points: number): number[ * @param {number} newMax The maximum value to scale to. * @returns {number[]} The rescaled array. */ -// ts-prune-ignore-next export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { const min: number = Math.min(...input); const max: number = Math.max(...input); diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 3852443d1e6..0c549038b47 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -110,12 +110,7 @@ export default abstract class Exporter { } protected setEventMetadata(event: MatrixEvent): MatrixEvent { - const roomState = this.room.currentState; - const sender = event.getSender(); - event.sender = (!!sender && roomState?.getSentinelMember(sender)) || null; - if (event.getType() === "m.room.member") { - event.target = roomState?.getSentinelMember(event.getStateKey()!) ?? null; - } + event.setMetadata(this.room.currentState, false); return event; } diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap index 1ffef77d9c0..4a4ac6d6d63 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap @@ -145,6 +145,7 @@ exports[` should show two pinned messages 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -222,6 +223,7 @@ exports[` should show two pinned messages 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -364,6 +366,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a @@ -441,6 +444,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` data-type="round" role="presentation" style="--cpd-avatar-size: 32px;" + title="@alice:example.org" > a