Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ export const LotOfSeconds: Story = {
seconds: 99999999999999,
},
};

export const SingleDigitMinutes: Story = {
args: {
seconds: 100,
minutesMaxLength: 1,
},
};
7 changes: 6 additions & 1 deletion packages/shared-components/src/audio/Clock/Clock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { describe, it, expect } from "vitest";

import * as stories from "./Clock.stories.tsx";

const { Default, LotOfSeconds } = composeStories(stories);
const { Default, LotOfSeconds, SingleDigitMinutes } = composeStories(stories);

describe("Clock", () => {
it("renders the clock", () => {
Expand All @@ -24,4 +24,9 @@ describe("Clock", () => {
const { container } = render(<LotOfSeconds />);
expect(container).toMatchSnapshot();
});

it("renders the clock with single digit minute", () => {
const { container } = render(<SingleDigitMinutes />);
expect(container).toMatchSnapshot();
});
});
23 changes: 21 additions & 2 deletions packages/shared-components/src/audio/Clock/Clock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@
* The number of seconds to display.
*/
seconds: number;

/**
* The number of positions to pad the minutes part.
*
* @example
* If minutesMaxLength = 1, the clock will show 5:31 instead of 05:31.
*/
minutesMaxLength?: number;

/**
* The number of positions to pad the hour part.
*
* @example
* If hoursMaxLength = 1, the clock will show 1:05:31 instead of 01:05:31.
*/
hoursMaxLength?: number;
}

/**
Expand All @@ -28,7 +44,7 @@
* <Clock seconds={125} />
* ```
*/
export function Clock({ seconds, className, ...rest }: Props): JSX.Element {
export function Clock({ seconds, className, minutesMaxLength, hoursMaxLength, ...rest }: Props): JSX.Element {

Check warning on line 47 in packages/shared-components/src/audio/Clock/Clock.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9DU_oPc3bRDoohYsQm&open=AZ9DU_oPc3bRDoohYsQm&pullRequest=34197
// Memoize current second to avoid recalculating the duration when seconds changes slightly (e.g. 1.2 -> 1.3)
const currentSecond = useMemo(() => Math.floor(seconds), [seconds]);
const duration = useMemo(() => calculateDuration(currentSecond), [currentSecond]);
Expand All @@ -40,7 +56,10 @@
className={classNames("mx_Clock", className)}
{...rest}
>
{formatSeconds(seconds)}
{formatSeconds(seconds, {
minutesMaxLength,
hoursMaxLength,
})}
</time>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ exports[`Clock > renders the clock with a lot of seconds 1`] = `
</time>
</div>
`;

exports[`Clock > renders the clock with single digit minute 1`] = `
<div>
<time
class="mx_Clock"
datetime="PT1M40S"
>
1:40
</time>
</div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";

import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FacePileViewSnapshot, FacePileView } from "./FacePileView";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import profileLink1 from "../../../static/profile-pics/profile1.png";
import profileLink2 from "../../../static/profile-pics/profile2.png";
import profileLink3 from "../../../static/profile-pics/profile3.png";
import { MockViewModel, useMockedViewModel } from "../viewmodel";
import { type MemberAvatarViewSnapshot } from "../MemberAvatar/MemberAvatarView";

const FacePileViewWrapperImpl = (snapshot: FacePileViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(snapshot, {});
return <FacePileView vm={vm} />;
};

const FacePileViewWrapper = withViewDocs(FacePileViewWrapperImpl, FacePileView);

type MockProp = { id: string; name: string; url?: string };

export class MockMemberAvatarViewModel extends MockViewModel<MemberAvatarViewSnapshot> {
public constructor({ id, name, url }: MockProp) {
super({
id,
name,
size: "20px",
title: id,
url,
});
}
}

const meta = {
title: "Core/FacePileView",
component: FacePileViewWrapper,
tags: ["autodocs"],
args: {
memberAvatarViewModels: [
new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }),
new MockMemberAvatarViewModel({ id: "@riley:m.org", name: "Riley", url: profileLink2 }),
new MockMemberAvatarViewModel({ id: "@andi:m.org", name: "Andi", url: profileLink3 }),
],
},
} satisfies Meta<typeof FacePileViewWrapper>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
45 changes: 45 additions & 0 deletions packages/shared-components/src/core/FacePile/FacePileView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { AvatarStack } from "@vector-im/compound-web";

import { type MemberAvatarViewModel, MemberAvatarView } from "../MemberAvatar/MemberAvatarView";
import { useViewModel, type ViewModel } from "../viewmodel";

export interface FacePileViewSnapshot {
/**
* The sub vms for the member avatars.
*/
memberAvatarViewModels: MemberAvatarViewModel[];
}

export type FacePileViewModel = ViewModel<FacePileViewSnapshot>;

export interface FacePileViewProps {
vm: FacePileViewModel;

/**
* Additional class names for this component.
*/
classNames?: string;
}

/**
* View that renders a face pile view.
*/
export function FacePileView(props: FacePileViewProps): React.ReactNode {

Check warning on line 35 in packages/shared-components/src/core/FacePile/FacePileView.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9DU_onc3bRDoohYsQo&open=AZ9DU_onc3bRDoohYsQo&pullRequest=34197
const { memberAvatarViewModels } = useViewModel(props.vm);
if (memberAvatarViewModels.length === 0) return null;
return (
<AvatarStack className={props.classNames}>
{memberAvatarViewModels.map((vm) => (
<MemberAvatarView vm={vm} key={vm.getSnapshot().id} />
))}
</AvatarStack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";

import type { Meta, StoryObj } from "@storybook/react-vite";
import { type MemberAvatarViewSnapshot, MemberAvatarView } from "./MemberAvatarView";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import profile from "../../../static/profile-pics/profile3.png";
import { useMockedViewModel } from "../viewmodel";

const MemberAvatarViewWrapperImpl = (snapshot: MemberAvatarViewSnapshot): React.ReactNode => {
const vm = useMockedViewModel(snapshot, {});
return <MemberAvatarView vm={vm} />;
};

const MemberAvatarViewWrapper = withViewDocs(MemberAvatarViewWrapperImpl, MemberAvatarView);

const meta = {
title: "Core/MemberAvatarView",
component: MemberAvatarViewWrapper,
tags: ["autodocs"],
argTypes: {
url: {
control: "text",
},
},
args: {
id: "@bob:m.org",
name: "Bob",
size: "20px",
title: "@bob:m.org",
url: profile,
},
} satisfies Meta<typeof MemberAvatarViewWrapper>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { Avatar } from "@vector-im/compound-web";

import { useViewModel, type ViewModel } from "../viewmodel";

export interface MemberAvatarViewSnapshot {
/**
* The display name of this member.
*/
name: string;
/**
* The mxid of this member.
*/
id: string;
/**
* The avatar url.
*/
url?: string;
/**
* Size of the avatar.
*/
size: string;
/**
* Title passed to the avatar container (button or span).
*/
title?: string;
}

export type MemberAvatarViewModel = ViewModel<MemberAvatarViewSnapshot>;

interface MemberAvatarViewProps {
vm: MemberAvatarViewModel;

/**
* Additional class names for this component.
*/
classNames?: string;
}

/**
* View for rendering the avatar for a given member.
*/
export function MemberAvatarView(props: MemberAvatarViewProps): React.ReactNode {

Check warning on line 50 in packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9DU_obc3bRDoohYsQn&open=AZ9DU_obc3bRDoohYsQn&pullRequest=34197
const { name, id, url, size } = useViewModel(props.vm);

return <Avatar className={props.classNames} name={name} id={id} src={url} size={size} />;
}
11 changes: 8 additions & 3 deletions packages/shared-components/src/core/utils/DateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@
* Formats a number of seconds into a human-readable string.
* @param inSeconds
*/
export function formatSeconds(inSeconds: number): string {
export function formatSeconds(
inSeconds: number,
opts?: { hoursMaxLength?: number; minutesMaxLength?: number },
): string {
const isNegative = inSeconds < 0;
inSeconds = Math.abs(inSeconds);

const hours = Math.floor(inSeconds / (60 * 60))
.toFixed(0)
.padStart(2, "0");
.padStart(opts?.hoursMaxLength ?? 2, "0");

const minutes = Math.floor((inSeconds % (60 * 60)) / 60)
.toFixed(0)
.padStart(2, "0");
.padStart(opts?.minutesMaxLength ?? 2, "0");

const seconds = Math.floor((inSeconds % (60 * 60)) % 60)
.toFixed(0)
.padStart(2, "0");
Expand Down
14 changes: 14 additions & 0 deletions packages/shared-components/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@
"call_declined": "Call declined",
"call_declined_by_us": "You declined a call"
},
"ongoing": {
"common": {
"call_started_by": "%(startedByDisplayName)s started a call",
"join_button": "Join"
},
"dm": {
"call_started": "Call started",
"title": "Call in progress"
},
"room": {
"join_count": "%(joinedCount)s joined",
"title": "Group call in progress"
}
},
"tombstone": {
"room": {
"title": "Group call ended"
Expand Down
2 changes: 2 additions & 0 deletions packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./core/AvatarWithDetails";
export * from "./core/MemberAvatar/MemberAvatarView.tsx";
export * from "./core/FacePile/FacePileView.tsx";
export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import React from "react";

import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import { RoomOngoingCallTileView, type RoomCallStartedTileViewModel } from "./ongoing/room/RoomOngoingCallTileView";
import { DmOngoingCallTileView, type DmOngoingCallTileViewModel } from "./ongoing/dm/DmOngoingCallTileView";
import {
RoomTombstoneCallTileView,
type RoomTombstoneCallTileViewModel,
Expand All @@ -18,6 +20,8 @@
* Map from tile type to view model.
*/
interface TileTypeToViewModelMap {
"ongoing-call-room": RoomCallStartedTileViewModel;
"ongoing-call-dm": DmOngoingCallTileViewModel;
"tombstone-call-room": RoomTombstoneCallTileViewModel;
"tombstone-call-dm": DmTombstoneCallTileViewModel;
}
Expand All @@ -39,6 +43,10 @@
export function RootCallTileView({ vm }: Props): React.ReactNode {
const { tileType, tileViewModel } = useViewModel(vm);
switch (tileType) {
case "ongoing-call-room":
return <RoomOngoingCallTileView vm={tileViewModel as TileTypeToViewModelMap["ongoing-call-room"]} />;

Check warning on line 47 in packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 47 is not covered by tests
case "ongoing-call-dm":
return <DmOngoingCallTileView vm={tileViewModel as TileTypeToViewModelMap["ongoing-call-dm"]} />;

Check warning on line 49 in packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 49 is not covered by tests
case "tombstone-call-room":
return <RoomTombstoneCallTileView vm={tileViewModel as TileTypeToViewModelMap["tombstone-call-room"]} />;
case "tombstone-call-dm":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@

export * from "./tombstone/room/RoomTombstoneCallTileView";
export * from "./tombstone/dm/DmTombstoneCallTileView";
export type * from "./ongoing/common";
export * from "./ongoing/room/RoomOngoingCallTileView";
export * from "./ongoing/components/Duration/DurationView";
export * from "./ongoing/dm/DmOngoingCallTileView";
export * from "./RootCallTileView";
export * from "./common";
Loading
Loading