Skip to content

Commit 4f9e75c

Browse files
committed
frontend: allow setting custom names to sessions
1 parent 1696102 commit 4f9e75c

10 files changed

+427
-30
lines changed

frontend/locales/en.json

+5
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,11 @@
258258
"last_active_label": "Last Active",
259259
"name_for_platform": "{{name}} for {{platform}}",
260260
"scopes_label": "Scopes",
261+
"set_device_name": {
262+
"help": "Set a name that will help you identify this device.",
263+
"label": "Device name",
264+
"title": "Edit device name"
265+
},
261266
"signed_in_label": "Signed in",
262267
"title": "Device details",
263268
"unknown_browser": "Unknown browser",

frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx

+10-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
// @vitest-environment happy-dom
88

9+
import { TooltipProvider } from "@vector-im/compound-web";
910
import { beforeAll, describe, expect, it } from "vitest";
1011
import { makeFragmentData } from "../../gql";
1112
import { mockLocale } from "../../test-utils/mockLocale";
@@ -33,7 +34,9 @@ describe("<CompatSessionDetail>", () => {
3334
const data = makeFragmentData({ ...baseSession }, FRAGMENT);
3435

3536
const { container, getByText, queryByText } = render(
36-
<CompatSessionDetail session={data} />,
37+
<TooltipProvider>
38+
<CompatSessionDetail session={data} />
39+
</TooltipProvider>,
3740
);
3841

3942
expect(container).toMatchSnapshot();
@@ -51,7 +54,9 @@ describe("<CompatSessionDetail>", () => {
5154
);
5255

5356
const { container, getByText, queryByText } = render(
54-
<CompatSessionDetail session={data} />,
57+
<TooltipProvider>
58+
<CompatSessionDetail session={data} />
59+
</TooltipProvider>,
5560
);
5661

5762
expect(container).toMatchSnapshot();
@@ -69,7 +74,9 @@ describe("<CompatSessionDetail>", () => {
6974
);
7075

7176
const { container, getByText, queryByText } = render(
72-
<CompatSessionDetail session={data} />,
77+
<TooltipProvider>
78+
<CompatSessionDetail session={data} />
79+
</TooltipProvider>,
7380
);
7481

7582
expect(container).toMatchSnapshot();

frontend/src/components/SessionDetail/CompatSessionDetail.tsx

+28-1
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,28 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7+
import { useMutation, useQueryClient } from "@tanstack/react-query";
78
import { VisualList } from "@vector-im/compound-web";
89
import { parseISO } from "date-fns";
910
import { useTranslation } from "react-i18next";
1011
import { type FragmentType, graphql, useFragment } from "../../gql";
12+
import { graphqlRequest } from "../../graphql";
1113
import simplifyUrl from "../../utils/simplifyUrl";
1214
import DateTime from "../DateTime";
1315
import EndCompatSessionButton from "../Session/EndCompatSessionButton";
1416
import LastActive from "../Session/LastActive";
17+
import EditSessionName from "./EditSessionName";
1518
import SessionHeader from "./SessionHeader";
1619
import * as Info from "./SessionInfo";
1720

21+
const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ `
22+
mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {
23+
setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) {
24+
status
25+
}
26+
}
27+
`);
28+
1829
export const FRAGMENT = graphql(/* GraphQL */ `
1930
fragment CompatSession_detail on CompatSession {
2031
id
@@ -47,6 +58,19 @@ type Props = {
4758
const CompatSessionDetail: React.FC<Props> = ({ session }) => {
4859
const data = useFragment(FRAGMENT, session);
4960
const { t } = useTranslation();
61+
const queryClient = useQueryClient();
62+
63+
const setDisplayName = useMutation({
64+
mutationFn: (displayName: string) =>
65+
graphqlRequest({
66+
query: SET_SESSION_NAME_MUTATION,
67+
variables: { sessionId: data.id, displayName },
68+
}),
69+
onSuccess: () => {
70+
queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] });
71+
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
72+
},
73+
});
5074

5175
const deviceName =
5276
data.userAgent?.model ??
@@ -67,7 +91,10 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
6791

6892
return (
6993
<div className="flex flex-col gap-10">
70-
<SessionHeader to="/sessions">{sessionName}</SessionHeader>
94+
<SessionHeader to="/sessions">
95+
{sessionName}
96+
<EditSessionName mutation={setDisplayName} deviceName={sessionName} />
97+
</SessionHeader>
7198
<Info.DataSection>
7299
<Info.DataSectionHeader>
73100
{t("frontend.session.title")}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit";
7+
import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web";
8+
import {
9+
type ComponentPropsWithoutRef,
10+
forwardRef,
11+
useRef,
12+
useState,
13+
} from "react";
14+
import * as Dialog from "../Dialog";
15+
import LoadingSpinner from "../LoadingSpinner";
16+
17+
import type { UseMutationResult } from "@tanstack/react-query";
18+
import { useTranslation } from "react-i18next";
19+
20+
// This needs to be its own component because else props and refs aren't passed properly in the trigger
21+
const EditButton = forwardRef<
22+
HTMLButtonElement,
23+
{ label: string } & ComponentPropsWithoutRef<"button">
24+
>(({ label, ...props }, ref) => (
25+
<Tooltip label={label}>
26+
<IconButton
27+
ref={ref}
28+
type="button"
29+
size="var(--cpd-space-6x)"
30+
style={{ marginInline: "var(--cpd-space-2x)" }}
31+
{...props}
32+
>
33+
<IconEdit />
34+
</IconButton>
35+
</Tooltip>
36+
));
37+
38+
type Props = {
39+
mutation: UseMutationResult<unknown, unknown, string, unknown>;
40+
deviceName: string;
41+
};
42+
43+
const EditSessionName: React.FC<Props> = ({ mutation, deviceName }) => {
44+
const { t } = useTranslation();
45+
const fieldRef = useRef<HTMLInputElement>(null);
46+
const [open, setOpen] = useState(false);
47+
48+
const onSubmit = async (
49+
event: React.FormEvent<HTMLFormElement>,
50+
): Promise<void> => {
51+
event.preventDefault();
52+
53+
const form = event.currentTarget;
54+
const formData = new FormData(form);
55+
const displayName = formData.get("name") as string;
56+
await mutation.mutateAsync(displayName);
57+
setOpen(false);
58+
};
59+
return (
60+
<Dialog.Dialog
61+
trigger={<EditButton label={t("action.edit")} />}
62+
open={open}
63+
onOpenChange={(open) => {
64+
// Reset the form when the dialog is opened or closed
65+
fieldRef.current?.form?.reset();
66+
setOpen(open);
67+
}}
68+
>
69+
<Dialog.Title>{t("frontend.session.set_device_name.title")}</Dialog.Title>
70+
71+
<Form.Root onSubmit={onSubmit}>
72+
<Form.Field name="name">
73+
<Form.Label>{t("frontend.session.set_device_name.label")}</Form.Label>
74+
75+
<Form.TextControl
76+
type="text"
77+
required
78+
defaultValue={deviceName}
79+
ref={fieldRef}
80+
/>
81+
82+
<Form.HelpMessage>
83+
{t("frontend.session.set_device_name.help")}
84+
</Form.HelpMessage>
85+
</Form.Field>
86+
87+
<Form.Submit disabled={mutation.isPending}>
88+
{mutation.isPending && <LoadingSpinner inline />}
89+
{t("action.save")}
90+
</Form.Submit>
91+
</Form.Root>
92+
93+
<Dialog.Close asChild>
94+
<Button kind="tertiary">{t("action.cancel")}</Button>
95+
</Dialog.Close>
96+
</Dialog.Dialog>
97+
);
98+
};
99+
100+
export default EditSessionName;

frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from "vitest";
1111
import { makeFragmentData } from "../../gql";
1212
import { mockLocale } from "../../test-utils/mockLocale";
1313

14+
import { TooltipProvider } from "@vector-im/compound-web";
1415
import render from "../../test-utils/render";
1516
import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail";
1617

@@ -39,7 +40,9 @@ describe("<OAuth2SessionDetail>", () => {
3940
const data = makeFragmentData(baseSession, FRAGMENT);
4041

4142
const { asFragment, getByText, queryByText } = render(
42-
<OAuth2SessionDetail session={data} />,
43+
<TooltipProvider>
44+
<OAuth2SessionDetail session={data} />
45+
</TooltipProvider>,
4346
);
4447

4548
expect(asFragment()).toMatchSnapshot();
@@ -57,7 +60,9 @@ describe("<OAuth2SessionDetail>", () => {
5760
);
5861

5962
const { asFragment, getByText, queryByText } = render(
60-
<OAuth2SessionDetail session={data} />,
63+
<TooltipProvider>
64+
<OAuth2SessionDetail session={data} />
65+
</TooltipProvider>,
6166
);
6267

6368
expect(asFragment()).toMatchSnapshot();

frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,28 @@
44
// SPDX-License-Identifier: AGPL-3.0-only
55
// Please see LICENSE in the repository root for full details.
66

7+
import { useMutation, useQueryClient } from "@tanstack/react-query";
78
import { parseISO } from "date-fns";
89
import { useTranslation } from "react-i18next";
910
import { type FragmentType, graphql, useFragment } from "../../gql";
11+
import { graphqlRequest } from "../../graphql";
1012
import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope";
1113
import DateTime from "../DateTime";
1214
import ClientAvatar from "../Session/ClientAvatar";
1315
import EndOAuth2SessionButton from "../Session/EndOAuth2SessionButton";
1416
import LastActive from "../Session/LastActive";
17+
import EditSessionName from "./EditSessionName";
1518
import SessionHeader from "./SessionHeader";
1619
import * as Info from "./SessionInfo";
1720

21+
const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ `
22+
mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {
23+
setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) {
24+
status
25+
}
26+
}
27+
`);
28+
1829
export const FRAGMENT = graphql(/* GraphQL */ `
1930
fragment OAuth2Session_detail on Oauth2Session {
2031
id
@@ -50,6 +61,19 @@ type Props = {
5061
const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
5162
const data = useFragment(FRAGMENT, session);
5263
const { t } = useTranslation();
64+
const queryClient = useQueryClient();
65+
66+
const setDisplayName = useMutation({
67+
mutationFn: (displayName: string) =>
68+
graphqlRequest({
69+
query: SET_SESSION_NAME_MUTATION,
70+
variables: { sessionId: data.id, displayName },
71+
}),
72+
onSuccess: () => {
73+
queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] });
74+
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
75+
},
76+
});
5377

5478
const deviceId = getDeviceIdFromScope(data.scope);
5579
const clientName = data.client.clientName || data.client.clientId;
@@ -70,7 +94,9 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
7094
<div className="flex flex-col gap-10">
7195
<SessionHeader to="/sessions">
7296
{clientName}: {deviceName}
97+
<EditSessionName mutation={setDisplayName} deviceName={deviceName} />
7398
</SessionHeader>
99+
74100
<Info.DataSection>
75101
<Info.DataSectionHeader>
76102
{t("frontend.session.title")}

0 commit comments

Comments
 (0)