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
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)),
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -36,9 +36,8 @@ describe("EncryptionEventView", () => {
|
||||
state,
|
||||
encryptedStateEvents,
|
||||
userName,
|
||||
className,
|
||||
});
|
||||
render(<EncryptionEventView vm={vm} />);
|
||||
render(<EncryptionEventView vm={vm} className={className} />);
|
||||
};
|
||||
|
||||
it("renders Default story", () => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: "~",
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||