1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-11-23 11:02:35 +03:00

Finish replacing jotai-urql with bare urql

Mostly remaining was pagination related stuff & fixing tests
This commit is contained in:
Quentin Gliech
2024-02-12 13:31:12 +01:00
parent 3f90839744
commit f0f7497d2d
17 changed files with 397 additions and 452 deletions

View File

@@ -39,7 +39,6 @@
}
},
"app_sessions_list": {
"error": "Failed to load app sessions",
"heading": "Apps"
},
"browser_session_details": {

View File

@@ -38,7 +38,6 @@
"jotai": "^2.6.4",
"jotai-devtools": "^0.7.1",
"jotai-location": "^0.5.2",
"jotai-urql": "^0.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.0.5",

View File

@@ -1,88 +0,0 @@
// 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 { AnyVariables, CombinedError, OperationContext } from "@urql/core";
import { atom, WritableAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { AtomWithQuery, clientAtom } from "jotai-urql";
import type { ReactElement } from "react";
import { useQuery } from "urql";
import { graphql } from "./gql";
import { client } from "./graphql";
import { err, ok, Result } from "./result";
export type GqlResult<T> = Result<T, CombinedError>;
export type GqlAtom<T> = WritableAtom<
Promise<GqlResult<T>>,
[context?: Partial<OperationContext>],
void
>;
/**
* Map the result of a query atom to a new value, making it a GqlResult
*
* @param queryAtom: An atom got from atomWithQuery
* @param mapper: A function that takes the data from the query and returns a new value
*/
export const mapQueryAtom = <Data, Variables extends AnyVariables, NewData>(
queryAtom: AtomWithQuery<Data, Variables>,
mapper: (data: Data) => NewData,
): GqlAtom<NewData> => {
return atom(
async (get): Promise<GqlResult<NewData>> => {
const result = await get(queryAtom);
if (result.error) {
return err(result.error);
}
if (result.data === undefined) {
throw new Error("Query result is undefined");
}
return ok(mapper(result.data));
},
(_get, set, context) => {
set(queryAtom, context);
},
);
};
export const HydrateAtoms: React.FC<{ children: ReactElement }> = ({
children,
}) => {
useHydrateAtoms([[clientAtom, client]]);
return children;
};
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
viewer {
__typename
... on User {
id
}
}
}
`);
export const useCurrentUserId = (): string | null => {
const [result] = useQuery({ query: CURRENT_VIEWER_QUERY });
if (result.error) throw result.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
return result.data.viewer.__typename === "User"
? result.data.viewer.id
: null;
};

View File

@@ -12,21 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { useQuery } from "urql";
import { mapQueryAtom } from "../atoms";
import { graphql } from "../gql";
import { SessionState, PageInfo } from "../gql/graphql";
import {
atomForCurrentPagination,
atomWithPagination,
FIRST_PAGE,
Pagination,
} from "../pagination";
import { isOk, unwrap, unwrapOk } from "../result";
import { SessionState } from "../gql/graphql";
import { FIRST_PAGE, Pagination, usePages, usePagination } from "../pagination";
import BlockList from "./BlockList";
import BrowserSession from "./BrowserSession";
@@ -72,52 +63,22 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const filterAtom = atom<SessionState | null>(SessionState.Active);
const currentPaginationAtom = atomForCurrentPagination();
const browserSessionListFamily = atomFamily((userId: string) => {
const browserSessionListQuery = atomWithQuery({
query: QUERY,
getVariables: (get) => ({
userId,
state: get(filterAtom),
...get(currentPaginationAtom),
}),
});
const browserSessionList = mapQueryAtom(
browserSessionListQuery,
(data) => data.user?.browserSessions || null,
);
return browserSessionList;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(browserSessionListFamily(userId));
return (isOk(result) && unwrapOk(result)?.pageInfo) || null;
});
return pageInfoAtom;
});
const paginationFamily = atomFamily((userId: string) => {
const paginationAtom = atomWithPagination(
currentPaginationAtom,
pageInfoFamily(userId),
);
return paginationAtom;
});
const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
const [pagination, setPagination] = usePagination();
const [pending, startTransition] = useTransition();
const result = useAtomValue(browserSessionListFamily(userId));
const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
const [filter, setFilter] = useAtom(filterAtom);
const [filter, setFilter] = useState<SessionState | null>(
SessionState.Active,
);
const [result] = useQuery({
query: QUERY,
variables: { userId, state: filter, ...pagination },
});
if (result.error) throw result.error;
const browserSessions = result.data?.user?.browserSessions;
if (!browserSessions) throw new Error(); // Suspense mode is enabled
const [prevPage, nextPage] = usePages(pagination, browserSessions.pageInfo);
const browserSessions = unwrap(result);
if (browserSessions === null) return <>Failed to load browser sessions</>;
const paginate = (pagination: Pagination): void => {
@@ -145,6 +106,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
<label>
<input
type="checkbox"
disabled={pending}
checked={filter === SessionState.Active}
onChange={toggleFilter}
/>{" "}

View File

@@ -15,7 +15,9 @@
// @vitest-environment happy-dom
import { create } from "react-test-renderer";
import { Provider } from "urql";
import { describe, expect, it, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../gql";
import { WithLocation } from "../test-utils/WithLocation";
@@ -24,6 +26,10 @@ import { mockLocale } from "../test-utils/mockLocale";
import CompatSession, { FRAGMENT } from "./CompatSession";
describe("<CompatSession />", () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const baseSession = {
id: "session-id",
deviceId: "abcd1234",
@@ -42,9 +48,11 @@ describe("<CompatSession />", () => {
it("renders an active session", () => {
const session = makeFragmentData(baseSession, FRAGMENT);
const component = create(
<WithLocation>
<CompatSession session={session} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<CompatSession session={session} />
</WithLocation>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -58,9 +66,11 @@ describe("<CompatSession />", () => {
FRAGMENT,
);
const component = create(
<WithLocation>
<CompatSession session={session} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<CompatSession session={session} />
</WithLocation>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
});

View File

@@ -15,7 +15,9 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { Provider } from "urql";
import { describe, expect, it } from "vitest";
import { never } from "wonka";
import { WithLocation } from "../../test-utils/WithLocation";
@@ -23,10 +25,16 @@ import Layout from "./Layout";
describe("<Layout />", () => {
it("renders app navigation correctly", async () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const component = render(
<WithLocation path="/">
<Layout userId="abc123" />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation path="/">
<Layout userId="abc123" />
</WithLocation>
</Provider>,
);
expect(await component.findByText("Profile")).toMatchSnapshot();

View File

@@ -15,7 +15,9 @@
// @vitest-environment happy-dom
import { create } from "react-test-renderer";
import { Provider } from "urql";
import { describe, expect, it, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../gql";
import { Oauth2ApplicationType } from "../gql/graphql";
@@ -25,6 +27,10 @@ import { mockLocale } from "../test-utils/mockLocale";
import OAuth2Session, { FRAGMENT } from "./OAuth2Session";
describe("<OAuth2Session />", () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const defaultSession = {
id: "session-id",
scope:
@@ -48,9 +54,11 @@ describe("<OAuth2Session />", () => {
const session = makeFragmentData(defaultSession, FRAGMENT);
const component = create(
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -64,9 +72,11 @@ describe("<OAuth2Session />", () => {
FRAGMENT,
);
const component = create(
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
});
@@ -84,9 +94,11 @@ describe("<OAuth2Session />", () => {
FRAGMENT,
);
const component = create(
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<OAuth2Session session={session} />
</WithLocation>
</Provider>,
);
expect(component.toJSON()).toMatchSnapshot();
});

View File

@@ -15,7 +15,9 @@
// @vitest-environment happy-dom
import { render, cleanup } from "@testing-library/react";
import { Provider } from "urql";
import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../../gql";
import { WithLocation } from "../../test-utils/WithLocation";
@@ -24,6 +26,10 @@ import { mockLocale } from "../../test-utils/mockLocale";
import CompatSessionDetail, { FRAGMENT } from "./CompatSessionDetail";
describe("<CompatSessionDetail>", () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const baseSession = {
id: "session-id",
deviceId: "abcd1234",
@@ -43,9 +49,11 @@ describe("<CompatSessionDetail>", () => {
const data = makeFragmentData(baseSession, FRAGMENT);
const { container } = render(
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>
</Provider>,
);
expect(container).toMatchSnapshot();
@@ -61,9 +69,11 @@ describe("<CompatSessionDetail>", () => {
);
const { container } = render(
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>
</Provider>,
);
expect(container).toMatchSnapshot();
@@ -79,9 +89,11 @@ describe("<CompatSessionDetail>", () => {
);
const { getByText, queryByText } = render(
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<CompatSessionDetail session={data} />
</WithLocation>
</Provider>,
);
expect(getByText("Finished")).toBeTruthy();

View File

@@ -15,7 +15,9 @@
// @vitest-environment happy-dom
import { render, cleanup } from "@testing-library/react";
import { Provider } from "urql";
import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { never } from "wonka";
import { makeFragmentData } from "../../gql";
import { WithLocation } from "../../test-utils/WithLocation";
@@ -24,6 +26,10 @@ import { mockLocale } from "../../test-utils/mockLocale";
import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail";
describe("<OAuth2SessionDetail>", () => {
const mockClient = {
executeQuery: (): typeof never => never,
};
const baseSession = {
id: "session-id",
scope:
@@ -46,9 +52,11 @@ describe("<OAuth2SessionDetail>", () => {
const data = makeFragmentData(baseSession, FRAGMENT);
const { container } = render(
<WithLocation>
<OAuth2SessionDetail session={data} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<OAuth2SessionDetail session={data} />
</WithLocation>
</Provider>,
);
expect(container).toMatchSnapshot();
@@ -64,9 +72,11 @@ describe("<OAuth2SessionDetail>", () => {
);
const { getByText, queryByText } = render(
<WithLocation>
<OAuth2SessionDetail session={data} />
</WithLocation>,
<Provider value={mockClient}>
<WithLocation>
<OAuth2SessionDetail session={data} />
</WithLocation>
</Provider>,
);
expect(getByText("Finished")).toBeTruthy();

View File

@@ -13,19 +13,17 @@
// limitations under the License.
import { Alert, H3 } from "@vector-im/compound-web";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useSetAtom } from "jotai";
import { useTransition } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { graphql } from "../../gql";
import { PageInfo } from "../../gql/graphql";
import {
atomForCurrentPagination,
atomWithPagination,
FIRST_PAGE,
Pagination,
usePages,
usePagination,
} from "../../pagination";
import { routeAtom } from "../../routing";
import BlockList from "../BlockList";
@@ -76,67 +74,31 @@ const PRIMARY_EMAIL_QUERY = graphql(/* GraphQL */ `
}
`);
export const primaryEmailResultFamily = atomFamily((userId: string) => {
const primaryEmailResult = atomWithQuery({
query: PRIMARY_EMAIL_QUERY,
getVariables: () => ({ userId }),
});
return primaryEmailResult;
});
const primaryEmailIdFamily = atomFamily((userId: string) => {
const primaryEmailIdAtom = atom(
async (get) => {
const result = await get(primaryEmailResultFamily(userId));
return result.data?.user?.primaryEmail?.id ?? null;
},
(get, set) => {
set(primaryEmailResultFamily(userId));
},
);
return primaryEmailIdAtom;
});
export const currentPaginationAtom = atomForCurrentPagination();
export const emailPageResultFamily = atomFamily((userId: string) => {
const emailPageResult = atomWithQuery({
query: QUERY,
getVariables: (get) => ({ userId, ...get(currentPaginationAtom) }),
});
return emailPageResult;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(emailPageResultFamily(userId));
return result.data?.user?.emails?.pageInfo ?? null;
});
return pageInfoAtom;
});
const paginationFamily = atomFamily((userId: string) => {
const paginationAtom = atomWithPagination(
currentPaginationAtom,
pageInfoFamily(userId),
);
return paginationAtom;
});
const UserEmailList: React.FC<{
userId: string;
}> = ({ userId }) => {
const [pending, startTransition] = useTransition();
const [result, refreshList] = useAtom(emailPageResultFamily(userId));
const setPagination = useSetAtom(currentPaginationAtom);
const setRoute = useSetAtom(routeAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
const [primaryEmailId, refreshPrimaryEmailId] = useAtom(
primaryEmailIdFamily(userId),
);
const { t } = useTranslation();
const [pending, startTransition] = useTransition();
const [pagination, setPagination] = usePagination();
const [result, refreshList] = useQuery({
query: QUERY,
variables: { userId, ...pagination },
});
if (result.error) throw result.error;
const emails = result.data?.user?.emails;
if (!emails) throw new Error(); // Suspense mode is enabled
const setRoute = useSetAtom(routeAtom);
const [prevPage, nextPage] = usePages(pagination, emails.pageInfo);
const [primaryEmailResult, refreshPrimaryEmail] = useQuery({
query: PRIMARY_EMAIL_QUERY,
variables: { userId },
});
if (primaryEmailResult.error) throw primaryEmailResult.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
const primaryEmailId = primaryEmailResult.data?.user?.primaryEmail?.id;
const paginate = (pagination: Pagination): void => {
startTransition(() => {
@@ -168,19 +130,19 @@ const UserEmailList: React.FC<{
title={t("frontend.user_email_list.no_primary_email_alert")}
/>
)}
{result.data?.user?.emails?.edges?.map((edge) => (
{emails.edges.map((edge) => (
<UserEmail
email={edge.node}
key={edge.cursor}
isPrimary={primaryEmailId === edge.node.id}
onSetPrimary={refreshPrimaryEmailId}
onSetPrimary={refreshPrimaryEmail}
onRemove={onRemove}
/>
))}
<PaginationControls
autoHide
count={result.data?.user?.emails?.totalCount ?? 0}
count={emails.totalCount ?? 0}
onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending}

View File

@@ -13,17 +13,26 @@
// limitations under the License.
import { Alert, Button, Form } from "@vector-im/compound-web";
import { useAtom } from "jotai";
import { useState, ChangeEventHandler } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "urql";
import { useMutation, useQuery } from "urql";
import { graphql } from "../../gql";
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
import { userGreetingFamily } from "../UserGreeting";
import styles from "./UserName.module.css";
const QUERY = graphql(/* GraphQL */ `
query UserDisplayName($userId: ID!) {
user(id: $userId) {
id
matrix {
displayName
}
}
}
`);
const SET_DISPLAYNAME_MUTATION = graphql(/* GraphQL */ `
mutation SetDisplayName($userId: ID!, $displayName: String) {
setDisplayName(input: { userId: $userId, displayName: $displayName }) {
@@ -51,10 +60,11 @@ const getErrorMessage = (result: {
};
const UserName: React.FC<{ userId: string }> = ({ userId }) => {
const [userGreeting, refreshUserGreeting] = useAtom(
userGreetingFamily(userId),
);
const displayName = userGreeting.data?.user?.matrix.displayName || "";
const [result, refreshUserGreeting] = useQuery({
query: QUERY,
variables: { userId },
});
const displayName = result.data?.user?.matrix.displayName || "";
const [setDisplayNameResult, setDisplayName] = useMutation(
SET_DISPLAYNAME_MUTATION,

View File

@@ -13,21 +13,12 @@
// limitations under the License.
import { H5 } from "@vector-im/compound-web";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { mapQueryAtom } from "../../atoms";
import { graphql } from "../../gql";
import { SessionState, PageInfo } from "../../gql/graphql";
import {
atomForCurrentPagination,
atomWithPagination,
Pagination,
} from "../../pagination";
import { isOk, unwrap, unwrapOk } from "../../result";
import { Pagination, usePages, usePagination } from "../../pagination";
import BlockList from "../BlockList";
import CompatSession from "../CompatSession";
import OAuth2Session from "../OAuth2Session";
@@ -73,44 +64,6 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const filterAtom = atom<SessionState | null>(SessionState.Active);
const currentPaginationAtom = atomForCurrentPagination();
export const appSessionListFamily = atomFamily((userId: string) => {
const appSessionListQuery = atomWithQuery({
query: QUERY,
getVariables: (get) => ({
userId,
state: get(filterAtom),
...get(currentPaginationAtom),
}),
});
const appSessionList = mapQueryAtom(
appSessionListQuery,
(data) => data.user?.appSessions || null,
);
return appSessionList;
});
const pageInfoFamily = atomFamily((userId: string) => {
const pageInfoAtom = atom(async (get): Promise<PageInfo | null> => {
const result = await get(appSessionListFamily(userId));
return (isOk(result) && unwrapOk(result)?.pageInfo) || null;
});
return pageInfoAtom;
});
const paginationFamily = atomFamily((userId: string) => {
const paginationAtom = atomWithPagination(
currentPaginationAtom,
pageInfoFamily(userId),
);
return paginationAtom;
});
// A type-safe way to ensure we've handled all session types
const unknownSessionType = (type: never): never => {
throw new Error(`Unknown session type: ${type}`);
@@ -118,14 +71,17 @@ const unknownSessionType = (type: never): never => {
const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
const [pending, startTransition] = useTransition();
const result = useAtomValue(appSessionListFamily(userId));
const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
const [pagination, setPagination] = usePagination();
const [result] = useQuery({
query: QUERY,
variables: { userId, ...pagination },
});
if (result.error) throw result.error;
const appSessions = result.data?.user?.appSessions;
if (!appSessions) throw new Error(); // Suspense mode is enabled
const [prevPage, nextPage] = usePages(pagination, appSessions.pageInfo);
const { t } = useTranslation();
const appSessions = unwrap(result);
if (!appSessions) return <>{t("frontend.app_sessions_list.error")}</>;
const paginate = (pagination: Pagination): void => {
startTransition(() => {
setPagination(pagination);

View File

@@ -13,8 +13,6 @@ import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n":
types.CurrentViewerQueryDocument,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
types.BrowserSession_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n":
@@ -57,6 +55,8 @@ const documents = {
types.UserEmailListQueryDocument,
"\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n":
types.UserPrimaryEmailDocument,
"\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n":
types.UserDisplayNameDocument,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n user {\n id\n matrix {\n displayName\n }\n }\n }\n }\n":
types.SetDisplayNameDocument,
"\n query AppSessionList(\n $userId: ID!\n $state: SessionState\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n appSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: $state\n ) {\n totalCount\n\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
@@ -79,6 +79,8 @@ const documents = {
types.VerifyEmailQueryDocument,
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument,
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n":
types.CurrentViewerQueryDocument,
};
/**
@@ -95,12 +97,6 @@ const documents = {
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -227,6 +223,12 @@ export function graphql(
export function graphql(
source: "\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n",
): (typeof documents)["\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n",
): (typeof documents)["\n query UserDisplayName($userId: ID!) {\n user(id: $userId) {\n id\n matrix {\n displayName\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -293,6 +295,12 @@ export function graphql(
export function graphql(
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on User {\n id\n }\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};

View File

@@ -1107,13 +1107,6 @@ export type Viewer = Anonymous | User;
/** Represents the current viewer's session */
export type ViewerSession = Anonymous | BrowserSession | Oauth2Session;
export type CurrentViewerQueryQueryVariables = Exact<{ [key: string]: never }>;
export type CurrentViewerQueryQuery = {
__typename?: "Query";
viewer: { __typename: "Anonymous" } | { __typename: "User"; id: string };
};
export type BrowserSession_SessionFragment = {
__typename?: "BrowserSession";
id: string;
@@ -1485,6 +1478,19 @@ export type UserPrimaryEmailQuery = {
} | null;
};
export type UserDisplayNameQueryVariables = Exact<{
userId: Scalars["ID"]["input"];
}>;
export type UserDisplayNameQuery = {
__typename?: "Query";
user?: {
__typename?: "User";
id: string;
matrix: { __typename?: "MatrixUser"; displayName?: string | null };
} | null;
};
export type SetDisplayNameMutationVariables = Exact<{
userId: Scalars["ID"]["input"];
displayName?: InputMaybe<Scalars["String"]["input"]>;
@@ -1678,6 +1684,13 @@ export type CurrentViewerSessionQueryQuery = {
| { __typename: "Oauth2Session" };
};
export type CurrentViewerQueryQueryVariables = Exact<{ [key: string]: never }>;
export type CurrentViewerQueryQuery = {
__typename?: "Query";
viewer: { __typename: "Anonymous" } | { __typename: "User"; id: string };
};
export const BrowserSession_SessionFragmentDoc = {
kind: "Document",
definitions: [
@@ -2058,47 +2071,6 @@ export const UserEmail_VerifyEmailFragmentDoc = {
},
],
} as unknown as DocumentNode<UserEmail_VerifyEmailFragment, unknown>;
export const CurrentViewerQueryDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "CurrentViewerQuery" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "viewer" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "User" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CurrentViewerQueryQuery,
CurrentViewerQueryQueryVariables
>;
export const EndBrowserSessionDocument = {
kind: "Document",
definitions: [
@@ -3442,6 +3414,70 @@ export const UserPrimaryEmailDocument = {
UserPrimaryEmailQuery,
UserPrimaryEmailQueryVariables
>;
export const UserDisplayNameDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "UserDisplayName" },
variableDefinitions: [
{
kind: "VariableDefinition",
variable: {
kind: "Variable",
name: { kind: "Name", value: "userId" },
},
type: {
kind: "NonNullType",
type: { kind: "NamedType", name: { kind: "Name", value: "ID" } },
},
},
],
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "user" },
arguments: [
{
kind: "Argument",
name: { kind: "Name", value: "id" },
value: {
kind: "Variable",
name: { kind: "Name", value: "userId" },
},
},
],
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
{
kind: "Field",
name: { kind: "Name", value: "matrix" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "displayName" },
},
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
UserDisplayNameQuery,
UserDisplayNameQueryVariables
>;
export const SetDisplayNameDocument = {
kind: "Document",
definitions: [
@@ -4407,3 +4443,44 @@ export const CurrentViewerSessionQueryDocument = {
CurrentViewerSessionQueryQuery,
CurrentViewerSessionQueryQueryVariables
>;
export const CurrentViewerQueryDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "CurrentViewerQuery" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "viewer" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "User" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CurrentViewerQueryQuery,
CurrentViewerQueryQueryVariables
>;

View File

@@ -20,7 +20,6 @@ import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Provider as UrqlProvider } from "urql";
import { HydrateAtoms, useCurrentUserId } from "./atoms";
import ErrorBoundary from "./components/ErrorBoundary";
import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen";
@@ -30,6 +29,7 @@ import { client } from "./graphql";
import i18n from "./i18n";
import { Router } from "./routing";
import "./main.css";
import { useCurrentUserId } from "./utils/useCurrentUserId";
const App: React.FC = () => {
const userId = useCurrentUserId();
@@ -50,15 +50,13 @@ createRoot(document.getElementById("root") as HTMLElement).render(
<UrqlProvider value={client}>
<Provider>
{import.meta.env.DEV && <DevTools />}
<HydrateAtoms>
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<App />
</TooltipProvider>
</I18nextProvider>
</Suspense>
</HydrateAtoms>
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<TooltipProvider>
<App />
</TooltipProvider>
</I18nextProvider>
</Suspense>
</Provider>
</UrqlProvider>
</ErrorBoundary>

View File

@@ -12,13 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { atom, Atom, WritableAtom } from "jotai";
import { useState } from "react";
import { PageInfo } from "./gql/graphql";
export const FIRST_PAGE = Symbol("FIRST_PAGE");
export const LAST_PAGE = Symbol("LAST_PAGE");
const EMPTY = Symbol("EMPTY");
export type ForwardPagination = {
first: number;
@@ -36,99 +35,73 @@ export type Pagination = ForwardPagination | BackwardPagination;
export const isForwardPagination = (
pagination: Pagination,
): pagination is ForwardPagination => {
return pagination.hasOwnProperty("first");
return Object.hasOwn(pagination, "first");
};
// Check if the pagination is backward pagination.
export const isBackwardPagination = (
pagination: Pagination,
): pagination is BackwardPagination => {
return pagination.hasOwnProperty("last");
return Object.hasOwn(pagination, "last");
};
// This atom sets the default page size for pagination.
export const pageSizeAtom = atom(6);
type Action = typeof FIRST_PAGE | typeof LAST_PAGE | Pagination;
export const atomForCurrentPagination = (): WritableAtom<
Pagination,
[Action],
void
> => {
const dataAtom = atom<typeof EMPTY | Pagination>(EMPTY);
// Hook to handle pagination state.
export const usePagination = (
pageSize = 6,
): [Pagination, (action: Action) => void] => {
const [pagination, setPagination] = useState<Pagination>({
first: pageSize,
after: null,
});
const currentPaginationAtom = atom(
(get) => {
const data = get(dataAtom);
if (data === EMPTY) {
return {
first: get(pageSizeAtom),
after: null,
};
}
return data;
},
(get, set, action: Action) => {
if (action === FIRST_PAGE) {
set(dataAtom, EMPTY);
} else if (action === LAST_PAGE) {
set(dataAtom, {
last: get(pageSizeAtom),
before: null,
});
} else {
set(dataAtom, action);
}
},
);
currentPaginationAtom.onMount = (setAtom): void => {
setAtom(FIRST_PAGE);
const handlePagination = (action: Action): void => {
if (action === FIRST_PAGE) {
setPagination({
first: pageSize,
after: null,
});
} else if (action === LAST_PAGE) {
setPagination({
last: pageSize,
before: null,
});
} else {
setPagination(action);
}
};
return currentPaginationAtom;
return [pagination, handlePagination];
};
// This atom is used to create a pagination atom that gives the previous and
// next pagination objects, given the current pagination and the page info.
export const atomWithPagination = (
currentPaginationAtom: Atom<Pagination>,
pageInfoAtom: Atom<Promise<PageInfo | null>>,
): Atom<Promise<[BackwardPagination | null, ForwardPagination | null]>> => {
const paginationAtom = atom(
async (
get,
): Promise<[BackwardPagination | null, ForwardPagination | null]> => {
const currentPagination = get(currentPaginationAtom);
const pageInfo = await get(pageInfoAtom);
const hasProbablyPreviousPage =
isForwardPagination(currentPagination) &&
currentPagination.after !== null;
const hasProbablyNextPage =
isBackwardPagination(currentPagination) &&
currentPagination.before !== null;
// Compute the previous and next pagination based on the current pagination and the page info.
export const usePages = (
currentPagination: Pagination,
pageInfo: PageInfo | null,
pageSize = 6,
): [BackwardPagination | null, ForwardPagination | null] => {
const hasProbablyPreviousPage =
isForwardPagination(currentPagination) && currentPagination.after !== null;
const hasProbablyNextPage =
isBackwardPagination(currentPagination) &&
currentPagination.before !== null;
let previousPagination: BackwardPagination | null = null;
let nextPagination: ForwardPagination | null = null;
if (pageInfo?.hasPreviousPage || hasProbablyPreviousPage) {
previousPagination = {
last: get(pageSizeAtom),
before: pageInfo?.startCursor ?? null,
};
}
let previousPagination: BackwardPagination | null = null;
let nextPagination: ForwardPagination | null = null;
if (pageInfo?.hasPreviousPage || hasProbablyPreviousPage) {
previousPagination = {
last: pageSize,
before: pageInfo?.startCursor ?? null,
};
}
if (pageInfo?.hasNextPage || hasProbablyNextPage) {
nextPagination = {
first: get(pageSizeAtom),
after: pageInfo?.endCursor ?? null,
};
}
if (pageInfo?.hasNextPage || hasProbablyNextPage) {
nextPagination = {
first: pageSize,
after: pageInfo?.endCursor ?? null,
};
}
return [previousPagination, nextPagination];
},
);
return paginationAtom;
return [previousPagination, nextPagination];
};

View File

@@ -0,0 +1,37 @@
// 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 { useQuery } from "urql";
import { graphql } from "../gql";
const CURRENT_VIEWER_QUERY = graphql(/* GraphQL */ `
query CurrentViewerQuery {
viewer {
__typename
... on User {
id
}
}
}
`);
export const useCurrentUserId = (): string | null => {
const [result] = useQuery({ query: CURRENT_VIEWER_QUERY });
if (result.error) throw result.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
return result.data.viewer.__typename === "User"
? result.data.viewer.id
: null;
};