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": { "app_sessions_list": {
"error": "Failed to load app sessions",
"heading": "Apps" "heading": "Apps"
}, },
"browser_session_details": { "browser_session_details": {

View File

@@ -38,7 +38,6 @@
"jotai": "^2.6.4", "jotai": "^2.6.4",
"jotai-devtools": "^0.7.1", "jotai-devtools": "^0.7.1",
"jotai-location": "^0.5.2", "jotai-location": "^0.5.2",
"jotai-urql": "^0.7.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.0.5", "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 // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { useState, useTransition } from "react";
import { atomFamily } from "jotai/utils"; import { useQuery } from "urql";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react";
import { mapQueryAtom } from "../atoms";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { SessionState, PageInfo } from "../gql/graphql"; import { SessionState } from "../gql/graphql";
import { import { FIRST_PAGE, Pagination, usePages, usePagination } from "../pagination";
atomForCurrentPagination,
atomWithPagination,
FIRST_PAGE,
Pagination,
} from "../pagination";
import { isOk, unwrap, unwrapOk } from "../result";
import BlockList from "./BlockList"; import BlockList from "./BlockList";
import BrowserSession from "./BrowserSession"; 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 BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
const [pagination, setPagination] = usePagination();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const result = useAtomValue(browserSessionListFamily(userId)); const [filter, setFilter] = useState<SessionState | null>(
const setPagination = useSetAtom(currentPaginationAtom); SessionState.Active,
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); );
const [filter, setFilter] = useAtom(filterAtom); 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</>; if (browserSessions === null) return <>Failed to load browser sessions</>;
const paginate = (pagination: Pagination): void => { const paginate = (pagination: Pagination): void => {
@@ -145,6 +106,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {
<label> <label>
<input <input
type="checkbox" type="checkbox"
disabled={pending}
checked={filter === SessionState.Active} checked={filter === SessionState.Active}
onChange={toggleFilter} onChange={toggleFilter}
/>{" "} />{" "}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,19 +13,17 @@
// limitations under the License. // limitations under the License.
import { Alert, H3 } from "@vector-im/compound-web"; import { Alert, H3 } from "@vector-im/compound-web";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import { PageInfo } from "../../gql/graphql";
import { import {
atomForCurrentPagination,
atomWithPagination,
FIRST_PAGE, FIRST_PAGE,
Pagination, Pagination,
usePages,
usePagination,
} from "../../pagination"; } from "../../pagination";
import { routeAtom } from "../../routing"; import { routeAtom } from "../../routing";
import BlockList from "../BlockList"; 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<{ const UserEmailList: React.FC<{
userId: string; userId: string;
}> = ({ userId }) => { }> = ({ 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 { 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 => { const paginate = (pagination: Pagination): void => {
startTransition(() => { startTransition(() => {
@@ -168,19 +130,19 @@ const UserEmailList: React.FC<{
title={t("frontend.user_email_list.no_primary_email_alert")} title={t("frontend.user_email_list.no_primary_email_alert")}
/> />
)} )}
{result.data?.user?.emails?.edges?.map((edge) => ( {emails.edges.map((edge) => (
<UserEmail <UserEmail
email={edge.node} email={edge.node}
key={edge.cursor} key={edge.cursor}
isPrimary={primaryEmailId === edge.node.id} isPrimary={primaryEmailId === edge.node.id}
onSetPrimary={refreshPrimaryEmailId} onSetPrimary={refreshPrimaryEmail}
onRemove={onRemove} onRemove={onRemove}
/> />
))} ))}
<PaginationControls <PaginationControls
autoHide autoHide
count={result.data?.user?.emails?.totalCount ?? 0} count={emails.totalCount ?? 0}
onPrev={prevPage ? (): void => paginate(prevPage) : null} onPrev={prevPage ? (): void => paginate(prevPage) : null}
onNext={nextPage ? (): void => paginate(nextPage) : null} onNext={nextPage ? (): void => paginate(nextPage) : null}
disabled={pending} disabled={pending}

View File

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

View File

@@ -13,21 +13,12 @@
// limitations under the License. // limitations under the License.
import { H5 } from "@vector-im/compound-web"; 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 { useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import { mapQueryAtom } from "../../atoms";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import { SessionState, PageInfo } from "../../gql/graphql"; import { Pagination, usePages, usePagination } from "../../pagination";
import {
atomForCurrentPagination,
atomWithPagination,
Pagination,
} from "../../pagination";
import { isOk, unwrap, unwrapOk } from "../../result";
import BlockList from "../BlockList"; import BlockList from "../BlockList";
import CompatSession from "../CompatSession"; import CompatSession from "../CompatSession";
import OAuth2Session from "../OAuth2Session"; 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 // A type-safe way to ensure we've handled all session types
const unknownSessionType = (type: never): never => { const unknownSessionType = (type: never): never => {
throw new Error(`Unknown session type: ${type}`); throw new Error(`Unknown session type: ${type}`);
@@ -118,14 +71,17 @@ const unknownSessionType = (type: never): never => {
const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => { const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const result = useAtomValue(appSessionListFamily(userId)); const [pagination, setPagination] = usePagination();
const setPagination = useSetAtom(currentPaginationAtom); const [result] = useQuery({
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); 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 { t } = useTranslation();
const appSessions = unwrap(result);
if (!appSessions) return <>{t("frontend.app_sessions_list.error")}</>;
const paginate = (pagination: Pagination): void => { const paginate = (pagination: Pagination): void => {
startTransition(() => { startTransition(() => {
setPagination(pagination); 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. * Therefore it is highly recommended to use the babel or swc plugin for production.
*/ */
const documents = { 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": "\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, 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": "\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, types.UserEmailListQueryDocument,
"\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n": "\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n":
types.UserPrimaryEmailDocument, 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": "\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, 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": "\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, types.VerifyEmailQueryDocument,
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n": "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument, 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; 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. * 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( export function graphql(
source: "\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n", 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"]; ): (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. * 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( export function graphql(
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n", 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"]; ): (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) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};

View File

@@ -1107,13 +1107,6 @@ export type Viewer = Anonymous | User;
/** Represents the current viewer's session */ /** Represents the current viewer's session */
export type ViewerSession = Anonymous | BrowserSession | Oauth2Session; 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 = { export type BrowserSession_SessionFragment = {
__typename?: "BrowserSession"; __typename?: "BrowserSession";
id: string; id: string;
@@ -1485,6 +1478,19 @@ export type UserPrimaryEmailQuery = {
} | null; } | 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<{ export type SetDisplayNameMutationVariables = Exact<{
userId: Scalars["ID"]["input"]; userId: Scalars["ID"]["input"];
displayName?: InputMaybe<Scalars["String"]["input"]>; displayName?: InputMaybe<Scalars["String"]["input"]>;
@@ -1678,6 +1684,13 @@ export type CurrentViewerSessionQueryQuery = {
| { __typename: "Oauth2Session" }; | { __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 = { export const BrowserSession_SessionFragmentDoc = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@@ -2058,47 +2071,6 @@ export const UserEmail_VerifyEmailFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<UserEmail_VerifyEmailFragment, unknown>; } 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 = { export const EndBrowserSessionDocument = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@@ -3442,6 +3414,70 @@ export const UserPrimaryEmailDocument = {
UserPrimaryEmailQuery, UserPrimaryEmailQuery,
UserPrimaryEmailQueryVariables 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 = { export const SetDisplayNameDocument = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@@ -4407,3 +4443,44 @@ export const CurrentViewerSessionQueryDocument = {
CurrentViewerSessionQueryQuery, CurrentViewerSessionQueryQuery,
CurrentViewerSessionQueryQueryVariables 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 { I18nextProvider } from "react-i18next";
import { Provider as UrqlProvider } from "urql"; import { Provider as UrqlProvider } from "urql";
import { HydrateAtoms, useCurrentUserId } from "./atoms";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
@@ -30,6 +29,7 @@ import { client } from "./graphql";
import i18n from "./i18n"; import i18n from "./i18n";
import { Router } from "./routing"; import { Router } from "./routing";
import "./main.css"; import "./main.css";
import { useCurrentUserId } from "./utils/useCurrentUserId";
const App: React.FC = () => { const App: React.FC = () => {
const userId = useCurrentUserId(); const userId = useCurrentUserId();
@@ -50,15 +50,13 @@ createRoot(document.getElementById("root") as HTMLElement).render(
<UrqlProvider value={client}> <UrqlProvider value={client}>
<Provider> <Provider>
{import.meta.env.DEV && <DevTools />} {import.meta.env.DEV && <DevTools />}
<HydrateAtoms> <Suspense fallback={<LoadingScreen />}>
<Suspense fallback={<LoadingScreen />}> <I18nextProvider i18n={i18n}>
<I18nextProvider i18n={i18n}> <TooltipProvider>
<TooltipProvider> <App />
<App /> </TooltipProvider>
</TooltipProvider> </I18nextProvider>
</I18nextProvider> </Suspense>
</Suspense>
</HydrateAtoms>
</Provider> </Provider>
</UrqlProvider> </UrqlProvider>
</ErrorBoundary> </ErrorBoundary>

View File

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

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;
};