Refactor className and children to component properties instead och view model snapshots in shared components (#32711)

* Refactor className? to component property in EncryptionEventView

* Refactor extraClassNames to default react className as component property for DecryptionFailureBodyView

* Refactor className to component property for MessageTimestampView

* Refactor className and children to component properties for ReactionsRowButton

* Refactor className to component property for DisambiguatedProfile

* Refactor className to a component property in DateSeparatorView

* Fix for lint errors and EncryptionEventView unsupported icon color

* EncryptionEventView fix for icon color css specificity/order
This commit is contained in:
rbondesson 2026-03-05 09:36:45 +01:00 committed by GitHub
parent 1963f268aa
commit 83d732d60e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 232 additions and 255 deletions

View File

@ -9,29 +9,7 @@ Please see LICENSE files in the repository root for full details.
.mx_EventTileBubble.mx_cryptoEvent {
margin: var(--EventTileBubble_margin-block) auto;
&.mx_cryptoEvent_icon svg {
svg[data-state="supported"] {
color: $header-panel-text-primary-color;
}
.mx_cryptoEvent_state,
.mx_cryptoEvent_buttons {
grid-column: 3;
grid-row: 1 / 3;
}
.mx_cryptoEvent_buttons {
align-items: center;
display: flex;
gap: 5px;
}
.mx_cryptoEvent_state {
width: 130px;
padding: 10px 20px;
margin: auto 0;
text-align: center;
color: $tertiary-content;
overflow-wrap: break-word;
font-size: $font-12px;
}
}

View File

@ -66,7 +66,7 @@ const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
*/
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />;
return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
}
/**

View File

@ -416,7 +416,8 @@ function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatus
function EncryptionEventWrappedView({ mxEvent }: { mxEvent: MatrixEvent }): ReactElement | null {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli }));
return <EncryptionEventView vm={vm} />;
return <EncryptionEventView vm={vm} className="mx_EventTileBubble mx_cryptoEvent" />;
}
export class RoomView extends React.Component<IRoomProps, IRoomState> {

View File

@ -45,7 +45,7 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
<ScrollPanel className="mx_RoomView_messagePanel">
<EventTileBubble
icon={<LockSolidIcon />}
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
className="mx_EventTileBubble mx_cryptoEvent"
title={_t("room|waiting_for_join_title", { brand })}
subtitle={_t("room|waiting_for_join_subtitle", { brand })}
/>

View File

@ -29,7 +29,7 @@ import { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparat
*/
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />;
return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
}
export class CreationGrouper extends BaseGrouper {

View File

@ -31,7 +31,7 @@ const groupedStateEvents = [
*/
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />;
return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
}
// Wrap consecutive grouped events in a ListSummary

View File

@ -143,7 +143,10 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
const separatorTs = e.getTs();
nodes.push(
<li key={`${separatorRoomId}-${separatorTs}~`}>
<DateSeparatorView vm={this.getDateSeparatorVm(separatorRoomId, separatorTs)} />
<DateSeparatorView
vm={this.getDateSeparatorVm(separatorRoomId, separatorTs)}
className="mx_TimelineSeparator"
/>
</li>,
);
}

View File

@ -657,5 +657,5 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
vm.setHref(props.href);
vm.setHandlers({ onClick: props.onClick });
}, [vm, props]);
return <MessageTimestampView vm={vm} />;
return <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
}

View File

@ -75,7 +75,7 @@ const MKeyVerificationRequest: React.FC<Props> = ({ mxEvent, timestamp }) => {
return (
<EventTileBubble
icon={<LockSolidIcon />}
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
className="mx_EventTileBubble mx_cryptoEvent"
title={title}
subtitle={subtitle}
>

View File

@ -347,5 +347,5 @@ function DecryptionFailureBodyWrapper({ mxEvent, ref }: IBodyProps): JSX.Element
useEffect(() => {
vm.setVerificationState(verificationState);
}, [verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} ref={ref} />;
return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}

View File

@ -37,7 +37,6 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
colored: true,
emphasizeDisplayName: true,
withTooltip,
className: "mx_DisambiguatedProfile",
}),
);
@ -45,7 +44,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
disambiguatedProfileVM.setMember(sender ?? "", member);
}, [disambiguatedProfileVM, member, sender]);
return mxEvent.getContent().msgtype !== MsgType.Emote ? (
<DisambiguatedProfileView vm={disambiguatedProfileVM} />
<DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
) : (
<></>
);

View File

@ -1617,7 +1617,7 @@ function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JS
vm.setVerificationState(verificationState);
}, [verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} />;
return <DecryptionFailureBodyView vm={vm} className="mx_DecryptionFailureBody mx_EventTile_content" />;
}
/**
@ -1643,7 +1643,7 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
{props.receivedTs ? (
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
) : undefined}
<MessageTimestampView vm={vm} />
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
</>
);
}
@ -1859,10 +1859,6 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
snapshot.showAllButtonVisible,
]);
useEffect(() => {
vm.setChildren(items);
}, [items, vm]);
if (!snapshot.isVisible || !items?.length) {
return null;
}
@ -1878,7 +1874,9 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
return (
<>
<ReactionsRowView vm={vm} />
<ReactionsRowView vm={vm} className="mx_ReactionsRow">
{items}
</ReactionsRowView>
{contextMenu}
</>
);

View File

@ -53,13 +53,12 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
fallbackName: name,
member,
withTooltip: true,
className: "mx_DisambiguatedProfile",
}),
);
useEffect(() => {
disambiguatedProfileVM.setMember(name, member);
}, [disambiguatedProfileVM, member, name]);
const nameJSX = <DisambiguatedProfileView vm={disambiguatedProfileVM} />;
const nameJSX = <DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />;
const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined;

View File

@ -40,7 +40,7 @@ interface IProps {
*/
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />;
return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
}
export default class SearchResultTile extends React.Component<IProps> {

View File

@ -88,7 +88,8 @@ export const TextualEventFactory: Factory = (ref, props) => {
function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli }));
return <EncryptionEventView vm={vm} ref={ref} />;
return <EncryptionEventView vm={vm} ref={ref} className="mx_EventTileBubble mx_cryptoEvent" />;
}
const EncryptionEventFactory: Factory = (ref, props) => {
return <EncryptionEventWrappedView ref={ref} {...props} />;

View File

@ -266,7 +266,7 @@ export default class HTMLExporter extends Exporter {
try {
const dateSeparator = (
<li key={ts}>
<DateSeparatorView vm={dateSeparatorViewModel} />
<DateSeparatorView vm={dateSeparatorViewModel} className="mx_TimelineSeparator" />
</li>
);
return this.renderToStaticMarkupWithProviders(dateSeparator);

View File

@ -78,13 +78,11 @@ export class EncryptionEventViewModel
props: EncryptionEventViewModelProps,
isEncrypted: boolean,
): EncryptionEventViewSnapshotInterface {
// Keep legacy class names for compatibility with existing timeline layout and styling.
const newSnapshot: EncryptionEventViewSnapshotInterface = {
state: EncryptionEventState.CHANGED,
encryptedStateEvents: undefined,
userName: undefined,
timestamp: props.timestamp,
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
};
const content = props.mxEvent.getContent<RoomEncryptionEventContent>();
@ -113,8 +111,6 @@ export class EncryptionEventViewModel
newSnapshot.state = EncryptionEventState.DISABLE_ATTEMPT;
} else {
newSnapshot.state = EncryptionEventState.UNSUPPORTED;
// Unsupported branch matches legacy EncryptionEvent class usage (no icon class).
newSnapshot.className = "mx_EventTileBubble mx_cryptoEvent";
}
return newSnapshot;

View File

@ -22,10 +22,6 @@ export interface DecryptionFailureBodyViewModelProps {
* The local device verification state.
*/
verificationState?: boolean;
/**
* Extra CSS classes to apply to the component
*/
extraClassNames?: string[];
}
/**
@ -62,30 +58,21 @@ export class DecryptionFailureBodyViewModel
/**
* @param decryptionFailureCode - The decryption failure code for the event.
* @param verificationState - The local device verification state.
* @param extraClassNames - Extra CSS classes to apply to the component.
*/
private static readonly computeSnapshot = (
decryptionFailureCode: DecryptionFailureCode | null,
verificationState?: boolean,
extraClassNames?: string[],
): DecryptionFailureBodyViewSnapshotInterface => {
// Keep mx_DecryptionFailureBody and mx_EventTile_content to support the compatibility with existing timeline and the all the layout
const defaultClassNames = ["mx_DecryptionFailureBody", "mx_EventTile_content"];
return {
decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode),
isLocalDeviceVerified: verificationState,
extraClassNames: extraClassNames ? defaultClassNames.concat(extraClassNames) : defaultClassNames,
};
};
public constructor(props: DecryptionFailureBodyViewModelProps) {
super(
props,
DecryptionFailureBodyViewModel.computeSnapshot(
props.decryptionFailureCode,
props.verificationState,
props.extraClassNames,
),
DecryptionFailureBodyViewModel.computeSnapshot(props.decryptionFailureCode, props.verificationState),
);
}

View File

@ -91,14 +91,12 @@ export class MessageTimestampViewModel
receivedAt = formatFullDate(receivedDate, props.showTwelveHour);
}
// Keep mx_MessageTimestamp for compatibility with the existing timeline and layout.
return {
ts: timestamp,
tsSentAt: sentAt,
tsReceivedAt: receivedAt,
inhibitTooltip: props.inhibitTooltip,
href: props.href,
className: "mx_MessageTimestamp",
};
};

View File

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type MouseEvent, type MouseEventHandler, type ReactNode } from "react";
import { type MouseEvent, type MouseEventHandler } from "react";
import {
BaseViewModel,
type ReactionsRowViewSnapshot,
@ -41,10 +41,6 @@ export interface ReactionsRowViewModelProps {
* Optional callback invoked on add-reaction button context-menu.
*/
onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
/**
* Reaction row children (typically reaction buttons).
*/
children?: ReactNode;
}
interface InternalProps extends ReactionsRowViewModelProps {
@ -59,18 +55,16 @@ export class ReactionsRowViewModel
props: InternalProps,
): Pick<
ReactionsRowViewSnapshot,
"isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive" | "children"
"isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive"
> => ({
isVisible: props.isActionable && props.reactionGroupCount > 0,
showAllButtonVisible: props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !props.showAll,
showAddReactionButton: props.canReact,
addReactionButtonActive: !!props.addReactionButtonActive,
children: props.children,
});
private static readonly computeSnapshot = (props: InternalProps): ReactionsRowViewSnapshot => ({
ariaLabel: _t("common|reactions"),
className: "mx_ReactionsRow",
showAllButtonLabel: _t("action|show_all"),
addReactionButtonLabel: _t("timeline|reactions|add_reaction_prompt"),
addReactionButtonVisible: false,
@ -136,15 +130,6 @@ export class ReactionsRowViewModel
this.snapshot.merge({ addReactionButtonActive });
}
public setChildren(children?: ReactNode): void {
this.props = {
...this.props,
children,
};
this.snapshot.merge({ children });
}
public setAddReactionHandlers({
onAddReactionClick,
onAddReactionContextMenu,

View File

@ -66,10 +66,6 @@ export interface DisambiguatedProfileViewModelProps {
* Optional click handler for the profile.
*/
onClick?: DisambiguatedProfileViewActions["onClick"];
/**
* Optional CSS class name to apply to the profile.
*/
className?: string;
}
/**
@ -83,7 +79,7 @@ export class DisambiguatedProfileViewModel
private static readonly computeSnapshot = (
props: DisambiguatedProfileViewModelProps,
): DisambiguatedProfileViewSnapshot => {
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, className } = props;
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props;
// Compute display name
const displayName = member?.rawDisplayName || fallbackName;
@ -123,7 +119,6 @@ export class DisambiguatedProfileViewModel
return {
displayName,
colorClass,
className,
displayIdentifier,
title,
emphasizeDisplayName,

View File

@ -68,7 +68,6 @@ export class DateSeparatorViewModel
super(props, {
label: DateSeparatorViewModel.computeLabel(props, relativeDatesEnabled),
className: "mx_TimelineSeparator",
});
this.relativeDatesEnabled = relativeDatesEnabled;
@ -101,7 +100,6 @@ export class DateSeparatorViewModel
const label = DateSeparatorViewModel.computeLabel(this.props, this.relativeDatesEnabled);
return {
label,
className: "mx_TimelineSeparator",
jumpToEnabled: this.jumpToDateEnabled && !this.props.forExport,
jumpFromDate: formatDateForInput(new Date(this.props.ts)),
};

View File

@ -717,9 +717,10 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
style="height: 400px;"
>
<div
class="_container_sq5fu_8 mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
class="_container_sq5fu_8 mx_EventTileBubble mx_cryptoEvent _content_m88ar_8"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@ -64,7 +64,6 @@ describe("EncryptionEventViewModel", () => {
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
expect(vm.getSnapshot()).toMatchObject({
state: EncryptionEventState.ENABLED,
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
encryptedStateEvents: false,
});
});
@ -125,7 +124,6 @@ describe("EncryptionEventViewModel", () => {
const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED));
expect(vm.getSnapshot().className).toBe("mx_EventTileBubble mx_cryptoEvent");
});
it("sets ENABLED_DM with partner display name", async () => {

View File

@ -22,19 +22,6 @@ describe("DecryptionFailureBodyViewModel", () => {
});
});
it("should return the snapshot with extra class names", () => {
const vm = new DecryptionFailureBodyViewModel({
decryptionFailureCode: null,
verificationState: true,
extraClassNames: ["custom-class"],
});
expect(vm.getSnapshot()).toMatchObject({
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true,
extraClassNames: ["mx_DecryptionFailureBody", "mx_EventTile_content", "custom-class"],
});
});
it.each([
{
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,

View File

@ -42,14 +42,13 @@ describe("MessageTimestampViewModel", () => {
});
});
it("should return the snapshot with extra class names", () => {
it("should return the snapshot without presentation class names", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
expect(vm.getSnapshot()).toMatchObject({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
className: "mx_MessageTimestamp",
});
});

View File

@ -29,7 +29,6 @@ describe("ReactionsRowViewModel", () => {
expect(snapshot.showAllButtonVisible).toBe(true);
expect(snapshot.showAddReactionButton).toBe(true);
expect(snapshot.addReactionButtonActive).toBe(false);
expect(snapshot.className).toContain("mx_ReactionsRow");
});
it("hides show-all after onShowAllClick", () => {

View File

@ -31,7 +31,6 @@ describe("DisambiguatedProfileViewModel", () => {
expect(vm.getSnapshot()).toEqual({
displayName: "Alice",
colorClass: "mx_Username_color3",
className: undefined,
displayIdentifier: "@alice:example.org",
title: "Alice (@alice:example.org)",
emphasizeDisplayName: true,
@ -47,23 +46,12 @@ describe("DisambiguatedProfileViewModel", () => {
expect(vm.getSnapshot()).toMatchObject({
displayName: "Fallback",
colorClass: undefined,
className: undefined,
displayIdentifier: undefined,
title: undefined,
emphasizeDisplayName: undefined,
});
});
it("should pass through className prop", () => {
const vm = new DisambiguatedProfileViewModel({
member,
fallbackName: "Fallback",
className: "mx_DisambiguatedProfile",
});
expect(vm.getSnapshot().className).toBe("mx_DisambiguatedProfile");
});
it("should delegate onClick without emitting a snapshot update", () => {
const onClick = jest.fn();
const vm = new DisambiguatedProfileViewModel({

View File

@ -109,7 +109,6 @@ describe("DateSeparatorViewModel", () => {
const vm = createViewModel();
expect(vm.getSnapshot().label).toBe("today");
expect(vm.getSnapshot().className).toBe("mx_TimelineSeparator");
});
it("uses full date when exporting", () => {

View File

@ -5,6 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
.error {
.content svg[data-state="supported"] {
color: inherit;
}
.content svg[data-state="unsupported"] {
color: var(--cpd-color-icon-critical-primary);
}

View File

@ -14,10 +14,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type EncryptionEventProps = EncryptionEventViewSnapshot;
const EncryptionEventViewWrapperImpl = ({ ...rest }: EncryptionEventProps): JSX.Element => {
const EncryptionEventViewWrapperImpl = ({
className,
...rest
}: EncryptionEventProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {});
return <EncryptionEventView vm={vm} />;
return <EncryptionEventView vm={vm} className={className} />;
};
const EncryptionEventViewWrapper = withViewDocs(EncryptionEventViewWrapperImpl, EncryptionEventView);

View File

@ -36,9 +36,8 @@ describe("EncryptionEventView", () => {
state,
encryptedStateEvents,
userName,
className,
});
render(<EncryptionEventView vm={vm} />);
render(<EncryptionEventView vm={vm} className={className} />);
};
it("renders Default story", () => {

View File

@ -7,6 +7,7 @@
import React, { type JSX } from "react";
import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./EncryptionEventView.module.css";
@ -35,8 +36,6 @@ export type EncryptionEventViewSnapshot = {
encryptedStateEvents?: boolean;
/** Display name for DM partner, used by ENABLED_DM subtitle text. */
userName?: string;
/** Optional CSS classes passed through to EventTileBubble. */
className?: string;
/** Optional timestamp element rendered in the EventTileBubble footer slot. */
timestamp?: JSX.Element;
};
@ -52,16 +51,35 @@ export interface EncryptionEventViewProps {
*/
vm: ViewModel<EncryptionEventViewSnapshot>;
/**
* Ref forwarded to the root DOM element.
* Optional CSS classes passed through to EventTileBubble.
*/
className?: string;
/**
* Optional Ref forwarded to the root DOM element.
*/
ref?: React.RefObject<HTMLDivElement>;
}
export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewProps>): JSX.Element {
/**
* Renders a timeline bubble describing an encryption-related room event.
*
* Text and icon are selected from `snapshot.state` with optional context:
* - `encryptedStateEvents` switches to state-event specific wording.
* - `userName` is used for DM-specific subtitle text.
* - `timestamp` renders in the bubble footer slot.
*
* Use `className` for host-level styling, following the default React pattern.
*
* @example
* ```tsx
* <EncryptionEventView vm={encryptionEventVm} className="mx_EncryptionEvent" />
* ```
*/
export function EncryptionEventView({ vm, ref, className }: Readonly<EncryptionEventViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { state, encryptedStateEvents, userName, className, timestamp } = useViewModel(vm);
const { state, encryptedStateEvents, userName, timestamp } = useViewModel(vm);
let icon = <LockSolidIcon />;
let icon = <LockSolidIcon data-state="supported" />;
let title = encryptedStateEvents ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled");
let subtitle = "";
@ -86,14 +104,20 @@ export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewPro
break;
case EncryptionEventState.UNSUPPORTED:
default:
icon = <ErrorSolidIcon className={styles.error} />;
icon = <ErrorSolidIcon data-state="unsupported" />;
title = _t("timeline|m.room.encryption|disabled");
subtitle = _t("timeline|m.room.encryption|unsupported");
break;
}
return (
<EventTileBubble icon={icon} className={className} title={title} subtitle={subtitle} ref={ref}>
<EventTileBubble
icon={icon}
className={classNames(className, styles.content)}
title={title}
subtitle={subtitle}
ref={ref}
>
{timestamp}
</EventTileBubble>
);

View File

@ -3,9 +3,10 @@
exports[`EncryptionEventView > renders Default story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -33,9 +34,10 @@ exports[`EncryptionEventView > renders Default story 1`] = `
exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -63,9 +65,10 @@ exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -93,9 +96,10 @@ exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -123,9 +127,10 @@ exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -153,9 +158,10 @@ exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -183,10 +189,10 @@ exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
exports[`EncryptionEventView > renders Unsupported story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
class="error"
data-state="unsupported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -214,9 +220,10 @@ exports[`EncryptionEventView > renders Unsupported story 1`] = `
exports[`EncryptionEventView > renders WithTimestamp story 1`] = `
<div>
<div
class="container"
class="container content"
>
<svg
data-state="supported"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@ -21,7 +21,7 @@ export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/ReactionsRowButtonTooltip";
export * from "./message-body/ReactionsRowButton";
export * from "./message-body/ReactionRow";
export * from "./message-body/ReactionsRow";
export * from "./message-body/TimelineSeparator/";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";

View File

@ -18,10 +18,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type DecryptionFailureBodyProps = DecryptionFailureBodyViewSnapshot;
const DecryptionFailureBodyViewWrapperImpl = ({ ...rest }: DecryptionFailureBodyProps): JSX.Element => {
const DecryptionFailureBodyViewWrapperImpl = ({
className,
...rest
}: DecryptionFailureBodyProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {});
return <DecryptionFailureBodyView vm={vm} />;
return <DecryptionFailureBodyView vm={vm} className={className} />;
};
const DecryptionFailureBodyViewWrapper = withViewDocs(DecryptionFailureBodyViewWrapperImpl, DecryptionFailureBodyView);
@ -40,7 +43,7 @@ const meta = {
args: {
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true,
extraClassNames: ["extra_class"],
className: "extra_class",
},
} satisfies Meta<typeof DecryptionFailureBodyViewWrapper>;
@ -49,24 +52,17 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const HasExtraClassNames: Story = {
args: {
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
extraClassNames: ["extra_class_1", "extra_class_2"],
},
};
export const HasErrorClassName: Story = {
args: {
decryptionFailureReason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
extraClassNames: undefined,
className: undefined,
},
};
export const HasErrorBlockIcon: Story = {
args: {
decryptionFailureReason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
extraClassNames: undefined,
className: undefined,
},
};
@ -74,7 +70,7 @@ export const HasBackupConfiguredVerifiedFalse: Story = {
args: {
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
isLocalDeviceVerified: false,
extraClassNames: undefined,
className: undefined,
},
};
@ -82,6 +78,6 @@ export const HasBackupConfiguredVerifiedTrue: Story = {
args: {
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
isLocalDeviceVerified: true,
extraClassNames: undefined,
className: undefined,
},
};

View File

@ -5,26 +5,23 @@
* Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import { DecryptionFailureBodyView, DecryptionFailureReason } from "./DecryptionFailureBodyView";
import { MockViewModel } from "../../viewmodel";
import * as stories from "./DecryptionFailureBodyView.stories";
const { HasExtraClassNames } = composeStories(stories);
describe("DecryptionFailureBodyView", () => {
function customRender(
decryptionFailureReason: DecryptionFailureReason,
isLocalDeviceVerified: boolean = false,
extraClassNames: string[] | undefined = undefined,
className: string | undefined = undefined,
): ReturnType<typeof render> {
return render(
<DecryptionFailureBodyView
vm={new MockViewModel({ decryptionFailureReason, isLocalDeviceVerified, extraClassNames })}
vm={new MockViewModel({ decryptionFailureReason, isLocalDeviceVerified })}
className={className}
/>,
);
}
@ -38,12 +35,14 @@ describe("DecryptionFailureBodyView", () => {
);
}
it("Should display with extra class names", () => {
// When
const { container } = render(<HasExtraClassNames />);
it("applies custom className to the root element", () => {
const { container } = customRender(
DecryptionFailureReason.UNABLE_TO_DECRYPT,
false,
"extra_class_1 extra_class_2",
);
// Then
expect(container).toMatchSnapshot();
expect(container.firstChild).toHaveClass("extra_class_1", "extra_class_2");
});
it.each([true, false])(`Should display "Unable to decrypt message and device verification is %s"`, (verified) => {

View File

@ -65,14 +65,14 @@ export interface DecryptionFailureBodyViewSnapshot {
* The local device verification state.
*/
isLocalDeviceVerified?: boolean;
/**
* Extra CSS classes to apply to the component
*/
extraClassNames?: string[];
}
/**
* The view model for the component.
*
* Snapshot data is intentionally content-focused (`decryptionFailureReason`
* plus optional `isLocalDeviceVerified`). Container styling is supplied
* via component props.
*/
export type DecryptionFailureBodyViewModel = ViewModel<DecryptionFailureBodyViewSnapshot>;
@ -81,6 +81,10 @@ interface DecryptionFailureBodyViewProps {
* The view model for the component.
*/
vm: DecryptionFailureBodyViewModel;
/**
* Optional CSS class names to apply to the component container.
*/
className?: string;
/**
* React ref to attach to any React components returned
*/
@ -156,17 +160,27 @@ function errorClassName(decryptionFailureReason: DecryptionFailureReason): strin
}
/**
* A placeholder element for messages that could not be decrypted
* Renders a message-body placeholder for events that could not be decrypted.
*
* Message copy and warning styling are derived from snapshot values:
* - `decryptionFailureReason` selects the base text/variant.
* - `isLocalDeviceVerified` influences historical-backup messaging.
*
* Use `className` for host-level container styling, following standard React patterns.
*
* @example
* ```tsx
* <DecryptionFailureBodyView vm={DecryptionFailureBodyViewModel} />
* <DecryptionFailureBodyView vm={decryptionFailureBodyVm} className="mx_DecryptionFailureBody" />
* ```
*/
export function DecryptionFailureBodyView({ vm, ref }: Readonly<DecryptionFailureBodyViewProps>): JSX.Element {
export function DecryptionFailureBodyView({
vm,
ref,
className,
}: Readonly<DecryptionFailureBodyViewProps>): JSX.Element {
const i18nApi = useI18n();
const { decryptionFailureReason, isLocalDeviceVerified, extraClassNames } = useViewModel(vm);
const classes = classNames(styles.content, errorClassName(decryptionFailureReason), extraClassNames);
const { decryptionFailureReason, isLocalDeviceVerified } = useViewModel(vm);
const classes = classNames(styles.content, errorClassName(decryptionFailureReason), className);
return (
<div className={classes} ref={ref}>

View File

@ -40,16 +40,6 @@ exports[`DecryptionFailureBodyView > Should display "Unable to decrypt message a
</div>
`;
exports[`DecryptionFailureBodyView > Should display with extra class names 1`] = `
<div>
<div
class="content extra_class_1 extra_class_2"
>
Unable to decrypt message
</div>
</div>
`;
exports[`DecryptionFailureBodyView > should handle historical messages with no key backup and device verification is false 1`] = `
<div>
<div

View File

@ -18,12 +18,17 @@ import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
type MessageTimestampProps = MessageTimestampViewSnapshot & MessageTimestampViewActions;
const MessageTimestampWrapperImpl = ({ onClick, onContextMenu, ...rest }: MessageTimestampProps): ReactNode => {
const MessageTimestampWrapperImpl = ({
onClick,
onContextMenu,
className,
...rest
}: MessageTimestampProps & { className?: string }): ReactNode => {
const vm = useMockedViewModel(rest, {
onClick,
onContextMenu,
});
return <MessageTimestampView vm={vm} />;
return <MessageTimestampView vm={vm} className={className} />;
};
const MessageTimestampWrapper = withViewDocs(MessageTimestampWrapperImpl, MessageTimestampView);
@ -69,12 +74,6 @@ export const HasInhibitTooltip: Story = {
},
};
export const HasExtraClassNames: Story = {
args: {
className: "extra_class_1 extra_class_2",
},
};
export const HasHref: Story = {
args: {
href: "~",

View File

@ -21,7 +21,7 @@ import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
import { I18nContext } from "../../utils/i18nContext.ts";
import { I18nApi } from "../../index.ts";
const { Default, HasHref, HasExtraClassNames } = composeStories(stories);
const { Default, HasHref } = composeStories(stories);
const renderWithI18n = (ui: React.ReactElement): ReturnType<typeof render> =>
render(ui, {
@ -38,9 +38,16 @@ describe("MessageTimestampView", () => {
expect(container).toMatchSnapshot();
});
it("renders the message timestamp with extra class names", async () => {
const { container } = render(<HasExtraClassNames />);
expect(container).toMatchSnapshot();
it("applies custom className to the timestamp element", async () => {
const vm = new MockViewModel<MessageTimestampViewSnapshot>({
ts: "04:58",
tsSentAt: "Thu, 17 Nov 2022, 4:58:32 pm",
});
renderWithI18n(<MessageTimestampView vm={vm} className="extra_class_1 extra_class_2" />);
const target = screen.getByText("04:58");
expect(target).toHaveClass("extra_class_1", "extra_class_2");
});
it("renders the message timestamp with href", async () => {

View File

@ -32,10 +32,6 @@ export interface MessageTimestampViewSnapshot {
* If set to true then no tooltip will be shown
*/
inhibitTooltip?: boolean;
/**
* Extra class name to apply to the component
*/
className?: string;
/**
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
*/
@ -55,6 +51,9 @@ export interface MessageTimestampViewActions {
/**
* The view model for the message timestamp.
*
* Snapshot data describes timestamp content and rendering behavior, while
* container styling is supplied via component props.
*/
export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
@ -63,6 +62,10 @@ interface MessageTimestampViewProps {
* The view model for the message timestamp.
*/
vm: MessageTimestampViewModel;
/**
* Optional CSS class name to apply to the component.
*/
className?: string;
}
/**
@ -70,17 +73,18 @@ interface MessageTimestampViewProps {
*
* The view model provides the timestamp values and display options. The component
* can render as a link when `href` is set, and can show both sent-at and received-at
* times in the tooltip when `tsReceivedAt` is provided.
* times in the tooltip when `tsReceivedAt` is provided. Use `className` for
* host-level styling.
*
* @example
* ```tsx
* <MessageTimestampView vm={messageTimestampViewModel} />
* <MessageTimestampView vm={messageTimestampViewModel} className="mx_MessageTimestamp" />
* ```
*/
export function MessageTimestampView({ vm }: Readonly<MessageTimestampViewProps>): JSX.Element {
export function MessageTimestampView({ vm, className }: Readonly<MessageTimestampViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, className, href } = useViewModel(vm);
const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, href } = useViewModel(vm);
const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
if (vm.onClick) {

View File

@ -12,18 +12,6 @@ exports[`MessageTimestampView > renders the message timestamp in default state 1
</div>
`;
exports[`MessageTimestampView > renders the message timestamp with extra class names 1`] = `
<div>
<span
aria-live="off"
class="extra_class_1 extra_class_2 content"
tabindex="0"
>
04:58
</span>
</div>
`;
exports[`MessageTimestampView > renders the message timestamp with href 1`] = `
<div>
<a

View File

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { type JSX, type ReactNode } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
@ -60,15 +60,21 @@ const ReactionsRowViewWrapper = ({
onShowAllClick,
onAddReactionClick,
onAddReactionContextMenu,
children,
className,
...snapshotProps
}: WrapperProps): JSX.Element => {
}: WrapperProps & { children?: ReactNode; className?: string }): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onShowAllClick: onShowAllClick ?? fn(),
onAddReactionClick: onAddReactionClick ?? fn(),
onAddReactionContextMenu: onAddReactionContextMenu ?? fn(),
});
return <ReactionsRowView vm={vm} />;
return (
<ReactionsRowView vm={vm} className={className}>
{children}
</ReactionsRowView>
);
};
const meta = {

View File

@ -68,7 +68,6 @@ describe("ReactionsRowView", () => {
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
children: <span>👍</span>,
},
{
onShowAllClick,
@ -77,7 +76,11 @@ describe("ReactionsRowView", () => {
},
) as ReactionsRowViewModel;
render(<ReactionsRowView vm={vm} />);
render(
<ReactionsRowView vm={vm}>
<span>👍</span>
</ReactionsRowView>,
);
await user.click(screen.getByRole("button", { name: "Show all" }));
await user.click(screen.getByRole("button", { name: "Add reaction" }));
@ -87,4 +90,16 @@ describe("ReactionsRowView", () => {
expect(onAddReactionClick).toHaveBeenCalledTimes(1);
expect(onAddReactionContextMenu).toHaveBeenCalledTimes(1);
});
it("applies custom className to the toolbar container", () => {
const vm = new MockViewModel<ReactionsRowViewSnapshot>({
ariaLabel: "Reactions",
isVisible: true,
addReactionButtonLabel: "Add reaction",
}) as ReactionsRowViewModel;
render(<ReactionsRowView vm={vm} className="custom-reactions-row another-class" />);
expect(screen.getByRole("toolbar", { name: "Reactions" })).toHaveClass("custom-reactions-row", "another-class");
});
});

View File

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler, type ReactNode } from "react";
import React, { type JSX, type MouseEventHandler, type PropsWithChildren } from "react";
import classNames from "classnames";
import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
@ -22,14 +22,6 @@ export interface ReactionsRowViewSnapshot {
* Controls whether the row should render at all.
*/
isVisible: boolean;
/**
* Reaction button elements to render in the row.
*/
children?: ReactNode;
/**
* Optional CSS className for the row container.
*/
className?: string;
/**
* Whether to render the "show all" button.
*/
@ -79,14 +71,20 @@ export type ReactionsRowViewModel = ViewModel<ReactionsRowViewSnapshot, Reaction
interface ReactionsRowViewProps {
vm: ReactionsRowViewModel;
/**
* Optional CSS className for the row container.
*/
className?: string;
/**
* Reaction button elements to render in the row.
*/
children?: PropsWithChildren["children"];
}
export function ReactionsRowView({ vm }: Readonly<ReactionsRowViewProps>): JSX.Element {
export function ReactionsRowView({ vm, className, children }: Readonly<ReactionsRowViewProps>): JSX.Element {
const {
ariaLabel,
isVisible,
children,
className,
showAllButtonVisible,
showAllButtonLabel,
showAddReactionButton,

View File

@ -19,9 +19,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type DisambiguatedProfileProps = DisambiguatedProfileViewSnapshot & DisambiguatedProfileViewActions;
const DisambiguatedProfileViewWrapperImpl = ({ onClick, ...rest }: DisambiguatedProfileProps): JSX.Element => {
const DisambiguatedProfileViewWrapperImpl = ({
onClick,
className,
...rest
}: DisambiguatedProfileProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick });
return <DisambiguatedProfileView vm={vm} />;
return <DisambiguatedProfileView vm={vm} className={className} />;
};
const DisambiguatedProfileViewWrapper = withViewDocs(DisambiguatedProfileViewWrapperImpl, DisambiguatedProfileView);

View File

@ -209,4 +209,14 @@ describe("DisambiguatedProfileView", () => {
const displayNameElement = screen.getByText("Emphasized User");
expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName");
});
it("should apply custom className to the profile container", () => {
const vm = new DisambiguatedProfileViewModel({
displayName: "Classed User",
});
render(<DisambiguatedProfileView vm={vm} className="custom-profile another-class" />);
const profileContainer = getProfileContainer("Classed User");
expect(profileContainer).toHaveClass("custom-profile", "another-class");
});
});

View File

@ -24,10 +24,6 @@ export interface DisambiguatedProfileViewSnapshot {
* Undefined if coloring is not enabled.
*/
colorClass?: string;
/**
* The CSS class name.
*/
className?: string;
/**
* The formatted user identifier to display when disambiguation is needed.
* Undefined if disambiguation is not required.
@ -67,6 +63,10 @@ interface DisambiguatedProfileViewProps {
* The view model for the disambiguated profile.
*/
vm: DisambiguatedProfileViewModel;
/**
* Optional CSS class name applied to the profile container.
*/
className?: string;
}
/**
@ -79,8 +79,8 @@ interface DisambiguatedProfileViewProps {
* <DisambiguatedProfileView vm={disambiguatedProfileViewModel} />
* ```
*/
export function DisambiguatedProfileView({ vm }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, className } = useViewModel(vm);
export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName } = useViewModel(vm);
const displayNameClasses = classNames(colorClass, {
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName,

View File

@ -20,10 +20,11 @@ const DateSeparatorViewWrapperImpl = ({
onLastMonthPicked,
onBeginningPicked,
onDatePicked,
className,
...rest
}: DateSeparatorProps): JSX.Element => {
}: DateSeparatorProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked });
return <DateSeparatorView vm={vm} />;
return <DateSeparatorView vm={vm} className={className} />;
};
const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView);

View File

@ -29,10 +29,6 @@ export interface DateSeparatorViewSnapshot {
* Reference date as input format used to prefill the jump-to-date picker value.
*/
jumpFromDate?: string;
/**
* Extra CSS classes to apply to the component.
*/
className?: string;
}
export interface DateSeparatorViewActions {
@ -56,6 +52,10 @@ interface DateSeparatorViewProps {
* The view model for the component.
*/
vm: DateSeparatorViewModel;
/**
* Extra CSS classes to apply to the component.
*/
className?: string;
}
/**
@ -68,8 +68,8 @@ interface DateSeparatorViewProps {
* <DateSeparatorView vm={vm} />
* ```
*/
export function DateSeparatorView({ vm }: Readonly<DateSeparatorViewProps>): JSX.Element {
const { label, className, jumpToEnabled } = useViewModel(vm);
export function DateSeparatorView({ vm, className }: Readonly<DateSeparatorViewProps>): JSX.Element {
const { label, jumpToEnabled } = useViewModel(vm);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isTriggerHovered, setIsTriggerHovered] = useState(false);
const [isTriggerFocused, setIsTriggerFocused] = useState(false);