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 08:36:45 +00:00
committed by GitHub
parent 1963f268aa
commit 83d732d60e
61 changed files with 232 additions and 255 deletions
@@ -9,29 +9,7 @@ Please see LICENSE files in the repository root for full details.
.mx_EventTileBubble.mx_cryptoEvent { .mx_EventTileBubble.mx_cryptoEvent {
margin: var(--EventTileBubble_margin-block) auto; margin: var(--EventTileBubble_margin-block) auto;
&.mx_cryptoEvent_icon svg { svg[data-state="supported"] {
color: $header-panel-text-primary-color; 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;
}
} }
@@ -66,7 +66,7 @@ const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
*/ */
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />; return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
} }
/** /**
@@ -416,7 +416,8 @@ function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatus
function EncryptionEventWrappedView({ mxEvent }: { mxEvent: MatrixEvent }): ReactElement | null { function EncryptionEventWrappedView({ mxEvent }: { mxEvent: MatrixEvent }): ReactElement | null {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli })); 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> { export class RoomView extends React.Component<IRoomProps, IRoomState> {
@@ -45,7 +45,7 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
<ScrollPanel className="mx_RoomView_messagePanel"> <ScrollPanel className="mx_RoomView_messagePanel">
<EventTileBubble <EventTileBubble
icon={<LockSolidIcon />} icon={<LockSolidIcon />}
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon" className="mx_EventTileBubble mx_cryptoEvent"
title={_t("room|waiting_for_join_title", { brand })} title={_t("room|waiting_for_join_title", { brand })}
subtitle={_t("room|waiting_for_join_subtitle", { brand })} subtitle={_t("room|waiting_for_join_subtitle", { brand })}
/> />
@@ -29,7 +29,7 @@ import { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparat
*/ */
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts }));
return <DateSeparatorView vm={vm} />; return <DateSeparatorView vm={vm} className="mx_TimelineSeparator" />;
} }
export class CreationGrouper extends BaseGrouper { export class CreationGrouper extends BaseGrouper {
@@ -31,7 +31,7 @@ const groupedStateEvents = [
*/ */
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); 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 // Wrap consecutive grouped events in a ListSummary
@@ -143,7 +143,10 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
const separatorTs = e.getTs(); const separatorTs = e.getTs();
nodes.push( nodes.push(
<li key={`${separatorRoomId}-${separatorTs}~`}> <li key={`${separatorRoomId}-${separatorTs}~`}>
<DateSeparatorView vm={this.getDateSeparatorVm(separatorRoomId, separatorTs)} /> <DateSeparatorView
vm={this.getDateSeparatorVm(separatorRoomId, separatorTs)}
className="mx_TimelineSeparator"
/>
</li>, </li>,
); );
} }
@@ -657,5 +657,5 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
vm.setHref(props.href); vm.setHref(props.href);
vm.setHandlers({ onClick: props.onClick }); vm.setHandlers({ onClick: props.onClick });
}, [vm, props]); }, [vm, props]);
return <MessageTimestampView vm={vm} />; return <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
} }
@@ -75,7 +75,7 @@ const MKeyVerificationRequest: React.FC<Props> = ({ mxEvent, timestamp }) => {
return ( return (
<EventTileBubble <EventTileBubble
icon={<LockSolidIcon />} icon={<LockSolidIcon />}
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon" className="mx_EventTileBubble mx_cryptoEvent"
title={title} title={title}
subtitle={subtitle} subtitle={subtitle}
> >
@@ -347,5 +347,5 @@ function DecryptionFailureBodyWrapper({ mxEvent, ref }: IBodyProps): JSX.Element
useEffect(() => { useEffect(() => {
vm.setVerificationState(verificationState); vm.setVerificationState(verificationState);
}, [verificationState, vm]); }, [verificationState, vm]);
return <DecryptionFailureBodyView vm={vm} ref={ref} />; return <DecryptionFailureBodyView vm={vm} ref={ref} className="mx_DecryptionFailureBody mx_EventTile_content" />;
} }
@@ -37,7 +37,6 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
colored: true, colored: true,
emphasizeDisplayName: true, emphasizeDisplayName: true,
withTooltip, withTooltip,
className: "mx_DisambiguatedProfile",
}), }),
); );
@@ -45,7 +44,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
disambiguatedProfileVM.setMember(sender ?? "", member); disambiguatedProfileVM.setMember(sender ?? "", member);
}, [disambiguatedProfileVM, member, sender]); }, [disambiguatedProfileVM, member, sender]);
return mxEvent.getContent().msgtype !== MsgType.Emote ? ( return mxEvent.getContent().msgtype !== MsgType.Emote ? (
<DisambiguatedProfileView vm={disambiguatedProfileVM} /> <DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />
) : ( ) : (
<></> <></>
); );
@@ -1617,7 +1617,7 @@ function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JS
vm.setVerificationState(verificationState); vm.setVerificationState(verificationState);
}, [verificationState, vm]); }, [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 ? ( {props.receivedTs ? (
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" /> <LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
) : undefined} ) : undefined}
<MessageTimestampView vm={vm} /> <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
</> </>
); );
} }
@@ -1859,10 +1859,6 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
snapshot.showAllButtonVisible, snapshot.showAllButtonVisible,
]); ]);
useEffect(() => {
vm.setChildren(items);
}, [items, vm]);
if (!snapshot.isVisible || !items?.length) { if (!snapshot.isVisible || !items?.length) {
return null; return null;
} }
@@ -1878,7 +1874,9 @@ function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrappe
return ( return (
<> <>
<ReactionsRowView vm={vm} /> <ReactionsRowView vm={vm} className="mx_ReactionsRow">
{items}
</ReactionsRowView>
{contextMenu} {contextMenu}
</> </>
); );
@@ -53,13 +53,12 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
fallbackName: name, fallbackName: name,
member, member,
withTooltip: true, withTooltip: true,
className: "mx_DisambiguatedProfile",
}), }),
); );
useEffect(() => { useEffect(() => {
disambiguatedProfileVM.setMember(name, member); disambiguatedProfileVM.setMember(name, member);
}, [disambiguatedProfileVM, member, name]); }, [disambiguatedProfileVM, member, name]);
const nameJSX = <DisambiguatedProfileView vm={disambiguatedProfileVM} />; const nameJSX = <DisambiguatedProfileView vm={disambiguatedProfileVM} className="mx_DisambiguatedProfile" />;
const presenceState = member.presenceState; const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined; let presenceJSX: JSX.Element | undefined;
@@ -40,7 +40,7 @@ interface IProps {
*/ */
function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); 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> { export default class SearchResultTile extends React.Component<IProps> {
+2 -1
View File
@@ -88,7 +88,8 @@ export const TextualEventFactory: Factory = (ref, props) => {
function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli })); 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) => { const EncryptionEventFactory: Factory = (ref, props) => {
return <EncryptionEventWrappedView ref={ref} {...props} />; return <EncryptionEventWrappedView ref={ref} {...props} />;
@@ -266,7 +266,7 @@ export default class HTMLExporter extends Exporter {
try { try {
const dateSeparator = ( const dateSeparator = (
<li key={ts}> <li key={ts}>
<DateSeparatorView vm={dateSeparatorViewModel} /> <DateSeparatorView vm={dateSeparatorViewModel} className="mx_TimelineSeparator" />
</li> </li>
); );
return this.renderToStaticMarkupWithProviders(dateSeparator); return this.renderToStaticMarkupWithProviders(dateSeparator);
@@ -78,13 +78,11 @@ export class EncryptionEventViewModel
props: EncryptionEventViewModelProps, props: EncryptionEventViewModelProps,
isEncrypted: boolean, isEncrypted: boolean,
): EncryptionEventViewSnapshotInterface { ): EncryptionEventViewSnapshotInterface {
// Keep legacy class names for compatibility with existing timeline layout and styling.
const newSnapshot: EncryptionEventViewSnapshotInterface = { const newSnapshot: EncryptionEventViewSnapshotInterface = {
state: EncryptionEventState.CHANGED, state: EncryptionEventState.CHANGED,
encryptedStateEvents: undefined, encryptedStateEvents: undefined,
userName: undefined, userName: undefined,
timestamp: props.timestamp, timestamp: props.timestamp,
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
}; };
const content = props.mxEvent.getContent<RoomEncryptionEventContent>(); const content = props.mxEvent.getContent<RoomEncryptionEventContent>();
@@ -113,8 +111,6 @@ export class EncryptionEventViewModel
newSnapshot.state = EncryptionEventState.DISABLE_ATTEMPT; newSnapshot.state = EncryptionEventState.DISABLE_ATTEMPT;
} else { } else {
newSnapshot.state = EncryptionEventState.UNSUPPORTED; newSnapshot.state = EncryptionEventState.UNSUPPORTED;
// Unsupported branch matches legacy EncryptionEvent class usage (no icon class).
newSnapshot.className = "mx_EventTileBubble mx_cryptoEvent";
} }
return newSnapshot; return newSnapshot;
@@ -22,10 +22,6 @@ export interface DecryptionFailureBodyViewModelProps {
* The local device verification state. * The local device verification state.
*/ */
verificationState?: boolean; 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 decryptionFailureCode - The decryption failure code for the event.
* @param verificationState - The local device verification state. * @param verificationState - The local device verification state.
* @param extraClassNames - Extra CSS classes to apply to the component.
*/ */
private static readonly computeSnapshot = ( private static readonly computeSnapshot = (
decryptionFailureCode: DecryptionFailureCode | null, decryptionFailureCode: DecryptionFailureCode | null,
verificationState?: boolean, verificationState?: boolean,
extraClassNames?: string[],
): DecryptionFailureBodyViewSnapshotInterface => { ): 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 { return {
decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode), decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode),
isLocalDeviceVerified: verificationState, isLocalDeviceVerified: verificationState,
extraClassNames: extraClassNames ? defaultClassNames.concat(extraClassNames) : defaultClassNames,
}; };
}; };
public constructor(props: DecryptionFailureBodyViewModelProps) { public constructor(props: DecryptionFailureBodyViewModelProps) {
super( super(
props, props,
DecryptionFailureBodyViewModel.computeSnapshot( DecryptionFailureBodyViewModel.computeSnapshot(props.decryptionFailureCode, props.verificationState),
props.decryptionFailureCode,
props.verificationState,
props.extraClassNames,
),
); );
} }
@@ -91,14 +91,12 @@ export class MessageTimestampViewModel
receivedAt = formatFullDate(receivedDate, props.showTwelveHour); receivedAt = formatFullDate(receivedDate, props.showTwelveHour);
} }
// Keep mx_MessageTimestamp for compatibility with the existing timeline and layout.
return { return {
ts: timestamp, ts: timestamp,
tsSentAt: sentAt, tsSentAt: sentAt,
tsReceivedAt: receivedAt, tsReceivedAt: receivedAt,
inhibitTooltip: props.inhibitTooltip, inhibitTooltip: props.inhibitTooltip,
href: props.href, href: props.href,
className: "mx_MessageTimestamp",
}; };
}; };
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * 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 { import {
BaseViewModel, BaseViewModel,
type ReactionsRowViewSnapshot, type ReactionsRowViewSnapshot,
@@ -41,10 +41,6 @@ export interface ReactionsRowViewModelProps {
* Optional callback invoked on add-reaction button context-menu. * Optional callback invoked on add-reaction button context-menu.
*/ */
onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>; onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
/**
* Reaction row children (typically reaction buttons).
*/
children?: ReactNode;
} }
interface InternalProps extends ReactionsRowViewModelProps { interface InternalProps extends ReactionsRowViewModelProps {
@@ -59,18 +55,16 @@ export class ReactionsRowViewModel
props: InternalProps, props: InternalProps,
): Pick< ): Pick<
ReactionsRowViewSnapshot, ReactionsRowViewSnapshot,
"isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive" | "children" "isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive"
> => ({ > => ({
isVisible: props.isActionable && props.reactionGroupCount > 0, isVisible: props.isActionable && props.reactionGroupCount > 0,
showAllButtonVisible: props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !props.showAll, showAllButtonVisible: props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !props.showAll,
showAddReactionButton: props.canReact, showAddReactionButton: props.canReact,
addReactionButtonActive: !!props.addReactionButtonActive, addReactionButtonActive: !!props.addReactionButtonActive,
children: props.children,
}); });
private static readonly computeSnapshot = (props: InternalProps): ReactionsRowViewSnapshot => ({ private static readonly computeSnapshot = (props: InternalProps): ReactionsRowViewSnapshot => ({
ariaLabel: _t("common|reactions"), ariaLabel: _t("common|reactions"),
className: "mx_ReactionsRow",
showAllButtonLabel: _t("action|show_all"), showAllButtonLabel: _t("action|show_all"),
addReactionButtonLabel: _t("timeline|reactions|add_reaction_prompt"), addReactionButtonLabel: _t("timeline|reactions|add_reaction_prompt"),
addReactionButtonVisible: false, addReactionButtonVisible: false,
@@ -136,15 +130,6 @@ export class ReactionsRowViewModel
this.snapshot.merge({ addReactionButtonActive }); this.snapshot.merge({ addReactionButtonActive });
} }
public setChildren(children?: ReactNode): void {
this.props = {
...this.props,
children,
};
this.snapshot.merge({ children });
}
public setAddReactionHandlers({ public setAddReactionHandlers({
onAddReactionClick, onAddReactionClick,
onAddReactionContextMenu, onAddReactionContextMenu,
@@ -66,10 +66,6 @@ export interface DisambiguatedProfileViewModelProps {
* Optional click handler for the profile. * Optional click handler for the profile.
*/ */
onClick?: DisambiguatedProfileViewActions["onClick"]; 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 = ( private static readonly computeSnapshot = (
props: DisambiguatedProfileViewModelProps, props: DisambiguatedProfileViewModelProps,
): DisambiguatedProfileViewSnapshot => { ): DisambiguatedProfileViewSnapshot => {
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, className } = props; const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props;
// Compute display name // Compute display name
const displayName = member?.rawDisplayName || fallbackName; const displayName = member?.rawDisplayName || fallbackName;
@@ -123,7 +119,6 @@ export class DisambiguatedProfileViewModel
return { return {
displayName, displayName,
colorClass, colorClass,
className,
displayIdentifier, displayIdentifier,
title, title,
emphasizeDisplayName, emphasizeDisplayName,
@@ -68,7 +68,6 @@ export class DateSeparatorViewModel
super(props, { super(props, {
label: DateSeparatorViewModel.computeLabel(props, relativeDatesEnabled), label: DateSeparatorViewModel.computeLabel(props, relativeDatesEnabled),
className: "mx_TimelineSeparator",
}); });
this.relativeDatesEnabled = relativeDatesEnabled; this.relativeDatesEnabled = relativeDatesEnabled;
@@ -101,7 +100,6 @@ export class DateSeparatorViewModel
const label = DateSeparatorViewModel.computeLabel(this.props, this.relativeDatesEnabled); const label = DateSeparatorViewModel.computeLabel(this.props, this.relativeDatesEnabled);
return { return {
label, label,
className: "mx_TimelineSeparator",
jumpToEnabled: this.jumpToDateEnabled && !this.props.forExport, jumpToEnabled: this.jumpToDateEnabled && !this.props.forExport,
jumpFromDate: formatDateForInput(new Date(this.props.ts)), jumpFromDate: formatDateForInput(new Date(this.props.ts)),
}; };
@@ -717,9 +717,10 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
style="height: 400px;" style="height: 400px;"
> >
<div <div
class="_container_sq5fu_8 mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon" class="_container_sq5fu_8 mx_EventTileBubble mx_cryptoEvent _content_m88ar_8"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -64,7 +64,6 @@ describe("EncryptionEventViewModel", () => {
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED)); await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
expect(vm.getSnapshot()).toMatchObject({ expect(vm.getSnapshot()).toMatchObject({
state: EncryptionEventState.ENABLED, state: EncryptionEventState.ENABLED,
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
encryptedStateEvents: false, encryptedStateEvents: false,
}); });
}); });
@@ -125,7 +124,6 @@ describe("EncryptionEventViewModel", () => {
const vm = createVm(); const vm = createVm();
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED)); 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 () => { it("sets ENABLED_DM with partner display name", async () => {
@@ -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([ it.each([
{ {
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
@@ -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({ const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(), ts: nowDate.getTime(),
}); });
expect(vm.getSnapshot()).toMatchObject({ expect(vm.getSnapshot()).toMatchObject({
ts: "08:09", ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00", tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
className: "mx_MessageTimestamp",
}); });
}); });
@@ -29,7 +29,6 @@ describe("ReactionsRowViewModel", () => {
expect(snapshot.showAllButtonVisible).toBe(true); expect(snapshot.showAllButtonVisible).toBe(true);
expect(snapshot.showAddReactionButton).toBe(true); expect(snapshot.showAddReactionButton).toBe(true);
expect(snapshot.addReactionButtonActive).toBe(false); expect(snapshot.addReactionButtonActive).toBe(false);
expect(snapshot.className).toContain("mx_ReactionsRow");
}); });
it("hides show-all after onShowAllClick", () => { it("hides show-all after onShowAllClick", () => {
@@ -31,7 +31,6 @@ describe("DisambiguatedProfileViewModel", () => {
expect(vm.getSnapshot()).toEqual({ expect(vm.getSnapshot()).toEqual({
displayName: "Alice", displayName: "Alice",
colorClass: "mx_Username_color3", colorClass: "mx_Username_color3",
className: undefined,
displayIdentifier: "@alice:example.org", displayIdentifier: "@alice:example.org",
title: "Alice (@alice:example.org)", title: "Alice (@alice:example.org)",
emphasizeDisplayName: true, emphasizeDisplayName: true,
@@ -47,23 +46,12 @@ describe("DisambiguatedProfileViewModel", () => {
expect(vm.getSnapshot()).toMatchObject({ expect(vm.getSnapshot()).toMatchObject({
displayName: "Fallback", displayName: "Fallback",
colorClass: undefined, colorClass: undefined,
className: undefined,
displayIdentifier: undefined, displayIdentifier: undefined,
title: undefined, title: undefined,
emphasizeDisplayName: 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", () => { it("should delegate onClick without emitting a snapshot update", () => {
const onClick = jest.fn(); const onClick = jest.fn();
const vm = new DisambiguatedProfileViewModel({ const vm = new DisambiguatedProfileViewModel({
@@ -109,7 +109,6 @@ describe("DateSeparatorViewModel", () => {
const vm = createViewModel(); const vm = createViewModel();
expect(vm.getSnapshot().label).toBe("today"); expect(vm.getSnapshot().label).toBe("today");
expect(vm.getSnapshot().className).toBe("mx_TimelineSeparator");
}); });
it("uses full date when exporting", () => { it("uses full date when exporting", () => {
@@ -5,6 +5,10 @@
* Please see LICENSE files in the repository root for full details. * 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); color: var(--cpd-color-icon-critical-primary);
} }
@@ -14,10 +14,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type EncryptionEventProps = EncryptionEventViewSnapshot; type EncryptionEventProps = EncryptionEventViewSnapshot;
const EncryptionEventViewWrapperImpl = ({ ...rest }: EncryptionEventProps): JSX.Element => { const EncryptionEventViewWrapperImpl = ({
className,
...rest
}: EncryptionEventProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {}); const vm = useMockedViewModel(rest, {});
return <EncryptionEventView vm={vm} />; return <EncryptionEventView vm={vm} className={className} />;
}; };
const EncryptionEventViewWrapper = withViewDocs(EncryptionEventViewWrapperImpl, EncryptionEventView); const EncryptionEventViewWrapper = withViewDocs(EncryptionEventViewWrapperImpl, EncryptionEventView);
@@ -36,9 +36,8 @@ describe("EncryptionEventView", () => {
state, state,
encryptedStateEvents, encryptedStateEvents,
userName, userName,
className,
}); });
render(<EncryptionEventView vm={vm} />); render(<EncryptionEventView vm={vm} className={className} />);
}; };
it("renders Default story", () => { it("renders Default story", () => {
@@ -7,6 +7,7 @@
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { type ViewModel, useViewModel } from "../../viewmodel"; import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./EncryptionEventView.module.css"; import styles from "./EncryptionEventView.module.css";
@@ -35,8 +36,6 @@ export type EncryptionEventViewSnapshot = {
encryptedStateEvents?: boolean; encryptedStateEvents?: boolean;
/** Display name for DM partner, used by ENABLED_DM subtitle text. */ /** Display name for DM partner, used by ENABLED_DM subtitle text. */
userName?: string; userName?: string;
/** Optional CSS classes passed through to EventTileBubble. */
className?: string;
/** Optional timestamp element rendered in the EventTileBubble footer slot. */ /** Optional timestamp element rendered in the EventTileBubble footer slot. */
timestamp?: JSX.Element; timestamp?: JSX.Element;
}; };
@@ -52,16 +51,35 @@ export interface EncryptionEventViewProps {
*/ */
vm: ViewModel<EncryptionEventViewSnapshot>; 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>; 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 { 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 title = encryptedStateEvents ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled");
let subtitle = ""; let subtitle = "";
@@ -86,14 +104,20 @@ export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewPro
break; break;
case EncryptionEventState.UNSUPPORTED: case EncryptionEventState.UNSUPPORTED:
default: default:
icon = <ErrorSolidIcon className={styles.error} />; icon = <ErrorSolidIcon data-state="unsupported" />;
title = _t("timeline|m.room.encryption|disabled"); title = _t("timeline|m.room.encryption|disabled");
subtitle = _t("timeline|m.room.encryption|unsupported"); subtitle = _t("timeline|m.room.encryption|unsupported");
break; break;
} }
return ( 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} {timestamp}
</EventTileBubble> </EventTileBubble>
); );
@@ -3,9 +3,10 @@
exports[`EncryptionEventView > renders Default story 1`] = ` exports[`EncryptionEventView > renders Default story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -33,9 +34,10 @@ exports[`EncryptionEventView > renders Default story 1`] = `
exports[`EncryptionEventView > renders DisableAttempt story 1`] = ` exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -63,9 +65,10 @@ exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = ` exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -93,9 +96,10 @@ exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = ` exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -123,9 +127,10 @@ exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
exports[`EncryptionEventView > renders ParametersChanged story 1`] = ` exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -153,9 +158,10 @@ exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = ` exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -183,10 +189,10 @@ exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
exports[`EncryptionEventView > renders Unsupported story 1`] = ` exports[`EncryptionEventView > renders Unsupported story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
class="error" data-state="unsupported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -214,9 +220,10 @@ exports[`EncryptionEventView > renders Unsupported story 1`] = `
exports[`EncryptionEventView > renders WithTimestamp story 1`] = ` exports[`EncryptionEventView > renders WithTimestamp story 1`] = `
<div> <div>
<div <div
class="container" class="container content"
> >
<svg <svg
data-state="supported"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
+1 -1
View File
@@ -21,7 +21,7 @@ export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView"; export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/ReactionsRowButtonTooltip"; export * from "./message-body/ReactionsRowButtonTooltip";
export * from "./message-body/ReactionsRowButton"; export * from "./message-body/ReactionsRowButton";
export * from "./message-body/ReactionRow"; export * from "./message-body/ReactionsRow";
export * from "./message-body/TimelineSeparator/"; export * from "./message-body/TimelineSeparator/";
export * from "./pill-input/Pill"; export * from "./pill-input/Pill";
export * from "./pill-input/PillInput"; export * from "./pill-input/PillInput";
@@ -18,10 +18,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type DecryptionFailureBodyProps = DecryptionFailureBodyViewSnapshot; type DecryptionFailureBodyProps = DecryptionFailureBodyViewSnapshot;
const DecryptionFailureBodyViewWrapperImpl = ({ ...rest }: DecryptionFailureBodyProps): JSX.Element => { const DecryptionFailureBodyViewWrapperImpl = ({
className,
...rest
}: DecryptionFailureBodyProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, {}); const vm = useMockedViewModel(rest, {});
return <DecryptionFailureBodyView vm={vm} />; return <DecryptionFailureBodyView vm={vm} className={className} />;
}; };
const DecryptionFailureBodyViewWrapper = withViewDocs(DecryptionFailureBodyViewWrapperImpl, DecryptionFailureBodyView); const DecryptionFailureBodyViewWrapper = withViewDocs(DecryptionFailureBodyViewWrapperImpl, DecryptionFailureBodyView);
@@ -40,7 +43,7 @@ const meta = {
args: { args: {
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT, decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
isLocalDeviceVerified: true, isLocalDeviceVerified: true,
extraClassNames: ["extra_class"], className: "extra_class",
}, },
} satisfies Meta<typeof DecryptionFailureBodyViewWrapper>; } satisfies Meta<typeof DecryptionFailureBodyViewWrapper>;
@@ -49,24 +52,17 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {}; 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 = { export const HasErrorClassName: Story = {
args: { args: {
decryptionFailureReason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE, decryptionFailureReason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
extraClassNames: undefined, className: undefined,
}, },
}; };
export const HasErrorBlockIcon: Story = { export const HasErrorBlockIcon: Story = {
args: { args: {
decryptionFailureReason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, decryptionFailureReason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
extraClassNames: undefined, className: undefined,
}, },
}; };
@@ -74,7 +70,7 @@ export const HasBackupConfiguredVerifiedFalse: Story = {
args: { args: {
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
isLocalDeviceVerified: false, isLocalDeviceVerified: false,
extraClassNames: undefined, className: undefined,
}, },
}; };
@@ -82,6 +78,6 @@ export const HasBackupConfiguredVerifiedTrue: Story = {
args: { args: {
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
isLocalDeviceVerified: true, isLocalDeviceVerified: true,
extraClassNames: undefined, className: undefined,
}, },
}; };
@@ -5,26 +5,23 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils"; import { render } from "@test-utils";
import React from "react"; import React from "react";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { DecryptionFailureBodyView, DecryptionFailureReason } from "./DecryptionFailureBodyView"; import { DecryptionFailureBodyView, DecryptionFailureReason } from "./DecryptionFailureBodyView";
import { MockViewModel } from "../../viewmodel"; import { MockViewModel } from "../../viewmodel";
import * as stories from "./DecryptionFailureBodyView.stories";
const { HasExtraClassNames } = composeStories(stories);
describe("DecryptionFailureBodyView", () => { describe("DecryptionFailureBodyView", () => {
function customRender( function customRender(
decryptionFailureReason: DecryptionFailureReason, decryptionFailureReason: DecryptionFailureReason,
isLocalDeviceVerified: boolean = false, isLocalDeviceVerified: boolean = false,
extraClassNames: string[] | undefined = undefined, className: string | undefined = undefined,
): ReturnType<typeof render> { ): ReturnType<typeof render> {
return render( return render(
<DecryptionFailureBodyView <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", () => { it("applies custom className to the root element", () => {
// When const { container } = customRender(
const { container } = render(<HasExtraClassNames />); DecryptionFailureReason.UNABLE_TO_DECRYPT,
false,
"extra_class_1 extra_class_2",
);
// Then expect(container.firstChild).toHaveClass("extra_class_1", "extra_class_2");
expect(container).toMatchSnapshot();
}); });
it.each([true, false])(`Should display "Unable to decrypt message and device verification is %s"`, (verified) => { it.each([true, false])(`Should display "Unable to decrypt message and device verification is %s"`, (verified) => {
@@ -65,14 +65,14 @@ export interface DecryptionFailureBodyViewSnapshot {
* The local device verification state. * The local device verification state.
*/ */
isLocalDeviceVerified?: boolean; isLocalDeviceVerified?: boolean;
/**
* Extra CSS classes to apply to the component
*/
extraClassNames?: string[];
} }
/** /**
* The view model for the component. * 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>; export type DecryptionFailureBodyViewModel = ViewModel<DecryptionFailureBodyViewSnapshot>;
@@ -81,6 +81,10 @@ interface DecryptionFailureBodyViewProps {
* The view model for the component. * The view model for the component.
*/ */
vm: DecryptionFailureBodyViewModel; vm: DecryptionFailureBodyViewModel;
/**
* Optional CSS class names to apply to the component container.
*/
className?: string;
/** /**
* React ref to attach to any React components returned * 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 * @example
* ```tsx * ```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 i18nApi = useI18n();
const { decryptionFailureReason, isLocalDeviceVerified, extraClassNames } = useViewModel(vm); const { decryptionFailureReason, isLocalDeviceVerified } = useViewModel(vm);
const classes = classNames(styles.content, errorClassName(decryptionFailureReason), extraClassNames); const classes = classNames(styles.content, errorClassName(decryptionFailureReason), className);
return ( return (
<div className={classes} ref={ref}> <div className={classes} ref={ref}>
@@ -40,16 +40,6 @@ exports[`DecryptionFailureBodyView > Should display "Unable to decrypt message a
</div> </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`] = ` exports[`DecryptionFailureBodyView > should handle historical messages with no key backup and device verification is false 1`] = `
<div> <div>
<div <div
@@ -18,12 +18,17 @@ import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../.storybook/withViewDocs";
type MessageTimestampProps = MessageTimestampViewSnapshot & MessageTimestampViewActions; 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, { const vm = useMockedViewModel(rest, {
onClick, onClick,
onContextMenu, onContextMenu,
}); });
return <MessageTimestampView vm={vm} />; return <MessageTimestampView vm={vm} className={className} />;
}; };
const MessageTimestampWrapper = withViewDocs(MessageTimestampWrapperImpl, MessageTimestampView); 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 = { export const HasHref: Story = {
args: { args: {
href: "~", href: "~",
@@ -21,7 +21,7 @@ import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
import { I18nContext } from "../../utils/i18nContext.ts"; import { I18nContext } from "../../utils/i18nContext.ts";
import { I18nApi } from "../../index.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> => const renderWithI18n = (ui: React.ReactElement): ReturnType<typeof render> =>
render(ui, { render(ui, {
@@ -38,9 +38,16 @@ describe("MessageTimestampView", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders the message timestamp with extra class names", async () => { it("applies custom className to the timestamp element", async () => {
const { container } = render(<HasExtraClassNames />); const vm = new MockViewModel<MessageTimestampViewSnapshot>({
expect(container).toMatchSnapshot(); 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 () => { it("renders the message timestamp with href", async () => {
@@ -32,10 +32,6 @@ export interface MessageTimestampViewSnapshot {
* If set to true then no tooltip will be shown * If set to true then no tooltip will be shown
*/ */
inhibitTooltip?: boolean; 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 * 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. * 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; export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
@@ -63,6 +62,10 @@ interface MessageTimestampViewProps {
* The view model for the message timestamp. * The view model for the message timestamp.
*/ */
vm: MessageTimestampViewModel; 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 * 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 * 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 * @example
* ```tsx * ```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 { 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 => { const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
if (vm.onClick) { if (vm.onClick) {
@@ -12,18 +12,6 @@ exports[`MessageTimestampView > renders the message timestamp in default state 1
</div> </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`] = ` exports[`MessageTimestampView > renders the message timestamp with href 1`] = `
<div> <div>
<a <a
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX } from "react"; import React, { type JSX, type ReactNode } from "react";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -60,15 +60,21 @@ const ReactionsRowViewWrapper = ({
onShowAllClick, onShowAllClick,
onAddReactionClick, onAddReactionClick,
onAddReactionContextMenu, onAddReactionContextMenu,
children,
className,
...snapshotProps ...snapshotProps
}: WrapperProps): JSX.Element => { }: WrapperProps & { children?: ReactNode; className?: string }): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, { const vm = useMockedViewModel(snapshotProps, {
onShowAllClick: onShowAllClick ?? fn(), onShowAllClick: onShowAllClick ?? fn(),
onAddReactionClick: onAddReactionClick ?? fn(), onAddReactionClick: onAddReactionClick ?? fn(),
onAddReactionContextMenu: onAddReactionContextMenu ?? fn(), onAddReactionContextMenu: onAddReactionContextMenu ?? fn(),
}); });
return <ReactionsRowView vm={vm} />; return (
<ReactionsRowView vm={vm} className={className}>
{children}
</ReactionsRowView>
);
}; };
const meta = { const meta = {
@@ -68,7 +68,6 @@ describe("ReactionsRowView", () => {
showAddReactionButton: true, showAddReactionButton: true,
addReactionButtonLabel: "Add reaction", addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true, addReactionButtonVisible: true,
children: <span>👍</span>,
}, },
{ {
onShowAllClick, onShowAllClick,
@@ -77,7 +76,11 @@ describe("ReactionsRowView", () => {
}, },
) as ReactionsRowViewModel; ) 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: "Show all" }));
await user.click(screen.getByRole("button", { name: "Add reaction" })); await user.click(screen.getByRole("button", { name: "Add reaction" }));
@@ -87,4 +90,16 @@ describe("ReactionsRowView", () => {
expect(onAddReactionClick).toHaveBeenCalledTimes(1); expect(onAddReactionClick).toHaveBeenCalledTimes(1);
expect(onAddReactionContextMenu).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");
});
}); });
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type MouseEventHandler, type ReactNode } from "react"; import React, { type JSX, type MouseEventHandler, type PropsWithChildren } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
@@ -22,14 +22,6 @@ export interface ReactionsRowViewSnapshot {
* Controls whether the row should render at all. * Controls whether the row should render at all.
*/ */
isVisible: boolean; 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. * Whether to render the "show all" button.
*/ */
@@ -79,14 +71,20 @@ export type ReactionsRowViewModel = ViewModel<ReactionsRowViewSnapshot, Reaction
interface ReactionsRowViewProps { interface ReactionsRowViewProps {
vm: ReactionsRowViewModel; 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 { const {
ariaLabel, ariaLabel,
isVisible, isVisible,
children,
className,
showAllButtonVisible, showAllButtonVisible,
showAllButtonLabel, showAllButtonLabel,
showAddReactionButton, showAddReactionButton,
@@ -19,9 +19,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
type DisambiguatedProfileProps = DisambiguatedProfileViewSnapshot & DisambiguatedProfileViewActions; 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 }); const vm = useMockedViewModel(rest, { onClick });
return <DisambiguatedProfileView vm={vm} />; return <DisambiguatedProfileView vm={vm} className={className} />;
}; };
const DisambiguatedProfileViewWrapper = withViewDocs(DisambiguatedProfileViewWrapperImpl, DisambiguatedProfileView); const DisambiguatedProfileViewWrapper = withViewDocs(DisambiguatedProfileViewWrapperImpl, DisambiguatedProfileView);
@@ -209,4 +209,14 @@ describe("DisambiguatedProfileView", () => {
const displayNameElement = screen.getByText("Emphasized User"); const displayNameElement = screen.getByText("Emphasized User");
expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName"); 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");
});
}); });
@@ -24,10 +24,6 @@ export interface DisambiguatedProfileViewSnapshot {
* Undefined if coloring is not enabled. * Undefined if coloring is not enabled.
*/ */
colorClass?: string; colorClass?: string;
/**
* The CSS class name.
*/
className?: string;
/** /**
* The formatted user identifier to display when disambiguation is needed. * The formatted user identifier to display when disambiguation is needed.
* Undefined if disambiguation is not required. * Undefined if disambiguation is not required.
@@ -67,6 +63,10 @@ interface DisambiguatedProfileViewProps {
* The view model for the disambiguated profile. * The view model for the disambiguated profile.
*/ */
vm: DisambiguatedProfileViewModel; vm: DisambiguatedProfileViewModel;
/**
* Optional CSS class name applied to the profile container.
*/
className?: string;
} }
/** /**
@@ -79,8 +79,8 @@ interface DisambiguatedProfileViewProps {
* <DisambiguatedProfileView vm={disambiguatedProfileViewModel} /> * <DisambiguatedProfileView vm={disambiguatedProfileViewModel} />
* ``` * ```
*/ */
export function DisambiguatedProfileView({ vm }: Readonly<DisambiguatedProfileViewProps>): JSX.Element { export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, className } = useViewModel(vm); const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName } = useViewModel(vm);
const displayNameClasses = classNames(colorClass, { const displayNameClasses = classNames(colorClass, {
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName, [styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
@@ -20,10 +20,11 @@ const DateSeparatorViewWrapperImpl = ({
onLastMonthPicked, onLastMonthPicked,
onBeginningPicked, onBeginningPicked,
onDatePicked, onDatePicked,
className,
...rest ...rest
}: DateSeparatorProps): JSX.Element => { }: DateSeparatorProps & { className?: string }): JSX.Element => {
const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked }); const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked });
return <DateSeparatorView vm={vm} />; return <DateSeparatorView vm={vm} className={className} />;
}; };
const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView); const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView);
@@ -29,10 +29,6 @@ export interface DateSeparatorViewSnapshot {
* Reference date as input format used to prefill the jump-to-date picker value. * Reference date as input format used to prefill the jump-to-date picker value.
*/ */
jumpFromDate?: string; jumpFromDate?: string;
/**
* Extra CSS classes to apply to the component.
*/
className?: string;
} }
export interface DateSeparatorViewActions { export interface DateSeparatorViewActions {
@@ -56,6 +52,10 @@ interface DateSeparatorViewProps {
* The view model for the component. * The view model for the component.
*/ */
vm: DateSeparatorViewModel; vm: DateSeparatorViewModel;
/**
* Extra CSS classes to apply to the component.
*/
className?: string;
} }
/** /**
@@ -68,8 +68,8 @@ interface DateSeparatorViewProps {
* <DateSeparatorView vm={vm} /> * <DateSeparatorView vm={vm} />
* ``` * ```
*/ */
export function DateSeparatorView({ vm }: Readonly<DateSeparatorViewProps>): JSX.Element { export function DateSeparatorView({ vm, className }: Readonly<DateSeparatorViewProps>): JSX.Element {
const { label, className, jumpToEnabled } = useViewModel(vm); const { label, jumpToEnabled } = useViewModel(vm);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isTriggerHovered, setIsTriggerHovered] = useState(false); const [isTriggerHovered, setIsTriggerHovered] = useState(false);
const [isTriggerFocused, setIsTriggerFocused] = useState(false); const [isTriggerFocused, setIsTriggerFocused] = useState(false);