diff --git a/.changeset/loud-houses-design.md b/.changeset/loud-houses-design.md new file mode 100644 index 00000000..22f1c61b --- /dev/null +++ b/.changeset/loud-houses-design.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +Add Space.findManyPublic plus the usePublicSpaces hook so apps can fetch and render public spaces (with invalid entries surfaced) via one shared SDK call + \ No newline at end of file diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index c8512b3c..a660adb5 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ -import { useEntities } from '@graphprotocol/hypergraph-react'; +import { useEntities, usePublicSpaces } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { Podcast, Space, Topic } from '@/schema'; +import { Podcast, Topic } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -76,12 +76,8 @@ function RouteComponent() { console.log({ topics }); - const { data: spaces } = useEntities(Space, { - mode: 'public', - spaces: 'all', - include: { - avatar: {}, - }, + const { data: spaces } = usePublicSpaces({ + filter: { memberAccountAddress: '0xE86b4a182779ae6320cA04ad43Fe6a1bed051e24' }, }); console.log('spaces', spaces); diff --git a/docs/docs/query-public-data.md b/docs/docs/query-public-data.md index cde23408..dcea4529 100644 --- a/docs/docs/query-public-data.md +++ b/docs/docs/query-public-data.md @@ -2,9 +2,45 @@ Based on your schema, you can query public data that you created using Hypergraph. It works very much like [querying private data](/docs/query-private-data). +## Fetching public spaces + +When you only need the list of public spaces (with optional avatar metadata) you can call the lower-level `Space.findManyPublic` helper directly in any Node/Edge environment, or use the `usePublicSpaces` hook inside React components. Both helpers expose the parsed space list (`data`) as well as any records that failed schema validation (`invalidSpaces`) so you can surface misconfigured entries during development. + +### `usePublicSpaces` + +```tsx +import { usePublicSpaces } from '@graphprotocol/hypergraph-react'; + +const { data: spaces, invalidSpaces, isPending } = usePublicSpaces({ + filter: { editorAccountAddress: '0x1234...' }, +}); +``` + +The hook wraps the same finder in React Query, so you also inherit caching, refetching, and loading state management. Omit `filter` to list every public space that is indexed by the Geo testnet. + +### `Space.findManyPublic` + +```ts +import { Space } from '@graphprotocol/hypergraph'; + +const { data, invalidSpaces } = await Space.findManyPublic(); +``` + +You can restrict the result set to spaces where a given account is a member or an editor with the mutually exclusive `filter` options: + +```ts +await Space.findManyPublic({ + filter: { memberAccountAddress: '0x1234...' }, +}); + +await Space.findManyPublic({ + filter: { editorAccountAddress: '0x1234...' }, +}); +``` + ## useEntities -In order to query private data, you need to pass in the schema type and set the mode to `public`. +In order to query public data, you need to pass in the schema type and set the mode to `public`. ```ts import { useEntities } from '@graphprotocol/hypergraph-react'; @@ -60,6 +96,41 @@ In addition you have access to the full response from `@tanstack/react-query`'s const { data, isPending, isError } = useEntities(Event, { mode: 'public' }); ``` +## Fetching a single public entity + +When you only need a single entity—for example to power a detail page—you can stay within React by calling `useEntity`, or drop down to the SDK-level helper `Entity.findOnePublic` for server-side scripts and non-React environments. + +### `useEntity` + +```tsx +import { useEntity } from '@graphprotocol/hypergraph-react'; +import { Project } from '../schema'; + +const { data: project, isPending, isError } = useEntity(Project, { + id: '9f130661-8c3f-4db7-9bdc-3ce69631c5ef', + space: '3f32353d-3b27-4a13-b71a-746f06e1f7db', + mode: 'public', + include: { + contributors: {}, + }, +}); +``` + +### `Entity.findOnePublic` + +```ts +import { Entity } from '@graphprotocol/hypergraph'; +import { Project } from '../schema'; + +const project = await Entity.findOnePublic(Project, { + id: '9f130661-8c3f-4db7-9bdc-3ce69631c5ef', + space: '3f32353d-3b27-4a13-b71a-746f06e1f7db', + include: { + contributors: {}, + }, +}); +``` + ## Querying Public Data from Geo Testnet using useQuery The Geo testnet contains public data that you can query immediately without any authentication. This section provides examples to quickly explore the available data. diff --git a/packages/hypergraph-react/src/hooks/usePublicSpaces.ts b/packages/hypergraph-react/src/hooks/usePublicSpaces.ts new file mode 100644 index 00000000..34514cc4 --- /dev/null +++ b/packages/hypergraph-react/src/hooks/usePublicSpaces.ts @@ -0,0 +1,23 @@ +import { Space } from '@graphprotocol/hypergraph'; +import { useQuery } from '@tanstack/react-query'; + +type UsePublicSpacesParams = Readonly<{ + filter?: Space.FindManyPublicParams['filter']; + enabled?: boolean; +}>; + +export const usePublicSpaces = (params?: UsePublicSpacesParams) => { + const { filter, enabled = true } = params ?? {}; + + const result = useQuery({ + queryKey: ['hypergraph-public-spaces', filter], + queryFn: () => (filter ? Space.findManyPublic({ filter }) : Space.findManyPublic()), + enabled, + }); + + return { + ...result, + data: result.data?.data ?? [], + invalidSpaces: result.data?.invalidSpaces ?? [], + }; +}; diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts index 66647031..51023808 100644 --- a/packages/hypergraph-react/src/index.ts +++ b/packages/hypergraph-react/src/index.ts @@ -22,6 +22,7 @@ export { useExternalSpaceInbox } from './hooks/useExternalSpaceInbox.js'; export { useOwnAccountInbox } from './hooks/useOwnAccountInbox.js'; export { useOwnSpaceInbox } from './hooks/useOwnSpaceInbox.js'; export { usePublicAccountInboxes } from './hooks/usePublicAccountInboxes.js'; +export { usePublicSpaces } from './hooks/usePublicSpaces.js'; export { usePublishToPublicSpace } from './hooks/usePublishToSpace.js'; export { generateDeleteOps as _generateDeleteOps } from './internal/generate-delete-ops.js'; export { useDeleteEntityPublic as _useDeleteEntityPublic } from './internal/use-delete-entity-public.js'; diff --git a/packages/hypergraph/package.json b/packages/hypergraph/package.json index 4de306d2..9127422f 100644 --- a/packages/hypergraph/package.json +++ b/packages/hypergraph/package.json @@ -32,6 +32,7 @@ "./key": "./dist/key/index.js", "./mapping": "./dist/mapping/index.js", "./messages": "./dist/messages/index.js", + "./space": "./dist/space/index.js", "./space-events": "./dist/space-events/index.js", "./space-info": "./dist/space-info/index.js", "./store": "./dist/store.js", diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index f88b0ab8..b5f45ec4 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -9,6 +9,7 @@ export * as Key from './key/index.js'; export * as Mapping from './mapping/index.js'; export * as Messages from './messages/index.js'; export * as PrivyAuth from './privy-auth/privy-auth.js'; +export * as Space from './space/index.js'; export * as SpaceEvents from './space-events/index.js'; export * as SpaceInfo from './space-info/index.js'; export * from './store.js'; diff --git a/packages/hypergraph/src/space/find-many-public.ts b/packages/hypergraph/src/space/find-many-public.ts new file mode 100644 index 00000000..ce0ebbf1 --- /dev/null +++ b/packages/hypergraph/src/space/find-many-public.ts @@ -0,0 +1,152 @@ +import { ContentIds, Graph, SystemIds } from '@graphprotocol/grc-20'; +import * as Either from 'effect/Either'; +import * as EffectSchema from 'effect/Schema'; +import { request } from 'graphql-request'; + +const spaceFields = ` + id + page { + name + relationsList(filter: { + typeId: { is: "${ContentIds.AVATAR_PROPERTY}"} + }) { + toEntity { + valuesList(filter: { + propertyId: { is: "${SystemIds.IMAGE_URL_PROPERTY}"} + }) { + propertyId + string + } + } + } + } +`; + +const spacesQueryDocument = ` +query spaces { + spaces { + ${spaceFields} + } +} +`; + +const memberSpacesQueryDocument = ` +query memberSpaces($accountAddress: String!) { + spaces(filter: {members: {some: {address: {is: $accountAddress}}}}) { + ${spaceFields} + } +} +`; + +const editorSpacesQueryDocument = ` +query editorSpaces($accountAddress: String!) { + spaces(filter: {editors: {some: {address: {is: $accountAddress}}}}) { + ${spaceFields} + } +} +`; + +export const PublicSpaceSchema = EffectSchema.Struct({ + id: EffectSchema.String, + name: EffectSchema.String, + avatar: EffectSchema.optional(EffectSchema.String), +}); + +export type PublicSpace = typeof PublicSpaceSchema.Type; + +type SpacesQueryResult = { + spaces?: { + id: string; + page: { + name?: string | null; + relationsList?: { + toEntity?: { + valuesList?: { + propertyId: string; + string: string | null; + }[]; + } | null; + }[]; + } | null; + }[]; +}; + +type SpacesQueryVariables = { + accountAddress: string; +}; + +type SpaceQueryEntry = NonNullable[number]; + +const decodeSpace = EffectSchema.decodeUnknownEither(PublicSpaceSchema); + +const getAvatarFromSpace = (space: SpaceQueryEntry) => { + const firstRelation = space.page?.relationsList?.[0]; + const firstValue = firstRelation?.toEntity?.valuesList?.[0]; + const avatar = firstValue?.string; + if (typeof avatar === 'string') { + return avatar; + } + return undefined; +}; + +export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => { + const data: PublicSpace[] = []; + const invalidSpaces: Record[] = []; + const spaces = queryResult.spaces ?? []; + + for (const space of spaces) { + const rawSpace: Record = { + id: space.id, + name: space.page?.name ?? undefined, + avatar: getAvatarFromSpace(space), + }; + + const decodedSpace = decodeSpace(rawSpace); + + if (Either.isRight(decodedSpace)) { + data.push(decodedSpace.right); + } else { + invalidSpaces.push(rawSpace); + } + } + + return { data, invalidSpaces }; +}; + +export type FindManyPublicFilter = + | Readonly<{ memberAccountAddress: string; editorAccountAddress?: never }> + | Readonly<{ editorAccountAddress: string; memberAccountAddress?: never }> + | Readonly<{ memberAccountAddress?: undefined; editorAccountAddress?: undefined }>; + +export type FindManyPublicParams = Readonly<{ + filter?: FindManyPublicFilter; +}>; + +export const findManyPublic = async (params?: FindManyPublicParams) => { + const filter = params?.filter; + const memberAccountAddress = filter?.memberAccountAddress; + const editorAccountAddress = filter?.editorAccountAddress; + + if (memberAccountAddress && editorAccountAddress) { + throw new Error('Provide only one of memberAccountAddress or editorAccountAddress when calling findManyPublic().'); + } + + const endpoint = `${Graph.TESTNET_API_ORIGIN}/graphql`; + + if (memberAccountAddress) { + const queryResult = await request(endpoint, memberSpacesQueryDocument, { + accountAddress: memberAccountAddress, + }); + return parseSpacesQueryResult(queryResult); + } + + if (editorAccountAddress) { + const queryResult = await request(endpoint, editorSpacesQueryDocument, { + accountAddress: editorAccountAddress, + }); + return parseSpacesQueryResult(queryResult); + } + + const queryResult = await request(endpoint, spacesQueryDocument); + return parseSpacesQueryResult(queryResult); +}; diff --git a/packages/hypergraph/src/space/index.ts b/packages/hypergraph/src/space/index.ts new file mode 100644 index 00000000..7ede2aa4 --- /dev/null +++ b/packages/hypergraph/src/space/index.ts @@ -0,0 +1 @@ +export * from './find-many-public.js'; diff --git a/packages/hypergraph/test/space/find-many-public.test.ts b/packages/hypergraph/test/space/find-many-public.test.ts new file mode 100644 index 00000000..ef674f32 --- /dev/null +++ b/packages/hypergraph/test/space/find-many-public.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { parseSpacesQueryResult } from '../../src/space/find-many-public.js'; + +const buildQuerySpace = ({ + id = 'space-id', + name = 'Space name', + avatar, +}: { + id?: string; + name?: string | null; + avatar?: string | null; +} = {}) => { + return { + id, + page: { + name, + relationsList: + avatar === undefined + ? [] + : [ + { + toEntity: { + valuesList: + avatar === null + ? [] + : [ + { + propertyId: '8a743832-c094-4a62-b665-0c3cc2f9c7bc', + string: avatar, + }, + ], + }, + }, + ], + }, + }; +}; + +describe('parseSpacesQueryResult', () => { + it('parses valid data', () => { + const { data, invalidSpaces } = parseSpacesQueryResult({ + spaces: [buildQuerySpace({ id: 'space-1', name: 'Space 1', avatar: 'https://example.com/avatar.png' })], + }); + + expect(data).toEqual([ + { + id: 'space-1', + name: 'Space 1', + avatar: 'https://example.com/avatar.png', + }, + ]); + expect(invalidSpaces).toHaveLength(0); + }); + + it('omits avatar when not provided', () => { + const { data } = parseSpacesQueryResult({ + spaces: [buildQuerySpace({ id: 'space-2', name: 'Space 2', avatar: undefined })], + }); + + expect(data).toEqual([ + { + id: 'space-2', + name: 'Space 2', + }, + ]); + }); + + it('filters invalid data', () => { + const { data, invalidSpaces } = parseSpacesQueryResult({ + spaces: [ + buildQuerySpace({ id: 'space-valid', name: 'Space valid', avatar: 'https://example.com/a.png' }), + buildQuerySpace({ id: 'space-invalid', name: null, avatar: 'https://example.com/b.png' }), + ], + }); + + expect(data).toEqual([ + { + id: 'space-valid', + name: 'Space valid', + avatar: 'https://example.com/a.png', + }, + ]); + expect(invalidSpaces).toHaveLength(1); + expect(invalidSpaces[0]).toMatchObject({ id: 'space-invalid' }); + }); +});