DM: Last active timestamp UI (#1793)

This commit is contained in:
Kerry
2023-09-19 21:48:10 +12:00
committed by GitHub
parent 4511108b51
commit 6885c82183
22 changed files with 380 additions and 121 deletions

View File

@@ -15,23 +15,14 @@
// @vitest-environment happy-dom
import { render, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach, vi } from "vitest";
import { describe, expect, it, afterEach } from "vitest";
import { makeFragmentData } from "../../gql/fragment-masking";
import DateTime from "../DateTime";
import OAuth2ClientDetail, {
OAUTH2_CLIENT_FRAGMENT,
} from "./OAuth2ClientDetail";
// Mock out datetime to avoid timezones/date formatting
vi.mock("./DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<OAuth2ClientDetail>", () => {
const baseClient = {
id: "test-id",

View File

@@ -15,21 +15,13 @@
// @vitest-environment happy-dom
import { create } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, beforeAll } from "vitest";
import { FragmentType } from "../gql/fragment-masking";
import { WithLocation } from "../test-utils/WithLocation";
import { mockLocale } from "../test-utils/mockLocale";
import CompatSession, { COMPAT_SESSION_FRAGMENT } from "./CompatSession";
import DateTime from "./DateTime";
// Mock out datetime to avoid timezones/date formatting
vi.mock("./DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<CompatSession />", () => {
const session = {
@@ -44,6 +36,8 @@ describe("<CompatSession />", () => {
const finishedAt = "2023-06-29T03:35:19.451292+00:00";
beforeAll(() => mockLocale());
it("renders an active session", () => {
const component = create(
<WithLocation>

View File

@@ -26,6 +26,26 @@ type Props = {
now?: Date;
};
export const formatDate = (datetime: Date): string =>
intlFormat(datetime, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "numeric",
});
/**
* Formats a datetime
* Uses distance when less than an hour ago
* Else internationalised `Fri, 21 Jul 2023, 16:14`
*/
export const formatReadableDate = (datetime: Date, now: Date): string =>
Math.abs(differenceInHours(now, datetime, { roundingMethod: "round" })) > 1
? formatDate(datetime)
: intlFormatDistance(datetime, now);
const DateTime: React.FC<Props> = ({
datetime: datetimeProps,
now: nowProps,
@@ -34,17 +54,8 @@ const DateTime: React.FC<Props> = ({
const datetime =
typeof datetimeProps === "string" ? parseISO(datetimeProps) : datetimeProps;
const now = nowProps || new Date();
const text =
Math.abs(differenceInHours(now, datetime, { roundingMethod: "round" })) > 1
? intlFormat(datetime, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "numeric",
})
: intlFormatDistance(datetime, now);
const text = formatReadableDate(datetime, now);
return (
<time className={className} dateTime={formatISO(datetime)}>
{text}

View File

@@ -15,22 +15,14 @@
// @vitest-environment happy-dom
import { create } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, beforeAll } from "vitest";
import { FragmentType } from "../gql/fragment-masking";
import { WithLocation } from "../test-utils/WithLocation";
import { mockLocale } from "../test-utils/mockLocale";
import DateTime from "./DateTime";
import OAuth2Session, { OAUTH2_SESSION_FRAGMENT } from "./OAuth2Session";
// Mock out datetime to avoid timezones/date formatting
vi.mock("./DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<OAuth2Session />", () => {
const defaultProps = {
session: {
@@ -49,6 +41,8 @@ describe("<OAuth2Session />", () => {
const finishedAt = "2023-06-29T03:35:19.451292+00:00";
beforeAll(() => mockLocale());
it("renders an active session", () => {
const component = create(
<WithLocation>

View File

@@ -0,0 +1,18 @@
/* Copyright 2023 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.active {
color: var(--cpd-color-text-success-primary);
}

View File

@@ -0,0 +1,60 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { Meta, StoryObj } from "@storybook/react";
import LastActive from "./LastActive";
type Props = {
lastActiveTimestamp: number;
now: number;
};
const Template: React.FC<Props> = ({ lastActiveTimestamp, now }) => {
return <LastActive lastActiveTimestamp={lastActiveTimestamp} now={now} />;
};
const meta = {
title: "UI/Session/Last active time",
component: Template,
tags: ["autodocs"],
} satisfies Meta<typeof Template>;
export default meta;
type Story = StoryObj<typeof Template>;
const now = 1694999531800;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export const Basic: Story = {
args: {
// yesterday
lastActiveTimestamp: now - ONE_DAY_MS,
now,
},
};
export const ActiveNow: Story = {
args: {
lastActiveTimestamp: now - 1000,
now,
},
};
export const Inactive: Story = {
args: {
// 91 days ago
lastActiveTimestamp: now - 91 * ONE_DAY_MS,
now,
},
};

View File

@@ -0,0 +1,46 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// @vitest-environment happy-dom
import { composeStory } from "@storybook/react";
import { render, cleanup } from "@testing-library/react";
import { describe, afterEach, expect, it, beforeAll } from "vitest";
import { mockLocale } from "../../test-utils/mockLocale";
import Meta, { ActiveNow, Basic, Inactive } from "./LastActive.stories";
describe("<LastActive", () => {
beforeAll(() => mockLocale());
afterEach(cleanup);
it("renders an 'active now' timestamp", () => {
const Component = composeStory(ActiveNow, { ...Meta });
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});
it("renders a default timestamp", () => {
const Component = composeStory(Basic, Meta);
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});
it("renders an inactive timestamp", () => {
const Component = composeStory(Inactive, Meta);
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,47 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { formatDate, formatReadableDate } from "../DateTime";
import styles from "./LastActive.module.css";
// 3 minutes
const ACTIVE_NOW_MAX_AGE = 1000 * 60 * 3;
/// 90 days
const INACTIVE_MIN_AGE = 1000 * 60 * 60 * 24 * 90;
const LastActive: React.FC<{ lastActiveTimestamp: number; now?: number }> = ({
lastActiveTimestamp,
now: nowProps,
}) => {
const now = nowProps || Date.now();
const formattedDate = formatDate(new Date(lastActiveTimestamp));
if (lastActiveTimestamp >= now - ACTIVE_NOW_MAX_AGE) {
return (
<span title={formattedDate} className={styles.active}>
Active now
</span>
);
}
if (lastActiveTimestamp < now - INACTIVE_MIN_AGE) {
return <span title={formattedDate}>Inactive for 90+ days</span>;
}
const relativeDate = formatReadableDate(
new Date(lastActiveTimestamp),
new Date(now),
);
return <span title={formattedDate}>{`Active ${relativeDate}`}</span>;
};
export default LastActive;

View File

@@ -15,11 +15,14 @@
// @vitest-environment happy-dom
import { render, cleanup, fireEvent } from "@testing-library/react";
import { describe, it, vi, expect, afterEach } from "vitest";
import { describe, it, vi, expect, afterEach, beforeAll } from "vitest";
import { mockLocale } from "../../test-utils/mockLocale";
import SelectableSession from "./SelectableSession";
describe("<SelectableSession />", () => {
beforeAll(() => mockLocale());
afterEach(cleanup);
it("renders an unselected session", () => {

View File

@@ -13,20 +13,12 @@
// limitations under the License.
import { create } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, beforeAll } from "vitest";
import DateTime from "../DateTime";
import { mockLocale } from "../../test-utils/mockLocale";
import Session from "./Session";
// Mock out datetime to avoid timezones/date formatting
vi.mock("../DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<Session />", () => {
const defaultProps = {
id: "session-id",
@@ -35,6 +27,8 @@ describe("<Session />", () => {
const finishedAt = "2023-06-29T03:35:19.451292+00:00";
beforeAll(() => mockLocale());
it("renders an active session", () => {
const component = create(<Session {...defaultProps} />);
expect(component.toJSON()).toMatchSnapshot();

View File

@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LastActive > renders a default timestamp 1`] = `
<div>
<span
title="Sun, 17 Sept 2023, 01:12"
>
Active Sun, 17 Sept 2023, 01:12
</span>
</div>
`;
exports[`<LastActive > renders an 'active now' timestamp 1`] = `
<div>
<span
class="_active_04756f"
title="Mon, 18 Sept 2023, 01:12"
>
Active now
</span>
</div>
`;
exports[`<LastActive > renders an inactive timestamp 1`] = `
<div>
<span
title="Mon, 19 Jun 2023, 01:12"
>
Inactive for 90+ days
</span>
</div>
`;

View File

@@ -14,18 +14,22 @@ exports[`<Session /> > renders a finished session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
data-finished={true}
>
Finished
<code>
2023-06-29T03:35:19.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:19Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</div>
`;
@@ -44,9 +48,11 @@ exports[`<Session /> > renders an active session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</div>
`;
@@ -65,18 +71,22 @@ exports[`<Session /> > uses client name when truthy 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
data-finished={true}
>
Finished
<code>
2023-06-29T03:35:19.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:19Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-regular_1jx6b_40 _sessionMetadata_634806"
@@ -105,18 +115,22 @@ exports[`<Session /> > uses session name when truthy 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
data-finished={true}
>
Finished
<code>
2023-06-29T03:35:19.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:19Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</div>
`;

View File

@@ -15,23 +15,15 @@
// @vitest-environment happy-dom
import { render, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach, vi } from "vitest";
import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { makeFragmentData } from "../../gql/fragment-masking";
import { WithLocation } from "../../test-utils/WithLocation";
import { mockLocale } from "../../test-utils/mockLocale";
import { COMPAT_SESSION_FRAGMENT } from "../CompatSession";
import DateTime from "../DateTime";
import CompatSessionDetail from "./CompatSessionDetail";
// Mock out datetime to avoid timezones/date formatting
vi.mock("../DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<CompatSessionDetail>", () => {
const baseSession = {
id: "session-id",
@@ -42,6 +34,8 @@ describe("<CompatSessionDetail>", () => {
redirectUri: "https://element.io",
},
};
beforeAll(() => mockLocale());
afterEach(cleanup);
it("renders a compatability session details", () => {

View File

@@ -15,23 +15,15 @@
// @vitest-environment happy-dom
import { render, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach, vi } from "vitest";
import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { makeFragmentData } from "../../gql/fragment-masking";
import { WithLocation } from "../../test-utils/WithLocation";
import DateTime from "../DateTime";
import { mockLocale } from "../../test-utils/mockLocale";
import { OAUTH2_SESSION_FRAGMENT } from "../OAuth2Session";
import OAuth2SessionDetail from "./OAuth2SessionDetail";
// Mock out datetime to avoid timezones/date formatting
vi.mock("../DateTime", () => {
const MockDateTime: typeof DateTime = ({ datetime }) => (
<code>{datetime.toString()}</code>
);
return { default: MockDateTime };
});
describe("<OAuth2SessionDetail>", () => {
const baseSession = {
id: "session-id",
@@ -45,6 +37,8 @@ describe("<OAuth2SessionDetail>", () => {
clientUri: "https://element.io",
},
};
beforeAll(() => mockLocale());
afterEach(cleanup);
it("renders session details", () => {

View File

@@ -87,9 +87,11 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
<p
class="_font-body-sm-regular_1jx6b_40 _detailValue_040867"
>
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
datetime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</li>
</ul>
@@ -248,9 +250,11 @@ exports[`<CompatSessionDetail> > renders a compatability session without an ssoL
<p
class="_font-body-sm-regular_1jx6b_40 _detailValue_040867"
>
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
datetime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</li>
</ul>

View File

@@ -87,9 +87,11 @@ exports[`<OAuth2SessionDetail> > renders session details 1`] = `
<p
class="_font-body-sm-regular_1jx6b_40 _detailValue_040867"
>
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
datetime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
</li>
<li

View File

@@ -20,18 +20,22 @@ exports[`<CompatSession /> > renders a finished session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
data-finished={true}
>
Finished
<code>
2023-06-29T03:35:19.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:19Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-regular_1jx6b_40 _sessionMetadata_634806"
@@ -66,9 +70,11 @@ exports[`<CompatSession /> > renders an active session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-regular_1jx6b_40 _sessionMetadata_634806"

View File

@@ -20,18 +20,22 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
data-finished={true}
>
Finished
<code>
2023-06-29T03:35:19.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:19Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-regular_1jx6b_40 _sessionMetadata_634806"
@@ -66,9 +70,11 @@ exports[`<OAuth2Session /> > renders an active session 1`] = `
className="_font-body-sm-semibold_1jx6b_45 _sessionMetadata_634806"
>
Signed in
<code>
2023-06-29T03:35:17.451292+00:00
</code>
<time
dateTime="2023-06-29T03:35:17Z"
>
Thu, 29 Jun 2023, 03:35
</time>
</p>
<p
className="_font-body-sm-regular_1jx6b_40 _sessionMetadata_634806"

View File

@@ -0,0 +1,30 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { vi } from "vitest";
/**
* Mock the locale on Intl.DateTimeFormat
* To achieve stable formatted dates across environments
* Defaults to `en-GB`
*/
export const mockLocale = (defaultLocale = "en-GB"): void => {
const { DateTimeFormat } = Intl;
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(
(
locales?: string | string[] | undefined,
options?: Intl.DateTimeFormatOptions | undefined,
) => new DateTimeFormat(locales || defaultLocale, options),
);
};

View File

@@ -9,6 +9,7 @@
"include": [
".storybook/main.ts",
"vite.config.ts",
"vitest.global-setup.ts",
".eslintrc.cjs",
"postcss.config.cjs",
"tailwind.config.cjs",

View File

@@ -122,11 +122,12 @@ export default defineConfig((env) => ({
proxy: {
// Routes mostly extracted from crates/router/src/endpoints.rs
"^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*)$":
"http://127.0.0.1:8080",
"https://auth-oidc.lab.element.dev",
},
},
test: {
globalSetup: "./vitest.global-setup.ts",
coverage: {
provider: "v8",
src: ["./src/"],

View File

@@ -0,0 +1,17 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export const setup = (): void => {
process.env.TZ = "UTC";
};