From f69b5fe20f4e72e22da6543b19359616a11ce244 Mon Sep 17 00:00:00 2001 From: Andri Schatz Date: Fri, 1 Nov 2024 14:36:12 +0100 Subject: [PATCH] Display timestamp of last usage for each Passkey device in PAsskey device selector (#2680) * start replacing DeviceData with DeviceWithUsage * finish replacing DeviceData with DeviceWithUsage, display last_usage formatted in devices list if available * remove unnecessary console log * BigInt in types -> bigint * adjust formatting * rearrange and use relative time format * format * create time format helper utility and tests * format --- .../src/flows/manage/authenticatorsSection.ts | 44 +++++++++++---- src/frontend/src/flows/manage/index.ts | 11 ++-- src/frontend/src/flows/manage/types.ts | 1 + src/frontend/src/styles/main.css | 4 ++ src/frontend/src/utils/time.test.ts | 55 +++++++++++++++++++ src/frontend/src/utils/time.ts | 30 ++++++++++ 6 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/frontend/src/utils/time.test.ts create mode 100644 src/frontend/src/utils/time.ts diff --git a/src/frontend/src/flows/manage/authenticatorsSection.ts b/src/frontend/src/flows/manage/authenticatorsSection.ts index 7e43fd8523..c88519cfbb 100644 --- a/src/frontend/src/flows/manage/authenticatorsSection.ts +++ b/src/frontend/src/flows/manage/authenticatorsSection.ts @@ -1,4 +1,5 @@ import { warningIcon } from "$src/components/icons"; +import { formatLastUsage } from "$src/utils/time"; import { isNullish, nonNullish } from "@dfinity/utils"; import { TemplateResult, html } from "lit-html"; import { settingsDropdown } from "./settingsDropdown"; @@ -109,7 +110,7 @@ export const authenticatorsSection = ({ }; export const authenticatorItem = ({ - authenticator: { alias, dupCount, warn, remove, rename }, + authenticator: { alias, last_usage, dupCount, warn, remove, rename }, index, icon, }: { @@ -125,21 +126,42 @@ export const authenticatorItem = ({ settings.push({ action: "remove", caption: "Remove", fn: () => remove() }); } + let lastUsageTimeStamp: Date | undefined; + let lastUsageFormattedString: string | undefined; + + if (last_usage.length > 0 && typeof last_usage[0] === "bigint") { + lastUsageTimeStamp = new Date(Number(last_usage[0] / BigInt(1000000))); + } + + if (lastUsageTimeStamp) { + lastUsageFormattedString = formatLastUsage(lastUsageTimeStamp); + } + return html`
  • ${isNullish(warn) ? undefined : itemWarning({ warn })} ${isNullish(icon) ? undefined : html`${icon}`} -
    - ${alias} - ${nonNullish(dupCount) && dupCount > 0 - ? html` (${dupCount})` - : undefined} +
    +
    + ${alias} + ${nonNullish(dupCount) && dupCount > 0 + ? html` (${dupCount})` + : undefined} +
    + ${settingsDropdown({ + alias, + id: `authenticator-${index}`, + settings, + })} +
    +
    + ${nonNullish(lastUsageFormattedString) + ? html`
    + Last used: ${lastUsageFormattedString} +
    ` + : undefined} +
    - ${settingsDropdown({ - alias, - id: `authenticator-${index}`, - settings, - })}
  • `; }; diff --git a/src/frontend/src/flows/manage/index.ts b/src/frontend/src/flows/manage/index.ts index 8d51f4d9ea..03e5ecaf4c 100644 --- a/src/frontend/src/flows/manage/index.ts +++ b/src/frontend/src/flows/manage/index.ts @@ -1,5 +1,6 @@ import { DeviceData, + DeviceWithUsage, IdentityAnchorInfo, } from "$generated/internet_identity_types"; import identityCardBackground from "$src/assets/identityCardBackground.png"; @@ -290,19 +291,20 @@ function isPinAuthenticated( export const displayManage = ( userNumber: bigint, connection: AuthenticatedConnection, - devices_: DeviceData[], + devices_: DeviceWithUsage[], identityBackground: PreLoadImage ): Promise => { // Fetch the dapps used in the teaser & explorer // (dapps are suffled to encourage discovery of new dapps) const dapps = shuffleArray(getDapps()); return new Promise((resolve) => { - const devices = devicesFromDeviceDatas({ + const devices = devicesFromDevicesWithUsage({ devices: devices_, userNumber, connection, reload: resolve, }); + if (devices.dupPhrase) { toast.error( "More than one recovery phrases are registered, which is unexpected. Only one will be shown." @@ -435,13 +437,13 @@ export const readRecovery = ({ // Convert devices read from the canister into types that are easier to work with // and that better represent what we expect. -export const devicesFromDeviceDatas = ({ +export const devicesFromDevicesWithUsage = ({ devices: devices_, reload, connection, userNumber, }: { - devices: DeviceData[]; + devices: DeviceWithUsage[]; reload: (connection?: AuthenticatedConnection) => void; connection: AuthenticatedConnection; userNumber: bigint; @@ -470,6 +472,7 @@ export const devicesFromDeviceDatas = ({ const authenticator = { alias: device.alias, + last_usage: device.last_usage, warn: domainWarning(device), rename: () => renameDevice({ connection, device, reload }), remove: hasSingleDevice diff --git a/src/frontend/src/flows/manage/types.ts b/src/frontend/src/flows/manage/types.ts index de42117240..468415999f 100644 --- a/src/frontend/src/flows/manage/types.ts +++ b/src/frontend/src/flows/manage/types.ts @@ -3,6 +3,7 @@ import { TemplateResult } from "lit-html"; // A simple authenticator (non-recovery device) export type Authenticator = { alias: string; + last_usage: [] | [bigint]; rename: () => void; remove?: () => void; warn?: TemplateResult; diff --git a/src/frontend/src/styles/main.css b/src/frontend/src/styles/main.css index 1debc99392..ab8aa7edb8 100644 --- a/src/frontend/src/styles/main.css +++ b/src/frontend/src/styles/main.css @@ -2757,6 +2757,10 @@ a.c-action-list__item { justify-content: center; } +.c-action-list__label--spacer { + width: 100%; +} + .c-action-list__action { cursor: pointer; color: var(--rc-text); diff --git a/src/frontend/src/utils/time.test.ts b/src/frontend/src/utils/time.test.ts new file mode 100644 index 0000000000..9b02a84748 --- /dev/null +++ b/src/frontend/src/utils/time.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { formatLastUsage } from "./time"; + +describe("formatLastUsage", () => { + const NOW = new Date(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("formats time within the last hour", () => { + const timestamp = new Date(NOW.getTime() - 30 * 60 * 1000); // 30 minutes ago + expect(formatLastUsage(timestamp)).toBe( + `today at ${timestamp.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + })}` + ); + }); + + test("formats time from earlier today", () => { + const timestamp = new Date(NOW.getTime() - 7 * 60 * 60 * 1000); // 7 hours ago + expect(formatLastUsage(timestamp)).toBe( + `today at ${timestamp.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + })}` + ); + }); + + test("formats time from yesterday", () => { + const timestamp = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago + expect(formatLastUsage(timestamp)).toBe("yesterday"); + }); + + test("formats time from several days ago", () => { + const timestamp = new Date(NOW.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + expect(formatLastUsage(timestamp)).toBe("5 days ago"); + }); + + test("formats time from last month", () => { + const timestamp = new Date(NOW.getTime() - 30 * 24 * 60 * 60 * 1000); // ~1 month ago + expect(formatLastUsage(timestamp)).toBe("last month"); + }); + + test("formats time from 5 months ago", () => { + const timestamp = new Date(NOW.getTime() - 5 * 30 * 24 * 60 * 60 * 1000); // ~1 month ago + expect(formatLastUsage(timestamp)).toBe("5 months ago"); + }); +}); diff --git a/src/frontend/src/utils/time.ts b/src/frontend/src/utils/time.ts new file mode 100644 index 0000000000..dbafd75af0 --- /dev/null +++ b/src/frontend/src/utils/time.ts @@ -0,0 +1,30 @@ +export const formatLastUsage = (timestamp: Date): string => { + const now = new Date(); + const diffInMillis = timestamp.getTime() - now.getTime(); + const diffInDays = Math.round(diffInMillis / (1000 * 60 * 60 * 24)); + + // If more than 25 days, use months + if (Math.abs(diffInDays) >= 25) { + const diffInMonths = Math.round(diffInDays / 30); + return new Intl.RelativeTimeFormat("en", { + numeric: "auto", + style: "long", + }).format(diffInMonths, "month"); + } + + const relativeTime = new Intl.RelativeTimeFormat("en", { + numeric: "auto", + style: "long", + }).format(diffInDays, "day"); + + // If within last 24 hours, append the time + if (Math.abs(diffInDays) < 1) { + const timeString = new Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "numeric", + }).format(timestamp); + return `${relativeTime} at ${timeString}`; + } + + return relativeTime; +};