From 010aa11a31b3610e3e0b121c7844f70e177c1b41 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 23:33:59 +0200 Subject: [PATCH 01/15] feat(responses): support claim stance and veracity actions --- apps/web/codegen.ts | 6 + apps/web/core/gql/gql.ts | 18 +- apps/web/core/gql/graphql.ts | 6979 ++--------------- apps/web/core/hooks/use-entity-vote.ts | 99 +- ...ies-ordered-by-property-connection.test.ts | 3 +- apps/web/core/io/queries.ts | 65 +- .../core/io/query-fragments.response.test.ts | 32 + apps/web/core/io/query-fragments.tsx | 32 +- apps/web/core/io/sort-order.ts | 6 + .../core/responses/entity-response.test.ts | 152 + apps/web/core/responses/entity-response.ts | 181 + apps/web/core/sync/orm.ts | 3 +- apps/web/package.json | 2 +- .../entity-page/claim-voter-avatars.tsx | 25 +- .../entity-page/entity-vote-buttons.tsx | 317 +- bun.lock | 8 +- package.json | 2 +- packages/auth/package.json | 2 +- 18 files changed, 1411 insertions(+), 6521 deletions(-) create mode 100644 apps/web/core/io/query-fragments.response.test.ts create mode 100644 apps/web/core/io/sort-order.ts create mode 100644 apps/web/core/responses/entity-response.test.ts create mode 100644 apps/web/core/responses/entity-response.ts diff --git a/apps/web/codegen.ts b/apps/web/codegen.ts index 5a9e4237d9..518628631a 100644 --- a/apps/web/codegen.ts +++ b/apps/web/codegen.ts @@ -9,6 +9,12 @@ const config: CodegenConfig = { generates: { './core/gql/': { preset: 'client', + config: { + // Preserve the runtime enum values and permissive custom-scalar shape + // used by existing consumers while adopting the v6 operation-only output. + enumType: 'native', + defaultScalarType: 'any', + }, }, }, }; diff --git a/apps/web/core/gql/gql.ts b/apps/web/core/gql/gql.ts index 9b5881e6d0..43f5de4975 100644 --- a/apps/web/core/gql/gql.ts +++ b/apps/web/core/gql/gql.ts @@ -51,9 +51,9 @@ type Documents = { "\n query ImportNameValues($propertyId: UUID!, $texts: [String!], $first: Int, $entityFilter: EntityFilter) {\n values(\n condition: { propertyId: $propertyId }\n filter: { text: { inInsensitive: $texts }, entity: $entityFilter }\n first: $first\n ) {\n id\n text\n spaceId\n entity {\n id\n name\n typeIds\n backlinks {\n totalCount\n }\n relations {\n totalCount\n }\n }\n }\n }\n": typeof types.ImportNameValuesDocument, "\n query EntityTiebreakerBatch($filter: EntityFilter) {\n entities(filter: $filter) {\n id\n createdAt\n backlinks {\n totalCount\n }\n relations {\n totalCount\n }\n values {\n totalCount\n }\n }\n }\n": typeof types.EntityTiebreakerBatchDocument, "\n query RelationEntityMinimal($id: UUID!, $spaceId: UUID) {\n relation(id: $id) {\n id\n entity {\n id\n name\n description\n spaceIds\n\n types {\n id\n name\n }\n\n allValuesList: valuesList(first: 1000) {\n spaceId\n propertyId\n }\n\n allRelationsList: relationsList(first: 1000) {\n spaceId\n }\n\n valuesList(first: 1000, filter: { spaceId: { is: $spaceId } }) {\n spaceId\n property {\n id\n name\n dataTypeId\n dataTypeName\n renderableTypeId\n renderableTypeName\n format\n }\n text\n integer\n float\n point\n boolean\n time\n language\n unit\n datetime\n date\n decimal\n schedule\n }\n relationsList(first: 1000) {\n verified\n toSpaceId\n position\n spaceId\n id\n entityId\n fromEntity {\n id\n name\n }\n toEntity {\n ...RelationToEntity\n }\n type {\n id\n name\n description\n }\n }\n }\n }\n }\n": typeof types.RelationEntityMinimalDocument, - "\n query EntityVoteCount($objectId: UUID!, $objectType: Int!) {\n votesCountsConnection(condition: { objectId: $objectId, objectType: $objectType }) {\n nodes {\n spaceId\n upvotes\n downvotes\n }\n }\n }\n": typeof types.EntityVoteCountDocument, - "\n query UserEntityVote($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n ) {\n voteType\n }\n }\n": typeof types.UserEntityVoteDocument, - "\n query EntityVoters($objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId }) {\n userId\n voteType\n }\n }\n": typeof types.EntityVotersDocument, + "\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\n }\n }\n": typeof types.EntityResponseCountsDocument, + "\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\n }\n }\n": typeof types.UserEntityResponseDocument, + "\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n": typeof types.EntityRespondersDocument, "\n query UserHasEntityVote($userId: UUID!) {\n userVotes(condition: { userId: $userId }, first: 1) {\n userId\n }\n }\n": typeof types.UserHasEntityVoteDocument, }; const documents: Documents = { @@ -94,9 +94,9 @@ const documents: Documents = { "\n query ImportNameValues($propertyId: UUID!, $texts: [String!], $first: Int, $entityFilter: EntityFilter) {\n values(\n condition: { propertyId: $propertyId }\n filter: { text: { inInsensitive: $texts }, entity: $entityFilter }\n first: $first\n ) {\n id\n text\n spaceId\n entity {\n id\n name\n typeIds\n backlinks {\n totalCount\n }\n relations {\n totalCount\n }\n }\n }\n }\n": types.ImportNameValuesDocument, "\n query EntityTiebreakerBatch($filter: EntityFilter) {\n entities(filter: $filter) {\n id\n createdAt\n backlinks {\n totalCount\n }\n relations {\n totalCount\n }\n values {\n totalCount\n }\n }\n }\n": types.EntityTiebreakerBatchDocument, "\n query RelationEntityMinimal($id: UUID!, $spaceId: UUID) {\n relation(id: $id) {\n id\n entity {\n id\n name\n description\n spaceIds\n\n types {\n id\n name\n }\n\n allValuesList: valuesList(first: 1000) {\n spaceId\n propertyId\n }\n\n allRelationsList: relationsList(first: 1000) {\n spaceId\n }\n\n valuesList(first: 1000, filter: { spaceId: { is: $spaceId } }) {\n spaceId\n property {\n id\n name\n dataTypeId\n dataTypeName\n renderableTypeId\n renderableTypeName\n format\n }\n text\n integer\n float\n point\n boolean\n time\n language\n unit\n datetime\n date\n decimal\n schedule\n }\n relationsList(first: 1000) {\n verified\n toSpaceId\n position\n spaceId\n id\n entityId\n fromEntity {\n id\n name\n }\n toEntity {\n ...RelationToEntity\n }\n type {\n id\n name\n description\n }\n }\n }\n }\n }\n": types.RelationEntityMinimalDocument, - "\n query EntityVoteCount($objectId: UUID!, $objectType: Int!) {\n votesCountsConnection(condition: { objectId: $objectId, objectType: $objectType }) {\n nodes {\n spaceId\n upvotes\n downvotes\n }\n }\n }\n": types.EntityVoteCountDocument, - "\n query UserEntityVote($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n ) {\n voteType\n }\n }\n": types.UserEntityVoteDocument, - "\n query EntityVoters($objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId }) {\n userId\n voteType\n }\n }\n": types.EntityVotersDocument, + "\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\n }\n }\n": types.EntityResponseCountsDocument, + "\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\n }\n }\n": types.UserEntityResponseDocument, + "\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n": types.EntityRespondersDocument, "\n query UserHasEntityVote($userId: UUID!) {\n userVotes(condition: { userId: $userId }, first: 1) {\n userId\n }\n }\n": types.UserHasEntityVoteDocument, }; @@ -265,15 +265,15 @@ export function graphql(source: "\n query RelationEntityMinimal($id: UUID!, $sp /** * 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 EntityVoteCount($objectId: UUID!, $objectType: Int!) {\n votesCountsConnection(condition: { objectId: $objectId, objectType: $objectType }) {\n nodes {\n spaceId\n upvotes\n downvotes\n }\n }\n }\n"): (typeof documents)["\n query EntityVoteCount($objectId: UUID!, $objectType: Int!) {\n votesCountsConnection(condition: { objectId: $objectId, objectType: $objectType }) {\n nodes {\n spaceId\n upvotes\n downvotes\n }\n }\n }\n"]; +export function graphql(source: "\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\n }\n }\n"): (typeof documents)["\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\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 UserEntityVote($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n ) {\n voteType\n }\n }\n"): (typeof documents)["\n query UserEntityVote($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n ) {\n voteType\n }\n }\n"]; +export function graphql(source: "\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\n }\n }\n"): (typeof documents)["\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\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 EntityVoters($objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId }) {\n userId\n voteType\n }\n }\n"): (typeof documents)["\n query EntityVoters($objectId: UUID!, $objectType: Int!, $spaceId: UUID!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId }) {\n userId\n voteType\n }\n }\n"]; +export function graphql(source: "\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n"): (typeof documents)["\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/apps/web/core/gql/graphql.ts b/apps/web/core/gql/graphql.ts index 13ab4b89ed..9d8c4c5e84 100644 --- a/apps/web/core/gql/graphql.ts +++ b/apps/web/core/gql/graphql.ts @@ -1,610 +1,127 @@ /* eslint-disable */ -import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type Maybe = T | null; -export type InputMaybe = T | null | undefined; -export type Exact = { [K in keyof T]: T[K] }; -export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; -export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; -export type MakeEmpty = { [_ in K]?: never }; +/** Internal type. DO NOT USE DIRECTLY. */ +type Exact = { [K in keyof T]: T[K] }; +/** Internal type. DO NOT USE DIRECTLY. */ export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; -/** All built-in and custom scalars, mapped to their actual values */ -export type Scalars = { - ID: { input: string; output: string; } - String: { input: string; output: string; } - Boolean: { input: boolean; output: boolean; } - Int: { input: number; output: number; } - Float: { input: number; output: number; } - /** A floating point number that requires more precision than IEEE 754 binary 64 */ - BigFloat: { input: any; output: any; } - /** - * A signed eight-byte integer. The upper big integer values are greater than the - * max value for a JavaScript number. Therefore all big integers will be output as - * strings and not numbers. - */ - BigInt: { input: any; output: any; } - /** Base64-encoded binary data (RFC 4648). */ - Bytes: { input: any; output: any; } - /** A location in a connection that can be used for resuming pagination. */ - Cursor: { input: any; output: any; } - /** Calendar date without time or timezone. Format: "YYYY-MM-DD" (ISO 8601). Unlike the Date scalar, this preserves the original string representation. */ - DateString: { input: any; output: any; } - /** Date and time with optional timezone. Format: ISO 8601 (e.g., "2024-01-15T14:30:00" or "2024-01-15T14:30:00+05:30"). Preserves original timezone offset. */ - DateTimeString: { input: any; output: any; } - /** - * A point in time as described by the [ISO - * 8601](https://en.wikipedia.org/wiki/ISO_8601) standard. May or may not include a timezone. - */ - Datetime: { input: any; output: any; } - /** WGS84 geographic coordinate. Format: "lat,lon" or "lat,lon,alt" where lat is [-90,90], lon is [-180,180], and alt is meters above ellipsoid. */ - GeoPoint: { input: any; output: any; } - /** WGS84 axis-aligned bounding box. Format: "min_lat,min_lon,max_lat,max_lon" (southwest corner, then northeast corner). Coordinates follow same bounds as GeoPoint. */ - GeoRect: { input: any; output: any; } - /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ - JSON: { input: any; output: any; } - /** BCP 47 language tag (e.g., "en", "en-US", "zh-Hans-CN"). */ - LanguageTag: { input: any; output: any; } - /** The exact time of day, does not include the date. May or may not have a timezone offset. */ - Time: { input: any; output: any; } - /** Time of day without date. Format: "HH:MM:SS" or "HH:MM:SS.sss" (ISO 8601). May include timezone offset. */ - TimeString: { input: any; output: any; } - /** A universally unique identifier (UUID) as per RFC 4122. Accepts dashed or undashed input; always serializes without dashes. */ - UUID: { input: any; output: any; } -}; - -export type AppWebhook = Node & { - __typename?: 'AppWebhook'; - appName: Scalars['String']['output']; - createdAt: Scalars['Datetime']['output']; - id: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads and enables pagination through a set of `NotificationDelivery`. */ - notificationDeliveriesByWebhookId: Array; - /** Reads and enables pagination through a set of `NotificationDelivery`. */ - notificationDeliveriesByWebhookIdConnection: NotificationDeliveriesConnection; - notificationTypes?: Maybe>>; - secret: Scalars['String']['output']; - spaceIds?: Maybe>>; - updatedAt: Scalars['Datetime']['output']; - url: Scalars['String']['output']; -}; - - -export type AppWebhookNotificationDeliveriesByWebhookIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type AppWebhookNotificationDeliveriesByWebhookIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -/** - * A condition to be used against `AppWebhook` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type AppWebhookCondition = { - /** Checks for equality with the object’s `appName` field. */ - appName?: InputMaybe; - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `notificationTypes` field. */ - notificationTypes?: InputMaybe>>; - /** Checks for equality with the object’s `secret` field. */ - secret?: InputMaybe; - /** Checks for equality with the object’s `spaceIds` field. */ - spaceIds?: InputMaybe>>; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Checks for equality with the object’s `url` field. */ - url?: InputMaybe; -}; - -/** A filter to be used against `AppWebhook` object types. All fields are combined with a logical ‘and.’ */ -export type AppWebhookFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `appName` field. */ - appName?: InputMaybe; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Filter by the object’s `notificationDeliveriesByWebhookIdConnection` relation. */ - notificationDeliveriesByWebhookIdConnection?: InputMaybe; - /** Some related `notificationDeliveriesByWebhookIdConnection` exist. */ - notificationDeliveriesByWebhookIdConnectionExist?: InputMaybe; - /** Filter by the object’s `notificationTypes` field. */ - notificationTypes?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `secret` field. */ - secret?: InputMaybe; - /** Filter by the object’s `spaceIds` field. */ - spaceIds?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Filter by the object’s `url` field. */ - url?: InputMaybe; -}; - -/** A filter to be used against many `NotificationDelivery` object types. All fields are combined with a logical ‘and.’ */ -export type AppWebhookToManyNotificationDeliveryFilter = { - /** Every related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; - /** No related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; - /** Some related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; -}; - -/** A connection to a list of `AppWebhook` values. */ -export type AppWebhooksConnection = { - __typename?: 'AppWebhooksConnection'; - /** A list of edges which contains the `AppWebhook` and cursor to aid in pagination. */ - edges: Array; - /** A list of `AppWebhook` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `AppWebhook` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `AppWebhook` edge in the connection. */ -export type AppWebhooksEdge = { - __typename?: 'AppWebhooksEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `AppWebhook` at the end of the edge. */ - node: AppWebhook; -}; - -/** Methods to use when ordering `AppWebhook`. */ -export enum AppWebhooksOrderBy { - AppNameAsc = 'APP_NAME_ASC', - AppNameDesc = 'APP_NAME_DESC', - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtDesc = 'CREATED_AT_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - NotificationTypesAsc = 'NOTIFICATION_TYPES_ASC', - NotificationTypesDesc = 'NOTIFICATION_TYPES_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SecretAsc = 'SECRET_ASC', - SecretDesc = 'SECRET_DESC', - SpaceIdsAsc = 'SPACE_IDS_ASC', - SpaceIdsDesc = 'SPACE_IDS_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC', - UrlAsc = 'URL_ASC', - UrlDesc = 'URL_DESC' -} - -export type AtlasCheckpoint = Node & { - __typename?: 'AtlasCheckpoint'; - blockNumber: Scalars['BigInt']['output']; - cursor: Scalars['String']['output']; - graphStateBlob: Scalars['JSON']['output']; - graphStateVersion: Scalars['Int']['output']; - indexerId: Scalars['String']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - rootSpaceId: Scalars['String']['output']; - runtimeCompatibilityMarker: Scalars['String']['output']; - schemaVersion: Scalars['Int']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `AtlasCheckpoint` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type AtlasCheckpointCondition = { - /** Checks for equality with the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Checks for equality with the object’s `cursor` field. */ - cursor?: InputMaybe; - /** Checks for equality with the object’s `graphStateBlob` field. */ - graphStateBlob?: InputMaybe; - /** Checks for equality with the object’s `graphStateVersion` field. */ - graphStateVersion?: InputMaybe; - /** Checks for equality with the object’s `indexerId` field. */ - indexerId?: InputMaybe; - /** Checks for equality with the object’s `rootSpaceId` field. */ - rootSpaceId?: InputMaybe; - /** Checks for equality with the object’s `runtimeCompatibilityMarker` field. */ - runtimeCompatibilityMarker?: InputMaybe; - /** Checks for equality with the object’s `schemaVersion` field. */ - schemaVersion?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `AtlasCheckpoint` object types. All fields are combined with a logical ‘and.’ */ -export type AtlasCheckpointFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Filter by the object’s `cursor` field. */ - cursor?: InputMaybe; - /** Filter by the object’s `graphStateBlob` field. */ - graphStateBlob?: InputMaybe; - /** Filter by the object’s `graphStateVersion` field. */ - graphStateVersion?: InputMaybe; - /** Filter by the object’s `indexerId` field. */ - indexerId?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `rootSpaceId` field. */ - rootSpaceId?: InputMaybe; - /** Filter by the object’s `runtimeCompatibilityMarker` field. */ - runtimeCompatibilityMarker?: InputMaybe; - /** Filter by the object’s `schemaVersion` field. */ - schemaVersion?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A connection to a list of `AtlasCheckpoint` values. */ -export type AtlasCheckpointsConnection = { - __typename?: 'AtlasCheckpointsConnection'; - /** A list of edges which contains the `AtlasCheckpoint` and cursor to aid in pagination. */ - edges: Array; - /** A list of `AtlasCheckpoint` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `AtlasCheckpoint` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `AtlasCheckpoint` edge in the connection. */ -export type AtlasCheckpointsEdge = { - __typename?: 'AtlasCheckpointsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `AtlasCheckpoint` at the end of the edge. */ - node: AtlasCheckpoint; -}; - -/** Methods to use when ordering `AtlasCheckpoint`. */ -export enum AtlasCheckpointsOrderBy { - BlockNumberAsc = 'BLOCK_NUMBER_ASC', - BlockNumberDesc = 'BLOCK_NUMBER_DESC', - CursorAsc = 'CURSOR_ASC', - CursorDesc = 'CURSOR_DESC', - GraphStateBlobAsc = 'GRAPH_STATE_BLOB_ASC', - GraphStateBlobDesc = 'GRAPH_STATE_BLOB_DESC', - GraphStateVersionAsc = 'GRAPH_STATE_VERSION_ASC', - GraphStateVersionDesc = 'GRAPH_STATE_VERSION_DESC', - IndexerIdAsc = 'INDEXER_ID_ASC', - IndexerIdDesc = 'INDEXER_ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - RootSpaceIdAsc = 'ROOT_SPACE_ID_ASC', - RootSpaceIdDesc = 'ROOT_SPACE_ID_DESC', - RuntimeCompatibilityMarkerAsc = 'RUNTIME_COMPATIBILITY_MARKER_ASC', - RuntimeCompatibilityMarkerDesc = 'RUNTIME_COMPATIBILITY_MARKER_DESC', - SchemaVersionAsc = 'SCHEMA_VERSION_ASC', - SchemaVersionDesc = 'SCHEMA_VERSION_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; /** A filter to be used against BigFloat fields. All fields are combined with a logical ‘and.’ */ export type BigFloatFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against BigInt fields. All fields are combined with a logical ‘and.’ */ export type BigIntFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against Boolean fields. All fields are combined with a logical ‘and.’ */ export type BooleanFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: boolean | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: boolean | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: boolean | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: boolean | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: boolean | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: boolean | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: boolean | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: boolean | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against Datetime fields. All fields are combined with a logical ‘and.’ */ export type DatetimeFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -export type EditVersion = Node & { - __typename?: 'EditVersion'; - blockNumber: Scalars['BigInt']['output']; - createdAt: Scalars['Datetime']['output']; - createdById?: Maybe; - editId: Scalars['UUID']['output']; - name?: Maybe; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - sequence: Scalars['BigInt']['output']; - versionKey: Scalars['BigInt']['output']; -}; - -/** - * A condition to be used against `EditVersion` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type EditVersionCondition = { - /** Checks for equality with the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `createdById` field. */ - createdById?: InputMaybe; - /** Checks for equality with the object’s `editId` field. */ - editId?: InputMaybe; - /** Checks for equality with the object’s `name` field. */ - name?: InputMaybe; - /** Checks for equality with the object’s `sequence` field. */ - sequence?: InputMaybe; - /** Checks for equality with the object’s `versionKey` field. */ - versionKey?: InputMaybe; -}; - -/** A filter to be used against `EditVersion` object types. All fields are combined with a logical ‘and.’ */ -export type EditVersionFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `createdById` field. */ - createdById?: InputMaybe; - /** Filter by the object’s `editId` field. */ - editId?: InputMaybe; - /** Filter by the object’s `name` field. */ - name?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `sequence` field. */ - sequence?: InputMaybe; - /** Filter by the object’s `versionKey` field. */ - versionKey?: InputMaybe; -}; - -/** A connection to a list of `EditVersion` values. */ -export type EditVersionsConnection = { - __typename?: 'EditVersionsConnection'; - /** A list of edges which contains the `EditVersion` and cursor to aid in pagination. */ - edges: Array; - /** A list of `EditVersion` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `EditVersion` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `EditVersion` edge in the connection. */ -export type EditVersionsEdge = { - __typename?: 'EditVersionsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `EditVersion` at the end of the edge. */ - node: EditVersion; -}; - -/** Methods to use when ordering `EditVersion`. */ -export enum EditVersionsOrderBy { - BlockNumberAsc = 'BLOCK_NUMBER_ASC', - BlockNumberDesc = 'BLOCK_NUMBER_DESC', - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtDesc = 'CREATED_AT_DESC', - CreatedByIdAsc = 'CREATED_BY_ID_ASC', - CreatedByIdDesc = 'CREATED_BY_ID_DESC', - EditIdAsc = 'EDIT_ID_ASC', - EditIdDesc = 'EDIT_ID_DESC', - NameAsc = 'NAME_ASC', - NameDesc = 'NAME_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SequenceAsc = 'SEQUENCE_ASC', - SequenceDesc = 'SEQUENCE_DESC', - VersionKeyAsc = 'VERSION_KEY_ASC', - VersionKeyDesc = 'VERSION_KEY_DESC' -} - -export type Editor = Node & { - __typename?: 'Editor'; - memberSpaceId: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `Space` that is related to this `Editor`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; -}; - -/** A condition to be used against `Editor` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type EditorCondition = { - /** Checks for equality with the object’s `memberSpaceId` field. */ - memberSpaceId?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; + notIn?: Array | null | undefined; }; /** A filter to be used against `Editor` object types. All fields are combined with a logical ‘and.’ */ export type EditorFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `memberSpaceId` field. */ - memberSpaceId?: InputMaybe; + memberSpaceId?: UuidFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: EditorFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; -}; - -/** A connection to a list of `Editor` values. */ -export type EditorsConnection = { - __typename?: 'EditorsConnection'; - /** A list of edges which contains the `Editor` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Editor` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Editor` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Editor` edge in the connection. */ -export type EditorsEdge = { - __typename?: 'EditorsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Editor` at the end of the edge. */ - node: Editor; -}; - -/** Methods to use when ordering `Editor`. */ -export enum EditorsOrderBy { - MemberSpaceIdAsc = 'MEMBER_SPACE_ID_ASC', - MemberSpaceIdDesc = 'MEMBER_SPACE_ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC' -} - -/** A connection to a list of `Entity` values. */ -export type EntitiesConnection = { - __typename?: 'EntitiesConnection'; - /** A list of edges which contains the `Entity` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Entity` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Entity` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Entity` edge in the connection. */ -export type EntitiesEdge = { - __typename?: 'EntitiesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Entity` at the end of the edge. */ - node: Entity; + spaceId?: UuidFilter | null | undefined; }; /** Methods to use when ordering `Entity`. */ @@ -624,4717 +141,663 @@ export enum EntitiesOrderBy { UpdatedAtDesc = 'UPDATED_AT_DESC' } -export type Entity = Node & { - __typename?: 'Entity'; - /** Reads and enables pagination through a set of `Relation`. */ - backlinks: RelationsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - backlinksList: Array; - createdAt: Scalars['String']['output']; - createdAtBlock: Scalars['String']['output']; - description?: Maybe; - id: Scalars['UUID']['output']; - name?: Maybe; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads and enables pagination through a set of `Relation`. */ - relations: RelationsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - relationsList: Array; - /** Reads and enables pagination through a set of `Relation`. */ - relationsWhereEntity: RelationsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - relationsWhereEntityList: Array; - spaceIds?: Maybe>>; - /** Reads and enables pagination through a set of `Space`. */ - spacesByTopicId: Array; - /** Reads and enables pagination through a set of `Space`. */ - spacesByTopicIdConnection: SpacesConnection; - /** Reads and enables pagination through a set of `Space`. */ - spacesIn?: Maybe>; - /** Reads and enables pagination through a set of `Space`. */ - spacesInConnection: SpacesConnection; - /** Reads and enables pagination through a set of `SubspaceTopic`. */ - subspaceTopicsByTopicId: Array; - /** Reads and enables pagination through a set of `SubspaceTopic`. */ - subspaceTopicsByTopicIdConnection: SubspaceTopicsConnection; - systemTypeIds?: Maybe>>; - typeIds?: Maybe>>; - /** Reads and enables pagination through a set of `Entity`. */ - types?: Maybe>; - /** Reads and enables pagination through a set of `Entity`. */ - typesConnection: EntitiesConnection; - updatedAt: Scalars['String']['output']; - updatedAtBlock: Scalars['String']['output']; - /** Reads and enables pagination through a set of `Value`. */ - values: ValuesConnection; - /** Reads and enables pagination through a set of `Value`. */ - valuesList: Array; -}; - - -export type EntityBacklinksArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityBacklinksListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityRelationsArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityRelationsListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityRelationsWhereEntityArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityRelationsWhereEntityListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntitySpacesByTopicIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntitySpacesByTopicIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntitySpacesInArgs = { - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; -}; - - -export type EntitySpacesInConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; -}; - - -export type EntitySubspaceTopicsByTopicIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntitySubspaceTopicsByTopicIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityTypesArgs = { - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; -}; - - -export type EntityTypesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; -}; - - -export type EntityValuesArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type EntityValuesListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -/** A condition to be used against `Entity` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type EntityCondition = { - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Checks for equality with the object’s `updatedAtBlock` field. */ - updatedAtBlock?: InputMaybe; -}; - /** A filter to be used against `Entity` object types. All fields are combined with a logical ‘and.’ */ export type EntityFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `backlinks` relation. */ - backlinks?: InputMaybe; + backlinks?: EntityToManyRelationFilter | null | undefined; /** Some related `backlinks` exist. */ - backlinksExist?: InputMaybe; + backlinksExist?: boolean | null | undefined; /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; + createdAt?: StringFilter | null | undefined; /** Filter by the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; + createdAtBlock?: StringFilter | null | undefined; /** Filter by the object’s `description` field. */ - description?: InputMaybe; + description?: StringFilter | null | undefined; /** Filter by the object’s `id` field. */ - id?: InputMaybe; + id?: UuidFilter | null | undefined; /** Filter by the object’s `name` field. */ - name?: InputMaybe; + name?: StringFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: EntityFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `relations` relation. */ - relations?: InputMaybe; + relations?: EntityToManyRelationFilter | null | undefined; /** Filter by the object’s `relationsByTypeIdConnection` relation. */ - relationsByTypeIdConnection?: InputMaybe; + relationsByTypeIdConnection?: EntityToManyRelationFilter | null | undefined; /** Some related `relationsByTypeIdConnection` exist. */ - relationsByTypeIdConnectionExist?: InputMaybe; + relationsByTypeIdConnectionExist?: boolean | null | undefined; /** Some related `relations` exist. */ - relationsExist?: InputMaybe; + relationsExist?: boolean | null | undefined; /** Filter by the object’s `relationsWhereEntity` relation. */ - relationsWhereEntity?: InputMaybe; + relationsWhereEntity?: EntityToManyRelationFilter | null | undefined; /** Some related `relationsWhereEntity` exist. */ - relationsWhereEntityExist?: InputMaybe; + relationsWhereEntityExist?: boolean | null | undefined; /** Filter by the object’s `spaceIds` field. */ - spaceIds?: InputMaybe; + spaceIds?: UuidListFilter | null | undefined; /** Filter by the object’s `spacesByTopicIdConnection` relation. */ - spacesByTopicIdConnection?: InputMaybe; + spacesByTopicIdConnection?: EntityToManySpaceFilter | null | undefined; /** Some related `spacesByTopicIdConnection` exist. */ - spacesByTopicIdConnectionExist?: InputMaybe; + spacesByTopicIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `subspaceTopicsByTopicIdConnection` relation. */ - subspaceTopicsByTopicIdConnection?: InputMaybe; + subspaceTopicsByTopicIdConnection?: EntityToManySubspaceTopicFilter | null | undefined; /** Some related `subspaceTopicsByTopicIdConnection` exist. */ - subspaceTopicsByTopicIdConnectionExist?: InputMaybe; + subspaceTopicsByTopicIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `systemTypeIds` field. */ - systemTypeIds?: InputMaybe; + systemTypeIds?: UuidListFilter | null | undefined; /** Filter by the object’s `typeIds` field. */ - typeIds?: InputMaybe; + typeIds?: UuidListFilter | null | undefined; /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; + updatedAt?: StringFilter | null | undefined; /** Filter by the object’s `updatedAtBlock` field. */ - updatedAtBlock?: InputMaybe; + updatedAtBlock?: StringFilter | null | undefined; /** Filter by the object’s `values` relation. */ - values?: InputMaybe; + values?: EntityToManyValueFilter | null | undefined; /** Filter by the object’s `valuesByPropertyIdConnection` relation. */ - valuesByPropertyIdConnection?: InputMaybe; + valuesByPropertyIdConnection?: EntityToManyValueFilter | null | undefined; /** Some related `valuesByPropertyIdConnection` exist. */ - valuesByPropertyIdConnectionExist?: InputMaybe; + valuesByPropertyIdConnectionExist?: boolean | null | undefined; /** Some related `values` exist. */ - valuesExist?: InputMaybe; + valuesExist?: boolean | null | undefined; }; /** A filter to be used against many `Relation` object types. All fields are combined with a logical ‘and.’ */ export type EntityToManyRelationFilter = { /** Every related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: RelationFilter | null | undefined; /** No related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: RelationFilter | null | undefined; /** Some related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: RelationFilter | null | undefined; }; /** A filter to be used against many `Space` object types. All fields are combined with a logical ‘and.’ */ export type EntityToManySpaceFilter = { /** Every related `Space` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: SpaceFilter | null | undefined; /** No related `Space` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: SpaceFilter | null | undefined; /** Some related `Space` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: SpaceFilter | null | undefined; }; /** A filter to be used against many `SubspaceTopic` object types. All fields are combined with a logical ‘and.’ */ export type EntityToManySubspaceTopicFilter = { /** Every related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: SubspaceTopicFilter | null | undefined; /** No related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: SubspaceTopicFilter | null | undefined; /** Some related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: SubspaceTopicFilter | null | undefined; }; /** A filter to be used against many `Value` object types. All fields are combined with a logical ‘and.’ */ export type EntityToManyValueFilter = { /** Every related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ValueFilter | null | undefined; /** No related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ValueFilter | null | undefined; /** Some related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: ValueFilter | null | undefined; }; /** A filter to be used against Float fields. All fields are combined with a logical ‘and.’ */ export type FloatFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: number | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: number | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: number | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: number | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: number | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: number | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: number | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: number | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -export type GlobalScore = Node & { - __typename?: 'GlobalScore'; - entityId: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - score: Scalars['BigFloat']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `GlobalScore` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type GlobalScoreCondition = { - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `score` field. */ - score?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `GlobalScore` object types. All fields are combined with a logical ‘and.’ */ -export type GlobalScoreFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `score` field. */ - score?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A connection to a list of `GlobalScore` values. */ -export type GlobalScoresConnection = { - __typename?: 'GlobalScoresConnection'; - /** A list of edges which contains the `GlobalScore` and cursor to aid in pagination. */ - edges: Array; - /** A list of `GlobalScore` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `GlobalScore` you could get from the connection. */ - totalCount: Scalars['Int']['output']; + notIn?: Array | null | undefined; }; -/** A `GlobalScore` edge in the connection. */ -export type GlobalScoresEdge = { - __typename?: 'GlobalScoresEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `GlobalScore` at the end of the edge. */ - node: GlobalScore; -}; - -/** Methods to use when ordering `GlobalScore`. */ -export enum GlobalScoresOrderBy { - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ScoreAsc = 'SCORE_ASC', - ScoreDesc = 'SCORE_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - /** A filter to be used against Int fields. All fields are combined with a logical ‘and.’ */ export type IntFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: number | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: number | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: number | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: number | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: number | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: number | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: number | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: number | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against JSON fields. All fields are combined with a logical ‘and.’ */ export type JsonFilter = { /** Contained by the specified JSON. */ - containedBy?: InputMaybe; + containedBy?: any; /** Contains all of the specified keys. */ - containsAllKeys?: InputMaybe>; + containsAllKeys?: Array | null | undefined; /** Contains any of the specified keys. */ - containsAnyKeys?: InputMaybe>; + containsAnyKeys?: Array | null | undefined; /** Contains the specified key. */ - containsKey?: InputMaybe; + containsKey?: string | null | undefined; /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Contains the specified JSON. */ - in?: InputMaybe; + in?: any; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -export type LocalScore = Node & { - __typename?: 'LocalScore'; - entityId: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - score: Scalars['BigFloat']['output']; - spaceId: Scalars['UUID']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `LocalScore` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type LocalScoreCondition = { - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `score` field. */ - score?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `LocalScore` object types. All fields are combined with a logical ‘and.’ */ -export type LocalScoreFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `score` field. */ - score?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A connection to a list of `LocalScore` values. */ -export type LocalScoresConnection = { - __typename?: 'LocalScoresConnection'; - /** A list of edges which contains the `LocalScore` and cursor to aid in pagination. */ - edges: Array; - /** A list of `LocalScore` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `LocalScore` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `LocalScore` edge in the connection. */ -export type LocalScoresEdge = { - __typename?: 'LocalScoresEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `LocalScore` at the end of the edge. */ - node: LocalScore; -}; - -/** Methods to use when ordering `LocalScore`. */ -export enum LocalScoresOrderBy { - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ScoreAsc = 'SCORE_ASC', - ScoreDesc = 'SCORE_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - -export type Member = Node & { - __typename?: 'Member'; - memberSpaceId: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `Space` that is related to this `Member`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; -}; - -/** A condition to be used against `Member` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type MemberCondition = { - /** Checks for equality with the object’s `memberSpaceId` field. */ - memberSpaceId?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; + notIn?: Array | null | undefined; }; /** A filter to be used against `Member` object types. All fields are combined with a logical ‘and.’ */ export type MemberFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `memberSpaceId` field. */ - memberSpaceId?: InputMaybe; + memberSpaceId?: UuidFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: MemberFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; -}; - -/** A connection to a list of `Member` values. */ -export type MembersConnection = { - __typename?: 'MembersConnection'; - /** A list of edges which contains the `Member` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Member` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Member` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Member` edge in the connection. */ -export type MembersEdge = { - __typename?: 'MembersEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Member` at the end of the edge. */ - node: Member; -}; - -/** Methods to use when ordering `Member`. */ -export enum MembersOrderBy { - MemberSpaceIdAsc = 'MEMBER_SPACE_ID_ASC', - MemberSpaceIdDesc = 'MEMBER_SPACE_ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC' -} - -export type Meta = Node & { - __typename?: 'Meta'; - blockNumber: Scalars['String']['output']; - cursor: Scalars['String']['output']; - id: Scalars['String']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; -}; - -/** A condition to be used against `Meta` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type MetaCondition = { - /** Checks for equality with the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Checks for equality with the object’s `cursor` field. */ - cursor?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; + spaceId?: UuidFilter | null | undefined; }; -/** A filter to be used against `Meta` object types. All fields are combined with a logical ‘and.’ */ -export type MetaFilter = { +/** A filter to be used against `ProposalAction` object types. All fields are combined with a logical ‘and.’ */ +export type ProposalActionFilter = { + /** Filter by the object’s `actionType` field. */ + actionType?: ProposalActionTypeFilter | null | undefined; /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Filter by the object’s `cursor` field. */ - cursor?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; + and?: Array | null | undefined; + /** Filter by the object’s `contentUri` field. */ + contentUri?: StringFilter | null | undefined; + /** Filter by the object’s `disableFastPathAccessForNewMembers` field. */ + disableFastPathAccessForNewMembers?: BooleanFilter | null | undefined; + /** Filter by the object’s `duration` field. */ + duration?: BigIntFilter | null | undefined; + /** Filter by the object’s `executionGracePeriod` field. */ + executionGracePeriod?: BigIntFilter | null | undefined; + /** Filter by the object’s `fastThreshold` field. */ + fastThreshold?: BigIntFilter | null | undefined; + /** Filter by the object’s `flatSupportThreshold` field. */ + flatSupportThreshold?: BigIntFilter | null | undefined; + /** Filter by the object’s `index` field. */ + index?: IntFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ProposalActionFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; -}; - -/** A connection to a list of `Meta` values. */ -export type MetasConnection = { - __typename?: 'MetasConnection'; - /** A list of edges which contains the `Meta` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Meta` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Meta` you could get from the connection. */ - totalCount: Scalars['Int']['output']; + or?: Array | null | undefined; + /** Filter by the object’s `partialPercentageSupportThreshold` field. */ + partialPercentageSupportThreshold?: BigIntFilter | null | undefined; + /** Filter by the object’s `proposalId` field. */ + proposalId?: UuidFilter | null | undefined; + /** Filter by the object’s `proposalVersion` field. */ + proposalVersion?: IntFilter | null | undefined; + /** Filter by the object’s `proposalVersionByProposalIdAndProposalVersion` relation. */ + proposalVersionByProposalIdAndProposalVersion?: ProposalVersionFilter | null | undefined; + /** Filter by the object’s `quorum` field. */ + quorum?: BigIntFilter | null | undefined; + /** Filter by the object’s `slowThreshold` field. */ + slowThreshold?: BigIntFilter | null | undefined; + /** Filter by the object’s `targetId` field. */ + targetId?: UuidFilter | null | undefined; + /** Filter by the object’s `universalPercentageSupportThreshold` field. */ + universalPercentageSupportThreshold?: BigIntFilter | null | undefined; }; -/** A `Meta` edge in the connection. */ -export type MetasEdge = { - __typename?: 'MetasEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Meta` at the end of the edge. */ - node: Meta; -}; - -/** Methods to use when ordering `Meta`. */ -export enum MetasOrderBy { - BlockNumberAsc = 'BLOCK_NUMBER_ASC', - BlockNumberDesc = 'BLOCK_NUMBER_DESC', - CursorAsc = 'CURSOR_ASC', - CursorDesc = 'CURSOR_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC' -} - -/** An object with a globally unique `ID`. */ -export type Node = { - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; -}; - -/** A connection to a list of `NotificationDelivery` values. */ -export type NotificationDeliveriesConnection = { - __typename?: 'NotificationDeliveriesConnection'; - /** A list of edges which contains the `NotificationDelivery` and cursor to aid in pagination. */ - edges: Array; - /** A list of `NotificationDelivery` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `NotificationDelivery` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `NotificationDelivery` edge in the connection. */ -export type NotificationDeliveriesEdge = { - __typename?: 'NotificationDeliveriesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `NotificationDelivery` at the end of the edge. */ - node: NotificationDelivery; -}; - -/** Methods to use when ordering `NotificationDelivery`. */ -export enum NotificationDeliveriesOrderBy { - AttemptsAsc = 'ATTEMPTS_ASC', - AttemptsDesc = 'ATTEMPTS_DESC', - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtDesc = 'CREATED_AT_DESC', - DeliveredAtAsc = 'DELIVERED_AT_ASC', - DeliveredAtDesc = 'DELIVERED_AT_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - LastErrorAsc = 'LAST_ERROR_ASC', - LastErrorDesc = 'LAST_ERROR_DESC', - Natural = 'NATURAL', - NextRetryAtAsc = 'NEXT_RETRY_AT_ASC', - NextRetryAtDesc = 'NEXT_RETRY_AT_DESC', - OutboxIdAsc = 'OUTBOX_ID_ASC', - OutboxIdDesc = 'OUTBOX_ID_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - StatusAsc = 'STATUS_ASC', - StatusDesc = 'STATUS_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC', - WebhookIdAsc = 'WEBHOOK_ID_ASC', - WebhookIdDesc = 'WEBHOOK_ID_DESC' -} - -export type NotificationDelivery = Node & { - __typename?: 'NotificationDelivery'; - attempts: Scalars['Int']['output']; - createdAt: Scalars['Datetime']['output']; - deliveredAt?: Maybe; - id: Scalars['UUID']['output']; - lastError?: Maybe; - nextRetryAt: Scalars['Datetime']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `NotificationOutbox` that is related to this `NotificationDelivery`. */ - outbox?: Maybe; - outboxId: Scalars['UUID']['output']; - status: Scalars['String']['output']; - updatedAt: Scalars['Datetime']['output']; - /** Reads a single `AppWebhook` that is related to this `NotificationDelivery`. */ - webhook?: Maybe; - webhookId: Scalars['UUID']['output']; -}; - -/** - * A condition to be used against `NotificationDelivery` object types. All fields - * are tested for equality and combined with a logical ‘and.’ - */ -export type NotificationDeliveryCondition = { - /** Checks for equality with the object’s `attempts` field. */ - attempts?: InputMaybe; - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `deliveredAt` field. */ - deliveredAt?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `lastError` field. */ - lastError?: InputMaybe; - /** Checks for equality with the object’s `nextRetryAt` field. */ - nextRetryAt?: InputMaybe; - /** Checks for equality with the object’s `outboxId` field. */ - outboxId?: InputMaybe; - /** Checks for equality with the object’s `status` field. */ - status?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Checks for equality with the object’s `webhookId` field. */ - webhookId?: InputMaybe; -}; - -/** A filter to be used against `NotificationDelivery` object types. All fields are combined with a logical ‘and.’ */ -export type NotificationDeliveryFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `attempts` field. */ - attempts?: InputMaybe; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `deliveredAt` field. */ - deliveredAt?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `lastError` field. */ - lastError?: InputMaybe; - /** Filter by the object’s `nextRetryAt` field. */ - nextRetryAt?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `outbox` relation. */ - outbox?: InputMaybe; - /** Filter by the object’s `outboxId` field. */ - outboxId?: InputMaybe; - /** Filter by the object’s `status` field. */ - status?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Filter by the object’s `webhook` relation. */ - webhook?: InputMaybe; - /** Filter by the object’s `webhookId` field. */ - webhookId?: InputMaybe; -}; - -export type NotificationOutbox = Node & { - __typename?: 'NotificationOutbox'; - createdAt: Scalars['Datetime']['output']; - eventType: Scalars['String']['output']; - id: Scalars['UUID']['output']; - idempotencyKey: Scalars['String']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads and enables pagination through a set of `NotificationDelivery`. */ - notificationDeliveriesByOutboxId: Array; - /** Reads and enables pagination through a set of `NotificationDelivery`. */ - notificationDeliveriesByOutboxIdConnection: NotificationDeliveriesConnection; - payload: Scalars['JSON']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - - -export type NotificationOutboxNotificationDeliveriesByOutboxIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type NotificationOutboxNotificationDeliveriesByOutboxIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -/** - * A condition to be used against `NotificationOutbox` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type NotificationOutboxCondition = { - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `eventType` field. */ - eventType?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `idempotencyKey` field. */ - idempotencyKey?: InputMaybe; - /** Checks for equality with the object’s `payload` field. */ - payload?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `NotificationOutbox` object types. All fields are combined with a logical ‘and.’ */ -export type NotificationOutboxFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `eventType` field. */ - eventType?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `idempotencyKey` field. */ - idempotencyKey?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Filter by the object’s `notificationDeliveriesByOutboxIdConnection` relation. */ - notificationDeliveriesByOutboxIdConnection?: InputMaybe; - /** Some related `notificationDeliveriesByOutboxIdConnection` exist. */ - notificationDeliveriesByOutboxIdConnectionExist?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `payload` field. */ - payload?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against many `NotificationDelivery` object types. All fields are combined with a logical ‘and.’ */ -export type NotificationOutboxToManyNotificationDeliveryFilter = { - /** Every related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; - /** No related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; - /** Some related `NotificationDelivery` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; -}; - -/** A connection to a list of `NotificationOutbox` values. */ -export type NotificationOutboxesConnection = { - __typename?: 'NotificationOutboxesConnection'; - /** A list of edges which contains the `NotificationOutbox` and cursor to aid in pagination. */ - edges: Array; - /** A list of `NotificationOutbox` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `NotificationOutbox` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `NotificationOutbox` edge in the connection. */ -export type NotificationOutboxesEdge = { - __typename?: 'NotificationOutboxesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `NotificationOutbox` at the end of the edge. */ - node: NotificationOutbox; -}; - -/** Methods to use when ordering `NotificationOutbox`. */ -export enum NotificationOutboxesOrderBy { - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtDesc = 'CREATED_AT_DESC', - EventTypeAsc = 'EVENT_TYPE_ASC', - EventTypeDesc = 'EVENT_TYPE_DESC', - IdempotencyKeyAsc = 'IDEMPOTENCY_KEY_ASC', - IdempotencyKeyDesc = 'IDEMPOTENCY_KEY_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - PayloadAsc = 'PAYLOAD_ASC', - PayloadDesc = 'PAYLOAD_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - -export type NotificationPollCursor = Node & { - __typename?: 'NotificationPollCursor'; - cursorId: Scalars['BigInt']['output']; - cursorUpdatedAt: Scalars['Datetime']['output']; - name: Scalars['String']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `NotificationPollCursor` object types. All fields - * are tested for equality and combined with a logical ‘and.’ - */ -export type NotificationPollCursorCondition = { - /** Checks for equality with the object’s `cursorId` field. */ - cursorId?: InputMaybe; - /** Checks for equality with the object’s `cursorUpdatedAt` field. */ - cursorUpdatedAt?: InputMaybe; - /** Checks for equality with the object’s `name` field. */ - name?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `NotificationPollCursor` object types. All fields are combined with a logical ‘and.’ */ -export type NotificationPollCursorFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `cursorId` field. */ - cursorId?: InputMaybe; - /** Filter by the object’s `cursorUpdatedAt` field. */ - cursorUpdatedAt?: InputMaybe; - /** Filter by the object’s `name` field. */ - name?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A connection to a list of `NotificationPollCursor` values. */ -export type NotificationPollCursorsConnection = { - __typename?: 'NotificationPollCursorsConnection'; - /** A list of edges which contains the `NotificationPollCursor` and cursor to aid in pagination. */ - edges: Array; - /** A list of `NotificationPollCursor` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `NotificationPollCursor` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `NotificationPollCursor` edge in the connection. */ -export type NotificationPollCursorsEdge = { - __typename?: 'NotificationPollCursorsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `NotificationPollCursor` at the end of the edge. */ - node: NotificationPollCursor; -}; - -/** Methods to use when ordering `NotificationPollCursor`. */ -export enum NotificationPollCursorsOrderBy { - CursorIdAsc = 'CURSOR_ID_ASC', - CursorIdDesc = 'CURSOR_ID_DESC', - CursorUpdatedAtAsc = 'CURSOR_UPDATED_AT_ASC', - CursorUpdatedAtDesc = 'CURSOR_UPDATED_AT_DESC', - NameAsc = 'NAME_ASC', - NameDesc = 'NAME_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - -/** Information about pagination in a connection. */ -export type PageInfo = { - __typename?: 'PageInfo'; - /** When paginating forwards, the cursor to continue. */ - endCursor?: Maybe; - /** When paginating forwards, are there more items? */ - hasNextPage: Scalars['Boolean']['output']; - /** When paginating backwards, are there more items? */ - hasPreviousPage: Scalars['Boolean']['output']; - /** When paginating backwards, the cursor to continue. */ - startCursor?: Maybe; -}; - -export type PropertyInfo = { - __typename?: 'PropertyInfo'; - dataTypeId?: Maybe; - dataTypeName?: Maybe; - description?: Maybe; - format?: Maybe; - id?: Maybe; - isType?: Maybe; - name?: Maybe; - renderableTypeId?: Maybe; - renderableTypeName?: Maybe; -}; - -/** A filter to be used against `PropertyInfo` object types. All fields are combined with a logical ‘and.’ */ -export type PropertyInfoFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `dataTypeId` field. */ - dataTypeId?: InputMaybe; - /** Filter by the object’s `dataTypeName` field. */ - dataTypeName?: InputMaybe; - /** Filter by the object’s `description` field. */ - description?: InputMaybe; - /** Filter by the object’s `format` field. */ - format?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `isType` field. */ - isType?: InputMaybe; - /** Filter by the object’s `name` field. */ - name?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `renderableTypeId` field. */ - renderableTypeId?: InputMaybe; - /** Filter by the object’s `renderableTypeName` field. */ - renderableTypeName?: InputMaybe; -}; - -/** A connection to a list of `PropertyInfo` values. */ -export type PropertyInfosConnection = { - __typename?: 'PropertyInfosConnection'; - /** A list of edges which contains the `PropertyInfo` and cursor to aid in pagination. */ - edges: Array; - /** A list of `PropertyInfo` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `PropertyInfo` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `PropertyInfo` edge in the connection. */ -export type PropertyInfosEdge = { - __typename?: 'PropertyInfosEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `PropertyInfo` at the end of the edge. */ - node: PropertyInfo; -}; - -export type Proposal = Node & { - __typename?: 'Proposal'; - createdAt: Scalars['String']['output']; - createdAtBlock: Scalars['String']['output']; - currentVersion: Scalars['Int']['output']; - executedAt?: Maybe; - id: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `ProposalTallyQueue` that is related to this `Proposal`. */ - proposalTallyQueue?: Maybe; - /** - * Reads and enables pagination through a set of `ProposalTallyQueue`. - * @deprecated Please use proposalTallyQueue instead - */ - proposalTallyQueuesConnection: ProposalTallyQueuesConnection; - /** Reads and enables pagination through a set of `ProposalVersion`. */ - proposalVersions: Array; - /** Reads and enables pagination through a set of `ProposalVersion`. */ - proposalVersionsConnection: ProposalVersionsConnection; - proposedBy: Scalars['UUID']['output']; - /** Reads a single `Space` that is related to this `Proposal`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; -}; - - -export type ProposalProposalTallyQueuesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type ProposalProposalVersionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type ProposalProposalVersionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -export type ProposalAction = Node & { - __typename?: 'ProposalAction'; - actionType: ProposalActionType; - contentId?: Maybe; - contentUri?: Maybe; - disableFastPathAccessForNewMembers?: Maybe; - duration?: Maybe; - executionGracePeriod?: Maybe; - fastThreshold?: Maybe; - flatSupportThreshold?: Maybe; - index: Scalars['Int']['output']; - metadata?: Maybe; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - partialPercentageSupportThreshold?: Maybe; - proposalId: Scalars['UUID']['output']; - proposalVersion: Scalars['Int']['output']; - /** Reads a single `ProposalVersion` that is related to this `ProposalAction`. */ - proposalVersionByProposalIdAndProposalVersion?: Maybe; - quorum?: Maybe; - slowThreshold?: Maybe; - targetId?: Maybe; - universalPercentageSupportThreshold?: Maybe; -}; - -/** - * A condition to be used against `ProposalAction` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ProposalActionCondition = { - /** Checks for equality with the object’s `actionType` field. */ - actionType?: InputMaybe; - /** Checks for equality with the object’s `contentId` field. */ - contentId?: InputMaybe; - /** Checks for equality with the object’s `contentUri` field. */ - contentUri?: InputMaybe; - /** Checks for equality with the object’s `disableFastPathAccessForNewMembers` field. */ - disableFastPathAccessForNewMembers?: InputMaybe; - /** Checks for equality with the object’s `duration` field. */ - duration?: InputMaybe; - /** Checks for equality with the object’s `executionGracePeriod` field. */ - executionGracePeriod?: InputMaybe; - /** Checks for equality with the object’s `fastThreshold` field. */ - fastThreshold?: InputMaybe; - /** Checks for equality with the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `index` field. */ - index?: InputMaybe; - /** Checks for equality with the object’s `metadata` field. */ - metadata?: InputMaybe; - /** Checks for equality with the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Checks for equality with the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Checks for equality with the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Checks for equality with the object’s `slowThreshold` field. */ - slowThreshold?: InputMaybe; - /** Checks for equality with the object’s `targetId` field. */ - targetId?: InputMaybe; - /** Checks for equality with the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; -}; - -/** A filter to be used against `ProposalAction` object types. All fields are combined with a logical ‘and.’ */ -export type ProposalActionFilter = { - /** Filter by the object’s `actionType` field. */ - actionType?: InputMaybe; - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `contentUri` field. */ - contentUri?: InputMaybe; - /** Filter by the object’s `disableFastPathAccessForNewMembers` field. */ - disableFastPathAccessForNewMembers?: InputMaybe; - /** Filter by the object’s `duration` field. */ - duration?: InputMaybe; - /** Filter by the object’s `executionGracePeriod` field. */ - executionGracePeriod?: InputMaybe; - /** Filter by the object’s `fastThreshold` field. */ - fastThreshold?: InputMaybe; - /** Filter by the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Filter by the object’s `index` field. */ - index?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Filter by the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Filter by the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Filter by the object’s `proposalVersionByProposalIdAndProposalVersion` relation. */ - proposalVersionByProposalIdAndProposalVersion?: InputMaybe; - /** Filter by the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Filter by the object’s `slowThreshold` field. */ - slowThreshold?: InputMaybe; - /** Filter by the object’s `targetId` field. */ - targetId?: InputMaybe; - /** Filter by the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; -}; - -export enum ProposalActionType { - AddEditor = 'ADD_EDITOR', - AddMember = 'ADD_MEMBER', - Flag = 'FLAG', - Publish = 'PUBLISH', - RemoveEditor = 'REMOVE_EDITOR', - RemoveMember = 'REMOVE_MEMBER', - SetTopic = 'SET_TOPIC', - SubspaceRelated = 'SUBSPACE_RELATED', - SubspaceTopicDeclared = 'SUBSPACE_TOPIC_DECLARED', - SubspaceTopicRemoved = 'SUBSPACE_TOPIC_REMOVED', - SubspaceUnrelated = 'SUBSPACE_UNRELATED', - SubspaceUnverified = 'SUBSPACE_UNVERIFIED', - SubspaceVerified = 'SUBSPACE_VERIFIED', - Unflag = 'UNFLAG', - UnflagEditor = 'UNFLAG_EDITOR', - Unknown = 'UNKNOWN', - UnsetTopic = 'UNSET_TOPIC', - UpdateVotingSettings = 'UPDATE_VOTING_SETTINGS' -} +export enum ProposalActionType { + AddEditor = 'ADD_EDITOR', + AddMember = 'ADD_MEMBER', + Flag = 'FLAG', + Publish = 'PUBLISH', + RemoveEditor = 'REMOVE_EDITOR', + RemoveMember = 'REMOVE_MEMBER', + SetTopic = 'SET_TOPIC', + SubspaceRelated = 'SUBSPACE_RELATED', + SubspaceTopicDeclared = 'SUBSPACE_TOPIC_DECLARED', + SubspaceTopicRemoved = 'SUBSPACE_TOPIC_REMOVED', + SubspaceUnrelated = 'SUBSPACE_UNRELATED', + SubspaceUnverified = 'SUBSPACE_UNVERIFIED', + SubspaceVerified = 'SUBSPACE_VERIFIED', + Unflag = 'UNFLAG', + UnflagEditor = 'UNFLAG_EDITOR', + Unknown = 'UNKNOWN', + UnsetTopic = 'UNSET_TOPIC', + UpdateVotingSettings = 'UPDATE_VOTING_SETTINGS' +} /** A filter to be used against ProposalActionType fields. All fields are combined with a logical ‘and.’ */ export type ProposalActionTypeFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: ProposalActionType | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: ProposalActionType | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: ProposalActionType | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: ProposalActionType | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: ProposalActionType | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: ProposalActionType | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: ProposalActionType | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: ProposalActionType | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -/** A connection to a list of `ProposalAction` values. */ -export type ProposalActionsConnection = { - __typename?: 'ProposalActionsConnection'; - /** A list of edges which contains the `ProposalAction` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ProposalAction` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ProposalAction` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ProposalAction` edge in the connection. */ -export type ProposalActionsEdge = { - __typename?: 'ProposalActionsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ProposalAction` at the end of the edge. */ - node: ProposalAction; -}; - -/** Methods to use when ordering `ProposalAction`. */ -export enum ProposalActionsOrderBy { - ActionTypeAsc = 'ACTION_TYPE_ASC', - ActionTypeDesc = 'ACTION_TYPE_DESC', - ContentIdAsc = 'CONTENT_ID_ASC', - ContentIdDesc = 'CONTENT_ID_DESC', - ContentUriAsc = 'CONTENT_URI_ASC', - ContentUriDesc = 'CONTENT_URI_DESC', - DisableFastPathAccessForNewMembersAsc = 'DISABLE_FAST_PATH_ACCESS_FOR_NEW_MEMBERS_ASC', - DisableFastPathAccessForNewMembersDesc = 'DISABLE_FAST_PATH_ACCESS_FOR_NEW_MEMBERS_DESC', - DurationAsc = 'DURATION_ASC', - DurationDesc = 'DURATION_DESC', - ExecutionGracePeriodAsc = 'EXECUTION_GRACE_PERIOD_ASC', - ExecutionGracePeriodDesc = 'EXECUTION_GRACE_PERIOD_DESC', - FastThresholdAsc = 'FAST_THRESHOLD_ASC', - FastThresholdDesc = 'FAST_THRESHOLD_DESC', - FlatSupportThresholdAsc = 'FLAT_SUPPORT_THRESHOLD_ASC', - FlatSupportThresholdDesc = 'FLAT_SUPPORT_THRESHOLD_DESC', - IndexAsc = 'INDEX_ASC', - IndexDesc = 'INDEX_DESC', - MetadataAsc = 'METADATA_ASC', - MetadataDesc = 'METADATA_DESC', - Natural = 'NATURAL', - PartialPercentageSupportThresholdAsc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - PartialPercentageSupportThresholdDesc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ProposalIdAsc = 'PROPOSAL_ID_ASC', - ProposalIdDesc = 'PROPOSAL_ID_DESC', - ProposalVersionAsc = 'PROPOSAL_VERSION_ASC', - ProposalVersionDesc = 'PROPOSAL_VERSION_DESC', - QuorumAsc = 'QUORUM_ASC', - QuorumDesc = 'QUORUM_DESC', - SlowThresholdAsc = 'SLOW_THRESHOLD_ASC', - SlowThresholdDesc = 'SLOW_THRESHOLD_DESC', - TargetIdAsc = 'TARGET_ID_ASC', - TargetIdDesc = 'TARGET_ID_DESC', - UniversalPercentageSupportThresholdAsc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - UniversalPercentageSupportThresholdDesc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC' -} - -/** - * A condition to be used against `Proposal` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type ProposalCondition = { - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; - /** Checks for equality with the object’s `currentVersion` field. */ - currentVersion?: InputMaybe; - /** Checks for equality with the object’s `executedAt` field. */ - executedAt?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `proposedBy` field. */ - proposedBy?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; + notIn?: Array | null | undefined; }; /** A filter to be used against `Proposal` object types. All fields are combined with a logical ‘and.’ */ export type ProposalFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; + createdAt?: StringFilter | null | undefined; /** Filter by the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; + createdAtBlock?: StringFilter | null | undefined; /** Filter by the object’s `currentVersion` field. */ - currentVersion?: InputMaybe; + currentVersion?: IntFilter | null | undefined; /** Filter by the object’s `executedAt` field. */ - executedAt?: InputMaybe; + executedAt?: BigIntFilter | null | undefined; /** Filter by the object’s `id` field. */ - id?: InputMaybe; + id?: UuidFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ProposalFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `proposalTallyQueue` relation. */ - proposalTallyQueue?: InputMaybe; + proposalTallyQueue?: ProposalTallyQueueFilter | null | undefined; /** A related `proposalTallyQueue` exists. */ - proposalTallyQueueExists?: InputMaybe; + proposalTallyQueueExists?: boolean | null | undefined; /** Filter by the object’s `proposalVersionsConnection` relation. */ - proposalVersionsConnection?: InputMaybe; + proposalVersionsConnection?: ProposalToManyProposalVersionFilter | null | undefined; /** Some related `proposalVersionsConnection` exist. */ - proposalVersionsConnectionExist?: InputMaybe; + proposalVersionsConnectionExist?: boolean | null | undefined; /** Filter by the object’s `proposedBy` field. */ - proposedBy?: InputMaybe; + proposedBy?: UuidFilter | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; -}; - -export type ProposalTallyQueue = Node & { - __typename?: 'ProposalTallyQueue'; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `Proposal` that is related to this `ProposalTallyQueue`. */ - proposal?: Maybe; - proposalId: Scalars['UUID']['output']; - queuedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `ProposalTallyQueue` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ProposalTallyQueueCondition = { - /** Checks for equality with the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Checks for equality with the object’s `queuedAt` field. */ - queuedAt?: InputMaybe; + spaceId?: UuidFilter | null | undefined; }; /** A filter to be used against `ProposalTallyQueue` object types. All fields are combined with a logical ‘and.’ */ export type ProposalTallyQueueFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ProposalTallyQueueFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `proposal` relation. */ - proposal?: InputMaybe; + proposal?: ProposalFilter | null | undefined; /** Filter by the object’s `proposalId` field. */ - proposalId?: InputMaybe; + proposalId?: UuidFilter | null | undefined; /** Filter by the object’s `queuedAt` field. */ - queuedAt?: InputMaybe; -}; - -/** A connection to a list of `ProposalTallyQueue` values. */ -export type ProposalTallyQueuesConnection = { - __typename?: 'ProposalTallyQueuesConnection'; - /** A list of edges which contains the `ProposalTallyQueue` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ProposalTallyQueue` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ProposalTallyQueue` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ProposalTallyQueue` edge in the connection. */ -export type ProposalTallyQueuesEdge = { - __typename?: 'ProposalTallyQueuesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ProposalTallyQueue` at the end of the edge. */ - node: ProposalTallyQueue; + queuedAt?: DatetimeFilter | null | undefined; }; -/** Methods to use when ordering `ProposalTallyQueue`. */ -export enum ProposalTallyQueuesOrderBy { - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ProposalIdAsc = 'PROPOSAL_ID_ASC', - ProposalIdDesc = 'PROPOSAL_ID_DESC', - QueuedAtAsc = 'QUEUED_AT_ASC', - QueuedAtDesc = 'QUEUED_AT_DESC' -} - /** A filter to be used against many `ProposalVersion` object types. All fields are combined with a logical ‘and.’ */ export type ProposalToManyProposalVersionFilter = { /** Every related `ProposalVersion` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ProposalVersionFilter | null | undefined; /** No related `ProposalVersion` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ProposalVersionFilter | null | undefined; /** Some related `ProposalVersion` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; -}; - -export type ProposalVersion = Node & { - __typename?: 'ProposalVersion'; - abstainCount: Scalars['BigInt']['output']; - endTime: Scalars['BigInt']['output']; - executeBy?: Maybe; - flatSupportThreshold: Scalars['BigInt']['output']; - name?: Maybe; - noCount: Scalars['BigInt']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - partialPercentageSupportThreshold: Scalars['BigInt']['output']; - /** Reads a single `Proposal` that is related to this `ProposalVersion`. */ - proposal?: Maybe; - /** Reads and enables pagination through a set of `ProposalAction`. */ - proposalActionsByProposalIdAndProposalVersion: Array; - /** Reads and enables pagination through a set of `ProposalAction`. */ - proposalActionsByProposalIdAndProposalVersionConnection: ProposalActionsConnection; - proposalId: Scalars['UUID']['output']; - proposalVersion: Scalars['Int']['output']; - /** Reads and enables pagination through a set of `ProposalVote`. */ - proposalVotesByProposalIdAndProposalVersion: Array; - /** Reads and enables pagination through a set of `ProposalVote`. */ - proposalVotesByProposalIdAndProposalVersionConnection: ProposalVotesConnection; - quorum: Scalars['BigInt']['output']; - startTime: Scalars['BigInt']['output']; - threshold: Scalars['BigInt']['output']; - universalPercentageSupportThreshold: Scalars['BigInt']['output']; - versionCreatedAt: Scalars['String']['output']; - versionCreatedAtBlock: Scalars['String']['output']; - votingMode: VotingMode; - yesCount: Scalars['BigInt']['output']; -}; - - -export type ProposalVersionProposalActionsByProposalIdAndProposalVersionArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type ProposalVersionProposalActionsByProposalIdAndProposalVersionConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type ProposalVersionProposalVotesByProposalIdAndProposalVersionArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type ProposalVersionProposalVotesByProposalIdAndProposalVersionConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -/** - * A condition to be used against `ProposalVersion` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ProposalVersionCondition = { - /** Checks for equality with the object’s `abstainCount` field. */ - abstainCount?: InputMaybe; - /** Checks for equality with the object’s `endTime` field. */ - endTime?: InputMaybe; - /** Checks for equality with the object’s `executeBy` field. */ - executeBy?: InputMaybe; - /** Checks for equality with the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `name` field. */ - name?: InputMaybe; - /** Checks for equality with the object’s `noCount` field. */ - noCount?: InputMaybe; - /** Checks for equality with the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Checks for equality with the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Checks for equality with the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Checks for equality with the object’s `startTime` field. */ - startTime?: InputMaybe; - /** Checks for equality with the object’s `threshold` field. */ - threshold?: InputMaybe; - /** Checks for equality with the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `versionCreatedAt` field. */ - versionCreatedAt?: InputMaybe; - /** Checks for equality with the object’s `versionCreatedAtBlock` field. */ - versionCreatedAtBlock?: InputMaybe; - /** Checks for equality with the object’s `votingMode` field. */ - votingMode?: InputMaybe; - /** Checks for equality with the object’s `yesCount` field. */ - yesCount?: InputMaybe; + some?: ProposalVersionFilter | null | undefined; }; /** A filter to be used against `ProposalVersion` object types. All fields are combined with a logical ‘and.’ */ export type ProposalVersionFilter = { /** Filter by the object’s `abstainCount` field. */ - abstainCount?: InputMaybe; + abstainCount?: BigIntFilter | null | undefined; /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `endTime` field. */ - endTime?: InputMaybe; + endTime?: BigIntFilter | null | undefined; /** Filter by the object’s `executeBy` field. */ - executeBy?: InputMaybe; + executeBy?: BigIntFilter | null | undefined; /** Filter by the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; + flatSupportThreshold?: BigIntFilter | null | undefined; /** Filter by the object’s `name` field. */ - name?: InputMaybe; + name?: StringFilter | null | undefined; /** Filter by the object’s `noCount` field. */ - noCount?: InputMaybe; + noCount?: BigIntFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ProposalVersionFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; + partialPercentageSupportThreshold?: BigIntFilter | null | undefined; /** Filter by the object’s `proposal` relation. */ - proposal?: InputMaybe; + proposal?: ProposalFilter | null | undefined; /** Filter by the object’s `proposalActionsByProposalIdAndProposalVersionConnection` relation. */ - proposalActionsByProposalIdAndProposalVersionConnection?: InputMaybe; + proposalActionsByProposalIdAndProposalVersionConnection?: ProposalVersionToManyProposalActionFilter | null | undefined; /** Some related `proposalActionsByProposalIdAndProposalVersionConnection` exist. */ - proposalActionsByProposalIdAndProposalVersionConnectionExist?: InputMaybe; + proposalActionsByProposalIdAndProposalVersionConnectionExist?: boolean | null | undefined; /** Filter by the object’s `proposalId` field. */ - proposalId?: InputMaybe; + proposalId?: UuidFilter | null | undefined; /** Filter by the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; + proposalVersion?: IntFilter | null | undefined; /** Filter by the object’s `proposalVotesByProposalIdAndProposalVersionConnection` relation. */ - proposalVotesByProposalIdAndProposalVersionConnection?: InputMaybe; + proposalVotesByProposalIdAndProposalVersionConnection?: ProposalVersionToManyProposalVoteFilter | null | undefined; /** Some related `proposalVotesByProposalIdAndProposalVersionConnection` exist. */ - proposalVotesByProposalIdAndProposalVersionConnectionExist?: InputMaybe; + proposalVotesByProposalIdAndProposalVersionConnectionExist?: boolean | null | undefined; /** Filter by the object’s `quorum` field. */ - quorum?: InputMaybe; + quorum?: BigIntFilter | null | undefined; /** Filter by the object’s `startTime` field. */ - startTime?: InputMaybe; + startTime?: BigIntFilter | null | undefined; /** Filter by the object’s `threshold` field. */ - threshold?: InputMaybe; + threshold?: BigIntFilter | null | undefined; /** Filter by the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; + universalPercentageSupportThreshold?: BigIntFilter | null | undefined; /** Filter by the object’s `versionCreatedAt` field. */ - versionCreatedAt?: InputMaybe; + versionCreatedAt?: StringFilter | null | undefined; /** Filter by the object’s `versionCreatedAtBlock` field. */ - versionCreatedAtBlock?: InputMaybe; + versionCreatedAtBlock?: StringFilter | null | undefined; /** Filter by the object’s `votingMode` field. */ - votingMode?: InputMaybe; + votingMode?: VotingModeFilter | null | undefined; /** Filter by the object’s `yesCount` field. */ - yesCount?: InputMaybe; + yesCount?: BigIntFilter | null | undefined; }; /** A filter to be used against many `ProposalAction` object types. All fields are combined with a logical ‘and.’ */ export type ProposalVersionToManyProposalActionFilter = { /** Every related `ProposalAction` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ProposalActionFilter | null | undefined; /** No related `ProposalAction` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ProposalActionFilter | null | undefined; /** Some related `ProposalAction` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: ProposalActionFilter | null | undefined; }; /** A filter to be used against many `ProposalVote` object types. All fields are combined with a logical ‘and.’ */ export type ProposalVersionToManyProposalVoteFilter = { /** Every related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ProposalVoteFilter | null | undefined; /** No related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ProposalVoteFilter | null | undefined; /** Some related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; -}; - -/** A connection to a list of `ProposalVersion` values. */ -export type ProposalVersionsConnection = { - __typename?: 'ProposalVersionsConnection'; - /** A list of edges which contains the `ProposalVersion` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ProposalVersion` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ProposalVersion` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ProposalVersion` edge in the connection. */ -export type ProposalVersionsEdge = { - __typename?: 'ProposalVersionsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ProposalVersion` at the end of the edge. */ - node: ProposalVersion; -}; - -/** Methods to use when ordering `ProposalVersion`. */ -export enum ProposalVersionsOrderBy { - AbstainCountAsc = 'ABSTAIN_COUNT_ASC', - AbstainCountDesc = 'ABSTAIN_COUNT_DESC', - EndTimeAsc = 'END_TIME_ASC', - EndTimeDesc = 'END_TIME_DESC', - ExecuteByAsc = 'EXECUTE_BY_ASC', - ExecuteByDesc = 'EXECUTE_BY_DESC', - FlatSupportThresholdAsc = 'FLAT_SUPPORT_THRESHOLD_ASC', - FlatSupportThresholdDesc = 'FLAT_SUPPORT_THRESHOLD_DESC', - NameAsc = 'NAME_ASC', - NameDesc = 'NAME_DESC', - Natural = 'NATURAL', - NoCountAsc = 'NO_COUNT_ASC', - NoCountDesc = 'NO_COUNT_DESC', - PartialPercentageSupportThresholdAsc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - PartialPercentageSupportThresholdDesc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ProposalIdAsc = 'PROPOSAL_ID_ASC', - ProposalIdDesc = 'PROPOSAL_ID_DESC', - ProposalVersionAsc = 'PROPOSAL_VERSION_ASC', - ProposalVersionDesc = 'PROPOSAL_VERSION_DESC', - QuorumAsc = 'QUORUM_ASC', - QuorumDesc = 'QUORUM_DESC', - StartTimeAsc = 'START_TIME_ASC', - StartTimeDesc = 'START_TIME_DESC', - ThresholdAsc = 'THRESHOLD_ASC', - ThresholdDesc = 'THRESHOLD_DESC', - UniversalPercentageSupportThresholdAsc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - UniversalPercentageSupportThresholdDesc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - VersionCreatedAtAsc = 'VERSION_CREATED_AT_ASC', - VersionCreatedAtBlockAsc = 'VERSION_CREATED_AT_BLOCK_ASC', - VersionCreatedAtBlockDesc = 'VERSION_CREATED_AT_BLOCK_DESC', - VersionCreatedAtDesc = 'VERSION_CREATED_AT_DESC', - VotingModeAsc = 'VOTING_MODE_ASC', - VotingModeDesc = 'VOTING_MODE_DESC', - YesCountAsc = 'YES_COUNT_ASC', - YesCountDesc = 'YES_COUNT_DESC' -} - -export type ProposalVote = Node & { - __typename?: 'ProposalVote'; - createdAt: Scalars['String']['output']; - createdAtBlock: Scalars['String']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - proposalId: Scalars['UUID']['output']; - proposalVersion: Scalars['Int']['output']; - /** Reads a single `ProposalVersion` that is related to this `ProposalVote`. */ - proposalVersionByProposalIdAndProposalVersion?: Maybe; - /** Reads a single `Space` that is related to this `ProposalVote`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; - vote: VoteOption; - voterId: Scalars['UUID']['output']; -}; - -/** - * A condition to be used against `ProposalVote` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ProposalVoteCondition = { - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; - /** Checks for equality with the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Checks for equality with the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `vote` field. */ - vote?: InputMaybe; - /** Checks for equality with the object’s `voterId` field. */ - voterId?: InputMaybe; + some?: ProposalVoteFilter | null | undefined; }; /** A filter to be used against `ProposalVote` object types. All fields are combined with a logical ‘and.’ */ export type ProposalVoteFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; + createdAt?: StringFilter | null | undefined; /** Filter by the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; + createdAtBlock?: StringFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ProposalVoteFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Filter by the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Filter by the object’s `proposalVersionByProposalIdAndProposalVersion` relation. */ - proposalVersionByProposalIdAndProposalVersion?: InputMaybe; - /** Filter by the object’s `space` relation. */ - space?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `vote` field. */ - vote?: InputMaybe; - /** Filter by the object’s `voterId` field. */ - voterId?: InputMaybe; -}; - -/** A connection to a list of `ProposalVote` values. */ -export type ProposalVotesConnection = { - __typename?: 'ProposalVotesConnection'; - /** A list of edges which contains the `ProposalVote` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ProposalVote` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ProposalVote` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ProposalVote` edge in the connection. */ -export type ProposalVotesEdge = { - __typename?: 'ProposalVotesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ProposalVote` at the end of the edge. */ - node: ProposalVote; -}; - -/** Methods to use when ordering `ProposalVote`. */ -export enum ProposalVotesOrderBy { - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtBlockAsc = 'CREATED_AT_BLOCK_ASC', - CreatedAtBlockDesc = 'CREATED_AT_BLOCK_DESC', - CreatedAtDesc = 'CREATED_AT_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ProposalIdAsc = 'PROPOSAL_ID_ASC', - ProposalIdDesc = 'PROPOSAL_ID_DESC', - ProposalVersionAsc = 'PROPOSAL_VERSION_ASC', - ProposalVersionDesc = 'PROPOSAL_VERSION_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - VoterIdAsc = 'VOTER_ID_ASC', - VoterIdDesc = 'VOTER_ID_DESC', - VoteAsc = 'VOTE_ASC', - VoteDesc = 'VOTE_DESC' -} - -/** A connection to a list of `Proposal` values. */ -export type ProposalsConnection = { - __typename?: 'ProposalsConnection'; - /** A list of edges which contains the `Proposal` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Proposal` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Proposal` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -export type ProposalsCurrent = { - __typename?: 'ProposalsCurrent'; - abstainCount?: Maybe; - createdAt?: Maybe; - createdAtBlock?: Maybe; - currentVersion?: Maybe; - endTime?: Maybe; - executeBy?: Maybe; - executedAt?: Maybe; - flatSupportThreshold?: Maybe; - id?: Maybe; - name?: Maybe; - noCount?: Maybe; - partialPercentageSupportThreshold?: Maybe; - proposalId?: Maybe; - proposalVersion?: Maybe; - proposedBy?: Maybe; - quorum?: Maybe; - spaceId?: Maybe; - startTime?: Maybe; - threshold?: Maybe; - universalPercentageSupportThreshold?: Maybe; - versionCreatedAt?: Maybe; - versionCreatedAtBlock?: Maybe; - votingMode?: Maybe; - yesCount?: Maybe; -}; - -/** - * A condition to be used against `ProposalsCurrent` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ProposalsCurrentCondition = { - /** Checks for equality with the object’s `abstainCount` field. */ - abstainCount?: InputMaybe; - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; - /** Checks for equality with the object’s `currentVersion` field. */ - currentVersion?: InputMaybe; - /** Checks for equality with the object’s `endTime` field. */ - endTime?: InputMaybe; - /** Checks for equality with the object’s `executeBy` field. */ - executeBy?: InputMaybe; - /** Checks for equality with the object’s `executedAt` field. */ - executedAt?: InputMaybe; - /** Checks for equality with the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `name` field. */ - name?: InputMaybe; - /** Checks for equality with the object’s `noCount` field. */ - noCount?: InputMaybe; - /** Checks for equality with the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Checks for equality with the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Checks for equality with the object’s `proposedBy` field. */ - proposedBy?: InputMaybe; - /** Checks for equality with the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `startTime` field. */ - startTime?: InputMaybe; - /** Checks for equality with the object’s `threshold` field. */ - threshold?: InputMaybe; - /** Checks for equality with the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `versionCreatedAt` field. */ - versionCreatedAt?: InputMaybe; - /** Checks for equality with the object’s `versionCreatedAtBlock` field. */ - versionCreatedAtBlock?: InputMaybe; - /** Checks for equality with the object’s `votingMode` field. */ - votingMode?: InputMaybe; - /** Checks for equality with the object’s `yesCount` field. */ - yesCount?: InputMaybe; -}; - -/** A filter to be used against `ProposalsCurrent` object types. All fields are combined with a logical ‘and.’ */ -export type ProposalsCurrentFilter = { - /** Filter by the object’s `abstainCount` field. */ - abstainCount?: InputMaybe; - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `createdAtBlock` field. */ - createdAtBlock?: InputMaybe; - /** Filter by the object’s `currentVersion` field. */ - currentVersion?: InputMaybe; - /** Filter by the object’s `endTime` field. */ - endTime?: InputMaybe; - /** Filter by the object’s `executeBy` field. */ - executeBy?: InputMaybe; - /** Filter by the object’s `executedAt` field. */ - executedAt?: InputMaybe; - /** Filter by the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `name` field. */ - name?: InputMaybe; - /** Filter by the object’s `noCount` field. */ - noCount?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Filter by the object’s `proposalId` field. */ - proposalId?: InputMaybe; - /** Filter by the object’s `proposalVersion` field. */ - proposalVersion?: InputMaybe; - /** Filter by the object’s `proposedBy` field. */ - proposedBy?: InputMaybe; - /** Filter by the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `startTime` field. */ - startTime?: InputMaybe; - /** Filter by the object’s `threshold` field. */ - threshold?: InputMaybe; - /** Filter by the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; - /** Filter by the object’s `versionCreatedAt` field. */ - versionCreatedAt?: InputMaybe; - /** Filter by the object’s `versionCreatedAtBlock` field. */ - versionCreatedAtBlock?: InputMaybe; - /** Filter by the object’s `votingMode` field. */ - votingMode?: InputMaybe; - /** Filter by the object’s `yesCount` field. */ - yesCount?: InputMaybe; -}; - -/** A connection to a list of `ProposalsCurrent` values. */ -export type ProposalsCurrentsConnection = { - __typename?: 'ProposalsCurrentsConnection'; - /** A list of edges which contains the `ProposalsCurrent` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ProposalsCurrent` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ProposalsCurrent` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ProposalsCurrent` edge in the connection. */ -export type ProposalsCurrentsEdge = { - __typename?: 'ProposalsCurrentsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ProposalsCurrent` at the end of the edge. */ - node: ProposalsCurrent; -}; - -/** Methods to use when ordering `ProposalsCurrent`. */ -export enum ProposalsCurrentsOrderBy { - AbstainCountAsc = 'ABSTAIN_COUNT_ASC', - AbstainCountDesc = 'ABSTAIN_COUNT_DESC', - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtBlockAsc = 'CREATED_AT_BLOCK_ASC', - CreatedAtBlockDesc = 'CREATED_AT_BLOCK_DESC', - CreatedAtDesc = 'CREATED_AT_DESC', - CurrentVersionAsc = 'CURRENT_VERSION_ASC', - CurrentVersionDesc = 'CURRENT_VERSION_DESC', - EndTimeAsc = 'END_TIME_ASC', - EndTimeDesc = 'END_TIME_DESC', - ExecutedAtAsc = 'EXECUTED_AT_ASC', - ExecutedAtDesc = 'EXECUTED_AT_DESC', - ExecuteByAsc = 'EXECUTE_BY_ASC', - ExecuteByDesc = 'EXECUTE_BY_DESC', - FlatSupportThresholdAsc = 'FLAT_SUPPORT_THRESHOLD_ASC', - FlatSupportThresholdDesc = 'FLAT_SUPPORT_THRESHOLD_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - NameAsc = 'NAME_ASC', - NameDesc = 'NAME_DESC', - Natural = 'NATURAL', - NoCountAsc = 'NO_COUNT_ASC', - NoCountDesc = 'NO_COUNT_DESC', - PartialPercentageSupportThresholdAsc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - PartialPercentageSupportThresholdDesc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - ProposalIdAsc = 'PROPOSAL_ID_ASC', - ProposalIdDesc = 'PROPOSAL_ID_DESC', - ProposalVersionAsc = 'PROPOSAL_VERSION_ASC', - ProposalVersionDesc = 'PROPOSAL_VERSION_DESC', - ProposedByAsc = 'PROPOSED_BY_ASC', - ProposedByDesc = 'PROPOSED_BY_DESC', - QuorumAsc = 'QUORUM_ASC', - QuorumDesc = 'QUORUM_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - StartTimeAsc = 'START_TIME_ASC', - StartTimeDesc = 'START_TIME_DESC', - ThresholdAsc = 'THRESHOLD_ASC', - ThresholdDesc = 'THRESHOLD_DESC', - UniversalPercentageSupportThresholdAsc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - UniversalPercentageSupportThresholdDesc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - VersionCreatedAtAsc = 'VERSION_CREATED_AT_ASC', - VersionCreatedAtBlockAsc = 'VERSION_CREATED_AT_BLOCK_ASC', - VersionCreatedAtBlockDesc = 'VERSION_CREATED_AT_BLOCK_DESC', - VersionCreatedAtDesc = 'VERSION_CREATED_AT_DESC', - VotingModeAsc = 'VOTING_MODE_ASC', - VotingModeDesc = 'VOTING_MODE_DESC', - YesCountAsc = 'YES_COUNT_ASC', - YesCountDesc = 'YES_COUNT_DESC' -} - -/** A `Proposal` edge in the connection. */ -export type ProposalsEdge = { - __typename?: 'ProposalsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Proposal` at the end of the edge. */ - node: Proposal; -}; - -/** Methods to use when ordering `Proposal`. */ -export enum ProposalsOrderBy { - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtBlockAsc = 'CREATED_AT_BLOCK_ASC', - CreatedAtBlockDesc = 'CREATED_AT_BLOCK_DESC', - CreatedAtDesc = 'CREATED_AT_DESC', - CurrentVersionAsc = 'CURRENT_VERSION_ASC', - CurrentVersionDesc = 'CURRENT_VERSION_DESC', - ExecutedAtAsc = 'EXECUTED_AT_ASC', - ExecutedAtDesc = 'EXECUTED_AT_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ProposedByAsc = 'PROPOSED_BY_ASC', - ProposedByDesc = 'PROPOSED_BY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC' -} - -/** The root query type which gives access points into the data universe. */ -export type Query = Node & { - __typename?: 'Query'; - appWebhook?: Maybe; - appWebhookByAppName?: Maybe; - /** Reads a single `AppWebhook` using its globally unique `ID`. */ - appWebhookByNodeId?: Maybe; - /** Reads a set of `AppWebhook`. */ - appWebhooks?: Maybe>; - /** Reads and enables pagination through a set of `AppWebhook`. */ - appWebhooksConnection?: Maybe; - atlasCheckpoint?: Maybe; - /** Reads a single `AtlasCheckpoint` using its globally unique `ID`. */ - atlasCheckpointByNodeId?: Maybe; - /** Reads a set of `AtlasCheckpoint`. */ - atlasCheckpoints?: Maybe>; - /** Reads and enables pagination through a set of `AtlasCheckpoint`. */ - atlasCheckpointsConnection?: Maybe; - buildPropertyInfo?: Maybe; - editVersion?: Maybe; - editVersionByBlockNumberAndSequence?: Maybe; - /** Reads a single `EditVersion` using its globally unique `ID`. */ - editVersionByNodeId?: Maybe; - /** Reads a set of `EditVersion`. */ - editVersions?: Maybe>; - /** Reads and enables pagination through a set of `EditVersion`. */ - editVersionsConnection?: Maybe; - editor?: Maybe; - /** Reads a single `Editor` using its globally unique `ID`. */ - editorByNodeId?: Maybe; - /** Reads a set of `Editor`. */ - editors?: Maybe>; - /** Reads and enables pagination through a set of `Editor`. */ - editorsConnection?: Maybe; - /** Reads a set of `Entity`. */ - entities?: Maybe>; - /** Reads and enables pagination through a set of `Entity`. */ - entitiesConnection?: Maybe; - /** Reads and enables pagination through a set of `Entity`. */ - entitiesOrderedByProperty?: Maybe>; - /** Reads and enables pagination through a set of `Entity`. */ - entitiesOrderedByPropertyConnection?: Maybe; - entity?: Maybe; - /** Reads a single `Entity` using its globally unique `ID`. */ - entityByNodeId?: Maybe; - globalScore?: Maybe; - /** Reads a single `GlobalScore` using its globally unique `ID`. */ - globalScoreByNodeId?: Maybe; - /** Reads a set of `GlobalScore`. */ - globalScores?: Maybe>; - /** Reads and enables pagination through a set of `GlobalScore`. */ - globalScoresConnection?: Maybe; - localScore?: Maybe; - /** Reads a single `LocalScore` using its globally unique `ID`. */ - localScoreByNodeId?: Maybe; - /** Reads a set of `LocalScore`. */ - localScores?: Maybe>; - /** Reads and enables pagination through a set of `LocalScore`. */ - localScoresConnection?: Maybe; - member?: Maybe; - /** Reads a single `Member` using its globally unique `ID`. */ - memberByNodeId?: Maybe; - /** Reads a set of `Member`. */ - members?: Maybe>; - /** Reads and enables pagination through a set of `Member`. */ - membersConnection?: Maybe; - meta?: Maybe; - /** Reads a single `Meta` using its globally unique `ID`. */ - metaByNodeId?: Maybe; - /** Reads a set of `Meta`. */ - metas?: Maybe>; - /** Reads and enables pagination through a set of `Meta`. */ - metasConnection?: Maybe; - /** Fetches an object given its globally unique `ID`. */ - node?: Maybe; - /** The root query type must be a `Node` to work well with Relay 1 mutations. This just resolves to `query`. */ - nodeId: Scalars['ID']['output']; - /** Reads a set of `NotificationDelivery`. */ - notificationDeliveries?: Maybe>; - /** Reads and enables pagination through a set of `NotificationDelivery`. */ - notificationDeliveriesConnection?: Maybe; - notificationDelivery?: Maybe; - /** Reads a single `NotificationDelivery` using its globally unique `ID`. */ - notificationDeliveryByNodeId?: Maybe; - notificationDeliveryByOutboxIdAndWebhookId?: Maybe; - notificationOutbox?: Maybe; - notificationOutboxByIdempotencyKey?: Maybe; - /** Reads a single `NotificationOutbox` using its globally unique `ID`. */ - notificationOutboxByNodeId?: Maybe; - /** Reads a set of `NotificationOutbox`. */ - notificationOutboxes?: Maybe>; - /** Reads and enables pagination through a set of `NotificationOutbox`. */ - notificationOutboxesConnection?: Maybe; - notificationPollCursor?: Maybe; - /** Reads a single `NotificationPollCursor` using its globally unique `ID`. */ - notificationPollCursorByNodeId?: Maybe; - /** Reads a set of `NotificationPollCursor`. */ - notificationPollCursors?: Maybe>; - /** Reads and enables pagination through a set of `NotificationPollCursor`. */ - notificationPollCursorsConnection?: Maybe; - /** Reads and enables pagination through a set of `PropertyInfo`. */ - properties?: Maybe>; - /** Reads and enables pagination through a set of `PropertyInfo`. */ - propertiesConnection?: Maybe; - property?: Maybe; - proposal?: Maybe; - proposalAction?: Maybe; - /** Reads a single `ProposalAction` using its globally unique `ID`. */ - proposalActionByNodeId?: Maybe; - /** Reads a set of `ProposalAction`. */ - proposalActions?: Maybe>; - /** Reads and enables pagination through a set of `ProposalAction`. */ - proposalActionsConnection?: Maybe; - /** Reads a single `Proposal` using its globally unique `ID`. */ - proposalByNodeId?: Maybe; - proposalTallyQueue?: Maybe; - /** Reads a single `ProposalTallyQueue` using its globally unique `ID`. */ - proposalTallyQueueByNodeId?: Maybe; - /** Reads a set of `ProposalTallyQueue`. */ - proposalTallyQueues?: Maybe>; - /** Reads and enables pagination through a set of `ProposalTallyQueue`. */ - proposalTallyQueuesConnection?: Maybe; - proposalVersion?: Maybe; - /** Reads a single `ProposalVersion` using its globally unique `ID`. */ - proposalVersionByNodeId?: Maybe; - proposalVersionByProposalIdAndVersionCreatedAtBlock?: Maybe; - /** Reads a set of `ProposalVersion`. */ - proposalVersions?: Maybe>; - /** Reads and enables pagination through a set of `ProposalVersion`. */ - proposalVersionsConnection?: Maybe; - proposalVote?: Maybe; - /** Reads a single `ProposalVote` using its globally unique `ID`. */ - proposalVoteByNodeId?: Maybe; - /** Reads a set of `ProposalVote`. */ - proposalVotes?: Maybe>; - /** Reads and enables pagination through a set of `ProposalVote`. */ - proposalVotesConnection?: Maybe; - /** Reads a set of `Proposal`. */ - proposals?: Maybe>; - /** Reads and enables pagination through a set of `Proposal`. */ - proposalsConnection?: Maybe; - /** Reads a set of `ProposalsCurrent`. */ - proposalsCurrents?: Maybe>; - /** Reads and enables pagination through a set of `ProposalsCurrent`. */ - proposalsCurrentsConnection?: Maybe; - /** - * Exposes the root query type nested one level down. This is helpful for Relay 1 - * which can only query top level fields if they are in a particular form. - */ - query: Query; - relation?: Maybe; - /** Reads a single `Relation` using its globally unique `ID`. */ - relationByNodeId?: Maybe; - relationVersion?: Maybe; - /** Reads a single `RelationVersion` using its globally unique `ID`. */ - relationVersionByNodeId?: Maybe; - /** Reads a set of `RelationVersion`. */ - relationVersions?: Maybe>; - /** Reads and enables pagination through a set of `RelationVersion`. */ - relationVersionsConnection?: Maybe; - /** Reads a set of `Relation`. */ - relations?: Maybe>; - /** Reads and enables pagination through a set of `Relation`. */ - relationsConnection?: Maybe; - scoringTopologyDistance?: Maybe; - /** Reads a single `ScoringTopologyDistance` using its globally unique `ID`. */ - scoringTopologyDistanceByNodeId?: Maybe; - /** Reads a set of `ScoringTopologyDistance`. */ - scoringTopologyDistances?: Maybe>; - /** Reads and enables pagination through a set of `ScoringTopologyDistance`. */ - scoringTopologyDistancesConnection?: Maybe; - /** Reads and enables pagination through a set of `Entity`. */ - search?: Maybe>; - /** Reads and enables pagination through a set of `Entity`. */ - searchConnection?: Maybe; - space?: Maybe; - /** Reads a single `Space` using its globally unique `ID`. */ - spaceByNodeId?: Maybe; - /** Reads a set of `SpaceEditorCount`. */ - spaceEditorCounts?: Maybe>; - /** Reads and enables pagination through a set of `SpaceEditorCount`. */ - spaceEditorCountsConnection?: Maybe; - spaceScore?: Maybe; - /** Reads a single `SpaceScore` using its globally unique `ID`. */ - spaceScoreByNodeId?: Maybe; - /** Reads a set of `SpaceScore`. */ - spaceScores?: Maybe>; - /** Reads and enables pagination through a set of `SpaceScore`. */ - spaceScoresConnection?: Maybe; - spaceVotingSetting?: Maybe; - /** Reads a single `SpaceVotingSetting` using its globally unique `ID`. */ - spaceVotingSettingByNodeId?: Maybe; - /** Reads a set of `SpaceVotingSetting`. */ - spaceVotingSettings?: Maybe>; - /** Reads and enables pagination through a set of `SpaceVotingSetting`. */ - spaceVotingSettingsConnection?: Maybe; - /** Reads a set of `Space`. */ - spaces?: Maybe>; - /** Reads and enables pagination through a set of `Space`. */ - spacesConnection?: Maybe; - subspace?: Maybe; - /** Reads a single `Subspace` using its globally unique `ID`. */ - subspaceByNodeId?: Maybe; - subspaceTopic?: Maybe; - /** Reads a single `SubspaceTopic` using its globally unique `ID`. */ - subspaceTopicByNodeId?: Maybe; - /** Reads a set of `SubspaceTopic`. */ - subspaceTopics?: Maybe>; - /** Reads and enables pagination through a set of `SubspaceTopic`. */ - subspaceTopicsConnection?: Maybe; - /** Reads a set of `Subspace`. */ - subspaces?: Maybe>; - /** Reads and enables pagination through a set of `Subspace`. */ - subspacesConnection?: Maybe; - type?: Maybe; - /** Reads and enables pagination through a set of `Entity`. */ - typesList?: Maybe>; - /** Reads and enables pagination through a set of `Entity`. */ - typesListConnection?: Maybe; - userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId?: Maybe; - /** Reads a set of `UserVote`. */ - userVotes?: Maybe>; - /** Reads and enables pagination through a set of `UserVote`. */ - userVotesConnection?: Maybe; - value?: Maybe; - /** Reads a single `Value` using its globally unique `ID`. */ - valueByNodeId?: Maybe; - valueVersion?: Maybe; - /** Reads a single `ValueVersion` using its globally unique `ID`. */ - valueVersionByNodeId?: Maybe; - /** Reads a set of `ValueVersion`. */ - valueVersions?: Maybe>; - /** Reads and enables pagination through a set of `ValueVersion`. */ - valueVersionsConnection?: Maybe; - /** Reads a set of `Value`. */ - values?: Maybe>; - /** Reads and enables pagination through a set of `Value`. */ - valuesConnection?: Maybe; - vote?: Maybe; - /** Reads a single `Vote` using its globally unique `ID`. */ - voteByNodeId?: Maybe; - /** Reads a set of `Vote`. */ - votes?: Maybe>; - /** Reads and enables pagination through a set of `Vote`. */ - votesConnection?: Maybe; - votesCount?: Maybe; - /** Reads a single `VotesCount` using its globally unique `ID`. */ - votesCountByNodeId?: Maybe; - votesCountByObjectIdAndObjectTypeAndSpaceId?: Maybe; - /** Reads a set of `VotesCount`. */ - votesCounts?: Maybe>; - /** Reads and enables pagination through a set of `VotesCount`. */ - votesCountsConnection?: Maybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAppWebhookArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAppWebhookByAppNameArgs = { - appName: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAppWebhookByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAppWebhooksArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAppWebhooksConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAtlasCheckpointArgs = { - indexerId: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAtlasCheckpointByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAtlasCheckpointsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryAtlasCheckpointsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryBuildPropertyInfoArgs = { - entityId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditVersionArgs = { - editId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditVersionByBlockNumberAndSequenceArgs = { - blockNumber: Scalars['BigInt']['input']; - sequence: Scalars['BigInt']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditVersionByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditVersionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditVersionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditorArgs = { - memberSpaceId: Scalars['UUID']['input']; - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditorByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditorsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEditorsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntitiesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; - spaceId?: InputMaybe; - spaceIds?: InputMaybe; - systemTypeId?: InputMaybe; - systemTypeIds?: InputMaybe; - typeId?: InputMaybe; - typeIds?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntitiesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; - spaceId?: InputMaybe; - spaceIds?: InputMaybe; - systemTypeId?: InputMaybe; - systemTypeIds?: InputMaybe; - typeId?: InputMaybe; - typeIds?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntitiesOrderedByPropertyArgs = { - dataType?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - propertyId?: InputMaybe; - sortDirection?: InputMaybe; - spaceIds?: InputMaybe>>; - typeIds?: InputMaybe>>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntitiesOrderedByPropertyConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - dataType?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - includeWithoutValue?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - propertyId?: InputMaybe; - sortDirection?: InputMaybe; - spaceIds?: InputMaybe>>; - typeIds?: InputMaybe>>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntityArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryEntityByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryGlobalScoreArgs = { - entityId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryGlobalScoreByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryGlobalScoresArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryGlobalScoresConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryLocalScoreArgs = { - entityId: Scalars['UUID']['input']; - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryLocalScoreByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryLocalScoresArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryLocalScoresConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMemberArgs = { - memberSpaceId: Scalars['UUID']['input']; - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMemberByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMembersArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMembersConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMetaArgs = { - id: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMetaByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMetasArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryMetasConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNodeArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationDeliveriesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationDeliveriesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationDeliveryArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationDeliveryByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationDeliveryByOutboxIdAndWebhookIdArgs = { - outboxId: Scalars['UUID']['input']; - webhookId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationOutboxArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationOutboxByIdempotencyKeyArgs = { - idempotencyKey: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationOutboxByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationOutboxesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationOutboxesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationPollCursorArgs = { - name: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationPollCursorByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationPollCursorsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryNotificationPollCursorsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryPropertiesArgs = { - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryPropertiesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryPropertyArgs = { - id?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalActionArgs = { - index: Scalars['Int']['input']; - proposalId: Scalars['UUID']['input']; - proposalVersion: Scalars['Int']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalActionByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalActionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalActionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalTallyQueueArgs = { - proposalId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalTallyQueueByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalTallyQueuesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalTallyQueuesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVersionArgs = { - proposalId: Scalars['UUID']['input']; - proposalVersion: Scalars['Int']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVersionByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVersionByProposalIdAndVersionCreatedAtBlockArgs = { - proposalId: Scalars['UUID']['input']; - versionCreatedAtBlock: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVersionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVersionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVoteArgs = { - proposalId: Scalars['UUID']['input']; - proposalVersion: Scalars['Int']['input']; - voterId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVoteByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVotesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalVotesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalsCurrentsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryProposalsCurrentsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationVersionArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationVersionByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationVersionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationVersionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryRelationsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryScoringTopologyDistanceArgs = { - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryScoringTopologyDistanceByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryScoringTopologyDistancesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryScoringTopologyDistancesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySearchArgs = { - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - query?: InputMaybe; - similarityThreshold?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySearchConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - query?: InputMaybe; - similarityThreshold?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceEditorCountsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceEditorCountsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceScoreArgs = { - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceScoreByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceScoresArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceScoresConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceVotingSettingArgs = { - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceVotingSettingByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceVotingSettingsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpaceVotingSettingsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpacesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySpacesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceArgs = { - childSpaceId: Scalars['UUID']['input']; - parentSpaceId: Scalars['UUID']['input']; - type: SubspaceType; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceTopicArgs = { - spaceId: Scalars['UUID']['input']; - topicId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceTopicByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceTopicsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspaceTopicsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspacesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QuerySubspacesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryTypeArgs = { - id?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryTypesListArgs = { - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryTypesListConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - spaceId?: InputMaybe; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryUserVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdArgs = { - objectId: Scalars['UUID']['input']; - objectType: Scalars['Int']['input']; - spaceId: Scalars['UUID']['input']; - userId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryUserVotesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryUserVotesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueArgs = { - id: Scalars['String']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueVersionArgs = { - id: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueVersionByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueVersionsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValueVersionsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValuesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryValuesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVoteArgs = { - id: Scalars['Int']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVoteByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesCountArgs = { - id: Scalars['Int']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesCountByNodeIdArgs = { - nodeId: Scalars['ID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesCountByObjectIdAndObjectTypeAndSpaceIdArgs = { - objectId: Scalars['UUID']['input']; - objectType: Scalars['Int']['input']; - spaceId: Scalars['UUID']['input']; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesCountsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -/** The root query type which gives access points into the data universe. */ -export type QueryVotesCountsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -export type Relation = Node & { - __typename?: 'Relation'; - /** Reads a single `Entity` that is related to this `Relation`. */ - entity?: Maybe; - entityId: Scalars['UUID']['output']; - /** Reads a single `Entity` that is related to this `Relation`. */ - fromEntity?: Maybe; - fromEntityId: Scalars['UUID']['output']; - /** Reads a single `Space` that is related to this `Relation`. */ - fromSpace?: Maybe; - fromSpaceId?: Maybe; - fromVersionId?: Maybe; - id: Scalars['UUID']['output']; - isSystem: Scalars['Boolean']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - position?: Maybe; - /** Reads a single `Space` that is related to this `Relation`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; - /** Reads a single `Entity` that is related to this `Relation`. */ - toEntity?: Maybe; - toEntityId: Scalars['UUID']['output']; - /** Reads a single `Space` that is related to this `Relation`. */ - toSpace?: Maybe; - toSpaceId?: Maybe; - toVersionId?: Maybe; - type?: Maybe; - /** Reads a single `Entity` that is related to this `Relation`. */ - typeEntity?: Maybe; - typeId: Scalars['UUID']['output']; - verified?: Maybe; -}; - -/** - * A condition to be used against `Relation` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type RelationCondition = { - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `fromEntityId` field. */ - fromEntityId?: InputMaybe; - /** Checks for equality with the object’s `fromSpaceId` field. */ - fromSpaceId?: InputMaybe; - /** Checks for equality with the object’s `fromVersionId` field. */ - fromVersionId?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `isSystem` field. */ - isSystem?: InputMaybe; - /** Checks for equality with the object’s `position` field. */ - position?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `toEntityId` field. */ - toEntityId?: InputMaybe; - /** Checks for equality with the object’s `toSpaceId` field. */ - toSpaceId?: InputMaybe; - /** Checks for equality with the object’s `toVersionId` field. */ - toVersionId?: InputMaybe; - /** Checks for equality with the object’s `typeId` field. */ - typeId?: InputMaybe; - /** Checks for equality with the object’s `verified` field. */ - verified?: InputMaybe; -}; - -/** A filter to be used against `Relation` object types. All fields are combined with a logical ‘and.’ */ -export type RelationFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `entity` relation. */ - entity?: InputMaybe; - /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Filter by the object’s `fromEntity` relation. */ - fromEntity?: InputMaybe; - /** Filter by the object’s `fromEntityId` field. */ - fromEntityId?: InputMaybe; - /** Filter by the object’s `fromSpace` relation. */ - fromSpace?: InputMaybe; - /** A related `fromSpace` exists. */ - fromSpaceExists?: InputMaybe; - /** Filter by the object’s `fromSpaceId` field. */ - fromSpaceId?: InputMaybe; - /** Filter by the object’s `fromVersionId` field. */ - fromVersionId?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `isSystem` field. */ - isSystem?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `position` field. */ - position?: InputMaybe; - /** Filter by the object’s `space` relation. */ - space?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `toEntity` relation. */ - toEntity?: InputMaybe; - /** Filter by the object’s `toEntityId` field. */ - toEntityId?: InputMaybe; - /** Filter by the object’s `toSpace` relation. */ - toSpace?: InputMaybe; - /** A related `toSpace` exists. */ - toSpaceExists?: InputMaybe; - /** Filter by the object’s `toSpaceId` field. */ - toSpaceId?: InputMaybe; - /** Filter by the object’s `toVersionId` field. */ - toVersionId?: InputMaybe; - /** Filter by the object’s `typeEntity` relation. */ - typeEntity?: InputMaybe; - /** Filter by the object’s `typeId` field. */ - typeId?: InputMaybe; - /** Filter by the object’s `verified` field. */ - verified?: InputMaybe; -}; - -export type RelationVersion = Node & { - __typename?: 'RelationVersion'; - contextEdgeTypeId?: Maybe; - contextLastToEntityId?: Maybe; - contextRootId?: Maybe; - entityId: Scalars['UUID']['output']; - fromEntityId: Scalars['UUID']['output']; - fromSpaceId?: Maybe; - id: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - position?: Maybe; - relationId: Scalars['UUID']['output']; - spaceId: Scalars['UUID']['output']; - toEntityId: Scalars['UUID']['output']; - toSpaceId?: Maybe; - typeId: Scalars['UUID']['output']; - validFromKey: Scalars['BigInt']['output']; - validToKey?: Maybe; - verified?: Maybe; -}; - -/** - * A condition to be used against `RelationVersion` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type RelationVersionCondition = { - /** Checks for equality with the object’s `contextEdgeTypeId` field. */ - contextEdgeTypeId?: InputMaybe; - /** Checks for equality with the object’s `contextLastToEntityId` field. */ - contextLastToEntityId?: InputMaybe; - /** Checks for equality with the object’s `contextRootId` field. */ - contextRootId?: InputMaybe; - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `fromEntityId` field. */ - fromEntityId?: InputMaybe; - /** Checks for equality with the object’s `fromSpaceId` field. */ - fromSpaceId?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `position` field. */ - position?: InputMaybe; - /** Checks for equality with the object’s `relationId` field. */ - relationId?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `toEntityId` field. */ - toEntityId?: InputMaybe; - /** Checks for equality with the object’s `toSpaceId` field. */ - toSpaceId?: InputMaybe; - /** Checks for equality with the object’s `typeId` field. */ - typeId?: InputMaybe; - /** Checks for equality with the object’s `validFromKey` field. */ - validFromKey?: InputMaybe; - /** Checks for equality with the object’s `validToKey` field. */ - validToKey?: InputMaybe; - /** Checks for equality with the object’s `verified` field. */ - verified?: InputMaybe; -}; - -/** A filter to be used against `RelationVersion` object types. All fields are combined with a logical ‘and.’ */ -export type RelationVersionFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `contextEdgeTypeId` field. */ - contextEdgeTypeId?: InputMaybe; - /** Filter by the object’s `contextLastToEntityId` field. */ - contextLastToEntityId?: InputMaybe; - /** Filter by the object’s `contextRootId` field. */ - contextRootId?: InputMaybe; - /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Filter by the object’s `fromEntityId` field. */ - fromEntityId?: InputMaybe; - /** Filter by the object’s `fromSpaceId` field. */ - fromSpaceId?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `position` field. */ - position?: InputMaybe; - /** Filter by the object’s `relationId` field. */ - relationId?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `toEntityId` field. */ - toEntityId?: InputMaybe; - /** Filter by the object’s `toSpaceId` field. */ - toSpaceId?: InputMaybe; - /** Filter by the object’s `typeId` field. */ - typeId?: InputMaybe; - /** Filter by the object’s `validFromKey` field. */ - validFromKey?: InputMaybe; - /** Filter by the object’s `validToKey` field. */ - validToKey?: InputMaybe; - /** Filter by the object’s `verified` field. */ - verified?: InputMaybe; -}; - -/** A connection to a list of `RelationVersion` values. */ -export type RelationVersionsConnection = { - __typename?: 'RelationVersionsConnection'; - /** A list of edges which contains the `RelationVersion` and cursor to aid in pagination. */ - edges: Array; - /** A list of `RelationVersion` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `RelationVersion` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `RelationVersion` edge in the connection. */ -export type RelationVersionsEdge = { - __typename?: 'RelationVersionsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `RelationVersion` at the end of the edge. */ - node: RelationVersion; -}; - -/** Methods to use when ordering `RelationVersion`. */ -export enum RelationVersionsOrderBy { - ContextEdgeTypeIdAsc = 'CONTEXT_EDGE_TYPE_ID_ASC', - ContextEdgeTypeIdDesc = 'CONTEXT_EDGE_TYPE_ID_DESC', - ContextLastToEntityIdAsc = 'CONTEXT_LAST_TO_ENTITY_ID_ASC', - ContextLastToEntityIdDesc = 'CONTEXT_LAST_TO_ENTITY_ID_DESC', - ContextRootIdAsc = 'CONTEXT_ROOT_ID_ASC', - ContextRootIdDesc = 'CONTEXT_ROOT_ID_DESC', - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - FromEntityIdAsc = 'FROM_ENTITY_ID_ASC', - FromEntityIdDesc = 'FROM_ENTITY_ID_DESC', - FromSpaceIdAsc = 'FROM_SPACE_ID_ASC', - FromSpaceIdDesc = 'FROM_SPACE_ID_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - PositionAsc = 'POSITION_ASC', - PositionDesc = 'POSITION_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - RelationIdAsc = 'RELATION_ID_ASC', - RelationIdDesc = 'RELATION_ID_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - ToEntityIdAsc = 'TO_ENTITY_ID_ASC', - ToEntityIdDesc = 'TO_ENTITY_ID_DESC', - ToSpaceIdAsc = 'TO_SPACE_ID_ASC', - ToSpaceIdDesc = 'TO_SPACE_ID_DESC', - TypeIdAsc = 'TYPE_ID_ASC', - TypeIdDesc = 'TYPE_ID_DESC', - ValidFromKeyAsc = 'VALID_FROM_KEY_ASC', - ValidFromKeyDesc = 'VALID_FROM_KEY_DESC', - ValidToKeyAsc = 'VALID_TO_KEY_ASC', - ValidToKeyDesc = 'VALID_TO_KEY_DESC', - VerifiedAsc = 'VERIFIED_ASC', - VerifiedDesc = 'VERIFIED_DESC' -} - -/** A connection to a list of `Relation` values. */ -export type RelationsConnection = { - __typename?: 'RelationsConnection'; - /** A list of edges which contains the `Relation` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Relation` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Relation` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Relation` edge in the connection. */ -export type RelationsEdge = { - __typename?: 'RelationsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Relation` at the end of the edge. */ - node: Relation; -}; - -/** Methods to use when ordering `Relation`. */ -export enum RelationsOrderBy { - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - FromEntityIdAsc = 'FROM_ENTITY_ID_ASC', - FromEntityIdDesc = 'FROM_ENTITY_ID_DESC', - FromSpaceIdAsc = 'FROM_SPACE_ID_ASC', - FromSpaceIdDesc = 'FROM_SPACE_ID_DESC', - FromVersionIdAsc = 'FROM_VERSION_ID_ASC', - FromVersionIdDesc = 'FROM_VERSION_ID_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - IsSystemAsc = 'IS_SYSTEM_ASC', - IsSystemDesc = 'IS_SYSTEM_DESC', - Natural = 'NATURAL', - PositionAsc = 'POSITION_ASC', - PositionDesc = 'POSITION_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - ToEntityIdAsc = 'TO_ENTITY_ID_ASC', - ToEntityIdDesc = 'TO_ENTITY_ID_DESC', - ToSpaceIdAsc = 'TO_SPACE_ID_ASC', - ToSpaceIdDesc = 'TO_SPACE_ID_DESC', - ToVersionIdAsc = 'TO_VERSION_ID_ASC', - ToVersionIdDesc = 'TO_VERSION_ID_DESC', - TypeIdAsc = 'TYPE_ID_ASC', - TypeIdDesc = 'TYPE_ID_DESC', - VerifiedAsc = 'VERIFIED_ASC', - VerifiedDesc = 'VERIFIED_DESC' -} - -export type ScoringTopologyDistance = Node & { - __typename?: 'ScoringTopologyDistance'; - distance: Scalars['Int']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - spaceId: Scalars['UUID']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `ScoringTopologyDistance` object types. All - * fields are tested for equality and combined with a logical ‘and.’ - */ -export type ScoringTopologyDistanceCondition = { - /** Checks for equality with the object’s `distance` field. */ - distance?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `ScoringTopologyDistance` object types. All fields are combined with a logical ‘and.’ */ -export type ScoringTopologyDistanceFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `distance` field. */ - distance?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A connection to a list of `ScoringTopologyDistance` values. */ -export type ScoringTopologyDistancesConnection = { - __typename?: 'ScoringTopologyDistancesConnection'; - /** A list of edges which contains the `ScoringTopologyDistance` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ScoringTopologyDistance` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ScoringTopologyDistance` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ScoringTopologyDistance` edge in the connection. */ -export type ScoringTopologyDistancesEdge = { - __typename?: 'ScoringTopologyDistancesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ScoringTopologyDistance` at the end of the edge. */ - node: ScoringTopologyDistance; -}; - -/** Methods to use when ordering `ScoringTopologyDistance`. */ -export enum ScoringTopologyDistancesOrderBy { - DistanceAsc = 'DISTANCE_ASC', - DistanceDesc = 'DISTANCE_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - -export enum SortOrder { - Asc = 'ASC', - Desc = 'DESC' -} - -export type Space = Node & { - __typename?: 'Space'; - address: Scalars['String']['output']; - /** Reads and enables pagination through a set of `Editor`. */ - editors: EditorsConnection; - /** Reads and enables pagination through a set of `Editor`. */ - editorsList: Array; - id: Scalars['UUID']['output']; - /** Reads and enables pagination through a set of `Member`. */ - members: MembersConnection; - /** Reads and enables pagination through a set of `Member`. */ - membersList: Array; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - page?: Maybe; - /** Reads and enables pagination through a set of `ProposalVote`. */ - proposalVotes: Array; - /** Reads and enables pagination through a set of `ProposalVote`. */ - proposalVotesConnection: ProposalVotesConnection; - /** Reads and enables pagination through a set of `Proposal`. */ - proposals: Array; - /** Reads and enables pagination through a set of `Proposal`. */ - proposalsConnection: ProposalsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - relations: Array; - /** Reads and enables pagination through a set of `Relation`. */ - relationsByFromSpaceId: Array; - /** Reads and enables pagination through a set of `Relation`. */ - relationsByFromSpaceIdConnection: RelationsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - relationsByToSpaceId: Array; - /** Reads and enables pagination through a set of `Relation`. */ - relationsByToSpaceIdConnection: RelationsConnection; - /** Reads and enables pagination through a set of `Relation`. */ - relationsConnection: RelationsConnection; - /** Reads a single `SpaceVotingSetting` that is related to this `Space`. */ - spaceVotingSetting?: Maybe; - /** - * Reads and enables pagination through a set of `SpaceVotingSetting`. - * @deprecated Please use spaceVotingSetting instead - */ - spaceVotingSettingsConnection: SpaceVotingSettingsConnection; - /** Reads and enables pagination through a set of `SubspaceTopic`. */ - subspaceTopics: Array; - /** Reads and enables pagination through a set of `SubspaceTopic`. */ - subspaceTopicsConnection: SubspaceTopicsConnection; - /** Reads and enables pagination through a set of `Subspace`. */ - subspacesByChildSpaceId: Array; - /** Reads and enables pagination through a set of `Subspace`. */ - subspacesByChildSpaceIdConnection: SubspacesConnection; - /** Reads and enables pagination through a set of `Subspace`. */ - subspacesByParentSpaceId: Array; - /** Reads and enables pagination through a set of `Subspace`. */ - subspacesByParentSpaceIdConnection: SubspacesConnection; - /** Reads a single `Entity` that is related to this `Space`. */ - topic?: Maybe; - topicId?: Maybe; - type: SpaceTypes; - /** Reads and enables pagination through a set of `Value`. */ - values: Array; - /** Reads and enables pagination through a set of `Value`. */ - valuesConnection: ValuesConnection; -}; - - -export type SpaceEditorsArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceEditorsListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceMembersArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceMembersListArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceProposalVotesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceProposalVotesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceProposalsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceProposalsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsByFromSpaceIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsByFromSpaceIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsByToSpaceIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsByToSpaceIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceRelationsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSpaceVotingSettingsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspaceTopicsArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspaceTopicsConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspacesByChildSpaceIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspacesByChildSpaceIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspacesByParentSpaceIdArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceSubspacesByParentSpaceIdConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceValuesArgs = { - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - - -export type SpaceValuesConnectionArgs = { - after?: InputMaybe; - before?: InputMaybe; - condition?: InputMaybe; - filter?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; - orderBy?: InputMaybe>; -}; - -/** A condition to be used against `Space` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type SpaceCondition = { - /** Checks for equality with the object’s `address` field. */ - address?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `topicId` field. */ - topicId?: InputMaybe; - /** Checks for equality with the object’s `type` field. */ - type?: InputMaybe; -}; - -export type SpaceEditorCount = { - __typename?: 'SpaceEditorCount'; - spaceId?: Maybe; - totalEditors?: Maybe; -}; - -/** - * A condition to be used against `SpaceEditorCount` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type SpaceEditorCountCondition = { - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `totalEditors` field. */ - totalEditors?: InputMaybe; + or?: Array | null | undefined; + /** Filter by the object’s `proposalId` field. */ + proposalId?: UuidFilter | null | undefined; + /** Filter by the object’s `proposalVersion` field. */ + proposalVersion?: IntFilter | null | undefined; + /** Filter by the object’s `proposalVersionByProposalIdAndProposalVersion` relation. */ + proposalVersionByProposalIdAndProposalVersion?: ProposalVersionFilter | null | undefined; + /** Filter by the object’s `space` relation. */ + space?: SpaceFilter | null | undefined; + /** Filter by the object’s `spaceId` field. */ + spaceId?: UuidFilter | null | undefined; + /** Filter by the object’s `vote` field. */ + vote?: VoteOptionFilter | null | undefined; + /** Filter by the object’s `voterId` field. */ + voterId?: UuidFilter | null | undefined; }; -/** A filter to be used against `SpaceEditorCount` object types. All fields are combined with a logical ‘and.’ */ -export type SpaceEditorCountFilter = { +/** A filter to be used against `Relation` object types. All fields are combined with a logical ‘and.’ */ +export type RelationFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; + /** Filter by the object’s `entity` relation. */ + entity?: EntityFilter | null | undefined; + /** Filter by the object’s `entityId` field. */ + entityId?: UuidFilter | null | undefined; + /** Filter by the object’s `fromEntity` relation. */ + fromEntity?: EntityFilter | null | undefined; + /** Filter by the object’s `fromEntityId` field. */ + fromEntityId?: UuidFilter | null | undefined; + /** Filter by the object’s `fromSpace` relation. */ + fromSpace?: SpaceFilter | null | undefined; + /** A related `fromSpace` exists. */ + fromSpaceExists?: boolean | null | undefined; + /** Filter by the object’s `fromSpaceId` field. */ + fromSpaceId?: UuidFilter | null | undefined; + /** Filter by the object’s `fromVersionId` field. */ + fromVersionId?: UuidFilter | null | undefined; + /** Filter by the object’s `id` field. */ + id?: UuidFilter | null | undefined; + /** Filter by the object’s `isSystem` field. */ + isSystem?: BooleanFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: RelationFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; + /** Filter by the object’s `position` field. */ + position?: StringFilter | null | undefined; + /** Filter by the object’s `space` relation. */ + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `totalEditors` field. */ - totalEditors?: InputMaybe; -}; - -/** A connection to a list of `SpaceEditorCount` values. */ -export type SpaceEditorCountsConnection = { - __typename?: 'SpaceEditorCountsConnection'; - /** A list of edges which contains the `SpaceEditorCount` and cursor to aid in pagination. */ - edges: Array; - /** A list of `SpaceEditorCount` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `SpaceEditorCount` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `SpaceEditorCount` edge in the connection. */ -export type SpaceEditorCountsEdge = { - __typename?: 'SpaceEditorCountsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `SpaceEditorCount` at the end of the edge. */ - node: SpaceEditorCount; + spaceId?: UuidFilter | null | undefined; + /** Filter by the object’s `toEntity` relation. */ + toEntity?: EntityFilter | null | undefined; + /** Filter by the object’s `toEntityId` field. */ + toEntityId?: UuidFilter | null | undefined; + /** Filter by the object’s `toSpace` relation. */ + toSpace?: SpaceFilter | null | undefined; + /** A related `toSpace` exists. */ + toSpaceExists?: boolean | null | undefined; + /** Filter by the object’s `toSpaceId` field. */ + toSpaceId?: UuidFilter | null | undefined; + /** Filter by the object’s `toVersionId` field. */ + toVersionId?: UuidFilter | null | undefined; + /** Filter by the object’s `typeEntity` relation. */ + typeEntity?: EntityFilter | null | undefined; + /** Filter by the object’s `typeId` field. */ + typeId?: UuidFilter | null | undefined; + /** Filter by the object’s `verified` field. */ + verified?: BooleanFilter | null | undefined; }; -/** Methods to use when ordering `SpaceEditorCount`. */ -export enum SpaceEditorCountsOrderBy { - Natural = 'NATURAL', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - TotalEditorsAsc = 'TOTAL_EDITORS_ASC', - TotalEditorsDesc = 'TOTAL_EDITORS_DESC' -} - /** A filter to be used against `Space` object types. All fields are combined with a logical ‘and.’ */ export type SpaceFilter = { /** Filter by the object’s `address` field. */ - address?: InputMaybe; + address?: StringFilter | null | undefined; /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `editors` relation. */ - editors?: InputMaybe; + editors?: SpaceToManyEditorFilter | null | undefined; /** Some related `editors` exist. */ - editorsExist?: InputMaybe; + editorsExist?: boolean | null | undefined; /** Filter by the object’s `id` field. */ - id?: InputMaybe; + id?: UuidFilter | null | undefined; /** Filter by the object’s `members` relation. */ - members?: InputMaybe; + members?: SpaceToManyMemberFilter | null | undefined; /** Some related `members` exist. */ - membersExist?: InputMaybe; + membersExist?: boolean | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: SpaceFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `proposalVotesConnection` relation. */ - proposalVotesConnection?: InputMaybe; + proposalVotesConnection?: SpaceToManyProposalVoteFilter | null | undefined; /** Some related `proposalVotesConnection` exist. */ - proposalVotesConnectionExist?: InputMaybe; + proposalVotesConnectionExist?: boolean | null | undefined; /** Filter by the object’s `proposalsConnection` relation. */ - proposalsConnection?: InputMaybe; + proposalsConnection?: SpaceToManyProposalFilter | null | undefined; /** Some related `proposalsConnection` exist. */ - proposalsConnectionExist?: InputMaybe; + proposalsConnectionExist?: boolean | null | undefined; /** Filter by the object’s `relationsByFromSpaceIdConnection` relation. */ - relationsByFromSpaceIdConnection?: InputMaybe; + relationsByFromSpaceIdConnection?: SpaceToManyRelationFilter | null | undefined; /** Some related `relationsByFromSpaceIdConnection` exist. */ - relationsByFromSpaceIdConnectionExist?: InputMaybe; + relationsByFromSpaceIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `relationsByToSpaceIdConnection` relation. */ - relationsByToSpaceIdConnection?: InputMaybe; + relationsByToSpaceIdConnection?: SpaceToManyRelationFilter | null | undefined; /** Some related `relationsByToSpaceIdConnection` exist. */ - relationsByToSpaceIdConnectionExist?: InputMaybe; + relationsByToSpaceIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `relationsConnection` relation. */ - relationsConnection?: InputMaybe; + relationsConnection?: SpaceToManyRelationFilter | null | undefined; /** Some related `relationsConnection` exist. */ - relationsConnectionExist?: InputMaybe; + relationsConnectionExist?: boolean | null | undefined; /** Filter by the object’s `spaceVotingSetting` relation. */ - spaceVotingSetting?: InputMaybe; + spaceVotingSetting?: SpaceVotingSettingFilter | null | undefined; /** A related `spaceVotingSetting` exists. */ - spaceVotingSettingExists?: InputMaybe; + spaceVotingSettingExists?: boolean | null | undefined; /** Filter by the object’s `subspaceTopicsConnection` relation. */ - subspaceTopicsConnection?: InputMaybe; + subspaceTopicsConnection?: SpaceToManySubspaceTopicFilter | null | undefined; /** Some related `subspaceTopicsConnection` exist. */ - subspaceTopicsConnectionExist?: InputMaybe; + subspaceTopicsConnectionExist?: boolean | null | undefined; /** Filter by the object’s `subspacesByChildSpaceIdConnection` relation. */ - subspacesByChildSpaceIdConnection?: InputMaybe; + subspacesByChildSpaceIdConnection?: SpaceToManySubspaceFilter | null | undefined; /** Some related `subspacesByChildSpaceIdConnection` exist. */ - subspacesByChildSpaceIdConnectionExist?: InputMaybe; + subspacesByChildSpaceIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `subspacesByParentSpaceIdConnection` relation. */ - subspacesByParentSpaceIdConnection?: InputMaybe; + subspacesByParentSpaceIdConnection?: SpaceToManySubspaceFilter | null | undefined; /** Some related `subspacesByParentSpaceIdConnection` exist. */ - subspacesByParentSpaceIdConnectionExist?: InputMaybe; + subspacesByParentSpaceIdConnectionExist?: boolean | null | undefined; /** Filter by the object’s `topic` relation. */ - topic?: InputMaybe; + topic?: EntityFilter | null | undefined; /** A related `topic` exists. */ - topicExists?: InputMaybe; + topicExists?: boolean | null | undefined; /** Filter by the object’s `topicId` field. */ - topicId?: InputMaybe; + topicId?: UuidFilter | null | undefined; /** Filter by the object’s `type` field. */ - type?: InputMaybe; + type?: SpaceTypesFilter | null | undefined; /** Filter by the object’s `valuesConnection` relation. */ - valuesConnection?: InputMaybe; + valuesConnection?: SpaceToManyValueFilter | null | undefined; /** Some related `valuesConnection` exist. */ - valuesConnectionExist?: InputMaybe; -}; - -export type SpaceScore = Node & { - __typename?: 'SpaceScore'; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - score: Scalars['BigFloat']['output']; - spaceId: Scalars['UUID']['output']; - updatedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `SpaceScore` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type SpaceScoreCondition = { - /** Checks for equality with the object’s `score` field. */ - score?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; -}; - -/** A filter to be used against `SpaceScore` object types. All fields are combined with a logical ‘and.’ */ -export type SpaceScoreFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `score` field. */ - score?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; + valuesConnectionExist?: boolean | null | undefined; }; -/** A connection to a list of `SpaceScore` values. */ -export type SpaceScoresConnection = { - __typename?: 'SpaceScoresConnection'; - /** A list of edges which contains the `SpaceScore` and cursor to aid in pagination. */ - edges: Array; - /** A list of `SpaceScore` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `SpaceScore` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `SpaceScore` edge in the connection. */ -export type SpaceScoresEdge = { - __typename?: 'SpaceScoresEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `SpaceScore` at the end of the edge. */ - node: SpaceScore; -}; - -/** Methods to use when ordering `SpaceScore`. */ -export enum SpaceScoresOrderBy { - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - ScoreAsc = 'SCORE_ASC', - ScoreDesc = 'SCORE_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - /** A filter to be used against many `Editor` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyEditorFilter = { /** Every related `Editor` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: EditorFilter | null | undefined; /** No related `Editor` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: EditorFilter | null | undefined; /** Some related `Editor` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: EditorFilter | null | undefined; }; /** A filter to be used against many `Member` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyMemberFilter = { /** Every related `Member` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: MemberFilter | null | undefined; /** No related `Member` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: MemberFilter | null | undefined; /** Some related `Member` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: MemberFilter | null | undefined; }; /** A filter to be used against many `Proposal` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyProposalFilter = { /** Every related `Proposal` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ProposalFilter | null | undefined; /** No related `Proposal` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ProposalFilter | null | undefined; /** Some related `Proposal` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: ProposalFilter | null | undefined; }; /** A filter to be used against many `ProposalVote` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyProposalVoteFilter = { /** Every related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ProposalVoteFilter | null | undefined; /** No related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ProposalVoteFilter | null | undefined; /** Some related `ProposalVote` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: ProposalVoteFilter | null | undefined; }; /** A filter to be used against many `Relation` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyRelationFilter = { /** Every related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: RelationFilter | null | undefined; /** No related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: RelationFilter | null | undefined; /** Some related `Relation` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: RelationFilter | null | undefined; }; /** A filter to be used against many `Subspace` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManySubspaceFilter = { /** Every related `Subspace` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: SubspaceFilter | null | undefined; /** No related `Subspace` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: SubspaceFilter | null | undefined; /** Some related `Subspace` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: SubspaceFilter | null | undefined; }; /** A filter to be used against many `SubspaceTopic` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManySubspaceTopicFilter = { /** Every related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: SubspaceTopicFilter | null | undefined; /** No related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: SubspaceTopicFilter | null | undefined; /** Some related `SubspaceTopic` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: SubspaceTopicFilter | null | undefined; }; /** A filter to be used against many `Value` object types. All fields are combined with a logical ‘and.’ */ export type SpaceToManyValueFilter = { /** Every related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - every?: InputMaybe; + every?: ValueFilter | null | undefined; /** No related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - none?: InputMaybe; + none?: ValueFilter | null | undefined; /** Some related `Value` matches the filter criteria. All fields are combined with a logical ‘and.’ */ - some?: InputMaybe; + some?: ValueFilter | null | undefined; }; export enum SpaceTypes { @@ -5345,430 +808,177 @@ export enum SpaceTypes { /** A filter to be used against SpaceTypes fields. All fields are combined with a logical ‘and.’ */ export type SpaceTypesFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: SpaceTypes | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: SpaceTypes | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: SpaceTypes | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: SpaceTypes | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: SpaceTypes | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: SpaceTypes | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: SpaceTypes | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: SpaceTypes | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -export type SpaceVotingSetting = Node & { - __typename?: 'SpaceVotingSetting'; - disableFastPathAccessForNewMembers: Scalars['Boolean']['output']; - duration: Scalars['BigInt']['output']; - executionGracePeriod: Scalars['BigInt']['output']; - flatSupportThreshold: Scalars['BigInt']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - partialPercentageSupportThreshold: Scalars['BigInt']['output']; - quorum: Scalars['BigInt']['output']; - /** Reads a single `Space` that is related to this `SpaceVotingSetting`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; - universalPercentageSupportThreshold: Scalars['BigInt']['output']; - updatedAt: Scalars['String']['output']; - updatedAtBlock: Scalars['String']['output']; -}; - -/** - * A condition to be used against `SpaceVotingSetting` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type SpaceVotingSettingCondition = { - /** Checks for equality with the object’s `disableFastPathAccessForNewMembers` field. */ - disableFastPathAccessForNewMembers?: InputMaybe; - /** Checks for equality with the object’s `duration` field. */ - duration?: InputMaybe; - /** Checks for equality with the object’s `executionGracePeriod` field. */ - executionGracePeriod?: InputMaybe; - /** Checks for equality with the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `quorum` field. */ - quorum?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Checks for equality with the object’s `updatedAtBlock` field. */ - updatedAtBlock?: InputMaybe; + notIn?: Array | null | undefined; }; /** A filter to be used against `SpaceVotingSetting` object types. All fields are combined with a logical ‘and.’ */ export type SpaceVotingSettingFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `disableFastPathAccessForNewMembers` field. */ - disableFastPathAccessForNewMembers?: InputMaybe; + disableFastPathAccessForNewMembers?: BooleanFilter | null | undefined; /** Filter by the object’s `duration` field. */ - duration?: InputMaybe; + duration?: BigIntFilter | null | undefined; /** Filter by the object’s `executionGracePeriod` field. */ - executionGracePeriod?: InputMaybe; + executionGracePeriod?: BigIntFilter | null | undefined; /** Filter by the object’s `flatSupportThreshold` field. */ - flatSupportThreshold?: InputMaybe; + flatSupportThreshold?: BigIntFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: SpaceVotingSettingFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `partialPercentageSupportThreshold` field. */ - partialPercentageSupportThreshold?: InputMaybe; + partialPercentageSupportThreshold?: BigIntFilter | null | undefined; /** Filter by the object’s `quorum` field. */ - quorum?: InputMaybe; + quorum?: BigIntFilter | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; + spaceId?: UuidFilter | null | undefined; /** Filter by the object’s `universalPercentageSupportThreshold` field. */ - universalPercentageSupportThreshold?: InputMaybe; + universalPercentageSupportThreshold?: BigIntFilter | null | undefined; /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; + updatedAt?: StringFilter | null | undefined; /** Filter by the object’s `updatedAtBlock` field. */ - updatedAtBlock?: InputMaybe; -}; - -/** A connection to a list of `SpaceVotingSetting` values. */ -export type SpaceVotingSettingsConnection = { - __typename?: 'SpaceVotingSettingsConnection'; - /** A list of edges which contains the `SpaceVotingSetting` and cursor to aid in pagination. */ - edges: Array; - /** A list of `SpaceVotingSetting` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `SpaceVotingSetting` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `SpaceVotingSetting` edge in the connection. */ -export type SpaceVotingSettingsEdge = { - __typename?: 'SpaceVotingSettingsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `SpaceVotingSetting` at the end of the edge. */ - node: SpaceVotingSetting; -}; - -/** Methods to use when ordering `SpaceVotingSetting`. */ -export enum SpaceVotingSettingsOrderBy { - DisableFastPathAccessForNewMembersAsc = 'DISABLE_FAST_PATH_ACCESS_FOR_NEW_MEMBERS_ASC', - DisableFastPathAccessForNewMembersDesc = 'DISABLE_FAST_PATH_ACCESS_FOR_NEW_MEMBERS_DESC', - DurationAsc = 'DURATION_ASC', - DurationDesc = 'DURATION_DESC', - ExecutionGracePeriodAsc = 'EXECUTION_GRACE_PERIOD_ASC', - ExecutionGracePeriodDesc = 'EXECUTION_GRACE_PERIOD_DESC', - FlatSupportThresholdAsc = 'FLAT_SUPPORT_THRESHOLD_ASC', - FlatSupportThresholdDesc = 'FLAT_SUPPORT_THRESHOLD_DESC', - Natural = 'NATURAL', - PartialPercentageSupportThresholdAsc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - PartialPercentageSupportThresholdDesc = 'PARTIAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - QuorumAsc = 'QUORUM_ASC', - QuorumDesc = 'QUORUM_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UniversalPercentageSupportThresholdAsc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_ASC', - UniversalPercentageSupportThresholdDesc = 'UNIVERSAL_PERCENTAGE_SUPPORT_THRESHOLD_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtBlockAsc = 'UPDATED_AT_BLOCK_ASC', - UpdatedAtBlockDesc = 'UPDATED_AT_BLOCK_DESC', - UpdatedAtDesc = 'UPDATED_AT_DESC' -} - -/** A connection to a list of `Space` values. */ -export type SpacesConnection = { - __typename?: 'SpacesConnection'; - /** A list of edges which contains the `Space` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Space` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Space` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Space` edge in the connection. */ -export type SpacesEdge = { - __typename?: 'SpacesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Space` at the end of the edge. */ - node: Space; + updatedAtBlock?: StringFilter | null | undefined; }; -/** Methods to use when ordering `Space`. */ -export enum SpacesOrderBy { - AddressAsc = 'ADDRESS_ASC', - AddressDesc = 'ADDRESS_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - TopicIdAsc = 'TOPIC_ID_ASC', - TopicIdDesc = 'TOPIC_ID_DESC', - TypeAsc = 'TYPE_ASC', - TypeDesc = 'TYPE_DESC' -} - /** A filter to be used against String fields. All fields are combined with a logical ‘and.’ */ export type StringFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: string | null | undefined; /** Not equal to the specified value, treating null like an ordinary value (case-insensitive). */ - distinctFromInsensitive?: InputMaybe; + distinctFromInsensitive?: string | null | undefined; /** Ends with the specified string (case-sensitive). */ - endsWith?: InputMaybe; + endsWith?: string | null | undefined; /** Ends with the specified string (case-insensitive). */ - endsWithInsensitive?: InputMaybe; + endsWithInsensitive?: string | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: string | null | undefined; /** Greater than the specified value (case-insensitive). */ - greaterThanInsensitive?: InputMaybe; + greaterThanInsensitive?: string | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: string | null | undefined; /** Greater than or equal to the specified value (case-insensitive). */ - greaterThanOrEqualToInsensitive?: InputMaybe; + greaterThanOrEqualToInsensitive?: string | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Included in the specified list (case-insensitive). */ - inInsensitive?: InputMaybe>; + inInsensitive?: Array | null | undefined; /** Contains the specified string (case-sensitive). */ - includes?: InputMaybe; + includes?: string | null | undefined; /** Contains the specified string (case-insensitive). */ - includesInsensitive?: InputMaybe; + includesInsensitive?: string | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: string | null | undefined; /** Equal to the specified value (case-insensitive). */ - isInsensitive?: InputMaybe; + isInsensitive?: string | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: string | null | undefined; /** Not equal to the specified value (case-insensitive). */ - isNotInsensitive?: InputMaybe; + isNotInsensitive?: string | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: string | null | undefined; /** Less than the specified value (case-insensitive). */ - lessThanInsensitive?: InputMaybe; + lessThanInsensitive?: string | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: string | null | undefined; /** Less than or equal to the specified value (case-insensitive). */ - lessThanOrEqualToInsensitive?: InputMaybe; + lessThanOrEqualToInsensitive?: string | null | undefined; /** Matches the specified pattern (case-sensitive). An underscore (_) matches any single character; a percent sign (%) matches any sequence of zero or more characters. */ - like?: InputMaybe; + like?: string | null | undefined; /** Matches the specified pattern (case-insensitive). An underscore (_) matches any single character; a percent sign (%) matches any sequence of zero or more characters. */ - likeInsensitive?: InputMaybe; + likeInsensitive?: string | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: string | null | undefined; /** Equal to the specified value, treating null like an ordinary value (case-insensitive). */ - notDistinctFromInsensitive?: InputMaybe; + notDistinctFromInsensitive?: string | null | undefined; /** Does not end with the specified string (case-sensitive). */ - notEndsWith?: InputMaybe; + notEndsWith?: string | null | undefined; /** Does not end with the specified string (case-insensitive). */ - notEndsWithInsensitive?: InputMaybe; + notEndsWithInsensitive?: string | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; /** Not included in the specified list (case-insensitive). */ - notInInsensitive?: InputMaybe>; + notInInsensitive?: Array | null | undefined; /** Does not contain the specified string (case-sensitive). */ - notIncludes?: InputMaybe; + notIncludes?: string | null | undefined; /** Does not contain the specified string (case-insensitive). */ - notIncludesInsensitive?: InputMaybe; + notIncludesInsensitive?: string | null | undefined; /** Does not match the specified pattern (case-sensitive). An underscore (_) matches any single character; a percent sign (%) matches any sequence of zero or more characters. */ - notLike?: InputMaybe; + notLike?: string | null | undefined; /** Does not match the specified pattern (case-insensitive). An underscore (_) matches any single character; a percent sign (%) matches any sequence of zero or more characters. */ - notLikeInsensitive?: InputMaybe; + notLikeInsensitive?: string | null | undefined; /** Does not start with the specified string (case-sensitive). */ - notStartsWith?: InputMaybe; + notStartsWith?: string | null | undefined; /** Does not start with the specified string (case-insensitive). */ - notStartsWithInsensitive?: InputMaybe; + notStartsWithInsensitive?: string | null | undefined; /** Starts with the specified string (case-sensitive). */ - startsWith?: InputMaybe; + startsWith?: string | null | undefined; /** Starts with the specified string (case-insensitive). */ - startsWithInsensitive?: InputMaybe; -}; - -/** A filter to be used against String List fields. All fields are combined with a logical ‘and.’ */ -export type StringListFilter = { - /** Any array item is equal to the specified value. */ - anyEqualTo?: InputMaybe; - /** Any array item is greater than the specified value. */ - anyGreaterThan?: InputMaybe; - /** Any array item is greater than or equal to the specified value. */ - anyGreaterThanOrEqualTo?: InputMaybe; - /** Any array item is less than the specified value. */ - anyLessThan?: InputMaybe; - /** Any array item is less than or equal to the specified value. */ - anyLessThanOrEqualTo?: InputMaybe; - /** Any array item is not equal to the specified value. */ - anyNotEqualTo?: InputMaybe; - /** Contained by the specified list of values. */ - containedBy?: InputMaybe>>; - /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe>>; - /** Greater than the specified value. */ - greaterThan?: InputMaybe>>; - /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe>>; - /** Contains the specified list of values. */ - in?: InputMaybe>>; - /** Equal to the specified value. */ - is?: InputMaybe>>; - /** Not equal to the specified value. */ - isNot?: InputMaybe>>; - /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; - /** Less than the specified value. */ - lessThan?: InputMaybe>>; - /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe>>; - /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe>>; - /** Overlaps the specified list of values. */ - overlaps?: InputMaybe>>; -}; - -export type Subspace = Node & { - __typename?: 'Subspace'; - /** Reads a single `Space` that is related to this `Subspace`. */ - childSpace?: Maybe; - childSpaceId: Scalars['UUID']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `Space` that is related to this `Subspace`. */ - parentSpace?: Maybe; - parentSpaceId: Scalars['UUID']['output']; - type: SubspaceType; -}; - -/** - * A condition to be used against `Subspace` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type SubspaceCondition = { - /** Checks for equality with the object’s `childSpaceId` field. */ - childSpaceId?: InputMaybe; - /** Checks for equality with the object’s `parentSpaceId` field. */ - parentSpaceId?: InputMaybe; - /** Checks for equality with the object’s `type` field. */ - type?: InputMaybe; + startsWithInsensitive?: string | null | undefined; }; /** A filter to be used against `Subspace` object types. All fields are combined with a logical ‘and.’ */ export type SubspaceFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `childSpace` relation. */ - childSpace?: InputMaybe; + childSpace?: SpaceFilter | null | undefined; /** Filter by the object’s `childSpaceId` field. */ - childSpaceId?: InputMaybe; + childSpaceId?: UuidFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: SubspaceFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `parentSpace` relation. */ - parentSpace?: InputMaybe; + parentSpace?: SpaceFilter | null | undefined; /** Filter by the object’s `parentSpaceId` field. */ - parentSpaceId?: InputMaybe; + parentSpaceId?: UuidFilter | null | undefined; /** Filter by the object’s `type` field. */ - type?: InputMaybe; -}; - -export type SubspaceTopic = Node & { - __typename?: 'SubspaceTopic'; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - /** Reads a single `Space` that is related to this `SubspaceTopic`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; - /** Reads a single `Entity` that is related to this `SubspaceTopic`. */ - topic?: Maybe; - topicId: Scalars['UUID']['output']; -}; - -/** - * A condition to be used against `SubspaceTopic` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type SubspaceTopicCondition = { - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `topicId` field. */ - topicId?: InputMaybe; + type?: SubspaceTypeFilter | null | undefined; }; /** A filter to be used against `SubspaceTopic` object types. All fields are combined with a logical ‘and.’ */ export type SubspaceTopicFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: SubspaceTopicFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; + spaceId?: UuidFilter | null | undefined; /** Filter by the object’s `topic` relation. */ - topic?: InputMaybe; + topic?: EntityFilter | null | undefined; /** Filter by the object’s `topicId` field. */ - topicId?: InputMaybe; -}; - -/** A connection to a list of `SubspaceTopic` values. */ -export type SubspaceTopicsConnection = { - __typename?: 'SubspaceTopicsConnection'; - /** A list of edges which contains the `SubspaceTopic` and cursor to aid in pagination. */ - edges: Array; - /** A list of `SubspaceTopic` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `SubspaceTopic` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `SubspaceTopic` edge in the connection. */ -export type SubspaceTopicsEdge = { - __typename?: 'SubspaceTopicsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `SubspaceTopic` at the end of the edge. */ - node: SubspaceTopic; + topicId?: UuidFilter | null | undefined; }; -/** Methods to use when ordering `SubspaceTopic`. */ -export enum SubspaceTopicsOrderBy { - Natural = 'NATURAL', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - TopicIdAsc = 'TOPIC_ID_ASC', - TopicIdDesc = 'TOPIC_ID_DESC' -} - export enum SubspaceType { Related = 'RELATED', Verified = 'VERIFIED' @@ -5777,754 +987,175 @@ export enum SubspaceType { /** A filter to be used against SubspaceType fields. All fields are combined with a logical ‘and.’ */ export type SubspaceTypeFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: SubspaceType | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: SubspaceType | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: SubspaceType | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: SubspaceType | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: SubspaceType | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: SubspaceType | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: SubspaceType | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: SubspaceType | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -/** A connection to a list of `Subspace` values. */ -export type SubspacesConnection = { - __typename?: 'SubspacesConnection'; - /** A list of edges which contains the `Subspace` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Subspace` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Subspace` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Subspace` edge in the connection. */ -export type SubspacesEdge = { - __typename?: 'SubspacesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Subspace` at the end of the edge. */ - node: Subspace; + notIn?: Array | null | undefined; }; -/** Methods to use when ordering `Subspace`. */ -export enum SubspacesOrderBy { - ChildSpaceIdAsc = 'CHILD_SPACE_ID_ASC', - ChildSpaceIdDesc = 'CHILD_SPACE_ID_DESC', - Natural = 'NATURAL', - ParentSpaceIdAsc = 'PARENT_SPACE_ID_ASC', - ParentSpaceIdDesc = 'PARENT_SPACE_ID_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - TypeAsc = 'TYPE_ASC', - TypeDesc = 'TYPE_DESC' -} - /** A filter to be used against Time fields. All fields are combined with a logical ‘and.’ */ export type TimeFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against UUID fields. All fields are combined with a logical ‘and.’ */ export type UuidFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: any; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: any; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: any; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: any; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: any; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: any; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: any; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: any; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; /** A filter to be used against UUID List fields. All fields are combined with a logical ‘and.’ */ export type UuidListFilter = { /** Any array item is equal to the specified value. */ - anyEqualTo?: InputMaybe; + anyEqualTo?: any; /** Any array item is greater than the specified value. */ - anyGreaterThan?: InputMaybe; + anyGreaterThan?: any; /** Any array item is greater than or equal to the specified value. */ - anyGreaterThanOrEqualTo?: InputMaybe; + anyGreaterThanOrEqualTo?: any; /** Any array item is less than the specified value. */ - anyLessThan?: InputMaybe; + anyLessThan?: any; /** Any array item is less than or equal to the specified value. */ - anyLessThanOrEqualTo?: InputMaybe; + anyLessThanOrEqualTo?: any; /** Any array item is not equal to the specified value. */ - anyNotEqualTo?: InputMaybe; + anyNotEqualTo?: any; /** Contained by the specified list of values. */ - containedBy?: InputMaybe>>; + containedBy?: Array | null | undefined; /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe>>; + distinctFrom?: Array | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe>>; + greaterThan?: Array | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe>>; + greaterThanOrEqualTo?: Array | null | undefined; /** Contains the specified list of values. */ - in?: InputMaybe>>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe>>; + is?: Array | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe>>; + isNot?: Array | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe>>; + lessThan?: Array | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe>>; + lessThanOrEqualTo?: Array | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe>>; + notDistinctFrom?: Array | null | undefined; /** Overlaps the specified list of values. */ - overlaps?: InputMaybe>>; -}; - -export type UserVote = { - __typename?: 'UserVote'; - objectId: Scalars['UUID']['output']; - objectType: Scalars['Int']['output']; - spaceId: Scalars['UUID']['output']; - userId: Scalars['UUID']['output']; - voteType: Scalars['Int']['output']; - votedAt: Scalars['Datetime']['output']; -}; - -/** - * A condition to be used against `UserVote` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type UserVoteCondition = { - /** Checks for equality with the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Checks for equality with the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `userId` field. */ - userId?: InputMaybe; - /** Checks for equality with the object’s `voteType` field. */ - voteType?: InputMaybe; - /** Checks for equality with the object’s `votedAt` field. */ - votedAt?: InputMaybe; -}; - -/** A filter to be used against `UserVote` object types. All fields are combined with a logical ‘and.’ */ -export type UserVoteFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Negates the expression. */ - not?: InputMaybe; - /** Filter by the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Filter by the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `userId` field. */ - userId?: InputMaybe; - /** Filter by the object’s `voteType` field. */ - voteType?: InputMaybe; - /** Filter by the object’s `votedAt` field. */ - votedAt?: InputMaybe; -}; - -/** A connection to a list of `UserVote` values. */ -export type UserVotesConnection = { - __typename?: 'UserVotesConnection'; - /** A list of edges which contains the `UserVote` and cursor to aid in pagination. */ - edges: Array; - /** A list of `UserVote` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `UserVote` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `UserVote` edge in the connection. */ -export type UserVotesEdge = { - __typename?: 'UserVotesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `UserVote` at the end of the edge. */ - node: UserVote; -}; - -/** Methods to use when ordering `UserVote`. */ -export enum UserVotesOrderBy { - Natural = 'NATURAL', - ObjectIdAsc = 'OBJECT_ID_ASC', - ObjectIdDesc = 'OBJECT_ID_DESC', - ObjectTypeAsc = 'OBJECT_TYPE_ASC', - ObjectTypeDesc = 'OBJECT_TYPE_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UserIdAsc = 'USER_ID_ASC', - UserIdDesc = 'USER_ID_DESC', - VotedAtAsc = 'VOTED_AT_ASC', - VotedAtDesc = 'VOTED_AT_DESC', - VoteTypeAsc = 'VOTE_TYPE_ASC', - VoteTypeDesc = 'VOTE_TYPE_DESC' -} - -export type Value = Node & { - __typename?: 'Value'; - boolean?: Maybe; - bytes?: Maybe; - date?: Maybe; - datetime?: Maybe; - datetimeUtc?: Maybe; - decimal?: Maybe; - embedding?: Maybe; - /** Reads a single `Entity` that is related to this `Value`. */ - entity?: Maybe; - entityId: Scalars['UUID']['output']; - float?: Maybe; - id: Scalars['String']['output']; - integer?: Maybe; - language?: Maybe; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - point?: Maybe; - property?: Maybe; - /** Reads a single `Entity` that is related to this `Value`. */ - propertyEntity?: Maybe; - propertyId: Scalars['UUID']['output']; - rect?: Maybe; - schedule?: Maybe; - /** Reads a single `Space` that is related to this `Value`. */ - space?: Maybe; - spaceId: Scalars['UUID']['output']; - text?: Maybe; - time?: Maybe; - timeUtc?: Maybe; - unit?: Maybe; -}; - -/** A condition to be used against `Value` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type ValueCondition = { - /** Checks for equality with the object’s `boolean` field. */ - boolean?: InputMaybe; - /** Checks for equality with the object’s `bytes` field. */ - bytes?: InputMaybe; - /** Checks for equality with the object’s `date` field. */ - date?: InputMaybe; - /** Checks for equality with the object’s `datetime` field. */ - datetime?: InputMaybe; - /** Checks for equality with the object’s `datetimeUtc` field. */ - datetimeUtc?: InputMaybe; - /** Checks for equality with the object’s `decimal` field. */ - decimal?: InputMaybe; - /** Checks for equality with the object’s `embedding` field. */ - embedding?: InputMaybe; - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `float` field. */ - float?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `integer` field. */ - integer?: InputMaybe; - /** Checks for equality with the object’s `language` field. */ - language?: InputMaybe; - /** Checks for equality with the object’s `point` field. */ - point?: InputMaybe; - /** Checks for equality with the object’s `propertyId` field. */ - propertyId?: InputMaybe; - /** Checks for equality with the object’s `rect` field. */ - rect?: InputMaybe; - /** Checks for equality with the object’s `schedule` field. */ - schedule?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `text` field. */ - text?: InputMaybe; - /** Checks for equality with the object’s `time` field. */ - time?: InputMaybe; - /** Checks for equality with the object’s `timeUtc` field. */ - timeUtc?: InputMaybe; - /** Checks for equality with the object’s `unit` field. */ - unit?: InputMaybe; + overlaps?: Array | null | undefined; }; /** A filter to be used against `Value` object types. All fields are combined with a logical ‘and.’ */ export type ValueFilter = { /** Checks for all expressions in this list. */ - and?: InputMaybe>; + and?: Array | null | undefined; /** Filter by the object’s `boolean` field. */ - boolean?: InputMaybe; + boolean?: BooleanFilter | null | undefined; /** Filter by the object’s `date` field. */ - date?: InputMaybe; + date?: StringFilter | null | undefined; /** Filter by the object’s `datetime` field. */ - datetime?: InputMaybe; + datetime?: StringFilter | null | undefined; /** Filter by the object’s `datetimeUtc` field. */ - datetimeUtc?: InputMaybe; + datetimeUtc?: DatetimeFilter | null | undefined; /** Filter by the object’s `decimal` field. */ - decimal?: InputMaybe; + decimal?: BigFloatFilter | null | undefined; /** Filter by the object’s `embedding` field. */ - embedding?: InputMaybe; + embedding?: JsonFilter | null | undefined; /** Filter by the object’s `entity` relation. */ - entity?: InputMaybe; + entity?: EntityFilter | null | undefined; /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; + entityId?: UuidFilter | null | undefined; /** Filter by the object’s `float` field. */ - float?: InputMaybe; + float?: FloatFilter | null | undefined; /** Filter by the object’s `id` field. */ - id?: InputMaybe; + id?: StringFilter | null | undefined; /** Filter by the object’s `integer` field. */ - integer?: InputMaybe; + integer?: BigIntFilter | null | undefined; /** Filter by the object’s `language` field. */ - language?: InputMaybe; + language?: StringFilter | null | undefined; /** Negates the expression. */ - not?: InputMaybe; + not?: ValueFilter | null | undefined; /** Checks for any expressions in this list. */ - or?: InputMaybe>; + or?: Array | null | undefined; /** Filter by the object’s `point` field. */ - point?: InputMaybe; + point?: StringFilter | null | undefined; /** Filter by the object’s `propertyEntity` relation. */ - propertyEntity?: InputMaybe; + propertyEntity?: EntityFilter | null | undefined; /** Filter by the object’s `propertyId` field. */ - propertyId?: InputMaybe; + propertyId?: UuidFilter | null | undefined; /** Filter by the object’s `rect` field. */ - rect?: InputMaybe; + rect?: StringFilter | null | undefined; /** Filter by the object’s `schedule` field. */ - schedule?: InputMaybe; + schedule?: JsonFilter | null | undefined; /** Filter by the object’s `space` relation. */ - space?: InputMaybe; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `text` field. */ - text?: InputMaybe; - /** Filter by the object’s `time` field. */ - time?: InputMaybe; - /** Filter by the object’s `timeUtc` field. */ - timeUtc?: InputMaybe; - /** Filter by the object’s `unit` field. */ - unit?: InputMaybe; -}; - -export type ValueVersion = Node & { - __typename?: 'ValueVersion'; - boolean?: Maybe; - bytes?: Maybe; - contextEdgeTypeId?: Maybe; - contextLastToEntityId?: Maybe; - contextRootId?: Maybe; - date?: Maybe; - datetime?: Maybe; - datetimeUtc?: Maybe; - decimal?: Maybe; - embedding?: Maybe; - entityId: Scalars['UUID']['output']; - float?: Maybe; - id: Scalars['UUID']['output']; - integer?: Maybe; - language?: Maybe; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - point?: Maybe; - propertyId: Scalars['UUID']['output']; - rect?: Maybe; - schedule?: Maybe; - spaceId: Scalars['UUID']['output']; - text?: Maybe; - time?: Maybe; - timeUtc?: Maybe; - unit?: Maybe; - validFromKey: Scalars['BigInt']['output']; - validToKey?: Maybe; -}; - -/** - * A condition to be used against `ValueVersion` object types. All fields are - * tested for equality and combined with a logical ‘and.’ - */ -export type ValueVersionCondition = { - /** Checks for equality with the object’s `boolean` field. */ - boolean?: InputMaybe; - /** Checks for equality with the object’s `bytes` field. */ - bytes?: InputMaybe; - /** Checks for equality with the object’s `contextEdgeTypeId` field. */ - contextEdgeTypeId?: InputMaybe; - /** Checks for equality with the object’s `contextLastToEntityId` field. */ - contextLastToEntityId?: InputMaybe; - /** Checks for equality with the object’s `contextRootId` field. */ - contextRootId?: InputMaybe; - /** Checks for equality with the object’s `date` field. */ - date?: InputMaybe; - /** Checks for equality with the object’s `datetime` field. */ - datetime?: InputMaybe; - /** Checks for equality with the object’s `datetimeUtc` field. */ - datetimeUtc?: InputMaybe; - /** Checks for equality with the object’s `decimal` field. */ - decimal?: InputMaybe; - /** Checks for equality with the object’s `embedding` field. */ - embedding?: InputMaybe; - /** Checks for equality with the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Checks for equality with the object’s `float` field. */ - float?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `integer` field. */ - integer?: InputMaybe; - /** Checks for equality with the object’s `language` field. */ - language?: InputMaybe; - /** Checks for equality with the object’s `point` field. */ - point?: InputMaybe; - /** Checks for equality with the object’s `propertyId` field. */ - propertyId?: InputMaybe; - /** Checks for equality with the object’s `rect` field. */ - rect?: InputMaybe; - /** Checks for equality with the object’s `schedule` field. */ - schedule?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `text` field. */ - text?: InputMaybe; - /** Checks for equality with the object’s `time` field. */ - time?: InputMaybe; - /** Checks for equality with the object’s `timeUtc` field. */ - timeUtc?: InputMaybe; - /** Checks for equality with the object’s `unit` field. */ - unit?: InputMaybe; - /** Checks for equality with the object’s `validFromKey` field. */ - validFromKey?: InputMaybe; - /** Checks for equality with the object’s `validToKey` field. */ - validToKey?: InputMaybe; -}; - -/** A filter to be used against `ValueVersion` object types. All fields are combined with a logical ‘and.’ */ -export type ValueVersionFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `boolean` field. */ - boolean?: InputMaybe; - /** Filter by the object’s `contextEdgeTypeId` field. */ - contextEdgeTypeId?: InputMaybe; - /** Filter by the object’s `contextLastToEntityId` field. */ - contextLastToEntityId?: InputMaybe; - /** Filter by the object’s `contextRootId` field. */ - contextRootId?: InputMaybe; - /** Filter by the object’s `date` field. */ - date?: InputMaybe; - /** Filter by the object’s `datetime` field. */ - datetime?: InputMaybe; - /** Filter by the object’s `datetimeUtc` field. */ - datetimeUtc?: InputMaybe; - /** Filter by the object’s `decimal` field. */ - decimal?: InputMaybe; - /** Filter by the object’s `embedding` field. */ - embedding?: InputMaybe; - /** Filter by the object’s `entityId` field. */ - entityId?: InputMaybe; - /** Filter by the object’s `float` field. */ - float?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Filter by the object’s `integer` field. */ - integer?: InputMaybe; - /** Filter by the object’s `language` field. */ - language?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `point` field. */ - point?: InputMaybe; - /** Filter by the object’s `propertyId` field. */ - propertyId?: InputMaybe; - /** Filter by the object’s `rect` field. */ - rect?: InputMaybe; - /** Filter by the object’s `schedule` field. */ - schedule?: InputMaybe; + space?: SpaceFilter | null | undefined; /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; + spaceId?: UuidFilter | null | undefined; /** Filter by the object’s `text` field. */ - text?: InputMaybe; + text?: StringFilter | null | undefined; /** Filter by the object’s `time` field. */ - time?: InputMaybe; + time?: StringFilter | null | undefined; /** Filter by the object’s `timeUtc` field. */ - timeUtc?: InputMaybe; + timeUtc?: TimeFilter | null | undefined; /** Filter by the object’s `unit` field. */ - unit?: InputMaybe; - /** Filter by the object’s `validFromKey` field. */ - validFromKey?: InputMaybe; - /** Filter by the object’s `validToKey` field. */ - validToKey?: InputMaybe; -}; - -/** A connection to a list of `ValueVersion` values. */ -export type ValueVersionsConnection = { - __typename?: 'ValueVersionsConnection'; - /** A list of edges which contains the `ValueVersion` and cursor to aid in pagination. */ - edges: Array; - /** A list of `ValueVersion` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `ValueVersion` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `ValueVersion` edge in the connection. */ -export type ValueVersionsEdge = { - __typename?: 'ValueVersionsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `ValueVersion` at the end of the edge. */ - node: ValueVersion; -}; - -/** Methods to use when ordering `ValueVersion`. */ -export enum ValueVersionsOrderBy { - BooleanAsc = 'BOOLEAN_ASC', - BooleanDesc = 'BOOLEAN_DESC', - BytesAsc = 'BYTES_ASC', - BytesDesc = 'BYTES_DESC', - ContextEdgeTypeIdAsc = 'CONTEXT_EDGE_TYPE_ID_ASC', - ContextEdgeTypeIdDesc = 'CONTEXT_EDGE_TYPE_ID_DESC', - ContextLastToEntityIdAsc = 'CONTEXT_LAST_TO_ENTITY_ID_ASC', - ContextLastToEntityIdDesc = 'CONTEXT_LAST_TO_ENTITY_ID_DESC', - ContextRootIdAsc = 'CONTEXT_ROOT_ID_ASC', - ContextRootIdDesc = 'CONTEXT_ROOT_ID_DESC', - DatetimeAsc = 'DATETIME_ASC', - DatetimeDesc = 'DATETIME_DESC', - DatetimeUtcAsc = 'DATETIME_UTC_ASC', - DatetimeUtcDesc = 'DATETIME_UTC_DESC', - DateAsc = 'DATE_ASC', - DateDesc = 'DATE_DESC', - DecimalAsc = 'DECIMAL_ASC', - DecimalDesc = 'DECIMAL_DESC', - EmbeddingAsc = 'EMBEDDING_ASC', - EmbeddingDesc = 'EMBEDDING_DESC', - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - FloatAsc = 'FLOAT_ASC', - FloatDesc = 'FLOAT_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - IntegerAsc = 'INTEGER_ASC', - IntegerDesc = 'INTEGER_DESC', - LanguageAsc = 'LANGUAGE_ASC', - LanguageDesc = 'LANGUAGE_DESC', - Natural = 'NATURAL', - PointAsc = 'POINT_ASC', - PointDesc = 'POINT_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - PropertyIdAsc = 'PROPERTY_ID_ASC', - PropertyIdDesc = 'PROPERTY_ID_DESC', - RectAsc = 'RECT_ASC', - RectDesc = 'RECT_DESC', - ScheduleAsc = 'SCHEDULE_ASC', - ScheduleDesc = 'SCHEDULE_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - TextAsc = 'TEXT_ASC', - TextDesc = 'TEXT_DESC', - TimeAsc = 'TIME_ASC', - TimeDesc = 'TIME_DESC', - TimeUtcAsc = 'TIME_UTC_ASC', - TimeUtcDesc = 'TIME_UTC_DESC', - UnitAsc = 'UNIT_ASC', - UnitDesc = 'UNIT_DESC', - ValidFromKeyAsc = 'VALID_FROM_KEY_ASC', - ValidFromKeyDesc = 'VALID_FROM_KEY_DESC', - ValidToKeyAsc = 'VALID_TO_KEY_ASC', - ValidToKeyDesc = 'VALID_TO_KEY_DESC' -} - -/** A connection to a list of `Value` values. */ -export type ValuesConnection = { - __typename?: 'ValuesConnection'; - /** A list of edges which contains the `Value` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Value` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Value` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `Value` edge in the connection. */ -export type ValuesEdge = { - __typename?: 'ValuesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Value` at the end of the edge. */ - node: Value; -}; - -/** Methods to use when ordering `Value`. */ -export enum ValuesOrderBy { - BooleanAsc = 'BOOLEAN_ASC', - BooleanDesc = 'BOOLEAN_DESC', - BytesAsc = 'BYTES_ASC', - BytesDesc = 'BYTES_DESC', - DatetimeAsc = 'DATETIME_ASC', - DatetimeDesc = 'DATETIME_DESC', - DatetimeUtcAsc = 'DATETIME_UTC_ASC', - DatetimeUtcDesc = 'DATETIME_UTC_DESC', - DateAsc = 'DATE_ASC', - DateDesc = 'DATE_DESC', - DecimalAsc = 'DECIMAL_ASC', - DecimalDesc = 'DECIMAL_DESC', - EmbeddingAsc = 'EMBEDDING_ASC', - EmbeddingDesc = 'EMBEDDING_DESC', - EntityIdAsc = 'ENTITY_ID_ASC', - EntityIdDesc = 'ENTITY_ID_DESC', - FloatAsc = 'FLOAT_ASC', - FloatDesc = 'FLOAT_DESC', - GlobalScoreAsc = 'GLOBAL_SCORE_ASC', - GlobalScoreDesc = 'GLOBAL_SCORE_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - IntegerAsc = 'INTEGER_ASC', - IntegerDesc = 'INTEGER_DESC', - LanguageAsc = 'LANGUAGE_ASC', - LanguageDesc = 'LANGUAGE_DESC', - LocalScoreAsc = 'LOCAL_SCORE_ASC', - LocalScoreDesc = 'LOCAL_SCORE_DESC', - Natural = 'NATURAL', - PointAsc = 'POINT_ASC', - PointDesc = 'POINT_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - PropertyIdAsc = 'PROPERTY_ID_ASC', - PropertyIdDesc = 'PROPERTY_ID_DESC', - RawScoreAsc = 'RAW_SCORE_ASC', - RawScoreDesc = 'RAW_SCORE_DESC', - RectAsc = 'RECT_ASC', - RectDesc = 'RECT_DESC', - ScheduleAsc = 'SCHEDULE_ASC', - ScheduleDesc = 'SCHEDULE_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - TextAsc = 'TEXT_ASC', - TextDesc = 'TEXT_DESC', - TimeAsc = 'TIME_ASC', - TimeDesc = 'TIME_DESC', - TimeUtcAsc = 'TIME_UTC_ASC', - TimeUtcDesc = 'TIME_UTC_DESC', - UnitAsc = 'UNIT_ASC', - UnitDesc = 'UNIT_DESC' -} - -export type Vote = Node & { - __typename?: 'Vote'; - blockNumber: Scalars['BigInt']['output']; - blockTimestamp: Scalars['Datetime']['output']; - createdAt: Scalars['Datetime']['output']; - id: Scalars['Int']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - objectId: Scalars['UUID']['output']; - objectType: Scalars['Int']['output']; - spaceId: Scalars['UUID']['output']; - vote: Scalars['Int']['output']; - voterId: Scalars['UUID']['output']; -}; - -/** A condition to be used against `Vote` object types. All fields are tested for equality and combined with a logical ‘and.’ */ -export type VoteCondition = { - /** Checks for equality with the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Checks for equality with the object’s `blockTimestamp` field. */ - blockTimestamp?: InputMaybe; - /** Checks for equality with the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Checks for equality with the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `vote` field. */ - vote?: InputMaybe; - /** Checks for equality with the object’s `voterId` field. */ - voterId?: InputMaybe; -}; - -/** A filter to be used against `Vote` object types. All fields are combined with a logical ‘and.’ */ -export type VoteFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `blockNumber` field. */ - blockNumber?: InputMaybe; - /** Filter by the object’s `blockTimestamp` field. */ - blockTimestamp?: InputMaybe; - /** Filter by the object’s `createdAt` field. */ - createdAt?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Filter by the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Filter by the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `vote` field. */ - vote?: InputMaybe; - /** Filter by the object’s `voterId` field. */ - voterId?: InputMaybe; + unit?: StringFilter | null | undefined; }; export enum VoteOption { @@ -6536,177 +1167,29 @@ export enum VoteOption { /** A filter to be used against VoteOption fields. All fields are combined with a logical ‘and.’ */ export type VoteOptionFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: VoteOption | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: VoteOption | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: VoteOption | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: VoteOption | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: VoteOption | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: VoteOption | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: VoteOption | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: VoteOption | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; -}; - -/** A connection to a list of `Vote` values. */ -export type VotesConnection = { - __typename?: 'VotesConnection'; - /** A list of edges which contains the `Vote` and cursor to aid in pagination. */ - edges: Array; - /** A list of `Vote` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `Vote` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -export type VotesCount = Node & { - __typename?: 'VotesCount'; - downvotes: Scalars['BigInt']['output']; - id: Scalars['Int']['output']; - /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ - nodeId: Scalars['ID']['output']; - objectId: Scalars['UUID']['output']; - objectType: Scalars['Int']['output']; - spaceId: Scalars['UUID']['output']; - updatedAt: Scalars['Datetime']['output']; - upvotes: Scalars['BigInt']['output']; -}; - -/** - * A condition to be used against `VotesCount` object types. All fields are tested - * for equality and combined with a logical ‘and.’ - */ -export type VotesCountCondition = { - /** Checks for equality with the object’s `downvotes` field. */ - downvotes?: InputMaybe; - /** Checks for equality with the object’s `id` field. */ - id?: InputMaybe; - /** Checks for equality with the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Checks for equality with the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for equality with the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Checks for equality with the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Checks for equality with the object’s `upvotes` field. */ - upvotes?: InputMaybe; -}; - -/** A filter to be used against `VotesCount` object types. All fields are combined with a logical ‘and.’ */ -export type VotesCountFilter = { - /** Checks for all expressions in this list. */ - and?: InputMaybe>; - /** Filter by the object’s `downvotes` field. */ - downvotes?: InputMaybe; - /** Filter by the object’s `id` field. */ - id?: InputMaybe; - /** Negates the expression. */ - not?: InputMaybe; - /** Filter by the object’s `objectId` field. */ - objectId?: InputMaybe; - /** Filter by the object’s `objectType` field. */ - objectType?: InputMaybe; - /** Checks for any expressions in this list. */ - or?: InputMaybe>; - /** Filter by the object’s `spaceId` field. */ - spaceId?: InputMaybe; - /** Filter by the object’s `updatedAt` field. */ - updatedAt?: InputMaybe; - /** Filter by the object’s `upvotes` field. */ - upvotes?: InputMaybe; -}; - -/** A connection to a list of `VotesCount` values. */ -export type VotesCountsConnection = { - __typename?: 'VotesCountsConnection'; - /** A list of edges which contains the `VotesCount` and cursor to aid in pagination. */ - edges: Array; - /** A list of `VotesCount` objects. */ - nodes: Array; - /** Information to aid in pagination. */ - pageInfo: PageInfo; - /** The count of *all* `VotesCount` you could get from the connection. */ - totalCount: Scalars['Int']['output']; -}; - -/** A `VotesCount` edge in the connection. */ -export type VotesCountsEdge = { - __typename?: 'VotesCountsEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `VotesCount` at the end of the edge. */ - node: VotesCount; -}; - -/** Methods to use when ordering `VotesCount`. */ -export enum VotesCountsOrderBy { - DownvotesAsc = 'DOWNVOTES_ASC', - DownvotesDesc = 'DOWNVOTES_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - ObjectIdAsc = 'OBJECT_ID_ASC', - ObjectIdDesc = 'OBJECT_ID_DESC', - ObjectTypeAsc = 'OBJECT_TYPE_ASC', - ObjectTypeDesc = 'OBJECT_TYPE_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - UpdatedAtAsc = 'UPDATED_AT_ASC', - UpdatedAtDesc = 'UPDATED_AT_DESC', - UpvotesAsc = 'UPVOTES_ASC', - UpvotesDesc = 'UPVOTES_DESC' -} - -/** A `Vote` edge in the connection. */ -export type VotesEdge = { - __typename?: 'VotesEdge'; - /** A cursor for use in pagination. */ - cursor?: Maybe; - /** The `Vote` at the end of the edge. */ - node: Vote; + notIn?: Array | null | undefined; }; -/** Methods to use when ordering `Vote`. */ -export enum VotesOrderBy { - BlockNumberAsc = 'BLOCK_NUMBER_ASC', - BlockNumberDesc = 'BLOCK_NUMBER_DESC', - BlockTimestampAsc = 'BLOCK_TIMESTAMP_ASC', - BlockTimestampDesc = 'BLOCK_TIMESTAMP_DESC', - CreatedAtAsc = 'CREATED_AT_ASC', - CreatedAtDesc = 'CREATED_AT_DESC', - IdAsc = 'ID_ASC', - IdDesc = 'ID_DESC', - Natural = 'NATURAL', - ObjectIdAsc = 'OBJECT_ID_ASC', - ObjectIdDesc = 'OBJECT_ID_DESC', - ObjectTypeAsc = 'OBJECT_TYPE_ASC', - ObjectTypeDesc = 'OBJECT_TYPE_DESC', - PrimaryKeyAsc = 'PRIMARY_KEY_ASC', - PrimaryKeyDesc = 'PRIMARY_KEY_DESC', - SpaceIdAsc = 'SPACE_ID_ASC', - SpaceIdDesc = 'SPACE_ID_DESC', - VoterIdAsc = 'VOTER_ID_ASC', - VoterIdDesc = 'VOTER_ID_DESC', - VoteAsc = 'VOTE_ASC', - VoteDesc = 'VOTE_DESC' -} - export enum VotingMode { Fast = 'FAST', Slow = 'SLOW' @@ -6715,409 +1198,335 @@ export enum VotingMode { /** A filter to be used against VotingMode fields. All fields are combined with a logical ‘and.’ */ export type VotingModeFilter = { /** Not equal to the specified value, treating null like an ordinary value. */ - distinctFrom?: InputMaybe; + distinctFrom?: VotingMode | null | undefined; /** Greater than the specified value. */ - greaterThan?: InputMaybe; + greaterThan?: VotingMode | null | undefined; /** Greater than or equal to the specified value. */ - greaterThanOrEqualTo?: InputMaybe; + greaterThanOrEqualTo?: VotingMode | null | undefined; /** Included in the specified list. */ - in?: InputMaybe>; + in?: Array | null | undefined; /** Equal to the specified value. */ - is?: InputMaybe; + is?: VotingMode | null | undefined; /** Not equal to the specified value. */ - isNot?: InputMaybe; + isNot?: VotingMode | null | undefined; /** Is null (if `true` is specified) or is not null (if `false` is specified). */ - isNull?: InputMaybe; + isNull?: boolean | null | undefined; /** Less than the specified value. */ - lessThan?: InputMaybe; + lessThan?: VotingMode | null | undefined; /** Less than or equal to the specified value. */ - lessThanOrEqualTo?: InputMaybe; + lessThanOrEqualTo?: VotingMode | null | undefined; /** Equal to the specified value, treating null like an ordinary value. */ - notDistinctFrom?: InputMaybe; + notDistinctFrom?: VotingMode | null | undefined; /** Not included in the specified list. */ - notIn?: InputMaybe>; + notIn?: Array | null | undefined; }; -export type RelationToEntityFragment = { __typename?: 'Entity', id: any, name?: string | null, types?: Array<{ __typename?: 'Entity', id: any }> | null, valuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any, text?: string | null }> } & { ' $fragmentName'?: 'RelationToEntityFragment' }; +export type RelationToEntityFragment = { id: any, name: string | null, types: Array<{ id: any }> | null, valuesList: Array<{ spaceId: any, propertyId: any, text: string | null }> } & { ' $fragmentName'?: 'RelationToEntityFragment' }; -export type EntityValueFieldsFragment = { __typename?: 'Value', spaceId: any, text?: string | null, integer?: any | null, float?: number | null, point?: any | null, boolean?: boolean | null, time?: any | null, language?: any | null, unit?: string | null, datetime?: any | null, date?: any | null, decimal?: any | null, schedule?: any | null, property?: ( - { __typename?: 'PropertyInfo' } - & { ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } } - ) | null } & { ' $fragmentName'?: 'EntityValueFieldsFragment' }; +export type EntityValueFieldsFragment = { spaceId: any, text: string | null, integer: any, float: number | null, point: any, boolean: boolean | null, time: any, language: any, unit: string | null, datetime: any, date: any, decimal: any, schedule: any, property: { ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } } | null } & { ' $fragmentName'?: 'EntityValueFieldsFragment' }; -export type RelationFieldsFragment = { __typename?: 'Relation', id: any, spaceId: any, position?: string | null, verified?: boolean | null, entityId: any, toSpaceId?: any | null, fromEntity?: { __typename?: 'Entity', id: any, name?: string | null } | null, toEntity?: ( - { __typename?: 'Entity' } - & { ' $fragmentRefs'?: { 'RelationToEntityFragment': RelationToEntityFragment } } - ) | null, type?: { __typename?: 'PropertyInfo', id?: any | null, name?: string | null } | null } & { ' $fragmentName'?: 'RelationFieldsFragment' }; +export type RelationFieldsFragment = { id: any, spaceId: any, position: string | null, verified: boolean | null, entityId: any, toSpaceId: any, fromEntity: { id: any, name: string | null } | null, toEntity: { ' $fragmentRefs'?: { 'RelationToEntityFragment': RelationToEntityFragment } } | null, type: { id: any, name: string | null } | null } & { ' $fragmentName'?: 'RelationFieldsFragment' }; -export type FullEntityFragment = { __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, createdAt: string, createdAtBlock: string, updatedAt: string, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relationsList: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> } & { ' $fragmentName'?: 'FullEntityFragment' }; +export type FullEntityFragment = { id: any, name: string | null, description: string | null, spaceIds: Array | null, createdAt: string, createdAtBlock: string, updatedAt: string, types: Array<{ id: any, name: string | null }> | null, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relationsList: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> } & { ' $fragmentName'?: 'FullEntityFragment' }; export type AllEntitiesQueryVariables = Exact<{ - spaceId?: InputMaybe; - spaceIds?: InputMaybe; - typeId?: InputMaybe; - typeIds?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; - filter?: InputMaybe; - orderBy?: InputMaybe | EntitiesOrderBy>; + spaceId?: any; + spaceIds?: UuidFilter | null | undefined; + typeId?: any; + typeIds?: UuidFilter | null | undefined; + limit?: number | null | undefined; + offset?: number | null | undefined; + filter?: EntityFilter | null | undefined; + orderBy?: Array | EntitiesOrderBy | null | undefined; }>; -export type AllEntitiesQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, updatedAt: string, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, allValuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any }>, allRelationsList: Array<{ __typename?: 'Relation', spaceId: any }>, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relationsList: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> }> | null }; +export type AllEntitiesQuery = { entities: Array<{ id: any, name: string | null, description: string | null, spaceIds: Array | null, updatedAt: string, types: Array<{ id: any, name: string | null }> | null, allValuesList: Array<{ spaceId: any, propertyId: any }>, allRelationsList: Array<{ spaceId: any }>, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relationsList: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> }> | null }; export type EntitiesBatchQueryVariables = Exact<{ - filter?: InputMaybe; - spaceId?: InputMaybe; + filter?: EntityFilter | null | undefined; + spaceId?: any; }>; -export type EntitiesBatchQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relationsList: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> }> | null }; +export type EntitiesBatchQuery = { entities: Array<{ id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relationsList: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> }> | null }; export type EntitySpacesBatchQueryVariables = Exact<{ - filter?: InputMaybe; + filter?: EntityFilter | null | undefined; }>; -export type EntitySpacesBatchQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, spaceIds?: Array | null, allValuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any }>, allRelationsList: Array<{ __typename?: 'Relation', spaceId: any }> }> | null }; +export type EntitySpacesBatchQuery = { entities: Array<{ id: any, spaceIds: Array | null, allValuesList: Array<{ spaceId: any, propertyId: any }>, allRelationsList: Array<{ spaceId: any }> }> | null }; export type EntityQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; - cursor?: InputMaybe; + id: any; + spaceId?: any; + cursor?: any; }>; -export type EntityQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, updatedAt: string, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, allValuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any }>, allRelationsList: Array<{ __typename?: 'Relation', spaceId: any }>, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relations: { __typename?: 'RelationsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: any | null }, nodes: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> } } | null }; +export type EntityQuery = { entity: { id: any, name: string | null, description: string | null, spaceIds: Array | null, updatedAt: string, types: Array<{ id: any, name: string | null }> | null, allValuesList: Array<{ spaceId: any, propertyId: any }>, allRelationsList: Array<{ spaceId: any }>, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relations: { pageInfo: { hasNextPage: boolean, endCursor: any }, nodes: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> } } | null }; export type EntityRelationsPageQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; - cursor?: InputMaybe; + id: any; + spaceId?: any; + cursor?: any; }>; -export type EntityRelationsPageQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', relations: { __typename?: 'RelationsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: any | null }, nodes: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> } } | null }; +export type EntityRelationsPageQuery = { entity: { relations: { pageInfo: { hasNextPage: boolean, endCursor: any }, nodes: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> } } | null }; export type FullRelationFragment = ( - { __typename?: 'Relation', entity?: { __typename?: 'Entity', id: any, name?: string | null } | null } + { entity: { id: any, name: string | null } | null } & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } ) & { ' $fragmentName'?: 'FullRelationFragment' }; export type RelationEntityRelationsQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; + id: any; + spaceId?: any; }>; -export type RelationEntityRelationsQuery = { __typename?: 'Query', relations?: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } } - )> | null }; +export type RelationEntityRelationsQuery = { relations: Array<{ ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } }> | null }; export type RelationsByToEntityIdsQueryVariables = Exact<{ - toEntityIds: Array | Scalars['UUID']['input']; - typeId?: InputMaybe; - spaceId?: InputMaybe; + toEntityIds: Array | any; + typeId?: any; + spaceId?: any; }>; -export type RelationsByToEntityIdsQuery = { __typename?: 'Query', relations?: Array<{ __typename?: 'Relation', id: any, toEntityId: any, spaceId: any, fromEntityId: any }> | null }; +export type RelationsByToEntityIdsQuery = { relations: Array<{ id: any, toEntityId: any, spaceId: any, fromEntityId: any }> | null }; export type RelationsByFromEntityIdQueryVariables = Exact<{ - fromEntityId: Scalars['UUID']['input']; - typeId: Scalars['UUID']['input']; - spaceId: Scalars['UUID']['input']; + fromEntityId: any; + typeId: any; + spaceId: any; }>; -export type RelationsByFromEntityIdQuery = { __typename?: 'Query', relations?: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } } - )> | null }; +export type RelationsByFromEntityIdQuery = { relations: Array<{ ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } }> | null }; export type EntityPageQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; + id: any; + spaceId?: any; }>; -export type EntityPageQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, allValuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any }>, allRelationsList: Array<{ __typename?: 'Relation', spaceId: any }>, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relationsList: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> } | null, relations?: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } } - )> | null }; +export type EntityPageQuery = { entity: { id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null, allValuesList: Array<{ spaceId: any, propertyId: any }>, allRelationsList: Array<{ spaceId: any }>, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relationsList: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> } | null, relations: Array<{ ' $fragmentRefs'?: { 'FullRelationFragment': FullRelationFragment } }> | null }; export type EntityTypesQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; + id: any; + spaceId?: any; }>; -export type EntityTypesQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null } | null }; +export type EntityTypesQuery = { entity: { types: Array<{ id: any, name: string | null }> | null } | null }; export type EntityExistsQueryVariables = Exact<{ - id: Scalars['UUID']['input']; + id: any; }>; -export type EntityExistsQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', id: any } | null }; +export type EntityExistsQuery = { entity: { id: any } | null }; export type EntityCommentReplyBacklinksPageQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - replyToTypeId: Scalars['UUID']['input']; - commentTypeId: Scalars['UUID']['input']; - first: Scalars['Int']['input']; - offset: Scalars['Int']['input']; + id: any; + replyToTypeId: any; + commentTypeId: any; + first: number; + offset: number; }>; -export type EntityCommentReplyBacklinksPageQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', backlinksList: Array<{ __typename?: 'Relation', fromEntity?: { __typename?: 'Entity', id: any } | null }> } | null }; +export type EntityCommentReplyBacklinksPageQuery = { entity: { backlinksList: Array<{ fromEntity: { id: any } | null }> } | null }; export type EntitiesBatchForCommentsQueryVariables = Exact<{ - filter?: InputMaybe; + filter?: EntityFilter | null | undefined; }>; -export type EntitiesBatchForCommentsQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, createdAt: string, updatedAt: string, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, valuesList: Array<( - { __typename?: 'Value' } - & { ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } } - )>, relationsList: Array<( - { __typename?: 'Relation' } - & { ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } } - )> }> | null }; +export type EntitiesBatchForCommentsQuery = { entities: Array<{ id: any, name: string | null, description: string | null, spaceIds: Array | null, createdAt: string, updatedAt: string, types: Array<{ id: any, name: string | null }> | null, valuesList: Array<{ ' $fragmentRefs'?: { 'EntityValueFieldsFragment': EntityValueFieldsFragment } }>, relationsList: Array<{ ' $fragmentRefs'?: { 'RelationFieldsFragment': RelationFieldsFragment } }> }> | null }; export type EntityBacklinksPageQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; + id: any; + spaceId?: any; }>; -export type EntityBacklinksPageQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', backlinksList: Array<{ __typename?: 'Relation', spaceId: any, fromEntity?: { __typename?: 'Entity', id: any, name?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null, spaceIds?: Array | null }> | null } | null }> } | null }; +export type EntityBacklinksPageQuery = { entity: { backlinksList: Array<{ spaceId: any, fromEntity: { id: any, name: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null, spaceIds: Array | null }> | null } | null }> } | null }; -export type FullSpaceFragment = { __typename?: 'Space', id: any, type: SpaceTypes, address: string, topicId?: any | null, topic?: ( - { __typename?: 'Entity' } - & { ' $fragmentRefs'?: { 'FullEntityFragment': FullEntityFragment } } - ) | null, members: { __typename?: 'MembersConnection', totalCount: number }, membersList: Array<{ __typename?: 'Member', memberSpaceId: any }>, editors: { __typename?: 'EditorsConnection', totalCount: number }, editorsList: Array<{ __typename?: 'Editor', memberSpaceId: any }>, spaceVotingSetting?: { __typename?: 'SpaceVotingSetting', flatSupportThreshold: any } | null, page?: ( - { __typename?: 'Entity' } - & { ' $fragmentRefs'?: { 'FullEntityFragment': FullEntityFragment } } - ) | null } & { ' $fragmentName'?: 'FullSpaceFragment' }; +export type FullSpaceFragment = { id: any, type: SpaceTypes, address: string, topicId: any, topic: { ' $fragmentRefs'?: { 'FullEntityFragment': FullEntityFragment } } | null, members: { totalCount: number }, membersList: Array<{ memberSpaceId: any }>, editors: { totalCount: number }, editorsList: Array<{ memberSpaceId: any }>, spaceVotingSetting: { flatSupportThreshold: any } | null, page: { ' $fragmentRefs'?: { 'FullEntityFragment': FullEntityFragment } } | null } & { ' $fragmentName'?: 'FullSpaceFragment' }; export type SpaceQueryVariables = Exact<{ - id: Scalars['UUID']['input']; + id: any; }>; -export type SpaceQuery = { __typename?: 'Query', space?: ( - { __typename?: 'Space' } - & { ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } } - ) | null }; +export type SpaceQuery = { space: { ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } } | null }; export type SpacesQueryVariables = Exact<{ - filter?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; + filter?: SpaceFilter | null | undefined; + limit?: number | null | undefined; + offset?: number | null | undefined; }>; -export type SpacesQuery = { __typename?: 'Query', spaces?: Array<( - { __typename?: 'Space' } - & { ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } } - )> | null }; +export type SpacesQuery = { spaces: Array<{ ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } }> | null }; export type SpacesWhereMemberQueryVariables = Exact<{ - memberSpaceId: Scalars['UUID']['input']; + memberSpaceId: any; }>; -export type SpacesWhereMemberQuery = { __typename?: 'Query', spaces?: Array<( - { __typename?: 'Space' } - & { ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } } - )> | null }; +export type SpacesWhereMemberQuery = { spaces: Array<{ ' $fragmentRefs'?: { 'FullSpaceFragment': FullSpaceFragment } }> | null }; export type IsMemberOfSpaceQueryVariables = Exact<{ - spaceId: Scalars['UUID']['input']; - memberSpaceId: Scalars['UUID']['input']; + spaceId: any; + memberSpaceId: any; }>; -export type IsMemberOfSpaceQuery = { __typename?: 'Query', space?: { __typename?: 'Space', membersList: Array<{ __typename?: 'Member', memberSpaceId: any }> } | null }; +export type IsMemberOfSpaceQuery = { space: { membersList: Array<{ memberSpaceId: any }> } | null }; export type IsEditorOfSpaceQueryVariables = Exact<{ - spaceId: Scalars['UUID']['input']; - memberSpaceId: Scalars['UUID']['input']; + spaceId: any; + memberSpaceId: any; }>; -export type IsEditorOfSpaceQuery = { __typename?: 'Query', space?: { __typename?: 'Space', editorsList: Array<{ __typename?: 'Editor', memberSpaceId: any }> } | null }; +export type IsEditorOfSpaceQuery = { space: { editorsList: Array<{ memberSpaceId: any }> } | null }; export type SpaceMembersPageQueryVariables = Exact<{ - spaceId: Scalars['UUID']['input']; - first: Scalars['Int']['input']; - offset: Scalars['Int']['input']; + spaceId: any; + first: number; + offset: number; }>; -export type SpaceMembersPageQuery = { __typename?: 'Query', space?: { __typename?: 'Space', members: { __typename?: 'MembersConnection', totalCount: number }, membersList: Array<{ __typename?: 'Member', memberSpaceId: any }> } | null }; +export type SpaceMembersPageQuery = { space: { members: { totalCount: number }, membersList: Array<{ memberSpaceId: any }> } | null }; export type SpaceEditorsPageQueryVariables = Exact<{ - spaceId: Scalars['UUID']['input']; - first: Scalars['Int']['input']; - offset: Scalars['Int']['input']; + spaceId: any; + first: number; + offset: number; }>; -export type SpaceEditorsPageQuery = { __typename?: 'Query', space?: { __typename?: 'Space', editors: { __typename?: 'EditorsConnection', totalCount: number }, editorsList: Array<{ __typename?: 'Editor', memberSpaceId: any }> } | null }; +export type SpaceEditorsPageQuery = { space: { editors: { totalCount: number }, editorsList: Array<{ memberSpaceId: any }> } | null }; -export type PropertyFragmentFragment = { __typename?: 'PropertyInfo', id?: any | null, name?: string | null, dataTypeId?: any | null, dataTypeName?: string | null, renderableTypeId?: any | null, renderableTypeName?: string | null, format?: string | null, isType?: boolean | null } & { ' $fragmentName'?: 'PropertyFragmentFragment' }; +export type PropertyFragmentFragment = { id: any, name: string | null, dataTypeId: any, dataTypeName: string | null, renderableTypeId: any, renderableTypeName: string | null, format: string | null, isType: boolean | null } & { ' $fragmentName'?: 'PropertyFragmentFragment' }; export type PropertyQueryVariables = Exact<{ - id: Scalars['UUID']['input']; + id: any; }>; -export type PropertyQuery = { __typename?: 'Query', property?: ( - { __typename?: 'PropertyInfo' } - & { ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } } - ) | null }; +export type PropertyQuery = { property: { ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } } | null }; export type PropertiesBatchQueryVariables = Exact<{ - ids: Array | Scalars['UUID']['input']; + ids: Array | any; }>; -export type PropertiesBatchQuery = { __typename?: 'Query', properties?: Array<( - { __typename?: 'PropertyInfo' } - & { ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } } - )> | null }; +export type PropertiesBatchQuery = { properties: Array<{ ' $fragmentRefs'?: { 'PropertyFragmentFragment': PropertyFragmentFragment } }> | null }; export type EntityNamesQueryVariables = Exact<{ - filter?: InputMaybe; + filter?: EntityFilter | null | undefined; }>; -export type EntityNamesQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null }; +export type EntityNamesQuery = { entities: Array<{ id: any, name: string | null }> | null }; export type ResultQueryVariables = Exact<{ - id: Scalars['UUID']['input']; + id: any; }>; -export type ResultQuery = { __typename?: 'Query', entity?: { __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null } | null }; +export type ResultQuery = { entity: { id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null } | null }; export type ResultsQueryVariables = Exact<{ - query: Scalars['String']['input']; - filter?: InputMaybe; - spaceId?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; + query: string; + filter?: EntityFilter | null | undefined; + spaceId?: any; + limit?: number | null | undefined; + offset?: number | null | undefined; }>; -export type ResultsQuery = { __typename?: 'Query', search?: Array<{ __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null }> | null }; +export type ResultsQuery = { search: Array<{ id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null }> | null }; export type EntitiesPageQueryVariables = Exact<{ - filter?: InputMaybe; - first: Scalars['Int']['input']; - offset: Scalars['Int']['input']; + filter?: EntityFilter | null | undefined; + first: number; + offset: number; }>; -export type EntitiesPageQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null }> | null }; +export type EntitiesPageQuery = { entities: Array<{ id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null }> | null }; export type ImportNameValuesQueryVariables = Exact<{ - propertyId: Scalars['UUID']['input']; - texts?: InputMaybe | Scalars['String']['input']>; - first?: InputMaybe; - entityFilter?: InputMaybe; + propertyId: any; + texts?: Array | string | null | undefined; + first?: number | null | undefined; + entityFilter?: EntityFilter | null | undefined; }>; -export type ImportNameValuesQuery = { __typename?: 'Query', values?: Array<{ __typename?: 'Value', id: string, text?: string | null, spaceId: any, entity?: { __typename?: 'Entity', id: any, name?: string | null, typeIds?: Array | null, backlinks: { __typename?: 'RelationsConnection', totalCount: number }, relations: { __typename?: 'RelationsConnection', totalCount: number } } | null }> | null }; +export type ImportNameValuesQuery = { values: Array<{ id: string, text: string | null, spaceId: any, entity: { id: any, name: string | null, typeIds: Array | null, backlinks: { totalCount: number }, relations: { totalCount: number } } | null }> | null }; export type EntityTiebreakerBatchQueryVariables = Exact<{ - filter?: InputMaybe; + filter?: EntityFilter | null | undefined; }>; -export type EntityTiebreakerBatchQuery = { __typename?: 'Query', entities?: Array<{ __typename?: 'Entity', id: any, createdAt: string, backlinks: { __typename?: 'RelationsConnection', totalCount: number }, relations: { __typename?: 'RelationsConnection', totalCount: number }, values: { __typename?: 'ValuesConnection', totalCount: number } }> | null }; +export type EntityTiebreakerBatchQuery = { entities: Array<{ id: any, createdAt: string, backlinks: { totalCount: number }, relations: { totalCount: number }, values: { totalCount: number } }> | null }; export type RelationEntityMinimalQueryVariables = Exact<{ - id: Scalars['UUID']['input']; - spaceId?: InputMaybe; + id: any; + spaceId?: any; }>; -export type RelationEntityMinimalQuery = { __typename?: 'Query', relation?: { __typename?: 'Relation', id: any, entity?: { __typename?: 'Entity', id: any, name?: string | null, description?: string | null, spaceIds?: Array | null, types?: Array<{ __typename?: 'Entity', id: any, name?: string | null }> | null, allValuesList: Array<{ __typename?: 'Value', spaceId: any, propertyId: any }>, allRelationsList: Array<{ __typename?: 'Relation', spaceId: any }>, valuesList: Array<{ __typename?: 'Value', spaceId: any, text?: string | null, integer?: any | null, float?: number | null, point?: any | null, boolean?: boolean | null, time?: any | null, language?: any | null, unit?: string | null, datetime?: any | null, date?: any | null, decimal?: any | null, schedule?: any | null, property?: { __typename?: 'PropertyInfo', id?: any | null, name?: string | null, dataTypeId?: any | null, dataTypeName?: string | null, renderableTypeId?: any | null, renderableTypeName?: string | null, format?: string | null } | null }>, relationsList: Array<{ __typename?: 'Relation', verified?: boolean | null, toSpaceId?: any | null, position?: string | null, spaceId: any, id: any, entityId: any, fromEntity?: { __typename?: 'Entity', id: any, name?: string | null } | null, toEntity?: ( - { __typename?: 'Entity' } - & { ' $fragmentRefs'?: { 'RelationToEntityFragment': RelationToEntityFragment } } - ) | null, type?: { __typename?: 'PropertyInfo', id?: any | null, name?: string | null, description?: string | null } | null }> } | null } | null }; +export type RelationEntityMinimalQuery = { relation: { id: any, entity: { id: any, name: string | null, description: string | null, spaceIds: Array | null, types: Array<{ id: any, name: string | null }> | null, allValuesList: Array<{ spaceId: any, propertyId: any }>, allRelationsList: Array<{ spaceId: any }>, valuesList: Array<{ spaceId: any, text: string | null, integer: any, float: number | null, point: any, boolean: boolean | null, time: any, language: any, unit: string | null, datetime: any, date: any, decimal: any, schedule: any, property: { id: any, name: string | null, dataTypeId: any, dataTypeName: string | null, renderableTypeId: any, renderableTypeName: string | null, format: string | null } | null }>, relationsList: Array<{ verified: boolean | null, toSpaceId: any, position: string | null, spaceId: any, id: any, entityId: any, fromEntity: { id: any, name: string | null } | null, toEntity: { ' $fragmentRefs'?: { 'RelationToEntityFragment': RelationToEntityFragment } } | null, type: { id: any, name: string | null, description: string | null } | null }> } | null } | null }; -export type EntityVoteCountQueryVariables = Exact<{ - objectId: Scalars['UUID']['input']; - objectType: Scalars['Int']['input']; +export type EntityResponseCountsQueryVariables = Exact<{ + objectId: any; + objectType: number; + spaceId: any; + voteKind: number; }>; -export type EntityVoteCountQuery = { __typename?: 'Query', votesCountsConnection?: { __typename?: 'VotesCountsConnection', nodes: Array<{ __typename?: 'VotesCount', spaceId: any, upvotes: any, downvotes: any }> } | null }; +export type EntityResponseCountsQuery = { votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind: { positive: any, negative: any, voteKind: number } | null }; -export type UserEntityVoteQueryVariables = Exact<{ - userId: Scalars['UUID']['input']; - objectId: Scalars['UUID']['input']; - objectType: Scalars['Int']['input']; - spaceId: Scalars['UUID']['input']; +export type UserEntityResponseQueryVariables = Exact<{ + userId: any; + objectId: any; + objectType: number; + spaceId: any; + voteKind: number; }>; -export type UserEntityVoteQuery = { __typename?: 'Query', userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId?: { __typename?: 'UserVote', voteType: number } | null }; +export type UserEntityResponseQuery = { userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind: { voteType: number } | null }; -export type EntityVotersQueryVariables = Exact<{ - objectId: Scalars['UUID']['input']; - objectType: Scalars['Int']['input']; - spaceId: Scalars['UUID']['input']; +export type EntityRespondersQueryVariables = Exact<{ + objectId: any; + objectType: number; + spaceId: any; + voteKind: number; }>; -export type EntityVotersQuery = { __typename?: 'Query', userVotes?: Array<{ __typename?: 'UserVote', userId: any, voteType: number }> | null }; +export type EntityRespondersQuery = { userVotes: Array<{ userId: any, voteType: number }> | null }; export type UserHasEntityVoteQueryVariables = Exact<{ - userId: Scalars['UUID']['input']; + userId: any; }>; -export type UserHasEntityVoteQuery = { __typename?: 'Query', userVotes?: Array<{ __typename?: 'UserVote', userId: any }> | null }; +export type UserHasEntityVoteQuery = { userVotes: Array<{ userId: any }> | null }; export const RelationToEntityFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RelationToEntity"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Entity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"types"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"valuesList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"propertyId"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]} as unknown as DocumentNode; export const RelationFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RelationFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Relation"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"entityId"}},{"kind":"Field","name":{"kind":"Name","value":"fromEntity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"toEntity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RelationToEntity"}}]}},{"kind":"Field","name":{"kind":"Name","value":"toSpaceId"}},{"kind":"Field","name":{"kind":"Name","value":"type"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RelationToEntity"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Entity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"types"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"valuesList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"propertyId"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]} as unknown as DocumentNode; @@ -7156,7 +1565,7 @@ export const EntitiesPageDocument = {"kind":"Document","definitions":[{"kind":"O export const ImportNameValuesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImportNameValues"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"propertyId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"texts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entityFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"EntityFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"propertyId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"propertyId"}}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"text"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"inInsensitive"},"value":{"kind":"Variable","name":{"kind":"Name","value":"texts"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"entity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entityFilter"}}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"text"}},{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"entity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"typeIds"}},{"kind":"Field","name":{"kind":"Name","value":"backlinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const EntityTiebreakerBatchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityTiebreakerBatch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"EntityFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"entities"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"backlinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"values"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}}]} as unknown as DocumentNode; export const RelationEntityMinimalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"RelationEntityMinimal"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"entity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"spaceIds"}},{"kind":"Field","name":{"kind":"Name","value":"types"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allValuesList"},"name":{"kind":"Name","value":"valuesList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"propertyId"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allRelationsList"},"name":{"kind":"Name","value":"relationsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"valuesList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"is"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"property"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"dataTypeId"}},{"kind":"Field","name":{"kind":"Name","value":"dataTypeName"}},{"kind":"Field","name":{"kind":"Name","value":"renderableTypeId"}},{"kind":"Field","name":{"kind":"Name","value":"renderableTypeName"}},{"kind":"Field","name":{"kind":"Name","value":"format"}}]}},{"kind":"Field","name":{"kind":"Name","value":"text"}},{"kind":"Field","name":{"kind":"Name","value":"integer"}},{"kind":"Field","name":{"kind":"Name","value":"float"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"time"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","name":{"kind":"Name","value":"datetime"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"decimal"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relationsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verified"}},{"kind":"Field","name":{"kind":"Name","value":"toSpaceId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"entityId"}},{"kind":"Field","name":{"kind":"Name","value":"fromEntity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"toEntity"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RelationToEntity"}}]}},{"kind":"Field","name":{"kind":"Name","value":"type"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RelationToEntity"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Entity"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"types"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"valuesList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"propertyId"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]} as unknown as DocumentNode; -export const EntityVoteCountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityVoteCount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"votesCountsConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"spaceId"}},{"kind":"Field","name":{"kind":"Name","value":"upvotes"}},{"kind":"Field","name":{"kind":"Name","value":"downvotes"}}]}}]}}]}}]} as unknown as DocumentNode; -export const UserEntityVoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserEntityVote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"Argument","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; -export const EntityVotersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityVoters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; +export const EntityResponseCountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityResponseCounts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"Argument","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"positive"}},{"kind":"Field","name":{"kind":"Name","value":"negative"}},{"kind":"Field","name":{"kind":"Name","value":"voteKind"}}]}}]}}]} as unknown as DocumentNode; +export const UserEntityResponseDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserEntityResponse"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"Argument","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; +export const EntityRespondersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityResponders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; export const UserHasEntityVoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserHasEntityVote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index 2b863630bf..2fde8e7d9f 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -8,35 +8,41 @@ import { Effect, Either } from 'effect'; import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; import { useSmartAccountTransaction } from '~/core/hooks/use-smart-account-transaction'; +import { getUserEntityResponse } from '~/core/io/queries'; +import { + type ResponseDirection, + type ResponseKind, + entityResponderProfilesQueryKey, + entityRespondersQueryKey, + entityResponseCountsQueryKey, + getResponseActionMethod, + userEntityResponseQueryKey, + waitForIndexedEntityResponse, +} from '~/core/responses/entity-response'; import { geo } from '~/core/sdk/geo-client'; import { runEffectEither } from '~/core/telemetry/effect-runtime'; -import type { VoteDirection, VoteObjectType } from '~/core/utils/contracts/entity-vote'; import { validateSpaceId } from '~/core/utils/utils'; -export type { VoteDirection, VoteObjectType }; - -// The SDK's entityVotes.upvote/downvote/withdraw hardcode object type 0 in the -// vote topic, so this hook deliberately has no objectType parameter — accepting -// one would let a caller read type-N tallies while writing type-0 votes. -interface UseEntityVoteArgs { +interface UseEntityResponseArgs { entityId: string; spaceId: string; + responseKind: ResponseKind; } -export function useEntityVote({ entityId, spaceId }: UseEntityVoteArgs) { +export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { const queryClient = useQueryClient(); const { personalSpaceId, isRegistered } = usePersonalSpaceId(); const tx = useSmartAccountTransaction(); - const castVote = useCallback( - async (direction: VoteDirection) => { + const submitResponse = useCallback( + async (direction: ResponseDirection) => { if (!validateSpaceId(spaceId)) { - throw new Error('Invalid space ID format. Cannot submit vote.'); + throw new Error('Invalid space ID format. Cannot submit response.'); } if (!personalSpaceId || !isRegistered) { - throw new Error('You need a registered personal space to vote'); + throw new Error('You need a registered personal space to respond'); } const params = { @@ -45,19 +51,16 @@ export function useEntityVote({ entityId, spaceId }: UseEntityVoteArgs) { entityId, }; - const { to, calldata } = - direction === 'UP' - ? geo.entityVotes.upvote(params) - : direction === 'DOWN' - ? geo.entityVotes.downvote(params) - : geo.entityVotes.withdraw(params); + const action = getResponseActionMethod(responseKind, direction); + const { to, calldata } = geo.responses[action](params); const txEffect = tx({ to, data: calldata }).pipe( - Effect.withSpan('web.write.entity_vote'), + Effect.withSpan('web.write.entity_response'), Effect.annotateSpans({ - 'io.operation': 'entity_vote', - 'vote.direction': direction, - 'vote.objectType': '0', + 'io.operation': 'entity_response', + 'response.kind': responseKind, + 'response.direction': direction, + 'response.objectType': '0', }) ); const result = await runEffectEither(txEffect); @@ -65,42 +68,60 @@ export function useEntityVote({ entityId, spaceId }: UseEntityVoteArgs) { if (Either.isLeft(result)) { const error = result.left; console.error( - `Entity vote failed: ${error.message}`, - { authorSpaceId: personalSpaceId, spaceId, entityId, direction }, + `Entity response failed: ${error.message}`, + { authorSpaceId: personalSpaceId, spaceId, entityId, responseKind, direction }, error ); throw error; } + await waitForIndexedEntityResponse( + () => + Effect.runPromise(getUserEntityResponse(personalSpaceId, entityId, spaceId, responseKind)), + direction === 'clear' ? null : direction + ); + return result.right; }, - [personalSpaceId, isRegistered, spaceId, entityId, tx] + [personalSpaceId, isRegistered, spaceId, entityId, responseKind, tx] ); - const onSuccess = () => { - queryClient.invalidateQueries({ queryKey: ['entity-vote-count', entityId, 0] }); - queryClient.invalidateQueries({ queryKey: ['user-entity-vote', personalSpaceId, entityId, spaceId, 0] }); - }; - - const { mutate: upvote } = useMutation({ - mutationFn: () => castVote('UP'), + const onSuccess = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: entityResponseCountsQueryKey(entityId, spaceId, 0, responseKind), + }), + queryClient.invalidateQueries({ + queryKey: userEntityResponseQueryKey(personalSpaceId, entityId, spaceId, 0, responseKind), + }), + queryClient.invalidateQueries({ + queryKey: entityRespondersQueryKey(entityId, spaceId, 0, responseKind), + }), + queryClient.invalidateQueries({ + queryKey: entityResponderProfilesQueryKey(entityId, spaceId, 0, responseKind), + }), + ]); + + const positiveResponse = useMutation({ + mutationFn: () => submitResponse('positive'), onSuccess, }); - const { mutate: downvote } = useMutation({ - mutationFn: () => castVote('DOWN'), + const negativeResponse = useMutation({ + mutationFn: () => submitResponse('negative'), onSuccess, }); - const { mutate: unvote } = useMutation({ - mutationFn: () => castVote('NONE'), + const clearResponse = useMutation({ + mutationFn: () => submitResponse('clear'), onSuccess, }); return { - upvote, - downvote, - unvote, + submitPositiveResponse: positiveResponse.mutate, + submitNegativeResponse: negativeResponse.mutate, + clearResponse: clearResponse.mutate, + isProcessingResponse: positiveResponse.isPending || negativeResponse.isPending || clearResponse.isPending, isConnected: !!personalSpaceId && isRegistered, personalSpaceId, }; diff --git a/apps/web/core/io/entities-ordered-by-property-connection.test.ts b/apps/web/core/io/entities-ordered-by-property-connection.test.ts index 236f264c61..e3f2cc1d5d 100644 --- a/apps/web/core/io/entities-ordered-by-property-connection.test.ts +++ b/apps/web/core/io/entities-ordered-by-property-connection.test.ts @@ -1,10 +1,9 @@ import * as Effect from 'effect/Effect'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SortOrder } from '~/core/gql/graphql'; - import { graphql } from './graphql-client'; import { getEntitiesOrderedByPropertyConnection } from './queries'; +import { SortOrder } from './sort-order'; vi.mock('./graphql-client', () => ({ graphql: vi.fn(), diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index f110fff741..ac04a98e86 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -15,11 +15,17 @@ import { type EntityExistsQuery, type EntityFilter, type EntitySpacesBatchQuery, - SortOrder, UserHasEntityVoteDocument, type UuidFilter, } from '~/core/gql/graphql'; import { RANKING_BLOCK_TYPE_ID } from '~/core/ranking-block-ids'; +import { + type ActiveResponseDirection, + type ResponseKind, + type ResponseObjectType, + decodeActiveResponseDirection, + entityResponseQueryVariables, +} from '~/core/responses/entity-response'; import { Entity, SearchResult } from '~/core/types'; import { spacesFromRoutingProjections } from '~/core/utils/entity/entities'; import { sortSpaceIdsByRank } from '~/core/utils/space/space-ranking'; @@ -42,11 +48,11 @@ import { entityPageQuery, entityQuery, entityRelationsPageQuery, + entityRespondersQuery, + entityResponseCountsQuery, entitySpacesBatchQuery, entityTiebreakerBatchQuery, entityTypesQuery, - entityVoteCountQuery, - entityVotersQuery, importNameValuesQuery, isEditorOfSpaceQuery, isMemberOfSpaceQuery, @@ -62,10 +68,11 @@ import { spaceQuery, spacesQuery, spacesWhereMemberQuery, - userEntityVoteQuery, + userEntityResponseQuery, } from './query-fragments'; import { restFetch } from './rest'; import { extractSingleSpaceIdFromFilter, extractSpaceIdsFromFilter, removeSpaceIdsFromFilter } from './space-filter'; +import { type SortOrder } from './sort-order'; import { extractSingleTypeIdFromFilter, extractTypeIdsFromFilter, removeTypeIdsFromFilter } from './type-filter'; // `EntitiesBatch` has no `first` argument, so keep id.in calls under the API's default page size. @@ -1207,38 +1214,44 @@ export function getProperties(ids: string[], signal?: AbortController['signal']) }); } -export function getEntityVoteCount( +export function getEntityResponseCounts( entityId: string, - objectType: 0 | 1 = 0, // objectType: 0 = Entity, 1 = Relation + spaceId: string, + responseKind: ResponseKind, + objectType: ResponseObjectType = 0, // objectType: 0 = Entity, 1 = Relation signal?: AbortController['signal'] ) { return graphql({ - query: entityVoteCountQuery, + query: entityResponseCountsQuery, decoder: data => { - const nodes = data.votesCountsConnection?.nodes; - if (!nodes || nodes.length === 0) return null; - const upvotes = nodes.reduce((sum: number, n: { upvotes: string }) => sum + Number(n.upvotes), 0); - const downvotes = nodes.reduce((sum: number, n: { downvotes: string }) => sum + Number(n.downvotes), 0); - return { upvotes, downvotes }; + const counts = data.votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind; + if (!counts) return null; + return { positive: Number(counts.positive), negative: Number(counts.negative) }; }, - variables: { objectId: entityId, objectType }, + variables: entityResponseQueryVariables(entityId, spaceId, objectType, responseKind), signal, }); } -export function getUserEntityVote( +export function getUserEntityResponse( userId: string, entityId: string, spaceId: string, - objectType: 0 | 1 = 0, + responseKind: ResponseKind, + objectType: ResponseObjectType = 0, signal?: AbortController['signal'] ) { return graphql({ - query: userEntityVoteQuery, + query: userEntityResponseQuery, decoder: data => { - return data.userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId?.voteType ?? null; + return decodeActiveResponseDirection( + data.userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind?.voteType + ); + }, + variables: { + userId, + ...entityResponseQueryVariables(entityId, spaceId, objectType, responseKind), }, - variables: { userId, objectId: entityId, objectType, spaceId }, signal, }); } @@ -1252,20 +1265,24 @@ export function getUserHasEntityVote(userId: string, signal?: AbortController['s }); } -export type EntityVoter = { userId: string; voteType: number }; +export type EntityResponder = { userId: string; direction: ActiveResponseDirection }; -export function getEntityVoters( +export function getEntityResponders( entityId: string, spaceId: string, - objectType: 0 | 1 = 0, + responseKind: ResponseKind, + objectType: ResponseObjectType = 0, signal?: AbortController['signal'] ) { return graphql({ - query: entityVotersQuery, + query: entityRespondersQuery, decoder: data => { - return (data.userVotes ?? []) as EntityVoter[]; + return (data.userVotes ?? []).flatMap(vote => { + const direction = decodeActiveResponseDirection(vote.voteType); + return direction ? [{ userId: String(vote.userId), direction }] : []; + }); }, - variables: { objectId: entityId, objectType, spaceId }, + variables: entityResponseQueryVariables(entityId, spaceId, objectType, responseKind), signal, }); } diff --git a/apps/web/core/io/query-fragments.response.test.ts b/apps/web/core/io/query-fragments.response.test.ts new file mode 100644 index 0000000000..7a2c67c21d --- /dev/null +++ b/apps/web/core/io/query-fragments.response.test.ts @@ -0,0 +1,32 @@ +import { print } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { entityRespondersQuery, entityResponseCountsQuery, userEntityResponseQuery } from './query-fragments'; + +describe('entity response query fragments', () => { + it('reads counts for one exact object, space, and vote kind', () => { + const query = print(entityResponseCountsQuery); + + expect(query).toContain('votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind'); + expect(query).toContain('spaceId: $spaceId'); + expect(query).toContain('voteKind: $voteKind'); + expect(query).toContain('positive'); + expect(query).toContain('negative'); + }); + + it('reads the current user response for one exact kind', () => { + const query = print(userEntityResponseQuery); + + expect(query).toContain('userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind'); + expect(query).toContain('spaceId: $spaceId'); + expect(query).toContain('voteKind: $voteKind'); + }); + + it('scopes responders by space and vote kind', () => { + const query = print(entityRespondersQuery); + + expect(query).toContain('condition:'); + expect(query).toContain('spaceId: $spaceId'); + expect(query).toContain('voteKind: $voteKind'); + }); +}); diff --git a/apps/web/core/io/query-fragments.tsx b/apps/web/core/io/query-fragments.tsx index 70cb46a400..8eff4df86d 100644 --- a/apps/web/core/io/query-fragments.tsx +++ b/apps/web/core/io/query-fragments.tsx @@ -753,34 +753,38 @@ export const relationEntityQuery = graphql(/* GraphQL */ ` } `); -export const entityVoteCountQuery = graphql(/* GraphQL */ ` - query EntityVoteCount($objectId: UUID!, $objectType: Int!) { - votesCountsConnection(condition: { objectId: $objectId, objectType: $objectType }) { - nodes { - spaceId - upvotes - downvotes - } +export const entityResponseCountsQuery = graphql(/* GraphQL */ ` + query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) { + votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind( + objectId: $objectId + objectType: $objectType + spaceId: $spaceId + voteKind: $voteKind + ) { + positive + negative + voteKind } } `); -export const userEntityVoteQuery = graphql(/* GraphQL */ ` - query UserEntityVote($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!) { - userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceId( +export const userEntityResponseQuery = graphql(/* GraphQL */ ` + query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) { + userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind( userId: $userId objectId: $objectId objectType: $objectType spaceId: $spaceId + voteKind: $voteKind ) { voteType } } `); -export const entityVotersQuery = graphql(/* GraphQL */ ` - query EntityVoters($objectId: UUID!, $objectType: Int!, $spaceId: UUID!) { - userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId }) { +export const entityRespondersQuery = graphql(/* GraphQL */ ` + query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) { + userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) { userId voteType } diff --git a/apps/web/core/io/sort-order.ts b/apps/web/core/io/sort-order.ts new file mode 100644 index 0000000000..f29679877a --- /dev/null +++ b/apps/web/core/io/sort-order.ts @@ -0,0 +1,6 @@ +export const SortOrder = { + Asc: 'ASC', + Desc: 'DESC', +} as const; + +export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]; diff --git a/apps/web/core/responses/entity-response.test.ts b/apps/web/core/responses/entity-response.test.ts new file mode 100644 index 0000000000..76264c578a --- /dev/null +++ b/apps/web/core/responses/entity-response.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; + +import { getChecked } from '~/design-system/checkbox'; + +import { + ENTITY_RESPONSE_COPY, + decodeActiveResponseDirection, + entityRespondersQueryKey, + entityResponseCountsQueryKey, + entityResponseQueryVariables, + getEntityResponseKind, + getResponseActionMethod, + responseKindToVoteKind, + userEntityResponseQueryKey, + waitForIndexedEntityResponse, +} from './entity-response'; + +describe('entity response semantics', () => { + it.each([ + [{ isClaim: false, isFactual: false }, 'curation'], + [{ isClaim: false, isFactual: true }, 'curation'], + [{ isClaim: true, isFactual: false }, 'stance'], + [{ isClaim: true, isFactual: true }, 'veracity'], + ] as const)('selects the active response kind for %o', (input, expected) => { + expect(getEntityResponseKind(input)).toBe(expected); + }); + + it.each([ + ['1', 'veracity'], + ['0', 'stance'], + [undefined, 'stance'], + ['true', 'stance'], + ['yes', 'stance'], + ['malformed', 'stance'], + ] as const)('uses canonical checked semantics for factual value %s', (factualValue, expected) => { + expect(getEntityResponseKind({ isClaim: true, isFactual: getChecked(factualValue) === true })).toBe(expected); + }); + + it.each([ + ['curation', 0], + ['stance', 1], + ['veracity', 2], + ] as const)('maps %s to backend voteKind %i', (kind, expected) => { + expect(responseKindToVoteKind(kind)).toBe(expected); + }); + + it.each([ + ['curation', 'positive', 'upvote'], + ['curation', 'negative', 'downvote'], + ['curation', 'clear', 'unvote'], + ['stance', 'positive', 'agree'], + ['stance', 'negative', 'disagree'], + ['stance', 'clear', 'unagree'], + ['veracity', 'positive', 'verify'], + ['veracity', 'negative', 'dispute'], + ['veracity', 'clear', 'unverify'], + ] as const)('routes %s/%s to geo.responses.%s', (kind, direction, expected) => { + expect(getResponseActionMethod(kind, direction)).toBe(expected); + }); + + it.each([ + ['curation', 'Upvote', 'Downvote', 'No votes yet'], + ['stance', 'Agree', 'Disagree', 'No stances yet'], + ['veracity', 'Verify', 'Dispute', 'No veracity responses yet'], + ] as const)('uses semantic %s response copy', (kind, positive, negative, empty) => { + expect(ENTITY_RESPONSE_COPY[kind]).toMatchObject({ + positiveAction: positive, + negativeAction: negative, + empty, + }); + }); + + it.each([ + [0, 'positive'], + [1, 'negative'], + [2, null], + [null, null], + [undefined, null], + [9, null], + ] as const)('normalizes backend voteType %s to %s', (voteType, expected) => { + expect(decodeActiveResponseDirection(voteType)).toBe(expected); + }); + + it('keeps polling until Gaia serves the expected response', async () => { + const indexedResponses = [null, null, 'positive'] as const; + let attempt = 0; + + await expect( + waitForIndexedEntityResponse( + async () => indexedResponses[Math.min(attempt++, indexedResponses.length - 1)] ?? null, + 'positive', + 3, + 0 + ) + ).resolves.toBe(true); + expect(attempt).toBe(3); + }); + + it('treats a cleared Gaia response as indexed and tolerates transient failures', async () => { + let attempt = 0; + + await expect( + waitForIndexedEntityResponse( + async () => { + attempt += 1; + if (attempt === 1) throw new Error('temporarily unavailable'); + return null; + }, + null, + 2, + 0 + ) + ).resolves.toBe(true); + expect(attempt).toBe(2); + }); +}); + +describe('entity response query keys', () => { + it('builds exact-space and exact-kind query variables', () => { + expect(entityResponseQueryVariables('entity', 'space', 0, 'stance')).toEqual({ + objectId: 'entity', + objectType: 0, + spaceId: 'space', + voteKind: 1, + }); + }); + + it('includes exact space and response kind in cache keys', () => { + expect(entityResponseCountsQueryKey('entity', 'space', 0, 'stance')).toEqual([ + 'entity-response-counts', + 'entity', + 'space', + 0, + 'stance', + ]); + expect(userEntityResponseQueryKey('user', 'entity', 'space', 0, 'veracity')).toEqual([ + 'user-entity-response', + 'user', + 'entity', + 'space', + 0, + 'veracity', + ]); + expect(entityRespondersQueryKey('entity', 'space', 0, 'curation')).toEqual([ + 'entity-responders', + 'entity', + 'space', + 0, + 'curation', + ]); + }); +}); diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts new file mode 100644 index 0000000000..23f70f4a4c --- /dev/null +++ b/apps/web/core/responses/entity-response.ts @@ -0,0 +1,181 @@ +export type ResponseKind = 'curation' | 'stance' | 'veracity'; +export type ResponseDirection = 'positive' | 'negative' | 'clear'; +export type ActiveResponseDirection = Exclude; +export type ResponseVoteKind = 0 | 1 | 2; +export type ResponseVoteType = 0 | 1 | 2; +export type ResponseObjectType = 0 | 1; + +export type ResponseActionMethod = + 'upvote' | 'downvote' | 'unvote' | 'agree' | 'disagree' | 'unagree' | 'verify' | 'dispute' | 'unverify'; + +const RESPONSE_VOTE_KIND: Record = { + curation: 0, + stance: 1, + veracity: 2, +}; + +const RESPONSE_ACTION_METHOD: Record> = { + curation: { + positive: 'upvote', + negative: 'downvote', + clear: 'unvote', + }, + stance: { + positive: 'agree', + negative: 'disagree', + clear: 'unagree', + }, + veracity: { + positive: 'verify', + negative: 'dispute', + clear: 'unverify', + }, +}; + +export type EntityResponseCopy = { + positiveAction: string; + negativeAction: string; + removePositive: string; + removeNegative: string; + positiveSection: string; + negativeSection: string; + empty: string; + loading: string; + viewResponders: string; + signIn: string; + connect: string; +}; + +export const ENTITY_RESPONSE_COPY: Record = { + curation: { + positiveAction: 'Upvote', + negativeAction: 'Downvote', + removePositive: 'Remove upvote', + removeNegative: 'Remove downvote', + positiveSection: 'Upvotes', + negativeSection: 'Downvotes', + empty: 'No votes yet', + loading: 'Loading voters…', + viewResponders: 'View voters', + signIn: 'Sign in to vote', + connect: 'Connect wallet to vote', + }, + stance: { + positiveAction: 'Agree', + negativeAction: 'Disagree', + removePositive: 'Remove agreement', + removeNegative: 'Remove disagreement', + positiveSection: 'Agreements', + negativeSection: 'Disagreements', + empty: 'No stances yet', + loading: 'Loading responders…', + viewResponders: 'View stances', + signIn: 'Sign in to respond', + connect: 'Connect wallet to respond', + }, + veracity: { + positiveAction: 'Verify', + negativeAction: 'Dispute', + removePositive: 'Remove verification', + removeNegative: 'Remove dispute', + positiveSection: 'Verifications', + negativeSection: 'Disputes', + empty: 'No veracity responses yet', + loading: 'Loading responders…', + viewResponders: 'View veracity responses', + signIn: 'Sign in to respond', + connect: 'Connect wallet to respond', + }, +}; + +export function getEntityResponseKind({ isClaim, isFactual }: { isClaim: boolean; isFactual: boolean }): ResponseKind { + if (!isClaim) return 'curation'; + return isFactual ? 'veracity' : 'stance'; +} + +export function responseKindToVoteKind(kind: ResponseKind): ResponseVoteKind { + return RESPONSE_VOTE_KIND[kind]; +} + +export function getResponseActionMethod(kind: ResponseKind, direction: ResponseDirection): ResponseActionMethod { + return RESPONSE_ACTION_METHOD[kind][direction]; +} + +export function decodeActiveResponseDirection(voteType: unknown): ActiveResponseDirection | null { + if (voteType === 0) return 'positive'; + if (voteType === 1) return 'negative'; + return null; +} + +export async function waitForIndexedEntityResponse( + fetchResponse: () => Promise, + expectedResponse: ActiveResponseDirection | null, + maxAttempts = 30, + intervalMs = 2_000 +): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if ((await fetchResponse()) === expectedResponse) return true; + } catch { + // Gaia can fail transiently while indexing. Keep the transaction in its + // processing state and retry instead of presenting a false write error. + } + + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + } + + return false; +} + +export function entityResponseQueryVariables( + entityId: string, + spaceId: string, + objectType: ResponseObjectType, + responseKind: ResponseKind +) { + return { + objectId: entityId, + objectType, + spaceId, + voteKind: responseKindToVoteKind(responseKind), + } as const; +} + +export function entityResponseCountsQueryKey( + entityId: string, + spaceId: string, + objectType: ResponseObjectType, + responseKind: ResponseKind +) { + return ['entity-response-counts', entityId, spaceId, objectType, responseKind] as const; +} + +export function userEntityResponseQueryKey( + userId: string | null | undefined, + entityId: string, + spaceId: string, + objectType: ResponseObjectType, + responseKind: ResponseKind +) { + return ['user-entity-response', userId, entityId, spaceId, objectType, responseKind] as const; +} + +export function entityRespondersQueryKey( + entityId: string, + spaceId: string, + objectType: ResponseObjectType, + responseKind: ResponseKind +) { + return ['entity-responders', entityId, spaceId, objectType, responseKind] as const; +} + +export function entityResponderProfilesQueryKey( + entityId: string, + spaceId: string, + objectType: ResponseObjectType, + responseKind: ResponseKind +) { + return ['entity-responder-profiles', entityId, spaceId, objectType, responseKind] as const; +} diff --git a/apps/web/core/sync/orm.ts b/apps/web/core/sync/orm.ts index 143776c27d..810cf131fe 100644 --- a/apps/web/core/sync/orm.ts +++ b/apps/web/core/sync/orm.ts @@ -4,8 +4,9 @@ import { QueryClient } from '@tanstack/react-query'; import { Effect } from 'effect'; import { dedupeWith } from 'effect/Array'; -import { type EntitiesOrderBy, SortOrder } from '~/core/gql/graphql'; +import { type EntitiesOrderBy } from '~/core/gql/graphql'; import { convertWhereConditionToEntityFilter, extractTypeIdsFromWhere } from '~/core/io/converters'; +import { SortOrder } from '~/core/io/sort-order'; import { readTypes } from '../database/entities'; import { diff --git a/apps/web/package.json b/apps/web/package.json index c7719aada1..0d46c80fe3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "@effect/opentelemetry": "^0.63.0", "@floating-ui/dom": "^1.7.5", "@geogenesis/auth": "workspace:*", - "@geoprotocol/geo-sdk": "0.20.1", + "@geoprotocol/geo-sdk": "0.20.3", "@geoprotocol/grc-20": "^0.4.1", "@graphql-typed-document-node/core": "^3.2.0", "@livekit/components-react": "^2.9.21", diff --git a/apps/web/partials/entity-page/claim-voter-avatars.tsx b/apps/web/partials/entity-page/claim-voter-avatars.tsx index 2bace34052..fa5329a5a1 100644 --- a/apps/web/partials/entity-page/claim-voter-avatars.tsx +++ b/apps/web/partials/entity-page/claim-voter-avatars.tsx @@ -6,7 +6,8 @@ import * as React from 'react'; import { Effect } from 'effect'; -import { getEntityVoters } from '~/core/io/queries'; +import { getEntityResponders } from '~/core/io/queries'; +import { type ResponseKind, type ResponseObjectType, entityRespondersQueryKey } from '~/core/responses/entity-response'; import { RankingAggregatedSubmitterAvatars } from '~/partials/blocks/table/ranking-period-metadata'; @@ -14,27 +15,29 @@ export function ClaimVoterAvatars({ entityId, spaceId, objectType, - totalVoters, + responseKind, + totalResponders, }: { entityId: string; spaceId: string; - objectType: 0 | 1; - totalVoters: number; + objectType: ResponseObjectType; + responseKind: ResponseKind; + totalResponders: number; }) { - const { data: voters } = useQuery({ - queryKey: ['entity-voter-list', entityId, spaceId, objectType], - queryFn: () => Effect.runPromise(getEntityVoters(entityId, spaceId, objectType)), + const { data: responders } = useQuery({ + queryKey: entityRespondersQueryKey(entityId, spaceId, objectType, responseKind), + queryFn: () => Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)), staleTime: 30_000, }); - const voterSpaceIds = React.useMemo(() => voters?.map(v => v.userId) ?? [], [voters]); + const responderSpaceIds = React.useMemo(() => responders?.map(v => v.userId) ?? [], [responders]); - if (voterSpaceIds.length === 0) return null; + if (responderSpaceIds.length === 0) return null; return ( ); diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index 03692ca99f..5ec4b94236 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -1,6 +1,7 @@ 'use client'; import { useGeoLogin } from '@geogenesis/auth'; +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; import * as Popover from '@radix-ui/react-popover'; import { useQuery } from '@tanstack/react-query'; @@ -12,15 +13,30 @@ import { useSetAtom } from 'jotai'; import { downvoted, trackPrivyAuth, upvoted, voteCast } from '~/core/analytics'; import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; -import { useEntityVote } from '~/core/hooks/use-entity-vote'; +import { useEntityResponse } from '~/core/hooks/use-entity-vote'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; -import { type EntityVoter, getEntityVoteCount, getEntityVoters, getUserEntityVote } from '~/core/io/queries'; +import { + type EntityResponder, + getEntityResponders, + getEntityResponseCounts, + getUserEntityResponse, +} from '~/core/io/queries'; import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; +import { + type ActiveResponseDirection, + ENTITY_RESPONSE_COPY, + type ResponseKind, + entityResponderProfilesQueryKey, + entityResponseCountsQueryKey, + getEntityResponseKind, + userEntityResponseQueryKey, +} from '~/core/responses/entity-response'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; import { useQueryEntity } from '~/core/sync/use-store'; import { Profile } from '~/core/types'; import { Avatar } from '~/design-system/avatar'; +import { getChecked } from '~/design-system/checkbox'; import { ChevronDown } from '~/design-system/icons/chevron-down'; import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; @@ -32,24 +48,17 @@ import { Skeleton } from '~/design-system/skeleton'; import { ClaimVoterAvatars } from '~/partials/entity-page/claim-voter-avatars'; import { avatarAtom, nameAtom, spaceIdAtom, stepAtom, topicIdAtom } from '~/partials/onboarding/dialog'; -type OptimisticVote = 0 | 1 | 'none' | null; +type OptimisticResponse = ActiveResponseDirection | 'none' | null; -// Entity votes are always object type 0 — the SDK hardcodes it in the vote -// topic, so reads here pin the same value to stay consistent with writes. -const ENTITY_VOTE_OBJECT_TYPE = 0; +const ENTITY_RESPONSE_OBJECT_TYPE = 0; -type VoteVariant = 'default' | 'thumbs' | 'chevrons'; +type ResponseVariant = 'default' | 'thumbs' | 'chevrons'; const normalizeId = (id: string) => id.replace(/-/g, '').toLowerCase(); const CLAIM_TYPE = normalizeId(CLAIM_TYPE_ID); const CLAIM_IS_FACTUAL = normalizeId(CLAIM_IS_FACTUAL_PROPERTY_ID); - -function parseBoolean(value: string | null | undefined): boolean { - if (!value) return false; - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; -} +const TYPES_PROPERTY = normalizeId(SystemIds.TYPES_PROPERTY); type EntityVoteButtonsProps = { entityId: string; @@ -62,19 +71,33 @@ export function EntityVoteButtons({ spaceId, claimVoterAvatarsPosition = 'leading', }: EntityVoteButtonsProps) { - const { upvote, downvote, unvote, isConnected, personalSpaceId } = useEntityVote({ - entityId, - spaceId, - }); - const { smartAccount } = useSmartAccount(); - const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); - - // Claim entities render a different vote control. const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId }); - const isClaim = entity?.types.some(t => normalizeId(t.id) === CLAIM_TYPE) ?? false; + const isClaim = + entity?.relations.some( + relation => normalizeId(relation.type.id) === TYPES_PROPERTY && normalizeId(relation.toEntity.id) === CLAIM_TYPE + ) ?? false; const isFactualClaim = - isClaim && parseBoolean(entity?.values.find(v => normalizeId(v.property.id) === CLAIM_IS_FACTUAL)?.value); - const variant: VoteVariant = isClaim ? (isFactualClaim ? 'chevrons' : 'thumbs') : 'default'; + isClaim && + getChecked( + entity?.values.find( + v => normalizeId(v.spaceId) === normalizeId(spaceId) && normalizeId(v.property.id) === CLAIM_IS_FACTUAL + )?.value + ) === true; + const responseKind = getEntityResponseKind({ isClaim, isFactual: isFactualClaim }); + const variant: ResponseVariant = + responseKind === 'curation' ? 'default' : responseKind === 'veracity' ? 'chevrons' : 'thumbs'; + const responseCopy = ENTITY_RESPONSE_COPY[responseKind]; + + const { + submitPositiveResponse, + submitNegativeResponse, + clearResponse, + isProcessingResponse, + isConnected, + personalSpaceId, + } = useEntityResponse({ entityId, spaceId, responseKind }); + const { smartAccount } = useSmartAccount(); + const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); const setName = useSetAtom(nameAtom); const setTopicId = useSetAtom(topicIdAtom); @@ -86,23 +109,27 @@ export function EntityVoteButtons({ onComplete: args => trackPrivyAuth(args, { auth_flow: 'manual_login' }), }); - const [optimisticVote, setOptimisticVote] = React.useState(null); + const [optimisticResponse, setOptimisticResponse] = React.useState(null); const [optimisticScore, setOptimisticScore] = React.useState(null); - const [votersOpen, setVotersOpen] = React.useState(false); + const [respondersOpen, setRespondersOpen] = React.useState(false); - const { data: voteCounts } = useQuery<{ upvotes: number; downvotes: number } | null>({ - queryKey: ['entity-vote-count', entityId, ENTITY_VOTE_OBJECT_TYPE], - queryFn: () => Effect.runPromise(getEntityVoteCount(entityId, ENTITY_VOTE_OBJECT_TYPE)), + const { data: responseCounts } = useQuery<{ positive: number; negative: number } | null>({ + queryKey: entityResponseCountsQueryKey(entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, responseKind), + queryFn: () => + Effect.runPromise(getEntityResponseCounts(entityId, spaceId, responseKind, ENTITY_RESPONSE_OBJECT_TYPE)), + enabled: !isLoadingEntity, staleTime: 30_000, }); - const { data: userVoteType } = useQuery({ - queryKey: ['user-entity-vote', personalSpaceId, entityId, spaceId, ENTITY_VOTE_OBJECT_TYPE], + const { data: serverResponseDirection } = useQuery({ + queryKey: userEntityResponseQueryKey(personalSpaceId, entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, responseKind), queryFn: async () => { if (!personalSpaceId) return null; - return Effect.runPromise(getUserEntityVote(personalSpaceId, entityId, spaceId, ENTITY_VOTE_OBJECT_TYPE)); + return Effect.runPromise( + getUserEntityResponse(personalSpaceId, entityId, spaceId, responseKind, ENTITY_RESPONSE_OBJECT_TYPE) + ); }, - enabled: !!personalSpaceId, + enabled: !!personalSpaceId && !isLoadingEntity, staleTime: 30_000, }); @@ -110,23 +137,22 @@ export function EntityVoteButtons({ function clearOptimisticScoreOnServerUpdate() { setOptimisticScore(null); }, - [voteCounts] + [responseCounts] ); React.useEffect( - function clearOptimisticVoteOnServerUpdate() { - setOptimisticVote(null); + function clearOptimisticResponseOnServerUpdate() { + setOptimisticResponse(null); }, - [userVoteType] + [serverResponseDirection] ); - const serverVoteDirection = userVoteType === 0 ? 0 : userVoteType === 1 ? 1 : null; - const activeVote = - optimisticVote !== null ? (optimisticVote === 'none' ? null : optimisticVote) : serverVoteDirection; + const activeResponse = + optimisticResponse !== null ? (optimisticResponse === 'none' ? null : optimisticResponse) : serverResponseDirection; - const upvotes = BigInt(voteCounts?.upvotes ?? 0); - const downvotes = BigInt(voteCounts?.downvotes ?? 0); - const netScore = upvotes - downvotes; + const positiveResponses = BigInt(responseCounts?.positive ?? 0); + const negativeResponses = BigInt(responseCounts?.negative ?? 0); + const netScore = positiveResponses - negativeResponses; const displayScore = optimisticScore !== null ? optimisticScore : netScore; function openPrivySignIn() { @@ -138,72 +164,82 @@ export function EntityVoteButtons({ login(); } - function handleUpvote() { + function handlePositiveResponse() { if (!smartAccount) { openPrivySignIn(); return; } if (!isConnected) return; const base = optimisticScore !== null ? optimisticScore : netScore; - if (activeVote === 0) { - setOptimisticVote('none'); + if (activeResponse === 'positive') { + setOptimisticResponse('none'); setOptimisticScore(base - 1n); - unvote(undefined, { + clearResponse(undefined, { onSuccess: () => { voteCast('none', voteProperties('remove', 'up')); }, onError: () => { - setOptimisticVote(0); + setOptimisticResponse('positive'); setOptimisticScore(null); }, }); } else { - const delta = activeVote === 1 ? 2n : 1n; - const prevVote = activeVote; - setOptimisticVote(0); + const delta = activeResponse === 'negative' ? 2n : 1n; + const previousResponse = activeResponse ?? null; + setOptimisticResponse('positive'); setOptimisticScore(base + delta); - upvote(undefined, { + submitPositiveResponse(undefined, { onSuccess: () => { - upvoted(voteProperties(prevVote === 1 ? 'switch' : 'cast', prevVote === 1 ? 'down' : undefined)); + upvoted( + voteProperties( + previousResponse === 'negative' ? 'switch' : 'cast', + previousResponse === 'negative' ? 'down' : undefined + ) + ); }, onError: () => { - setOptimisticVote(prevVote === 1 ? 1 : null); + setOptimisticResponse(previousResponse); setOptimisticScore(null); }, }); } } - function handleDownvote() { + function handleNegativeResponse() { if (!smartAccount) { openPrivySignIn(); return; } if (!isConnected) return; const base = optimisticScore !== null ? optimisticScore : netScore; - if (activeVote === 1) { - setOptimisticVote('none'); + if (activeResponse === 'negative') { + setOptimisticResponse('none'); setOptimisticScore(base + 1n); - unvote(undefined, { + clearResponse(undefined, { onSuccess: () => { voteCast('none', voteProperties('remove', 'down')); }, onError: () => { - setOptimisticVote(1); + setOptimisticResponse('negative'); setOptimisticScore(null); }, }); } else { - const delta = activeVote === 0 ? 2n : 1n; - const prevVote = activeVote; - setOptimisticVote(1); + const delta = activeResponse === 'positive' ? 2n : 1n; + const previousResponse = activeResponse ?? null; + setOptimisticResponse('negative'); setOptimisticScore(base - delta); - downvote(undefined, { + submitNegativeResponse(undefined, { onSuccess: () => { - downvoted(voteProperties(prevVote === 0 ? 'switch' : 'cast', prevVote === 0 ? 'up' : undefined)); + downvoted( + voteProperties( + previousResponse === 'positive' ? 'switch' : 'cast', + previousResponse === 'positive' ? 'up' : undefined + ) + ); }, onError: () => { - setOptimisticVote(prevVote === 0 ? 0 : null); + setOptimisticResponse(previousResponse); setOptimisticScore(null); }, }); @@ -216,29 +252,30 @@ export function EntityVoteButtons({ previous_vote_direction: previousDirection, entity_id: entityId, space_id: spaceId, - object_type: ENTITY_VOTE_OBJECT_TYPE, + object_type: ENTITY_RESPONSE_OBJECT_TYPE, }; } const scoreLabel = formatScore(displayScore); - const upvoteActive = activeVote === 0; - const downvoteActive = activeVote === 1; + const positiveActive = activeResponse === 'positive'; + const negativeActive = activeResponse === 'negative'; - const totalVoters = (voteCounts?.upvotes ?? 0) + (voteCounts?.downvotes ?? 0); + const totalResponders = (responseCounts?.positive ?? 0) + (responseCounts?.negative ?? 0); - const optimisticUpDelta = optimisticVote !== null ? (upvoteActive ? 1 : 0) - (serverVoteDirection === 0 ? 1 : 0) : 0; - const optimisticDownDelta = - optimisticVote !== null ? (downvoteActive ? 1 : 0) - (serverVoteDirection === 1 ? 1 : 0) : 0; - const effectiveUpvotes = Math.max(0, (voteCounts?.upvotes ?? 0) + optimisticUpDelta); - const effectiveDownvotes = Math.max(0, (voteCounts?.downvotes ?? 0) + optimisticDownDelta); - const effectiveTotal = effectiveUpvotes + effectiveDownvotes; - const percentLabel = effectiveTotal > 0 ? `${Math.round((100 * effectiveUpvotes) / effectiveTotal)}%` : '0%'; + const optimisticPositiveDelta = + optimisticResponse !== null ? (positiveActive ? 1 : 0) - (serverResponseDirection === 'positive' ? 1 : 0) : 0; + const optimisticNegativeDelta = + optimisticResponse !== null ? (negativeActive ? 1 : 0) - (serverResponseDirection === 'negative' ? 1 : 0) : 0; + const effectivePositive = Math.max(0, (responseCounts?.positive ?? 0) + optimisticPositiveDelta); + const effectiveNegative = Math.max(0, (responseCounts?.negative ?? 0) + optimisticNegativeDelta); + const effectiveTotal = effectivePositive + effectiveNegative; + const percentLabel = effectiveTotal > 0 ? `${Math.round((100 * effectivePositive) / effectiveTotal)}%` : '0%'; const isClaimVariant = variant !== 'default'; const displayLabel = isClaimVariant ? percentLabel : scoreLabel; - const renderVoteIcon = (direction: 'up' | 'down', active: boolean) => { + const renderResponseIcon = (direction: 'up' | 'down', active: boolean) => { if (variant === 'chevrons') { return direction === 'up' ? : ; } @@ -250,23 +287,24 @@ export function EntityVoteButtons({ return ; }; - const claimVoteButtonColor = (active: boolean) => { + const claimResponseButtonColor = (active: boolean) => { if (variant === 'chevrons') { return active ? 'text-[#2A2B2E]' : 'text-grey-03 hover:text-grey-04'; } return isClaimVariant && (active ? 'text-grey-04' : 'text-grey-03 hover:text-grey-04'); }; - const claimVoterAvatars = isClaimVariant ? ( + const claimResponderAvatars = isClaimVariant ? ( ) : null; - const claimVoterAvatarsClassName = 'inline-flex h-5 shrink-0 items-center'; + const claimResponderAvatarsClassName = 'inline-flex h-5 shrink-0 items-center'; if (isLoadingEntity) { return ; @@ -274,38 +312,42 @@ export function EntityVoteButtons({ return (
- {claimVoterAvatarsPosition === 'leading' && claimVoterAvatars ? ( - {claimVoterAvatars} + {claimVoterAvatarsPosition === 'leading' && claimResponderAvatars ? ( + {claimResponderAvatars} ) : null} - + @@ -317,86 +359,103 @@ export function EntityVoteButtons({ sideOffset={8} className="z-100 w-[200px] overflow-hidden rounded-lg border border-grey-02 bg-white shadow-lg" > - + - {claimVoterAvatarsPosition === 'trailing' && claimVoterAvatars ? ( - {claimVoterAvatars} + {claimVoterAvatarsPosition === 'trailing' && claimResponderAvatars ? ( + {claimResponderAvatars} ) : null} + {isProcessingResponse ? Processing response… : null}
); } -type VoterWithProfile = EntityVoter & { profile: Profile }; +type ResponderWithProfile = EntityResponder & { profile: Profile }; -function VotersPopoverContent({ +function RespondersPopoverContent({ entityId, spaceId, objectType, + responseKind, }: { entityId: string; spaceId: string; objectType: 0 | 1; + responseKind: ResponseKind; }) { - const { data: votersWithProfiles, isLoading } = useQuery({ - queryKey: ['entity-voters', entityId, spaceId, objectType], + const copy = ENTITY_RESPONSE_COPY[responseKind]; + const { data: respondersWithProfiles, isLoading } = useQuery({ + queryKey: entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), queryFn: async () => { - const voters = await Effect.runPromise(getEntityVoters(entityId, spaceId, objectType)); - if (voters.length === 0) return []; - const profiles = await Effect.runPromise(fetchProfilesBySpaceIds(voters.map(v => v.userId))); - return voters.map((voter, i): VoterWithProfile => ({ ...voter, profile: profiles[i]! })); + const responders = await Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)); + if (responders.length === 0) return []; + const profiles = await Effect.runPromise(fetchProfilesBySpaceIds(responders.map(v => v.userId))); + return responders.map((responder, i): ResponderWithProfile => ({ ...responder, profile: profiles[i]! })); }, staleTime: 30_000, }); - const upvoters = votersWithProfiles?.filter(v => v.voteType === 0) ?? []; - const downvoters = votersWithProfiles?.filter(v => v.voteType === 1) ?? []; + const positiveResponders = respondersWithProfiles?.filter(v => v.direction === 'positive') ?? []; + const negativeResponders = respondersWithProfiles?.filter(v => v.direction === 'negative') ?? []; if (isLoading) { - return
Loading voters...
; + return
{copy.loading}
; } - if (!votersWithProfiles || votersWithProfiles.length === 0) { - return
No votes yet
; + if (!respondersWithProfiles || respondersWithProfiles.length === 0) { + return
{copy.empty}
; } return (
- {upvoters.length > 0 && } - {downvoters.length > 0 && } + {positiveResponders.length > 0 && ( + + )} + {negativeResponders.length > 0 && ( + + )}
); } -function VoterSection({ label, voters }: { label: string; voters: VoterWithProfile[] }) { +function ResponderSection({ label, responders }: { label: string; responders: ResponderWithProfile[] }) { return (
{label}
- {voters.map(v => ( + {responders.map(v => ( ))}
diff --git a/bun.lock b/bun.lock index 5b9823a182..c133ab1b87 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "geogenesis", "dependencies": { - "@geoprotocol/geo-sdk": "0.20.1", + "@geoprotocol/geo-sdk": "0.20.3", }, "devDependencies": { "@graphql-codegen/client-preset": "^6.0.1", @@ -31,7 +31,7 @@ "@effect/opentelemetry": "^0.63.0", "@floating-ui/dom": "^1.7.5", "@geogenesis/auth": "workspace:*", - "@geoprotocol/geo-sdk": "0.20.1", + "@geoprotocol/geo-sdk": "0.20.3", "@geoprotocol/grc-20": "^0.4.1", "@graphql-typed-document-node/core": "^3.2.0", "@livekit/components-react": "^2.9.21", @@ -163,7 +163,7 @@ "name": "@geogenesis/auth", "version": "1.0.0", "dependencies": { - "@geoprotocol/geo-sdk": "0.20.1", + "@geoprotocol/geo-sdk": "0.20.3", "@privy-io/react-auth": "^3.21.0", "@privy-io/wagmi": "^4.0.4", "@rhinestone/module-sdk": "^0.4.0", @@ -419,7 +419,7 @@ "@geogenesis/auth": ["@geogenesis/auth@workspace:packages/auth"], - "@geoprotocol/geo-sdk": ["@geoprotocol/geo-sdk@0.20.1", "", { "dependencies": { "@geoprotocol/grc-20": "^0.4.1", "@zerodev/sdk": "^5.5.10", "effect": "^3.17.13", "fflate": "^0.8.2", "fractional-indexing-jittered": "^1.0.0", "image-size": "^2.0.2", "uuid": "^13.0.0", "viem": "^2.37.6" } }, "sha512-n6L+i7XVELDqo5eQ+Ne3vWBi4mBaXYwUHl10vR4OYSCCMO/K811k17Qxf6D0qCYPeb3cvkkxegxEyGI9jpIZ6g=="], + "@geoprotocol/geo-sdk": ["@geoprotocol/geo-sdk@0.20.3", "", { "dependencies": { "@geoprotocol/grc-20": "^0.4.1", "@zerodev/sdk": "^5.5.10", "effect": "^3.17.13", "fflate": "^0.8.2", "fractional-indexing-jittered": "^1.0.0", "image-size": "^2.0.2", "uuid": "^13.0.0", "viem": "^2.37.6" } }, "sha512-vkMmVPZBp+LiHtOoeiC35WsAzu6arLvcU0a4UG1yEZ+sWaoBWS8HPxWZceRZRNsUzzIaDOy4Ae09i/z5qvdv1A=="], "@geoprotocol/grc-20": ["@geoprotocol/grc-20@0.4.1", "", { "dependencies": { "@bokuweb/zstd-wasm": "^0.0.27" } }, "sha512-J5trJZBydDOO5GsEMzy9ukX9GetblBMLnn4ugpTFOcwS4akA54Lxg/uYJbXUDh5EVPir3PAzC6UN6EiBz0K/Ww=="], diff --git a/package.json b/package.json index f7d14afcb5..69dbd25ae5 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "clean-node-modules": "rimraf --glob \"**/node_modules\"" }, "dependencies": { - "@geoprotocol/geo-sdk": "0.20.1" + "@geoprotocol/geo-sdk": "0.20.3" }, "devDependencies": { "@graphql-codegen/client-preset": "^6.0.1", diff --git a/packages/auth/package.json b/packages/auth/package.json index 932a7d0368..09eb9ada30 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,7 +22,7 @@ "test": "vitest" }, "dependencies": { - "@geoprotocol/geo-sdk": "0.20.1", + "@geoprotocol/geo-sdk": "0.20.3", "@privy-io/react-auth": "^3.21.0", "@privy-io/wagmi": "^4.0.4", "@rhinestone/module-sdk": "^0.4.0", From 78dbbe4ce9b5cd001a20b649f56c261e7a4a3a9a Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 6 Aug 2026 23:46:20 +0200 Subject: [PATCH 02/15] refactor(responses): simplify mutation and responder data flow --- apps/web/core/hooks/use-entity-vote.ts | 26 ++----- apps/web/core/responses/entity-response.ts | 5 +- .../entity-page/claim-voter-avatars.tsx | 2 +- .../entity-page/entity-vote-buttons.tsx | 69 +++++++++++-------- .../partials/explore/explore-feed-card.tsx | 2 +- 5 files changed, 51 insertions(+), 53 deletions(-) diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index 2fde8e7d9f..237ebac619 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -12,7 +12,6 @@ import { getUserEntityResponse } from '~/core/io/queries'; import { type ResponseDirection, type ResponseKind, - entityResponderProfilesQueryKey, entityRespondersQueryKey, entityResponseCountsQueryKey, getResponseActionMethod, @@ -35,7 +34,7 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity const tx = useSmartAccountTransaction(); - const submitResponse = useCallback( + const executeResponse = useCallback( async (direction: ResponseDirection) => { if (!validateSpaceId(spaceId)) { throw new Error('Invalid space ID format. Cannot submit response.'); @@ -97,31 +96,16 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity queryClient.invalidateQueries({ queryKey: entityRespondersQueryKey(entityId, spaceId, 0, responseKind), }), - queryClient.invalidateQueries({ - queryKey: entityResponderProfilesQueryKey(entityId, spaceId, 0, responseKind), - }), ]); - const positiveResponse = useMutation({ - mutationFn: () => submitResponse('positive'), - onSuccess, - }); - - const negativeResponse = useMutation({ - mutationFn: () => submitResponse('negative'), - onSuccess, - }); - - const clearResponse = useMutation({ - mutationFn: () => submitResponse('clear'), + const responseMutation = useMutation({ + mutationFn: executeResponse, onSuccess, }); return { - submitPositiveResponse: positiveResponse.mutate, - submitNegativeResponse: negativeResponse.mutate, - clearResponse: clearResponse.mutate, - isProcessingResponse: positiveResponse.isPending || negativeResponse.isPending || clearResponse.isPending, + submitResponse: responseMutation.mutate, + isProcessingResponse: responseMutation.isPending, isConnected: !!personalSpaceId && isRegistered, personalSpaceId, }; diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 23f70f4a4c..28018cbbe3 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -1,8 +1,9 @@ +import { sleep } from '~/core/utils/utils'; + export type ResponseKind = 'curation' | 'stance' | 'veracity'; export type ResponseDirection = 'positive' | 'negative' | 'clear'; export type ActiveResponseDirection = Exclude; export type ResponseVoteKind = 0 | 1 | 2; -export type ResponseVoteType = 0 | 1 | 2; export type ResponseObjectType = 0 | 1; export type ResponseActionMethod = @@ -122,7 +123,7 @@ export async function waitForIndexedEntityResponse( } if (attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, intervalMs)); + await sleep(intervalMs); } } diff --git a/apps/web/partials/entity-page/claim-voter-avatars.tsx b/apps/web/partials/entity-page/claim-voter-avatars.tsx index fa5329a5a1..2b517eb04a 100644 --- a/apps/web/partials/entity-page/claim-voter-avatars.tsx +++ b/apps/web/partials/entity-page/claim-voter-avatars.tsx @@ -11,7 +11,7 @@ import { type ResponseKind, type ResponseObjectType, entityRespondersQueryKey } import { RankingAggregatedSubmitterAvatars } from '~/partials/blocks/table/ranking-period-metadata'; -export function ClaimVoterAvatars({ +export function ClaimResponderAvatars({ entityId, spaceId, objectType, diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index 5ec4b94236..c9725d51d3 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -15,6 +15,7 @@ import { downvoted, trackPrivyAuth, upvoted, voteCast } from '~/core/analytics'; import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { useEntityResponse } from '~/core/hooks/use-entity-vote'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; +import { uuidToHex } from '~/core/id/normalize'; import { type EntityResponder, getEntityResponders, @@ -27,6 +28,7 @@ import { ENTITY_RESPONSE_COPY, type ResponseKind, entityResponderProfilesQueryKey, + entityRespondersQueryKey, entityResponseCountsQueryKey, getEntityResponseKind, userEntityResponseQueryKey, @@ -45,7 +47,7 @@ import { VoteArrow } from '~/design-system/icons/vote-arrow'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; -import { ClaimVoterAvatars } from '~/partials/entity-page/claim-voter-avatars'; +import { ClaimResponderAvatars } from '~/partials/entity-page/claim-voter-avatars'; import { avatarAtom, nameAtom, spaceIdAtom, stepAtom, topicIdAtom } from '~/partials/onboarding/dialog'; type OptimisticResponse = ActiveResponseDirection | 'none' | null; @@ -54,33 +56,31 @@ const ENTITY_RESPONSE_OBJECT_TYPE = 0; type ResponseVariant = 'default' | 'thumbs' | 'chevrons'; -const normalizeId = (id: string) => id.replace(/-/g, '').toLowerCase(); - -const CLAIM_TYPE = normalizeId(CLAIM_TYPE_ID); -const CLAIM_IS_FACTUAL = normalizeId(CLAIM_IS_FACTUAL_PROPERTY_ID); -const TYPES_PROPERTY = normalizeId(SystemIds.TYPES_PROPERTY); +const CLAIM_TYPE = uuidToHex(CLAIM_TYPE_ID); +const CLAIM_IS_FACTUAL = uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID); +const TYPES_PROPERTY = uuidToHex(SystemIds.TYPES_PROPERTY); type EntityVoteButtonsProps = { entityId: string; spaceId: string; - claimVoterAvatarsPosition?: 'leading' | 'trailing'; + claimResponderAvatarsPosition?: 'leading' | 'trailing'; }; export function EntityVoteButtons({ entityId, spaceId, - claimVoterAvatarsPosition = 'leading', + claimResponderAvatarsPosition = 'leading', }: EntityVoteButtonsProps) { const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId }); const isClaim = entity?.relations.some( - relation => normalizeId(relation.type.id) === TYPES_PROPERTY && normalizeId(relation.toEntity.id) === CLAIM_TYPE + relation => uuidToHex(relation.type.id) === TYPES_PROPERTY && uuidToHex(relation.toEntity.id) === CLAIM_TYPE ) ?? false; const isFactualClaim = isClaim && getChecked( entity?.values.find( - v => normalizeId(v.spaceId) === normalizeId(spaceId) && normalizeId(v.property.id) === CLAIM_IS_FACTUAL + v => uuidToHex(v.spaceId) === uuidToHex(spaceId) && uuidToHex(v.property.id) === CLAIM_IS_FACTUAL )?.value ) === true; const responseKind = getEntityResponseKind({ isClaim, isFactual: isFactualClaim }); @@ -89,9 +89,7 @@ export function EntityVoteButtons({ const responseCopy = ENTITY_RESPONSE_COPY[responseKind]; const { - submitPositiveResponse, - submitNegativeResponse, - clearResponse, + submitResponse, isProcessingResponse, isConnected, personalSpaceId, @@ -174,7 +172,7 @@ export function EntityVoteButtons({ if (activeResponse === 'positive') { setOptimisticResponse('none'); setOptimisticScore(base - 1n); - clearResponse(undefined, { + submitResponse('clear', { onSuccess: () => { voteCast('none', voteProperties('remove', 'up')); }, @@ -188,7 +186,7 @@ export function EntityVoteButtons({ const previousResponse = activeResponse ?? null; setOptimisticResponse('positive'); setOptimisticScore(base + delta); - submitPositiveResponse(undefined, { + submitResponse('positive', { onSuccess: () => { upvoted( voteProperties( @@ -215,7 +213,7 @@ export function EntityVoteButtons({ if (activeResponse === 'negative') { setOptimisticResponse('none'); setOptimisticScore(base + 1n); - clearResponse(undefined, { + submitResponse('clear', { onSuccess: () => { voteCast('none', voteProperties('remove', 'down')); }, @@ -229,7 +227,7 @@ export function EntityVoteButtons({ const previousResponse = activeResponse ?? null; setOptimisticResponse('negative'); setOptimisticScore(base - delta); - submitNegativeResponse(undefined, { + submitResponse('negative', { onSuccess: () => { downvoted( voteProperties( @@ -295,7 +293,7 @@ export function EntityVoteButtons({ }; const claimResponderAvatars = isClaimVariant ? ( - - {claimVoterAvatarsPosition === 'leading' && claimResponderAvatars ? ( + {claimResponderAvatarsPosition === 'leading' && claimResponderAvatars ? ( {claimResponderAvatars} ) : null} - {claimVoterAvatarsPosition === 'trailing' && claimResponderAvatars ? ( + {claimResponderAvatarsPosition === 'trailing' && claimResponderAvatars ? ( {claimResponderAvatars} ) : null} {isProcessingResponse ? Processing response… : null} @@ -417,16 +415,31 @@ function RespondersPopoverContent({ responseKind: ResponseKind; }) { const copy = ENTITY_RESPONSE_COPY[responseKind]; - const { data: respondersWithProfiles, isLoading } = useQuery({ - queryKey: entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), - queryFn: async () => { - const responders = await Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)); - if (responders.length === 0) return []; - const profiles = await Effect.runPromise(fetchProfilesBySpaceIds(responders.map(v => v.userId))); - return responders.map((responder, i): ResponderWithProfile => ({ ...responder, profile: profiles[i]! })); - }, + const respondersQueryKey = entityRespondersQueryKey(entityId, spaceId, objectType, responseKind); + const { data: responders, isLoading: isLoadingResponders } = useQuery({ + queryKey: respondersQueryKey, + queryFn: () => Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)), staleTime: 30_000, }); + const responderSpaceIds = React.useMemo(() => responders?.map(responder => responder.userId) ?? [], [responders]); + const { data: profiles, isLoading: isLoadingProfiles } = useQuery({ + queryKey: [ + ...entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), + responderSpaceIds, + ], + enabled: responderSpaceIds.length > 0, + queryFn: () => Effect.runPromise(fetchProfilesBySpaceIds(responderSpaceIds)), + staleTime: 30_000, + }); + let respondersWithProfiles: ResponderWithProfile[] | undefined; + if (responders?.length === 0) { + respondersWithProfiles = []; + } else if (responders && profiles) { + respondersWithProfiles = responders.map( + (responder, index): ResponderWithProfile => ({ ...responder, profile: profiles[index]! }) + ); + } + const isLoading = isLoadingResponders || (responderSpaceIds.length > 0 && isLoadingProfiles); const positiveResponders = respondersWithProfiles?.filter(v => v.direction === 'positive') ?? []; const negativeResponders = respondersWithProfiles?.filter(v => v.direction === 'negative') ?? []; diff --git a/apps/web/partials/explore/explore-feed-card.tsx b/apps/web/partials/explore/explore-feed-card.tsx index 477e441219..75f783aacf 100644 --- a/apps/web/partials/explore/explore-feed-card.tsx +++ b/apps/web/partials/explore/explore-feed-card.tsx @@ -116,7 +116,7 @@ export function ExploreFeedCard({ item, hideSpaceLink = false, hideJoinButton =
- + Date: Fri, 7 Aug 2026 00:15:22 +0200 Subject: [PATCH 03/15] feat(debates): gate readiness on claim responses --- .../claims/claims-page-client.test.tsx | 181 ++++++++---------- .../(space)/claims/claims-page-client.tsx | 173 ++--------------- .../debate-room-page-client.test.tsx | 1 + .../debates/debates-page-client.test.tsx | 1 + .../[sessionId]/rematch-page-client.test.tsx | 53 +++-- .../[sessionId]/rematch-page-client.tsx | 96 ++++++---- .../debug-debates-page-client.test.tsx | 1 + apps/web/core/debates/api.test.ts | 23 +++ apps/web/core/debates/api.ts | 53 ++--- .../core/debates/browse/debate-feed.test.tsx | 1 + .../debates/browse/join-debate-panel.test.tsx | 104 ++++++++++ .../core/debates/browse/join-debate-panel.tsx | 96 ++-------- .../core/debates/claim-debate-button.test.tsx | 56 ++++-- apps/web/core/debates/claim-debate-button.tsx | 137 ++----------- .../debates/claim-debate-readiness.test.tsx | 126 ++++++++++++ .../core/debates/claim-debate-readiness.tsx | 154 +++++++++++++++ .../core/debates/debate-coordinator.test.tsx | 1 + .../debate-entity-response-controls.test.tsx | 22 +++ .../debate-entity-response-controls.tsx | 12 ++ apps/web/core/debates/hooks.test.tsx | 54 ++++++ apps/web/core/debates/hooks.ts | 82 ++++---- apps/web/core/debates/match-prompt.test.tsx | 2 + .../debates/response-derived-contract.test.ts | 13 ++ .../entity-page/entity-vote-buttons.tsx | 31 +-- 24 files changed, 853 insertions(+), 620 deletions(-) create mode 100644 apps/web/core/debates/browse/join-debate-panel.test.tsx create mode 100644 apps/web/core/debates/claim-debate-readiness.test.tsx create mode 100644 apps/web/core/debates/claim-debate-readiness.tsx create mode 100644 apps/web/core/debates/debate-entity-response-controls.test.tsx create mode 100644 apps/web/core/debates/debate-entity-response-controls.tsx create mode 100644 apps/web/core/debates/response-derived-contract.test.ts diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx index 2e678bbb50..871a4cc171 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ bumpReviewVersion: vi.fn(), setIsReviewOpen: vi.fn(), joinMutate: vi.fn(), + leaveMutate: vi.fn(), })); let claims: Entity[] = []; @@ -36,10 +37,15 @@ vi.mock('~/core/state/feature-flags', () => ({ vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: debateClaimsResponse, error: null }), useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: joinPending, error: null }), + useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), useAcceptDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), useDeclineDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), })); +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: () =>
Entity response buttons
, +})); + vi.mock('~/core/state/diff-store', () => ({ useDiff: () => ({ setActiveSpace: mocks.setActiveSpace, @@ -112,60 +118,40 @@ describe('ClaimsPageClient', () => { expect(mocks.setIsReviewOpen).toHaveBeenCalledWith(true); }); - it('joins the claim queue with boolean Yes and No positions', () => { - claims = [ - { - id: 'claim-1', - name: 'Public transit should be free', - description: null, - spaces: ['space-1'], - types: [{ id: CLAIM_TYPE_ID, name: 'Claim' }], - values: [], - relations: [], - }, - ]; + it('shows no readiness toggle until a refetch exposes the viewer response', () => { + claims = [publishedClaim()]; debateClaimsResponse = { - claims: [ - { - claim_entity_id: 'claim-1', - viewer_waiting_position: null, - online_choices: [], - active_match: null, - active_debate: null, - }, - ], + claims: [debateClaim({ viewer_response: null })], }; - render(); + const { rerender } = render(); + + expect(screen.getByText('Respond before joining')).toBeInTheDocument(); + expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); + + debateClaimsResponse = { + claims: [debateClaim({ viewer_response: { position: true, position_label: 'Agree' } })], + }; + rerender(); - fireEvent.click(screen.getByRole('button', { name: 'Yes, 0 participants available' })); - fireEvent.click(screen.getByRole('button', { name: 'No, 0 participants available' })); + fireEvent.click(screen.getByRole('button', { name: 'Join debate' })); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1', request: { position: true } }); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1', request: { position: false } }); + expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); - it('renders online participants and highlights the viewer selected position', () => { - claims = [ - { - id: 'claim-1', - name: 'Public transit should be free', - description: null, - spaces: ['space-1'], - types: [{ id: CLAIM_TYPE_ID, name: 'Claim' }], - values: [], - relations: [], - }, - ]; + it('renders backend response labels and one leave-readiness toggle', () => { + claims = [publishedClaim()]; debateClaimsResponse = { claims: [ - { - claim_entity_id: 'claim-1', - viewer_waiting_position: true, + debateClaim({ + viewer_response: { position: true, position_label: 'Verify' }, + viewer_debate_ready: true, + response_kind: 'veracity', online_choices: [ { position: true, - position_label: 'Yes', + position_label: 'Verify', participant_count: 4, participants: [ { @@ -184,70 +170,44 @@ describe('ClaimsPageClient', () => { }, { position: false, - position_label: 'No', + position_label: 'Dispute', participant_count: 0, participants: [], }, ], - active_match: null, - active_debate: null, - }, + }), ], }; render(); - const yesButton = screen.getByRole('button', { name: 'Yes, 4 participants available, selected' }); - const noButton = screen.getByRole('button', { name: 'No, 0 participants available' }); - - expect(yesButton.parentElement).toHaveClass('grid-cols-2'); - expect(yesButton).toHaveClass('rounded-full', 'bg-green'); - expect(yesButton).toHaveAttribute('aria-pressed', 'true'); - expect(yesButton).toBeDisabled(); - expect(noButton).not.toHaveClass('bg-green'); - expect(noButton).toHaveAttribute('aria-pressed', 'false'); - expect(noButton).toBeEnabled(); - expect(within(yesButton).getByTitle('Current viewer')).toBeInTheDocument(); - expect(within(yesButton).getByTitle('Other participant')).toBeInTheDocument(); - expect(within(yesButton).getByText('+2')).toBeInTheDocument(); - expect(within(yesButton).queryByRole('list')).not.toBeInTheDocument(); - expect(yesButton.querySelector('li')).not.toBeInTheDocument(); - expect(within(noButton).queryByRole('img')).not.toBeInTheDocument(); + const verifyAvailability = screen.getByLabelText('Verify, 4 participants available'); + expect(within(verifyAvailability).getByTitle('Current viewer')).toBeInTheDocument(); + expect(within(verifyAvailability).getByTitle('Other participant')).toBeInTheDocument(); + expect(within(verifyAvailability).getByText('+2')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Verify' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Dispute' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Leave debate' })); + expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); - it('keeps position controls disabled while joining, unpublished, or matched', () => { - const publishedClaim: Entity = { - id: 'claim-1', - name: 'Public transit should be free', - description: null, - spaces: ['space-1'], - types: [{ id: CLAIM_TYPE_ID, name: 'Claim' }], - values: [], - relations: [], - }; - claims = [publishedClaim]; + it('keeps readiness disabled while joining, unpublished, or matched', () => { + const published = publishedClaim(); + claims = [published]; debateClaimsResponse = { - claims: [ - { - claim_entity_id: 'claim-1', - viewer_waiting_position: null, - online_choices: [], - active_match: null, - active_debate: null, - }, - ], + claims: [debateClaim()], }; joinPending = true; const { rerender } = render(); - expect(screen.getByRole('button', { name: 'Yes, 0 participants available' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'No, 0 participants available' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Join debate' })).toBeDisabled(); joinPending = false; claims = [ { - ...publishedClaim, + ...published, relations: [ { type: { id: 'local-change', name: 'Local change' }, @@ -260,25 +220,48 @@ describe('ClaimsPageClient', () => { rerender(); expect(screen.getByText('Publish this claim before starting a debate.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Yes, 0 participants available' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'No, 0 participants available' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); - claims = [publishedClaim]; + claims = [published]; debateClaimsResponse = { - claims: [ - { - claim_entity_id: 'claim-1', - viewer_waiting_position: null, - online_choices: [], - active_match: { id: 'match-1' }, - active_debate: null, - }, - ], + claims: [debateClaim({ active_match: { id: 'match-1' } })], }; rerender(); expect(screen.getByText('Match found. Both speakers need to accept.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Yes, 0 participants available' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'No, 0 participants available' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Join debate' })).toBeDisabled(); }); }); + +function publishedClaim(): Entity { + return { + id: 'claim-1', + name: 'Public transit should be free', + description: null, + spaces: ['space-1'], + types: [{ id: CLAIM_TYPE_ID, name: 'Claim' }], + values: [], + relations: [], + }; +} + +function debateClaim(overrides: Record = {}) { + return { + id: 'debate-claim-1', + space_id: 'space-1', + claim_entity_id: 'claim-1', + claim: 'Public transit should be free', + description: null, + response_kind: 'stance', + viewer_response: { position: true, position_label: 'Agree' }, + viewer_debate_ready: false, + readiness_disabled_reason: null, + readiness_changed_at: null, + online_choices: [], + active_match: null, + active_debate: null, + created_at: '2026-08-06T00:00:00.000Z', + updated_at: '2026-08-06T00:00:00.000Z', + ...overrides, + }; +} diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx index b8b3a9af39..7346f687cf 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx @@ -10,17 +10,16 @@ import { useRouter } from 'next/navigation'; import { buildClaimDraft } from '~/core/claims/claim-draft'; import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID, TOPIC_TYPE_ID } from '~/core/claims/ontology'; import { isClaimPublished } from '~/core/claims/publish'; -import type { DebateClaim, DebateOnlineChoice } from '~/core/debates/api'; -import { useDebateClaims, useJoinDebateQueue } from '~/core/debates/hooks'; +import type { DebateClaim } from '~/core/debates/api'; +import { ClaimDebateReadiness } from '~/core/debates/claim-debate-readiness'; +import { useDebateClaims } from '~/core/debates/hooks'; import { useDiff } from '~/core/state/diff-store'; import { useDebatesEnabled } from '~/core/state/feature-flags'; import { useMutate } from '~/core/sync/use-mutate'; import { useQueryEntities } from '~/core/sync/use-store'; import type { Entity, Relation } from '~/core/types'; -import { Avatar } from '~/design-system/avatar'; import { Button } from '~/design-system/button'; -import { Check } from '~/design-system/icons/check'; import { Plus } from '~/design-system/icons/plus'; import { SelectEntityCompact, type SelectEntityCompactResult } from '~/design-system/select-entity-compact'; import { Text } from '~/design-system/text'; @@ -295,19 +294,8 @@ function ClaimListItem({ }) { const topics = relationsForProperty(claim.relations, TOPICS_PROPERTY_ID); const published = isClaimPublished(claim); - const joinQueue = useJoinDebateQueue(spaceId); const activeMatch = debateClaim?.active_match ?? null; const activeDebate = debateClaim?.active_debate ?? null; - const mutationError = joinQueue.error instanceof Error ? joinQueue.error.message : null; - - const joinPosition = (position: boolean) => { - joinQueue.mutate({ - claimId: claim.id, - request: { - position, - }, - }); - }; return (
@@ -323,20 +311,19 @@ function ClaimListItem({ )}
- - - {debatesEnabled && ( - + {debatesEnabled && published && ( + )} + {debatesEnabled && } + {topics.length > 0 && (
@@ -346,129 +333,7 @@ function ClaimListItem({ ); } -function PositionButtonGroup({ - debatesEnabled, - canJoinDebate, - pendingPosition, - onlineChoices, - joinPending, - onJoinPosition, - className, -}: { - debatesEnabled: boolean; - canJoinDebate: boolean; - pendingPosition: boolean | null; - onlineChoices: DebateOnlineChoice[]; - joinPending: boolean; - onJoinPosition: (position: boolean) => void; - className?: string; -}) { - const positions = [ - { label: 'Yes', value: true }, - { label: 'No', value: false }, - ]; - - return ( -
- - Position - -
- {positions.map(position => { - const choice = onlineChoices.find(choice => choice.position === position.value); - const label = choice?.position_label ?? position.label; - const participantCount = choice?.participant_count ?? 0; - const selected = pendingPosition === position.value; - const accessibleLabel = `${label}, ${participantCount} participant${participantCount === 1 ? '' : 's'} available${selected ? ', selected' : ''}`; - - if (debatesEnabled) { - return ( - - ); - } - - return ( - - {label} - - ); - })} -
-
- ); -} - -function OnlineChoiceParticipants({ choice }: { choice: DebateOnlineChoice }) { - const participants = choice.participants.slice(0, 2); - const overflowCount = Math.max(0, choice.participant_count - participants.length); - - if (participants.length === 0 && overflowCount === 0) return null; - - return ( - - ); -} - -function ClaimDebateStatus({ - debateClaim, - mutationError, - published, -}: { - debateClaim: DebateClaim | null; - mutationError: string | null; - published: boolean; -}) { - if (mutationError) { - return ( - - {mutationError} - - ); - } - +function ClaimDebateStatus({ debateClaim, published }: { debateClaim: DebateClaim | null; published: boolean }) { if (!published) return null; if (debateClaim?.active_debate) { @@ -487,14 +352,6 @@ function ClaimDebateStatus({ ); } - if (debateClaim?.viewer_waiting_position !== null && debateClaim?.viewer_waiting_position !== undefined) { - return ( - - Waiting for someone with the opposite position. - - ); - } - return null; } diff --git a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx index 09a2b438f2..9597ef1dc4 100644 --- a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx @@ -3136,6 +3136,7 @@ function completedDebate(): Debate { description: null, }, status: 'complete', + response_kind: null, room_name: 'geo-debate-debate-1', first_participant_slot: 1, current_turn_index: 0, diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx index 6bf126f8a4..bd1da77b37 100644 --- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx @@ -138,6 +138,7 @@ function completedDebate(): Debate { description: null, }, status: 'complete', + response_kind: null, room_name: 'debate-1', first_participant_slot: 1, current_turn_index: 1, diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 0f59b1eaf4..8872f14bad 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -36,7 +36,6 @@ vi.mock('~/core/debates/hooks', () => ({ error: null, }), useDebate: () => ({ data: { claim: { claim_entity_id: 'claim-source' } } }), - useUpdateDebateRematchPosition: () => mutation(), useCreateDebateRematchRequest: () => mutation(), useLeaveDebateRematch: () => mutation(), useAcceptDebateRematchRequest: () => mutation(mocks.acceptMutate), @@ -64,6 +63,10 @@ vi.mock('~/core/sync/use-store', () => ({ }), })); +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: () => , +})); + function mutation(mutate = mocks.mutate) { return { mutate, mutateAsync: mutate, isPending: false, error: null }; } @@ -120,14 +123,35 @@ describe('DebateRematchPageClient', () => { expect(screen.getAllByRole('button', { name: 'Request debate' })[0]).toBeEnabled(); }); - it('shows each side its holder avatar in the claim position controls', () => { + it('shows backend response labels and holder avatars without position-update controls', () => { render(); const sharedClaimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article'); expect(sharedClaimCard).not.toBeNull(); - // Local picked Yes and the opponent picked No, so each pill carries one avatar. - expect(within(sharedClaimCard!).getByRole('button', { name: 'Yes' }).querySelector('img, svg')).not.toBeNull(); - expect(within(sharedClaimCard!).getByRole('button', { name: 'No' }).querySelector('img, svg')).not.toBeNull(); + expect(within(sharedClaimCard!).getByLabelText('Agree response').querySelector('img, svg')).not.toBeNull(); + expect(within(sharedClaimCard!).getByLabelText('Disagree response').querySelector('img, svg')).not.toBeNull(); + expect(within(sharedClaimCard!).queryByRole('button', { name: 'Agree' })).not.toBeInTheDocument(); + expect(within(sharedClaimCard!).queryByRole('button', { name: 'Disagree' })).not.toBeInTheDocument(); + }); + + it('shows response controls when the active responses are missing or on the same side', () => { + mocks.claims = [ + { + ...sharedClaim(), + participants: [ + { user_id: 'user-local', position: true, position_label: 'Agree' }, + { user_id: 'user-remote', position: true, position_label: 'Agree' }, + ], + }, + ]; + + render(); + + expect( + screen.getByText('You both have the same response. Change yours to request this debate.') + ).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Change response' }).length).toBeGreaterThan(0); + expect(screen.getByText('Respond before requesting')).toBeInTheDocument(); }); it('shows an incoming request in the shared dialog and preserves rematch actions', () => { @@ -140,7 +164,10 @@ describe('DebateRematchPageClient', () => { requester_user_id: 'user-remote', recipient_user_id: 'user-local', requester_position: false, + requester_position_label: 'No', recipient_position: true, + recipient_position_label: 'Yes', + response_kind: null, turn_format_id: 'standard', created_at: '2026-07-10T10:00:00.000Z', expires_at: '2026-07-10T10:02:00.000Z', @@ -173,7 +200,7 @@ describe('DebateRematchPageClient', () => { expect(document.documentElement.style.overflow).toBe(''); }); - it('disables every claim card while a rematch request is pending', () => { + it('disables debate requests while a rematch request is pending', () => { mocks.session = session({ status: 'request_pending', request: { @@ -183,7 +210,10 @@ describe('DebateRematchPageClient', () => { requester_user_id: 'user-local', recipient_user_id: 'user-remote', requester_position: true, + requester_position_label: 'Agree', recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', turn_format_id: 'standard', created_at: '2026-07-10T10:00:00.000Z', expires_at: '2026-07-10T10:02:00.000Z', @@ -193,9 +223,7 @@ describe('DebateRematchPageClient', () => { render(); expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled(); - expect(screen.getAllByRole('button', { name: /^(Yes|No)$/ }).every(button => button.hasAttribute('disabled'))).toBe( - true - ); + expect(screen.queryByRole('button', { name: /^(Agree|Disagree)$/ })).not.toBeInTheDocument(); }); it('filters to opponent-committed claims on the Debate now tab', () => { @@ -217,7 +245,7 @@ describe('DebateRematchPageClient', () => { fireEvent.click(screen.getByRole('button', { name: /Debate now/ })); - expect(screen.getByText(/Salina hasn't picked a side yet/)).toBeInTheDocument(); + expect(screen.getByText(/Salina hasn't responded yet/)).toBeInTheDocument(); }); it('narrows the list to the selected topic', () => { @@ -278,9 +306,10 @@ function session(overrides: Partial = {}): DebateRematchSe function sharedClaim(): DebateRematchClaim { return { claim: claimSummary('claim-shared', 'A claim both participants chose'), + response_kind: 'stance', participants: [ - { user_id: 'user-local', position: true }, - { user_id: 'user-remote', position: false }, + { user_id: 'user-local', position: true, position_label: 'Agree' }, + { user_id: 'user-remote', position: false, position_label: 'Disagree' }, ], shared_preference: true, recently_rejected: false, diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 8df38d75bf..50b8c5c4fc 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -14,6 +14,7 @@ import { type DebateRematchSession, getCurrentGeoChatUserId, } from '~/core/debates/api'; +import { DebateEntityResponseControls } from '~/core/debates/debate-entity-response-controls'; import { DebateRequestDialog } from '~/core/debates/debate-request-dialog'; import { defaultDebateFormatId } from '~/core/debates/formats'; import { @@ -24,7 +25,6 @@ import { useDebateRematchClaims, useLeaveDebateRematch, useRejectDebateRematchRequest, - useUpdateDebateRematchPosition, } from '~/core/debates/hooks'; import { useQueryEntities } from '~/core/sync/use-store'; @@ -63,7 +63,6 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const publishedClaimIds = React.useMemo(() => publishedClaims.map(claim => claim.id), [publishedClaims]); const savedClaimsQuery = useDebateRematchClaims(sessionId); const publishedClaimsQuery = useDebateRematchClaims(sessionId, publishedClaimIds); - const updatePosition = useUpdateDebateRematchPosition(sessionId); const createRequest = useCreateDebateRematchRequest(sessionId); const leaveSession = useLeaveDebateRematch(sessionId); const acceptRequest = useAcceptDebateRematchRequest(); @@ -98,9 +97,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { claim: claim.name!, description: claim.description, }, + response_kind: null, participants: (session?.participants ?? []).map(participant => ({ user_id: participant.user_id, position: null, + position_label: null, })), shared_preference: false, recently_rejected: false, @@ -149,7 +150,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const [tab, setTab] = React.useState<'all' | 'debate-now'>('all'); const [topicFilter, setTopicFilter] = React.useState(''); - // "Debate now" = claims the opponent has taken a position on; the tab badge counts them. + // "Debate now" = claims the opponent has responded to; the tab badge counts them. const opponentPositionCount = React.useMemo( () => claims.filter(claim => opponentPositionOf(claim) !== null).length, [claims, opponentPositionOf] @@ -194,15 +195,15 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const incomingRequestParticipants = incomingRequest && session ? session.participants.map(participant => { - const position = - participant.user_id === incomingRequest.requester_user_id - ? incomingRequest.requester_position - : incomingRequest.recipient_position; + const requester = participant.user_id === incomingRequest.requester_user_id; + const position = requester ? incomingRequest.requester_position : incomingRequest.recipient_position; return { ...participant, position, - position_label: position ? 'Yes' : 'No', + position_label: requester + ? incomingRequest.requester_position_label + : incomingRequest.recipient_position_label, }; }) : []; @@ -259,17 +260,13 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { : publishedClaimsQuery.error?.message} )} - {(updatePosition.error instanceof Error || - createRequest.error instanceof Error || - leaveSession.error instanceof Error) && ( + {(createRequest.error instanceof Error || leaveSession.error instanceof Error) && ( - {updatePosition.error instanceof Error - ? updatePosition.error.message - : createRequest.error instanceof Error - ? createRequest.error.message - : leaveSession.error instanceof Error - ? leaveSession.error.message - : null} + {createRequest.error instanceof Error + ? createRequest.error.message + : leaveSession.error instanceof Error + ? leaveSession.error.message + : null} )} @@ -281,13 +278,6 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { topic={topicsByClaimId.get(claim.claim.claim_entity_id)?.[0] ?? null} session={session} currentUserId={currentUserId} - onPositionChange={position => - updatePosition.mutate({ - claimId: claim.claim.claim_entity_id, - position, - sourceSpaceId: claim.claim.space_id, - }) - } onRequest={() => createRequest.mutate({ source_space_id: claim.claim.space_id, @@ -295,7 +285,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { format_id: defaultDebateFormatId, }) } - busy={updatePosition.isPending || createRequest.isPending || session?.status === 'request_pending'} + busy={createRequest.isPending || session?.status === 'request_pending'} /> ))}
@@ -304,7 +294,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
{tab === 'debate-now' - ? `${remoteName} hasn't picked a side yet. When they do, those claims show up here.` + ? `${remoteName} hasn't responded yet. When they do, those claims show up here.` : topicFilter ? 'No claims match this topic.' : 'No other eligible claims are available yet.'} @@ -354,7 +344,6 @@ function RematchClaimCard({ topic, session, currentUserId, - onPositionChange, onRequest, busy, }: { @@ -362,13 +351,22 @@ function RematchClaimCard({ topic: string | null; session: DebateRematchSession | null; currentUserId: string | null; - onPositionChange: (position: boolean) => void; onRequest: () => void; busy: boolean; }) { - const localPosition = claim.participants.find(position => position.user_id === currentUserId)?.position ?? null; - const remotePosition = claim.participants.find(position => position.user_id !== currentUserId)?.position ?? null; + const localResponse = claim.participants.find(position => position.user_id === currentUserId) ?? null; + const remoteResponse = claim.participants.find(position => position.user_id !== currentUserId) ?? null; + const localPosition = localResponse?.position ?? null; + const remotePosition = remoteResponse?.position ?? null; const opposing = localPosition !== null && remotePosition !== null && localPosition !== remotePosition; + const responsePrompt = + localPosition === null + ? 'Respond before requesting' + : remotePosition === null + ? 'Waiting for the other participant to respond.' + : localPosition === remotePosition + ? 'You both have the same response. Change yours to request this debate.' + : null; const request = session?.request; const requesting = session?.status === 'request_pending' && request?.claim.claim_entity_id === claim.claim.claim_entity_id; @@ -377,7 +375,7 @@ function RematchClaimCard({
- {topic ?? (claim.shared_preference ? 'You both picked a side' : 'More claims')} + {topic ?? (claim.shared_preference ? 'You both responded' : 'More claims')} {claim.recently_rejected && ( @@ -391,7 +389,11 @@ function RematchClaimCard({
{[true, false].map(position => { - const localOnPosition = localPosition === position; + const positionSnapshot = claim.participants.find( + participant => participant.position === position && participant.position_label + ); + const positionLabel = + positionSnapshot?.position_label ?? fallbackRematchPositionLabel(claim.response_kind, position); const holders = session ? claim.participants .filter(participant => participant.position === position) @@ -399,21 +401,27 @@ function RematchClaimCard({ .filter((participant): participant is DebateRematchParticipant => Boolean(participant)) : []; return ( - +
); })}
+ {responsePrompt && ( +
+ + {responsePrompt} + + +
+ )} + {(opposing || requesting) && ( - ); -} - -function ChoiceAvatars({ choice }: { choice: DebateOnlineChoice }) { - const participants = choice.participants.slice(0, 2); - const overflow = Math.max(0, choice.participant_count - participants.length); - return ( - - ); -} diff --git a/apps/web/core/debates/claim-debate-button.test.tsx b/apps/web/core/debates/claim-debate-button.test.tsx index 7bbc39f5ae..4702828141 100644 --- a/apps/web/core/debates/claim-debate-button.test.tsx +++ b/apps/web/core/debates/claim-debate-button.test.tsx @@ -31,6 +31,10 @@ vi.mock('./hooks', () => ({ useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), })); +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: () =>
Entity response buttons
, +})); + beforeEach(() => { mocks.debatesEnabled.mockReturnValue(true); mocks.debateClaims.mockReturnValue({ data: { claims: [] } }); @@ -50,11 +54,21 @@ function entity(relations: Entity['relations'], types: { id: string }[] = [{ id: function debateClaim(overrides: Partial = {}): DebateClaim { return { + id: 'debate-claim-1', + space_id: 'space-1', claim_entity_id: 'claim-entity-1', + claim: 'A claim', + description: null, + response_kind: 'stance', + viewer_response: null, + viewer_debate_ready: false, + readiness_disabled_reason: null, + readiness_changed_at: null, online_choices: [], - viewer_waiting_position: null, active_match: null, active_debate: null, + created_at: '2026-08-06T00:00:00.000Z', + updated_at: '2026-08-06T00:00:00.000Z', ...overrides, } as unknown as DebateClaim; } @@ -72,39 +86,45 @@ describe('ClaimDebateButton', () => { expect(screen.queryByRole('button', { name: 'Debate' })).not.toBeInTheDocument(); }); - it('prompts to publish and disables the positions for an unpublished claim', () => { + it('prompts to publish without rendering readiness controls for an unpublished claim', () => { render(); openPopover(); expect(screen.getByText('Publish this claim before starting a debate.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Yes,/ })).toBeDisabled(); - expect(screen.getByRole('button', { name: /^No,/ })).toBeDisabled(); + expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); }); - it('enables the positions for a published claim and joins the queue on click', () => { + it('requires a claim response before showing the readiness toggle', () => { mocks.debateClaims.mockReturnValue({ data: { claims: [debateClaim()] } }); render(); openPopover(); - const yes = screen.getByRole('button', { name: /^Yes,/ }); - expect(yes).toBeEnabled(); - expect(yes).toHaveAttribute('aria-pressed', 'false'); - - fireEvent.click(yes); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-entity-1', request: { position: true } }); + expect(screen.getByText('Respond before joining')).toBeInTheDocument(); + expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); }); - it('marks the chosen position selected while waiting and leaves the queue when clicked again', () => { - mocks.debateClaims.mockReturnValue({ data: { claims: [debateClaim({ viewer_waiting_position: true })] } }); + it('shows one readiness toggle for the backend response and leaves when ready', () => { + mocks.debateClaims.mockReturnValue({ + data: { + claims: [ + debateClaim({ + viewer_response: { position: true, position_label: 'Agree' }, + viewer_debate_ready: true, + }), + ], + }, + }); render(); openPopover(); - const yes = screen.getByRole('button', { name: /^Yes,.*selected/ }); - expect(yes).toHaveAttribute('aria-pressed', 'true'); - expect(yes).toBeEnabled(); - expect(screen.getByText('Waiting for someone with the opposite position.')).toBeInTheDocument(); + expect(screen.getByText('Your response: Agree')).toBeInTheDocument(); + const leave = screen.getByRole('button', { name: 'Leave debate' }); + expect(leave).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByRole('button', { name: /debate/i })).toHaveLength(2); + expect(screen.getByText('Waiting for someone with the opposite response.')).toBeInTheDocument(); - fireEvent.click(yes); + fireEvent.click(leave); expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-entity-1' }); expect(mocks.joinMutate).not.toHaveBeenCalled(); }); diff --git a/apps/web/core/debates/claim-debate-button.tsx b/apps/web/core/debates/claim-debate-button.tsx index 6d0784531c..cca42be93f 100644 --- a/apps/web/core/debates/claim-debate-button.tsx +++ b/apps/web/core/debates/claim-debate-button.tsx @@ -12,12 +12,11 @@ import { useDebatesEnabled } from '~/core/state/feature-flags'; import { useQueryEntity } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; -import { Avatar } from '~/design-system/avatar'; -import { Check } from '~/design-system/icons/check'; import { Text } from '~/design-system/text'; -import type { DebateClaim, DebateOnlineChoice } from './api'; -import { useDebateActivity, useDebateClaims, useJoinDebateQueue, useLeaveDebateQueue } from './hooks'; +import type { DebateClaim } from './api'; +import { ClaimDebateReadiness } from './claim-debate-readiness'; +import { useDebateActivity, useDebateClaims } from './hooks'; type ClaimDebateButtonProps = { entityId: string; @@ -30,11 +29,6 @@ type ClaimDebateButtonProps = { entity?: Entity | null; }; -const positions = [ - { label: 'Yes', value: true }, - { label: 'No', value: false }, -] as const; - export function ClaimDebateButton({ entityId, spaceId, entity: providedEntity }: ClaimDebateButtonProps) { const isDebatesEnabled = useDebatesEnabled(); @@ -55,31 +49,11 @@ export function ClaimDebateButton({ entityId, spaceId, entity: providedEntity }: const activity = activityQuery.data ?? null; const hasActiveFlowElsewhere = Boolean(activity?.match || activity?.debate || activity?.rematch); - const joinQueue = useJoinDebateQueue(spaceId); - const leaveQueue = useLeaveDebateQueue(spaceId); - if (!isDebatesEnabled || !isClaim) return null; const activeMatch = debateClaim?.active_match ?? null; const activeDebate = debateClaim?.active_debate ?? null; const canJoinDebate = published && !activeDebate && !activeMatch && !hasActiveFlowElsewhere; - const isMutating = joinQueue.isPending || leaveQueue.isPending; - const mutationError = - joinQueue.error instanceof Error - ? joinQueue.error.message - : leaveQueue.error instanceof Error - ? leaveQueue.error.message - : null; - - // Clicking the position you're already waiting on leaves the queue. - const togglePosition = (position: boolean) => { - if (debateClaim?.viewer_waiting_position === position) { - leaveQueue.mutate({ claimId: entityId }); - } else { - joinQueue.mutate({ claimId: entityId, request: { position } }); - } - }; - return ( @@ -105,98 +79,27 @@ export function ClaimDebateButton({ entityId, spaceId, entity: providedEntity }: Debate this claim - Select your position and start matchmaking + Respond to the claim, then choose whether you’re ready to debate. -
- {positions.map(position => { - const choice = debateClaim?.online_choices.find(choice => choice.position === position.value) ?? null; - const label = choice?.position_label ?? position.label; - const participantCount = choice?.participant_count ?? 0; - const selected = debateClaim?.viewer_waiting_position === position.value; - const accessibleLabel = `${label}, ${participantCount} participant${participantCount === 1 ? '' : 's'} available${selected ? ', selected' : ''}`; - - return ( - - ); - })} -
- - + {published && ( + + )} + +
); } -function OnlineChoiceParticipants({ choice }: { choice: DebateOnlineChoice }) { - const participants = choice.participants.slice(0, 2); - const overflowCount = Math.max(0, choice.participant_count - participants.length); - - if (participants.length === 0 && overflowCount === 0) return null; - - return ( - - ); -} - -function ClaimDebateStatus({ - debateClaim, - mutationError, - published, -}: { - debateClaim: DebateClaim | null; - mutationError: string | null; - published: boolean; -}) { - if (mutationError) { - return ( - - {mutationError} - - ); - } - +function ClaimDebateStatus({ debateClaim, published }: { debateClaim: DebateClaim | null; published: boolean }) { if (!published) { return ( @@ -221,13 +124,5 @@ function ClaimDebateStatus({ ); } - if (debateClaim?.viewer_waiting_position !== null && debateClaim?.viewer_waiting_position !== undefined) { - return ( - - Waiting for someone with the opposite position. - - ); - } - return null; } diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx new file mode 100644 index 0000000000..01f2d448e6 --- /dev/null +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -0,0 +1,126 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DebateClaim } from './api'; +import { ClaimDebateReadiness } from './claim-debate-readiness'; + +const mocks = vi.hoisted(() => ({ + joinMutate: vi.fn(), + leaveMutate: vi.fn(), +})); + +vi.mock('./hooks', () => ({ + useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: false, error: null }), + useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), +})); + +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: () =>
Entity response buttons
, +})); + +beforeEach(() => { + mocks.joinMutate.mockReset(); + mocks.leaveMutate.mockReset(); +}); + +afterEach(cleanup); + +describe('ClaimDebateReadiness', () => { + it('prompts for a response without rendering an inert readiness toggle', () => { + renderReadiness(claim({ viewer_response: null })); + + expect(screen.getByText('Respond before joining')).toBeInTheDocument(); + expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); + }); + + it('renders one readiness toggle after a response and joins without a position body', () => { + renderReadiness( + claim({ + viewer_response: { position: false, position_label: 'Disagree' }, + }) + ); + + expect(screen.getByText('Your response: Disagree')).toBeInTheDocument(); + const toggle = screen.getByRole('button', { name: 'Join debate' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + + fireEvent.click(toggle); + + expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + }); + + it('leaves readiness through the same toggle', () => { + renderReadiness(claim({ viewer_debate_ready: true })); + + fireEvent.click(screen.getByRole('button', { name: 'Leave debate' })); + + expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + expect(mocks.joinMutate).not.toHaveBeenCalled(); + }); + + it.each([ + ['stance', 'Agree', 'Disagree'], + ['veracity', 'Verify', 'Dispute'], + ] as const)('falls back to %s response labels', (responseKind, positiveLabel, negativeLabel) => { + renderReadiness( + claim({ + response_kind: responseKind, + online_choices: [choice(true, ''), choice(false, '')], + }) + ); + + expect(screen.getByText(positiveLabel)).toBeInTheDocument(); + expect(screen.getByText(negativeLabel)).toBeInTheDocument(); + }); + + it('shows the backend readiness-disabled reason after a refetch', () => { + renderReadiness(claim({ readiness_disabled_reason: 'Your response changed, so debate readiness was removed.' })); + + expect(screen.getByText('Your response changed, so debate readiness was removed.')).toBeInTheDocument(); + }); +}); + +function renderReadiness(debateClaim: DebateClaim) { + return render( + + ); +} + +function claim(overrides: Partial = {}): DebateClaim { + return { + id: 'debate-claim-1', + space_id: 'space-1', + claim_entity_id: 'claim-1', + claim: 'A claim', + description: null, + response_kind: 'stance', + viewer_response: { position: true, position_label: 'Agree' }, + viewer_debate_ready: false, + readiness_disabled_reason: null, + readiness_changed_at: null, + online_choices: [], + active_match: null, + active_debate: null, + created_at: '2026-08-06T00:00:00.000Z', + updated_at: '2026-08-06T00:00:00.000Z', + ...overrides, + }; +} + +function choice(position: boolean, positionLabel: string) { + return { + position, + position_label: positionLabel, + participant_count: 0, + participants: [], + }; +} diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx new file mode 100644 index 0000000000..e91e651b5a --- /dev/null +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -0,0 +1,154 @@ +'use client'; + +import cx from 'classnames'; + +import { Avatar } from '~/design-system/avatar'; +import { Text } from '~/design-system/text'; + +import type { DebateClaim, DebateOnlineChoice, DebateResponseKind } from './api'; +import { DebateEntityResponseControls } from './debate-entity-response-controls'; +import { useJoinDebateQueue, useLeaveDebateQueue } from './hooks'; + +type ClaimDebateReadinessProps = { + debateClaim: DebateClaim | null; + entityId: string; + spaceId: string; + canToggle: boolean; + className?: string; + textVariant?: 'metadata' | 'body'; +}; + +export function ClaimDebateReadiness({ + debateClaim, + entityId, + spaceId, + canToggle, + className, + textVariant = 'metadata', +}: ClaimDebateReadinessProps) { + const joinQueue = useJoinDebateQueue(spaceId); + const leaveQueue = useLeaveDebateQueue(spaceId); + + if (!debateClaim) return null; + + const isReady = debateClaim.viewer_debate_ready; + const isPending = joinQueue.isPending || leaveQueue.isPending; + const mutationError = + joinQueue.error instanceof Error + ? joinQueue.error.message + : leaveQueue.error instanceof Error + ? leaveQueue.error.message + : null; + + return ( +
+ + + {debateClaim.viewer_response ? ( +
+
+ + Your response: {debateClaim.viewer_response.position_label} + + +
+ +
+ ) : ( +
+ + Respond before joining + + +
+ )} + + {isReady && ( + + Waiting for someone with the opposite response. + + )} + {(mutationError || debateClaim.readiness_disabled_reason) && ( + + {mutationError ?? debateClaim.readiness_disabled_reason} + + )} +
+ ); +} + +function OnlineChoices({ responseKind, choices }: { responseKind: DebateResponseKind; choices: DebateOnlineChoice[] }) { + return ( +
+ + Ready to debate + +
+ {[true, false].map(position => { + const choice = choices.find(candidate => candidate.position === position); + const participantCount = choice?.participant_count ?? 0; + const label = choice?.position_label || fallbackPositionLabel(responseKind, position); + + return ( +
+ {label} + {choice && } +
+ ); + })} +
+
+ ); +} + +function fallbackPositionLabel(responseKind: DebateResponseKind, position: boolean) { + if (responseKind === 'veracity') return position ? 'Verify' : 'Dispute'; + return position ? 'Agree' : 'Disagree'; +} + +function OnlineChoiceParticipants({ choice }: { choice: DebateOnlineChoice }) { + const participants = choice.participants.slice(0, 2); + const overflowCount = Math.max(0, choice.participant_count - participants.length); + + if (participants.length === 0 && overflowCount === 0) return null; + + return ( + + ); +} diff --git a/apps/web/core/debates/debate-coordinator.test.tsx b/apps/web/core/debates/debate-coordinator.test.tsx index b5d65557ff..fee9393cf5 100644 --- a/apps/web/core/debates/debate-coordinator.test.tsx +++ b/apps/web/core/debates/debate-coordinator.test.tsx @@ -743,6 +743,7 @@ function activityWithMatch(): DebateActivity { match: { id: 'match-1', status: 'pending', + response_kind: null, claim: { id: 'claim-1', space_id: 'space-1', diff --git a/apps/web/core/debates/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx new file mode 100644 index 0000000000..72a17d8bb9 --- /dev/null +++ b/apps/web/core/debates/debate-entity-response-controls.test.tsx @@ -0,0 +1,22 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DebateEntityResponseControls } from './debate-entity-response-controls'; + +vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ + EntityVoteButtons: ({ showProcessingLabel }: { showProcessingLabel?: boolean }) => ( + {showProcessingLabel ? 'Visible processing label enabled' : 'Hidden processing label'} + ), +})); + +afterEach(cleanup); + +describe('DebateEntityResponseControls', () => { + it('requests a visible response-processing state in debate contexts', () => { + render(); + + expect(screen.getByText('Visible processing label enabled')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/core/debates/debate-entity-response-controls.tsx b/apps/web/core/debates/debate-entity-response-controls.tsx new file mode 100644 index 0000000000..91cd4d0aa3 --- /dev/null +++ b/apps/web/core/debates/debate-entity-response-controls.tsx @@ -0,0 +1,12 @@ +import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; + +export function DebateEntityResponseControls({ entityId, spaceId }: { entityId: string; spaceId: string }) { + return ( + + ); +} diff --git a/apps/web/core/debates/hooks.test.tsx b/apps/web/core/debates/hooks.test.tsx index 16bf800f83..a6e7459b71 100644 --- a/apps/web/core/debates/hooks.test.tsx +++ b/apps/web/core/debates/hooks.test.tsx @@ -16,6 +16,8 @@ import { useConsentToDebateRematch, useDebate, useDebateActivity, + useDebateClaims, + useDebateRematchClaims, useEndDebateTurn, useGeoChatAuth, useLeaveDebateRematch, @@ -30,6 +32,8 @@ const mocks = vi.hoisted(() => ({ consentToDebateRematch: vi.fn(), endDebateTurn: vi.fn(), leaveDebateRematch: vi.fn(), + listDebateClaims: vi.fn(), + listDebateRematchClaims: vi.fn(), listDebateSharePrompts: vi.fn(), markDebateReady: vi.fn(), pathname: '/space/space-1/debates/debate-1', @@ -69,6 +73,8 @@ vi.mock('./api', async importOriginal => { consentToDebateRematch: mocks.consentToDebateRematch, endDebateTurn: mocks.endDebateTurn, leaveDebateRematch: mocks.leaveDebateRematch, + listDebateClaims: mocks.listDebateClaims, + listDebateRematchClaims: mocks.listDebateRematchClaims, listDebateSharePrompts: mocks.listDebateSharePrompts, markDebateReady: mocks.markDebateReady, updateDebateAvailability: mocks.updateDebateAvailability, @@ -109,12 +115,16 @@ describe('useGeoChatAuth', () => { mocks.consentToDebateRematch.mockReset(); mocks.endDebateTurn.mockReset(); mocks.leaveDebateRematch.mockReset(); + mocks.listDebateClaims.mockReset(); + mocks.listDebateRematchClaims.mockReset(); mocks.listDebateSharePrompts.mockReset(); mocks.markDebateReady.mockReset(); mocks.push.mockReset(); mocks.back.mockReset(); mocks.pathname = '/space/space-1/debates/debate-1'; mocks.updateDebateAvailability.mockReset(); + mocks.listDebateClaims.mockResolvedValue({ claims: [] }); + mocks.listDebateRematchClaims.mockResolvedValue({ claims: [], excluded_claim_ids: [] }); mocks.listDebateSharePrompts.mockResolvedValue({ prompts: [] }); setCachedIdentityToken(null); }); @@ -179,6 +189,50 @@ describe('useGeoChatAuth', () => { expect(mocks.getIdentityToken).toHaveBeenCalledTimes(1); }); + it('refetches debate readiness when an indexed claim response query succeeds', async () => { + mocks.identityToken.mockReturnValue(null); + mocks.getIdentityToken.mockResolvedValue(null); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => useDebateClaims('space-1', ['claim-1'], true), { wrapper }); + await waitFor(() => expect(mocks.listDebateClaims).toHaveBeenCalled()); + invalidateQueries.mockClear(); + + act(() => { + queryClient.setQueryData(['user-entity-response', 'profile-1', 'claim-1', 'space-1', 0, 'stance'], 'positive'); + }); + + await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['debates', 'claims', 'space-1'] })); + }); + + it('refetches rematch snapshots when an indexed claim response query succeeds', async () => { + mocks.identityToken.mockReturnValue(null); + mocks.getIdentityToken.mockResolvedValue(null); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => useDebateRematchClaims('rematch-1', ['claim-1']), { wrapper }); + await waitFor(() => expect(mocks.listDebateRematchClaims).toHaveBeenCalled()); + invalidateQueries.mockClear(); + + act(() => { + queryClient.setQueryData(['user-entity-response', 'profile-1', 'claim-1', 'space-1', 0, 'veracity'], 'negative'); + }); + + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['debates', 'account', 'user-a', 'rematch', 'rematch-1', 'claims'], + }) + ); + }); + // A `users/me` sent before logout can resolve after it. Writing that result back would // repopulate the cache with the signed-out user's token, and nothing would clear it again // until it expired, because Privy's reactive token just stays null. diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index 3305686a29..bcc3b2bfec 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -7,8 +7,6 @@ import * as React from 'react'; import { getCachedIdentityToken, useIdentityTokenSync } from '~/core/auth/identity-token'; -import { useDebateAttention } from './debate-attention'; - import { type Debate, type DebateActivity, @@ -16,7 +14,6 @@ import { type DebateMediaProcessRequest, type DebateMediaResponse, GeoChatRequestError, - type JoinDebateQueueRequest, type LocalRecordingCompleteRequest, type LocalRecordingUploadRequest, type TranscriptFormat, @@ -56,9 +53,8 @@ import { requestDebateMediaProcessing, retryDebatePhaseBoundaryRequest, updateDebateAvailability, - updateDebatePreference, - updateDebateRematchPosition, } from './api'; +import { useDebateAttention } from './debate-attention'; import { useDebateGatewayScope } from './debate-gateway'; import { hasProcessedVideo } from './playback-utils'; @@ -100,10 +96,33 @@ export function useGeoChatAuth() { // claim in the space. geo-chat indexes them, so this skips the KG scan over all // the space's Claim entities that 504s on large spaces. export function useDebateClaims(spaceId: string, claimIds: string[] | null, enabled: boolean) { + const queryClient = useQueryClient(); const { accountKey, authenticated, getPrivyIdentityToken } = useGeoChatAuth(); const shouldFetch = enabled && (claimIds === null || claimIds.length > 0); useDebateGatewayScope({ scope: 'space', space_id: spaceId }, authenticated && shouldFetch); + React.useEffect( + function refetchReadinessAfterIndexedResponse() { + if (!shouldFetch) return; + + return queryClient.getQueryCache().subscribe(event => { + if (event.type !== 'updated' || event.action.type !== 'success') return; + const [scope, , responseEntityId, responseSpaceId, , responseKind] = event.query.queryKey; + if ( + scope !== 'user-entity-response' || + responseSpaceId !== spaceId || + (responseKind !== 'stance' && responseKind !== 'veracity') || + (claimIds !== null && !claimIds.includes(String(responseEntityId))) + ) { + return; + } + + void queryClient.invalidateQueries({ queryKey: ['debates', 'claims', spaceId] }); + }); + }, + [claimIds, queryClient, shouldFetch, spaceId] + ); + return useQuery({ ...debateQueryNetworkOptions, queryKey: debateQueryKeys.claims(spaceId, claimIds), @@ -124,8 +143,8 @@ export function useJoinDebateQueue(spaceId: string) { const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); return useMutation({ - mutationFn: ({ claimId, request }: { claimId: string; request: JoinDebateQueueRequest }) => - joinDebateQueue(spaceId, claimId, request, getPrivyIdentityToken, accountKey), + mutationFn: ({ claimId }: { claimId: string }) => + joinDebateQueue(spaceId, claimId, getPrivyIdentityToken, accountKey), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['debates'] }); }, @@ -145,17 +164,6 @@ export function useLeaveDebateQueue(spaceId: string) { }); } -export function useUpdateDebatePreference(spaceId: string) { - const queryClient = useQueryClient(); - const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); - - return useMutation({ - mutationFn: ({ claimId, position }: { claimId: string; position: boolean }) => - updateDebatePreference(spaceId, claimId, { position }, getPrivyIdentityToken, accountKey), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['debates'] }), - }); -} - export function useDebateActivity(enabled = true) { const queryClient = useQueryClient(); const { accountKey, authenticated, getPrivyIdentityToken } = useGeoChatAuth(); @@ -432,8 +440,32 @@ export function useLeaveDebateRematch(sessionId: string) { } export function useDebateRematchClaims(sessionId: string, claimIds: string[] = [], enabled = true) { + const queryClient = useQueryClient(); const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); + React.useEffect( + function refetchRematchClaimsAfterIndexedResponse() { + if (!enabled || !sessionId) return; + + return queryClient.getQueryCache().subscribe(event => { + if (event.type !== 'updated' || event.action.type !== 'success') return; + const [scope, , responseEntityId, , , responseKind] = event.query.queryKey; + if ( + scope !== 'user-entity-response' || + (responseKind !== 'stance' && responseKind !== 'veracity') || + (claimIds.length > 0 && !claimIds.includes(String(responseEntityId))) + ) { + return; + } + + void queryClient.invalidateQueries({ + queryKey: ['debates', 'account', accountKey, 'rematch', sessionId, 'claims'], + }); + }); + }, + [accountKey, claimIds, enabled, queryClient, sessionId] + ); + return useQuery({ ...debateQueryNetworkOptions, queryKey: debateQueryKeys.rematchClaims(accountKey, sessionId, claimIds), @@ -442,20 +474,6 @@ export function useDebateRematchClaims(sessionId: string, claimIds: string[] = [ }); } -export function useUpdateDebateRematchPosition(sessionId: string) { - const queryClient = useQueryClient(); - const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); - - return useMutation({ - mutationFn: ({ claimId, position, sourceSpaceId }: { claimId: string; position: boolean; sourceSpaceId: string }) => - updateDebateRematchPosition(sessionId, claimId, position, sourceSpaceId, getPrivyIdentityToken, accountKey), - onSuccess: () => - void queryClient.invalidateQueries({ - queryKey: ['debates', 'account', accountKey, 'rematch', sessionId, 'claims'], - }), - }); -} - export function useCreateDebateRematchRequest(sessionId: string) { const queryClient = useQueryClient(); const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); diff --git a/apps/web/core/debates/match-prompt.test.tsx b/apps/web/core/debates/match-prompt.test.tsx index 9241b955d7..9011c13c13 100644 --- a/apps/web/core/debates/match-prompt.test.tsx +++ b/apps/web/core/debates/match-prompt.test.tsx @@ -777,6 +777,7 @@ function match(): DebateMatch { return { id: 'match-1', status: 'pending', + response_kind: null, claim: { id: 'claim-1', space_id: 'space-1', @@ -831,6 +832,7 @@ function debate(): Debate { description: null, }, status: 'ready', + response_kind: null, room_name: 'geo-debate-debate-1', first_participant_slot: 1, current_turn_index: 0, diff --git a/apps/web/core/debates/response-derived-contract.test.ts b/apps/web/core/debates/response-derived-contract.test.ts new file mode 100644 index 0000000000..9cd4d845b9 --- /dev/null +++ b/apps/web/core/debates/response-derived-contract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import * as debateApi from './api'; +import * as debateHooks from './hooks'; + +describe('response-derived debate contract', () => { + it('does not expose deprecated position-update endpoints or hooks', () => { + expect(debateApi).not.toHaveProperty('updateDebatePreference'); + expect(debateApi).not.toHaveProperty('updateDebateRematchPosition'); + expect(debateHooks).not.toHaveProperty('useUpdateDebatePreference'); + expect(debateHooks).not.toHaveProperty('useUpdateDebateRematchPosition'); + }); +}); diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index c9725d51d3..cdd4e93abe 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -64,12 +64,14 @@ type EntityVoteButtonsProps = { entityId: string; spaceId: string; claimResponderAvatarsPosition?: 'leading' | 'trailing'; + showProcessingLabel?: boolean; }; export function EntityVoteButtons({ entityId, spaceId, claimResponderAvatarsPosition = 'leading', + showProcessingLabel = false, }: EntityVoteButtonsProps) { const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId }); const isClaim = @@ -88,12 +90,11 @@ export function EntityVoteButtons({ responseKind === 'curation' ? 'default' : responseKind === 'veracity' ? 'chevrons' : 'thumbs'; const responseCopy = ENTITY_RESPONSE_COPY[responseKind]; - const { - submitResponse, - isProcessingResponse, - isConnected, - personalSpaceId, - } = useEntityResponse({ entityId, spaceId, responseKind }); + const { submitResponse, isProcessingResponse, isConnected, personalSpaceId } = useEntityResponse({ + entityId, + spaceId, + responseKind, + }); const { smartAccount } = useSmartAccount(); const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); @@ -396,7 +397,11 @@ export function EntityVoteButtons({ {claimResponderAvatarsPosition === 'trailing' && claimResponderAvatars ? ( {claimResponderAvatars} ) : null} - {isProcessingResponse ? Processing response… : null} + {isProcessingResponse ? ( + + Processing response… + + ) : null}
); } @@ -423,10 +428,7 @@ function RespondersPopoverContent({ }); const responderSpaceIds = React.useMemo(() => responders?.map(responder => responder.userId) ?? [], [responders]); const { data: profiles, isLoading: isLoadingProfiles } = useQuery({ - queryKey: [ - ...entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), - responderSpaceIds, - ], + queryKey: [...entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), responderSpaceIds], enabled: responderSpaceIds.length > 0, queryFn: () => Effect.runPromise(fetchProfilesBySpaceIds(responderSpaceIds)), staleTime: 30_000, @@ -435,9 +437,10 @@ function RespondersPopoverContent({ if (responders?.length === 0) { respondersWithProfiles = []; } else if (responders && profiles) { - respondersWithProfiles = responders.map( - (responder, index): ResponderWithProfile => ({ ...responder, profile: profiles[index]! }) - ); + respondersWithProfiles = responders.map((responder, index): ResponderWithProfile => ({ + ...responder, + profile: profiles[index]!, + })); } const isLoading = isLoadingResponders || (responderSpaceIds.length > 0 && isLoadingProfiles); From baf12d2a2e3ccfb7026134e1ebc12a83aeb4812a Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 00:17:17 +0200 Subject: [PATCH 04/15] test(debates): support response cache subscriptions --- .../core/debates/hooks-query-network.test.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index 4c3d90a020..ac11878376 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -2,25 +2,30 @@ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GeoChatRequestError } from './api'; import { useCreateDebateChallenge, useDebate, useDebateActivity, useDebateClaims, useDebateMedia, + useDebateProfile, useDebateRematch, useDebateRematchClaims, - useDebateProfile, useDebateSharePrompts, useDebateTranscript, useSpaceDebates, } from './hooks'; -import { GeoChatRequestError } from './api'; const mocks = vi.hoisted(() => ({ authenticated: true, attention: true, - queryClient: { invalidateQueries: vi.fn(), setQueryData: vi.fn() }, + queryCache: { subscribe: vi.fn(() => vi.fn()) }, + queryClient: { + getQueryCache: vi.fn(() => mocks.queryCache), + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + }, queryRefetch: vi.fn(), useMutation: vi.fn((options: unknown) => options), useQuery: vi.fn((options: unknown) => ({ options, refetch: mocks.queryRefetch })), @@ -55,6 +60,8 @@ beforeEach(() => { mocks.authenticated = true; mocks.attention = true; mocks.queryClient.invalidateQueries.mockClear(); + mocks.queryClient.getQueryCache.mockClear(); + mocks.queryCache.subscribe.mockClear(); mocks.queryClient.setQueryData.mockClear(); mocks.queryRefetch.mockClear(); mocks.useMutation.mockClear(); @@ -103,10 +110,9 @@ describe('debate query network ownership', () => { onError(error: Error, request: { recipient_profile_space_id: string }): void; }; - mutation.onError( - new GeoChatRequestError('Unavailable', 'challenge_unavailable', 400), - { recipient_profile_space_id: 'profile-b' } - ); + mutation.onError(new GeoChatRequestError('Unavailable', 'challenge_unavailable', 400), { + recipient_profile_space_id: 'profile-b', + }); expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['debates', 'account', 'user-a', 'profile', 'profile-b'], From 6253d6133d87f642d141d2f327ca13b739a337e2 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 00:47:32 +0200 Subject: [PATCH 05/15] feat(debates): explain response lifecycle changes --- .../[sessionId]/rematch-page-client.test.tsx | 27 +++++++++++++++++++ .../[sessionId]/rematch-page-client.tsx | 20 ++++++++++++++ apps/web/core/debates/api.ts | 2 ++ .../debates/claim-debate-readiness.test.tsx | 6 +++++ .../core/debates/claim-debate-readiness.tsx | 15 ++++++++++- 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 8872f14bad..ef3747ffa9 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -226,6 +226,33 @@ describe('DebateRematchPageClient', () => { expect(screen.queryByRole('button', { name: /^(Agree|Disagree)$/ })).not.toBeInTheDocument(); }); + it('explains when response changes cancel a rematch request', () => { + mocks.session = session({ + request: { + id: 'request-1', + status: 'expired', + claim: claimSummary('claim-shared', 'A claim both participants chose'), + requester_user_id: 'user-local', + recipient_user_id: 'user-remote', + requester_position: true, + requester_position_label: 'Agree', + recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', + cancellation_reason: 'claim_response_position_changed', + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: '2026-07-10T10:02:00.000Z', + }, + }); + + render(); + + expect( + screen.getByText('This request was cancelled because the responses no longer oppose each other.') + ).toBeInTheDocument(); + }); + it('filters to opponent-committed claims on the Debate now tab', () => { render(); diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 50b8c5c4fc..91f80e2d9e 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -269,6 +269,11 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { : null} )} + {session?.request?.status === 'expired' && session.request.cancellation_reason && ( + + {rematchCancellationMessage(session.request.cancellation_reason)} + + )}
{visibleClaims.map(claim => ( @@ -442,6 +447,21 @@ function fallbackRematchPositionLabel(responseKind: DebateRematchClaim['response return null; } +function rematchCancellationMessage(reason: string) { + switch (reason) { + case 'claim_response_withdrawn': + return 'This request was cancelled because a participant withdrew their response.'; + case 'claim_response_kind_changed': + return 'This request was cancelled because the claim’s response type changed.'; + case 'claim_response_position_changed': + case 'claim_responses_not_opposed': + case 'claim_response_changed_during_accept': + return 'This request was cancelled because the responses no longer oppose each other.'; + default: + return 'This debate request is no longer available.'; + } +} + function PositionAvatars({ participants }: { participants: DebateRematchParticipant[] }) { return ( diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 1e023f4a7b..064d954701 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -27,6 +27,7 @@ export type DebateMatch = { id: string; status: DebateMatchStatus; response_kind: DebateResponseKind | null; + cancellation_reason?: string | null; claim: DebateClaimSummary; participants: DebateMatchParticipant[]; turn_format_id: string | null; @@ -258,6 +259,7 @@ export type DebateRematchRequest = { recipient_position: boolean; recipient_position_label: string; response_kind: DebateResponseKind | null; + cancellation_reason?: string | null; turn_format_id: string; created_at: string; expires_at: string; diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index 01f2d448e6..de18eb0d6a 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -81,6 +81,12 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByText('Your response changed, so debate readiness was removed.')).toBeInTheDocument(); }); + + it('turns backend lifecycle codes into actionable readiness messages', () => { + renderReadiness(claim({ readiness_disabled_reason: 'claim_response_kind_changed' })); + + expect(screen.getByText('This claim’s response type changed. Respond and join again.')).toBeInTheDocument(); + }); }); function renderReadiness(debateClaim: DebateClaim) { diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index e91e651b5a..36cee82745 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -83,13 +83,26 @@ export function ClaimDebateReadiness({ )} {(mutationError || debateClaim.readiness_disabled_reason) && ( - {mutationError ?? debateClaim.readiness_disabled_reason} + {mutationError ?? readinessReasonMessage(debateClaim.readiness_disabled_reason)} )}
); } +function readinessReasonMessage(reason: string | null) { + switch (reason) { + case 'claim_response_withdrawn': + return 'Your response was withdrawn, so debate readiness was turned off.'; + case 'claim_response_kind_changed': + return 'This claim’s response type changed. Respond and join again.'; + case 'claim_response_validation_failed': + return 'Your response could not be verified yet. You’ll remain ready while verification retries.'; + default: + return reason; + } +} + function OnlineChoices({ responseKind, choices }: { responseKind: DebateResponseKind; choices: DebateOnlineChoice[] }) { return (
From 18f46e8aeda151a561ff89f76c9ab77862e51b44 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 01:46:42 +0200 Subject: [PATCH 06/15] fix(review): keep debate responses synchronized --- .../claims/claims-page-client.test.tsx | 4 + .../[sessionId]/rematch-page-client.test.tsx | 57 ++++++- .../[sessionId]/rematch-page-client.tsx | 19 ++- apps/web/core/debates/api.ts | 6 +- .../debates/browse/join-debate-panel.test.tsx | 4 + .../core/debates/claim-debate-button.test.tsx | 4 + .../debates/claim-debate-readiness.test.tsx | 45 ++++- .../core/debates/claim-debate-readiness.tsx | 25 ++- .../debate-entity-response-controls.test.tsx | 23 ++- .../debate-entity-response-controls.tsx | 13 +- apps/web/core/hooks/use-entity-vote.test.tsx | 146 ++++++++++++++++ apps/web/core/hooks/use-entity-vote.ts | 159 +++++++++++++++--- .../core/responses/entity-response.test.ts | 33 ++++ apps/web/core/responses/entity-response.ts | 37 ++++ .../entity-page/entity-vote-buttons.tsx | 108 ++++++++---- 15 files changed, 594 insertions(+), 89 deletions(-) create mode 100644 apps/web/core/hooks/use-entity-vote.test.tsx diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx index 871a4cc171..c018eb7f36 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx @@ -34,6 +34,10 @@ vi.mock('~/core/state/feature-flags', () => ({ useDebatesEnabled: () => featureEnabled, })); +vi.mock('~/core/hooks/use-entity-vote', () => ({ + useEntityResponseIndexingState: () => 'idle', +})); + vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: debateClaimsResponse, error: null }), useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: joinPending, error: null }), diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index ef3747ffa9..f9c628e68a 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), acceptMutate: vi.fn(), rejectMutate: vi.fn(), + responseKinds: [] as Array<'stance' | 'veracity' | null>, })); vi.mock('next/navigation', () => ({ @@ -64,7 +65,10 @@ vi.mock('~/core/sync/use-store', () => ({ })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: () => , + EntityVoteButtons: ({ responseKind }: { responseKind: 'stance' | 'veracity' | null }) => { + mocks.responseKinds.push(responseKind); + return ; + }, })); function mutation(mutate = mocks.mutate) { @@ -76,6 +80,7 @@ beforeEach(() => { mocks.mutate.mockReset(); mocks.acceptMutate.mockReset(); mocks.rejectMutate.mockReset(); + mocks.responseKinds.length = 0; mocks.session = session(); mocks.claims = [sharedClaim()]; document.body.style.overflow = ''; @@ -150,11 +155,21 @@ describe('DebateRematchPageClient', () => { expect( screen.getByText('You both have the same response. Change yours to request this debate.') ).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Change response' }).length).toBeGreaterThan(0); - expect(screen.getByText('Respond before requesting')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Change response' })).toHaveLength(1); + const syntheticClaimCard = screen.getByRole('heading', { name: 'A newly published claim' }).closest('article'); + expect(syntheticClaimCard).not.toBeNull(); + expect(within(syntheticClaimCard!).getByText('Respond before requesting')).toBeInTheDocument(); + expect( + within(syntheticClaimCard!).getByText( + 'Response controls are unavailable while this claim is being prepared for rematches.' + ) + ).toBeInTheDocument(); + expect(within(syntheticClaimCard!).queryByRole('button', { name: 'Change response' })).not.toBeInTheDocument(); + expect(mocks.responseKinds).toContain('stance'); + expect(mocks.responseKinds).not.toContain(null); }); - it('shows an incoming request in the shared dialog and preserves rematch actions', () => { + it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', () => { mocks.session = session({ status: 'request_pending', request: { @@ -164,10 +179,10 @@ describe('DebateRematchPageClient', () => { requester_user_id: 'user-remote', recipient_user_id: 'user-local', requester_position: false, - requester_position_label: 'No', + requester_position_label: 'Disagree', recipient_position: true, - recipient_position_label: 'Yes', - response_kind: null, + recipient_position_label: 'Agree', + response_kind: 'stance', turn_format_id: 'standard', created_at: '2026-07-10T10:00:00.000Z', expires_at: '2026-07-10T10:02:00.000Z', @@ -181,8 +196,8 @@ describe('DebateRematchPageClient', () => { expect(within(dialog).getByText('You')).toBeInTheDocument(); expect(within(dialog).getByText('Salina')).toBeInTheDocument(); expect(within(dialog).getByText('VS')).toBeInTheDocument(); - expect(within(within(dialog).getByText('You').parentElement!).getByText('Yes')).toBeInTheDocument(); - expect(within(within(dialog).getByText('Salina').parentElement!).getByText('No')).toBeInTheDocument(); + expect(within(within(dialog).getByText('You').parentElement!).getByText('Agree')).toBeInTheDocument(); + expect(within(within(dialog).getByText('Salina').parentElement!).getByText('Disagree')).toBeInTheDocument(); expect(within(dialog).getAllByText('1m')).toHaveLength(2); expect(within(dialog).getAllByText('45s')).toHaveLength(2); expect(document.body.style.overflow).toBe('hidden'); @@ -200,6 +215,30 @@ describe('DebateRematchPageClient', () => { expect(document.documentElement.style.overflow).toBe(''); }); + it('falls back to Yes and No for legacy incoming requests without response metadata', () => { + mocks.session = session({ + status: 'request_pending', + request: { + id: 'request-legacy', + status: 'pending', + claim: claimSummary('claim-shared', 'A claim both participants chose'), + requester_user_id: 'user-remote', + recipient_user_id: 'user-local', + requester_position: false, + recipient_position: true, + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: '2026-07-10T10:02:00.000Z', + }, + }); + + render(); + + const dialog = screen.getByRole('dialog', { name: 'A claim both participants chose' }); + expect(within(within(dialog).getByText('You').parentElement!).getByText('Yes')).toBeInTheDocument(); + expect(within(within(dialog).getByText('Salina').parentElement!).getByText('No')).toBeInTheDocument(); + }); + it('disables debate requests while a rematch request is pending', () => { mocks.session = session({ status: 'request_pending', diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 91f80e2d9e..dff3ff8718 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -197,13 +197,14 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? session.participants.map(participant => { const requester = participant.user_id === incomingRequest.requester_user_id; const position = requester ? incomingRequest.requester_position : incomingRequest.recipient_position; + const positionLabel = requester + ? incomingRequest.requester_position_label + : incomingRequest.recipient_position_label; return { ...participant, position, - position_label: requester - ? incomingRequest.requester_position_label - : incomingRequest.recipient_position_label, + position_label: positionLabel ?? (position ? 'Yes' : 'No'), }; }) : []; @@ -423,7 +424,17 @@ function RematchClaimCard({ {responsePrompt} - + {claim.response_kind ? ( + + ) : ( + + Response controls are unavailable while this claim is being prepared for rematches. + + )}
)} diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 064d954701..930a293ff9 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -255,10 +255,10 @@ export type DebateRematchRequest = { requester_user_id: string; recipient_user_id: string; requester_position: boolean; - requester_position_label: string; + requester_position_label?: string | null; recipient_position: boolean; - recipient_position_label: string; - response_kind: DebateResponseKind | null; + recipient_position_label?: string | null; + response_kind?: DebateResponseKind | null; cancellation_reason?: string | null; turn_format_id: string; created_at: string; diff --git a/apps/web/core/debates/browse/join-debate-panel.test.tsx b/apps/web/core/debates/browse/join-debate-panel.test.tsx index 971c4c1b63..50b2592c24 100644 --- a/apps/web/core/debates/browse/join-debate-panel.test.tsx +++ b/apps/web/core/debates/browse/join-debate-panel.test.tsx @@ -21,6 +21,10 @@ vi.mock('~/core/debates/hooks', () => ({ useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), })); +vi.mock('~/core/hooks/use-entity-vote', () => ({ + useEntityResponseIndexingState: () => 'idle', +})); + vi.mock('~/core/sync/use-store', () => ({ useQueryEntities: () => ({ entities: [] }), })); diff --git a/apps/web/core/debates/claim-debate-button.test.tsx b/apps/web/core/debates/claim-debate-button.test.tsx index 4702828141..2bb94d1c5e 100644 --- a/apps/web/core/debates/claim-debate-button.test.tsx +++ b/apps/web/core/debates/claim-debate-button.test.tsx @@ -20,6 +20,10 @@ vi.mock('~/core/state/feature-flags', () => ({ useDebatesEnabled: () => mocks.debatesEnabled(), })); +vi.mock('~/core/hooks/use-entity-vote', () => ({ + useEntityResponseIndexingState: () => 'idle', +})); + vi.mock('~/core/sync/use-store', () => ({ useQueryEntity: () => ({ entity: undefined }), })); diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index de18eb0d6a..399487c705 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -1,14 +1,18 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { entityResponseIndexingQueryKey } from '~/core/responses/entity-response'; + import type { DebateClaim } from './api'; import { ClaimDebateReadiness } from './claim-debate-readiness'; const mocks = vi.hoisted(() => ({ joinMutate: vi.fn(), leaveMutate: vi.fn(), + responseKinds: [] as Array<'stance' | 'veracity' | null>, })); vi.mock('./hooks', () => ({ @@ -17,12 +21,16 @@ vi.mock('./hooks', () => ({ })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: () =>
Entity response buttons
, + EntityVoteButtons: ({ responseKind }: { responseKind: 'stance' | 'veracity' | null }) => { + mocks.responseKinds.push(responseKind); + return
Entity response buttons
; + }, })); beforeEach(() => { mocks.joinMutate.mockReset(); mocks.leaveMutate.mockReset(); + mocks.responseKinds.length = 0; }); afterEach(cleanup); @@ -61,6 +69,16 @@ describe('ClaimDebateReadiness', () => { expect(mocks.joinMutate).not.toHaveBeenCalled(); }); + it('blocks queue changes while the exact response is still indexing', () => { + renderReadiness(claim(), 'reconciling'); + + const toggle = screen.getByRole('button', { name: 'Processing response…' }); + expect(toggle).toBeDisabled(); + + fireEvent.click(toggle); + expect(mocks.joinMutate).not.toHaveBeenCalled(); + }); + it.each([ ['stance', 'Agree', 'Disagree'], ['veracity', 'Verify', 'Dispute'], @@ -74,6 +92,7 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByText(positiveLabel)).toBeInTheDocument(); expect(screen.getByText(negativeLabel)).toBeInTheDocument(); + expect(mocks.responseKinds).toContain(responseKind); }); it('shows the backend readiness-disabled reason after a refetch', () => { @@ -89,15 +108,23 @@ describe('ClaimDebateReadiness', () => { }); }); -function renderReadiness(debateClaim: DebateClaim) { +function renderReadiness(debateClaim: DebateClaim, indexingStatus: 'idle' | 'reconciling' = 'idle') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(entityResponseIndexingQueryKey('claim-1', 'space-1', debateClaim.response_kind), { + status: indexingStatus, + pending: null, + }); + return render( - + + + ); } diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index 36cee82745..b548037827 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -2,6 +2,8 @@ import cx from 'classnames'; +import { useEntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; + import { Avatar } from '~/design-system/avatar'; import { Text } from '~/design-system/text'; @@ -29,9 +31,16 @@ export function ClaimDebateReadiness({ const joinQueue = useJoinDebateQueue(spaceId); const leaveQueue = useLeaveDebateQueue(spaceId); + const responseIndexingState = useEntityResponseIndexingState({ + entityId, + spaceId, + responseKind: debateClaim?.response_kind ?? null, + }); + if (!debateClaim) return null; const isReady = debateClaim.viewer_debate_ready; + const isResponseProcessing = responseIndexingState !== 'idle'; const isPending = joinQueue.isPending || leaveQueue.isPending; const mutationError = joinQueue.error instanceof Error @@ -50,7 +59,11 @@ export function ClaimDebateReadiness({ Your response: {debateClaim.viewer_response.position_label} - + ) : ( @@ -72,7 +85,11 @@ export function ClaimDebateReadiness({ Respond before joining - + )} diff --git a/apps/web/core/debates/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx index 72a17d8bb9..c8e1c2d35a 100644 --- a/apps/web/core/debates/debate-entity-response-controls.test.tsx +++ b/apps/web/core/debates/debate-entity-response-controls.test.tsx @@ -6,8 +6,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DebateEntityResponseControls } from './debate-entity-response-controls'; vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: ({ showProcessingLabel }: { showProcessingLabel?: boolean }) => ( - {showProcessingLabel ? 'Visible processing label enabled' : 'Hidden processing label'} + EntityVoteButtons: ({ + responseKind, + showProcessingLabel, + }: { + responseKind?: 'stance' | 'veracity' | null; + showProcessingLabel?: boolean; + }) => ( + + {showProcessingLabel ? 'Visible processing label enabled' : 'Hidden processing label'}: + {responseKind ?? 'unavailable'} + ), })); @@ -15,8 +24,14 @@ afterEach(cleanup); describe('DebateEntityResponseControls', () => { it('requests a visible response-processing state in debate contexts', () => { - render(); + render(); - expect(screen.getByText('Visible processing label enabled')).toBeInTheDocument(); + expect(screen.getByText('Visible processing label enabled:veracity')).toBeInTheDocument(); + }); + + it('preserves an unavailable backend response kind instead of inferring curation', () => { + render(); + + expect(screen.getByText('Visible processing label enabled:unavailable')).toBeInTheDocument(); }); }); diff --git a/apps/web/core/debates/debate-entity-response-controls.tsx b/apps/web/core/debates/debate-entity-response-controls.tsx index 91cd4d0aa3..e7f6ececc5 100644 --- a/apps/web/core/debates/debate-entity-response-controls.tsx +++ b/apps/web/core/debates/debate-entity-response-controls.tsx @@ -1,10 +1,21 @@ import { EntityVoteButtons } from '~/partials/entity-page/entity-vote-buttons'; -export function DebateEntityResponseControls({ entityId, spaceId }: { entityId: string; spaceId: string }) { +import type { DebateResponseKind } from './api'; + +export function DebateEntityResponseControls({ + entityId, + spaceId, + responseKind, +}: { + entityId: string; + spaceId: string; + responseKind: DebateResponseKind | null; +}) { return ( diff --git a/apps/web/core/hooks/use-entity-vote.test.tsx b/apps/web/core/hooks/use-entity-vote.test.tsx new file mode 100644 index 0000000000..470d35e3fc --- /dev/null +++ b/apps/web/core/hooks/use-entity-vote.test.tsx @@ -0,0 +1,146 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, renderHook } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useEntityResponse } from './use-entity-vote'; + +const PERSONAL_SPACE_ID = 'd4bee0928fb5405baba3b1513f085835'; +const TARGET_SPACE_ID = '1234567890abcdef1234567890abcdef'; + +const mocks = vi.hoisted(() => ({ + fetchResponse: vi.fn<() => 'positive' | 'negative' | null>(), + runEffectEither: vi.fn(), + responseAction: vi.fn(() => ({ to: '0x1234' as const, calldata: '0xabcd' as const })), +})); + +vi.mock('~/core/hooks/use-personal-space-id', () => ({ + usePersonalSpaceId: () => ({ personalSpaceId: PERSONAL_SPACE_ID, isRegistered: true }), +})); + +vi.mock('~/core/hooks/use-smart-account-transaction', async () => { + const { Effect } = await import('effect'); + return { useSmartAccountTransaction: () => () => Effect.succeed('0xtransaction') }; +}); + +vi.mock('~/core/io/queries', async () => { + const { Effect } = await import('effect'); + return { getUserEntityResponse: () => Effect.sync(() => mocks.fetchResponse()) }; +}); + +vi.mock('~/core/sdk/geo-client', () => ({ + geo: { + responses: { + upvote: mocks.responseAction, + downvote: mocks.responseAction, + unvote: mocks.responseAction, + agree: mocks.responseAction, + disagree: mocks.responseAction, + unagree: mocks.responseAction, + verify: mocks.responseAction, + dispute: mocks.responseAction, + unverify: mocks.responseAction, + }, + }, +})); + +vi.mock('~/core/telemetry/effect-runtime', () => ({ + runEffectEither: (...args: unknown[]) => mocks.runEffectEither(...args), +})); + +beforeEach(() => { + vi.useFakeTimers(); + mocks.fetchResponse.mockReset(); + mocks.runEffectEither.mockReset(); + mocks.responseAction.mockClear(); + mocks.runEffectEither.mockResolvedValue({ _tag: 'Right', right: '0xtransaction' }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('useEntityResponse indexing reconciliation', () => { + it('keeps a submitted response unsettled and exposes retry after reconciliation exhausts', async () => { + mocks.fetchResponse.mockReturnValue(null); + const onSuccess = vi.fn(); + const onError = vi.fn(); + const { queryClient, wrapper } = createHarness(); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook( + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + { wrapper } + ); + + act(() => result.current.submitResponse('positive', { onSuccess, onError })); + await act(async () => Promise.resolve()); + + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + expect(result.current.isProcessingResponse).toBe(true); + expect(result.current.isResponseIndexingDelayed).toBe(false); + + await act(async () => vi.advanceTimersByTimeAsync(58_000)); + + expect(mocks.fetchResponse).toHaveBeenCalledTimes(30); + expect(result.current.isProcessingResponse).toBe(true); + expect(result.current.isResponseIndexingDelayed).toBe(true); + expect(result.current.retryResponseIndexing).toEqual(expect.any(Function)); + expect(invalidateQueries).not.toHaveBeenCalled(); + + mocks.fetchResponse.mockReturnValue('positive'); + await act(async () => { + result.current.retryResponseIndexing(); + await Promise.resolve(); + }); + + expect(result.current.isProcessingResponse).toBe(false); + expect(result.current.isResponseIndexingDelayed).toBe(false); + expect(invalidateQueries).toHaveBeenCalledTimes(3); + }); + + it('stays visibly processing after submission and invalidates when Gaia indexes late', async () => { + let fetchCount = 0; + mocks.fetchResponse.mockImplementation(() => (++fetchCount >= 2 ? 'positive' : null)); + const { queryClient, wrapper } = createHarness(); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook( + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + { wrapper } + ); + + act(() => result.current.submitResponse('positive')); + await act(async () => Promise.resolve()); + + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + expect(result.current.isProcessingResponse).toBe(true); + expect(result.current.isResponseIndexingDelayed).toBe(false); + expect(invalidateQueries).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(2_000)); + + expect(mocks.fetchResponse).toHaveBeenCalledTimes(2); + expect(result.current.isProcessingResponse).toBe(false); + expect(result.current.isResponseIndexingDelayed).toBe(false); + expect(invalidateQueries).toHaveBeenCalledTimes(3); + }); +}); + +function createHarness() { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + return { + queryClient, + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index 237ebac619..5d3d981fcb 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback } from 'react'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; import { Effect, Either } from 'effect'; @@ -10,10 +10,12 @@ import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; import { useSmartAccountTransaction } from '~/core/hooks/use-smart-account-transaction'; import { getUserEntityResponse } from '~/core/io/queries'; import { + type ActiveResponseDirection, type ResponseDirection, type ResponseKind, entityRespondersQueryKey, entityResponseCountsQueryKey, + entityResponseIndexingQueryKey, getResponseActionMethod, userEntityResponseQueryKey, waitForIndexedEntityResponse, @@ -25,17 +27,120 @@ import { validateSpaceId } from '~/core/utils/utils'; interface UseEntityResponseArgs { entityId: string; spaceId: string; + responseKind: ResponseKind | null; +} + +type PendingEntityResponseIndex = { + entityId: string; + expectedResponse: ActiveResponseDirection | null; + personalSpaceId: string; responseKind: ResponseKind; + spaceId: string; +}; + +type EntityResponseIndexingState = + | { status: 'idle'; pending: null } + | { status: 'reconciling'; pending: PendingEntityResponseIndex | null } + | { status: 'delayed'; pending: PendingEntityResponseIndex }; + +const IDLE_INDEXING_STATE: EntityResponseIndexingState = { status: 'idle', pending: null }; + +export function useEntityResponseIndexingState({ + entityId, + spaceId, + responseKind, +}: UseEntityResponseArgs): EntityResponseIndexingState['status'] { + return useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }).status; +} + +function useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { + const queryClient = useQueryClient(); + const getSnapshot = useCallback( + () => + queryClient.getQueryData( + entityResponseIndexingQueryKey(entityId, spaceId, responseKind) + ) ?? IDLE_INDEXING_STATE, + [entityId, queryClient, responseKind, spaceId] + ); + const subscribe = useCallback( + (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), + [queryClient] + ); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { const queryClient = useQueryClient(); const { personalSpaceId, isRegistered } = usePersonalSpaceId(); + const indexingQueryKey = entityResponseIndexingQueryKey(entityId, spaceId, responseKind); + const indexingState = useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }); + const reconciliationRun = useRef(0); const tx = useSmartAccountTransaction(); + const invalidateResponseQueries = useCallback( + (pending: PendingEntityResponseIndex) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: entityResponseCountsQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), + }), + queryClient.invalidateQueries({ + queryKey: userEntityResponseQueryKey( + pending.personalSpaceId, + pending.entityId, + pending.spaceId, + 0, + pending.responseKind + ), + }), + queryClient.invalidateQueries({ + queryKey: entityRespondersQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), + }), + ]), + [queryClient] + ); + + const reconcileResponseIndexing = useCallback( + async (pending: PendingEntityResponseIndex) => { + const run = ++reconciliationRun.current; + queryClient.setQueryData(indexingQueryKey, { + status: 'reconciling', + pending, + }); + + const indexed = await waitForIndexedEntityResponse( + () => + Effect.runPromise( + getUserEntityResponse(pending.personalSpaceId, pending.entityId, pending.spaceId, pending.responseKind) + ), + pending.expectedResponse + ); + + if (run !== reconciliationRun.current) return; + + if (!indexed) { + queryClient.setQueryData(indexingQueryKey, { + status: 'delayed', + pending, + }); + return; + } + + await invalidateResponseQueries(pending); + if (run !== reconciliationRun.current) return; + + queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + }, + [indexingQueryKey, invalidateResponseQueries, queryClient] + ); + const executeResponse = useCallback( async (direction: ResponseDirection) => { + if (!responseKind) { + throw new Error('Response type is unavailable. Cannot submit response.'); + } + if (!validateSpaceId(spaceId)) { throw new Error('Invalid space ID format. Cannot submit response.'); } @@ -74,38 +179,46 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity throw error; } - await waitForIndexedEntityResponse( - () => - Effect.runPromise(getUserEntityResponse(personalSpaceId, entityId, spaceId, responseKind)), - direction === 'clear' ? null : direction - ); - - return result.right; + const expectedResponse = direction === 'clear' ? null : direction; + const pending: PendingEntityResponseIndex = { + entityId, + expectedResponse, + personalSpaceId, + responseKind, + spaceId, + }; + return { pending, transaction: result.right }; }, [personalSpaceId, isRegistered, spaceId, entityId, responseKind, tx] ); - const onSuccess = () => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: entityResponseCountsQueryKey(entityId, spaceId, 0, responseKind), - }), - queryClient.invalidateQueries({ - queryKey: userEntityResponseQueryKey(personalSpaceId, entityId, spaceId, 0, responseKind), - }), - queryClient.invalidateQueries({ - queryKey: entityRespondersQueryKey(entityId, spaceId, 0, responseKind), - }), - ]); - const responseMutation = useMutation({ mutationFn: executeResponse, - onSuccess, + onMutate: () => { + reconciliationRun.current += 1; + queryClient.setQueryData(indexingQueryKey, { + status: 'reconciling', + pending: null, + }); + }, + onSuccess: submission => { + void reconcileResponseIndexing(submission.pending); + }, + onError: () => { + queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + }, }); + const retryResponseIndexing = useCallback(() => { + if (!indexingState.pending || indexingState.status === 'reconciling') return; + void reconcileResponseIndexing(indexingState.pending); + }, [indexingState, reconcileResponseIndexing]); + return { submitResponse: responseMutation.mutate, - isProcessingResponse: responseMutation.isPending, + isProcessingResponse: responseMutation.isPending || indexingState.status !== 'idle', + isResponseIndexingDelayed: indexingState.status === 'delayed', + retryResponseIndexing, isConnected: !!personalSpaceId && isRegistered, personalSpaceId, }; diff --git a/apps/web/core/responses/entity-response.test.ts b/apps/web/core/responses/entity-response.test.ts index 76264c578a..ce6f8e2f2a 100644 --- a/apps/web/core/responses/entity-response.test.ts +++ b/apps/web/core/responses/entity-response.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { CLAIM_IS_FACTUAL_PROPERTY_ID } from '~/core/claims/ontology'; + import { getChecked } from '~/design-system/checkbox'; import { @@ -7,14 +9,19 @@ import { decodeActiveResponseDirection, entityRespondersQueryKey, entityResponseCountsQueryKey, + entityResponseIndexingQueryKey, entityResponseQueryVariables, getEntityResponseKind, getResponseActionMethod, + hasUnpublishedClaimResponseKindEdit, responseKindToVoteKind, userEntityResponseQueryKey, waitForIndexedEntityResponse, } from './entity-response'; +const SPACE_ID = '1234567890abcdef1234567890abcdef'; +const OTHER_SPACE_ID = 'abcdef1234567890abcdef1234567890'; + describe('entity response semantics', () => { it.each([ [{ isClaim: false, isFactual: false }, 'curation'], @@ -81,6 +88,26 @@ describe('entity response semantics', () => { expect(decodeActiveResponseDirection(voteType)).toBe(expected); }); + it('blocks responses while an exact-space factual edit is still unpublished', () => { + const entity = { + relations: [], + values: [ + { + spaceId: SPACE_ID, + property: { id: CLAIM_IS_FACTUAL_PROPERTY_ID }, + isLocal: true, + hasBeenPublished: false, + }, + ], + } as unknown as NonNullable[0]>; + + expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(true); + expect(hasUnpublishedClaimResponseKindEdit(entity, OTHER_SPACE_ID)).toBe(false); + + entity.values[0]!.hasBeenPublished = true; + expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(false); + }); + it('keeps polling until Gaia serves the expected response', async () => { const indexedResponses = [null, null, 'positive'] as const; let attempt = 0; @@ -148,5 +175,11 @@ describe('entity response query keys', () => { 0, 'curation', ]); + expect(entityResponseIndexingQueryKey('entity', 'space', 'stance')).toEqual([ + 'entity-response-indexing', + 'entity', + 'space', + 'stance', + ]); }); }); diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 28018cbbe3..beb6e650b1 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -1,3 +1,8 @@ +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; + +import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { uuidToHex } from '~/core/id/normalize'; +import type { Entity } from '~/core/types'; import { sleep } from '~/core/utils/utils'; export type ResponseKind = 'curation' | 'stance' | 'veracity'; @@ -15,6 +20,10 @@ const RESPONSE_VOTE_KIND: Record = { veracity: 2, }; +const CLAIM_TYPE = uuidToHex(CLAIM_TYPE_ID); +const CLAIM_IS_FACTUAL = uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID); +const TYPES_PROPERTY = uuidToHex(SystemIds.TYPES_PROPERTY); + const RESPONSE_ACTION_METHOD: Record> = { curation: { positive: 'upvote', @@ -94,6 +103,30 @@ export function getEntityResponseKind({ isClaim, isFactual }: { isClaim: boolean return isFactual ? 'veracity' : 'stance'; } +export function hasUnpublishedClaimResponseKindEdit( + entity: Pick | null | undefined, + spaceId: string +) { + return ( + entity?.values.some( + value => + uuidToHex(value.spaceId) === uuidToHex(spaceId) && + uuidToHex(value.property.id) === CLAIM_IS_FACTUAL && + value.isLocal === true && + value.hasBeenPublished !== true + ) || + entity?.relations.some( + relation => + uuidToHex(relation.spaceId) === uuidToHex(spaceId) && + uuidToHex(relation.type.id) === TYPES_PROPERTY && + uuidToHex(relation.toEntity.id) === CLAIM_TYPE && + relation.isLocal === true && + relation.hasBeenPublished !== true + ) || + false + ); +} + export function responseKindToVoteKind(kind: ResponseKind): ResponseVoteKind { return RESPONSE_VOTE_KIND[kind]; } @@ -163,6 +196,10 @@ export function userEntityResponseQueryKey( return ['user-entity-response', userId, entityId, spaceId, objectType, responseKind] as const; } +export function entityResponseIndexingQueryKey(entityId: string, spaceId: string, responseKind: ResponseKind | null) { + return ['entity-response-indexing', entityId, spaceId, responseKind] as const; +} + export function entityRespondersQueryKey( entityId: string, spaceId: string, diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index cdd4e93abe..ebc3312dbe 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -31,6 +31,7 @@ import { entityRespondersQueryKey, entityResponseCountsQueryKey, getEntityResponseKind, + hasUnpublishedClaimResponseKindEdit, userEntityResponseQueryKey, } from '~/core/responses/entity-response'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; @@ -63,6 +64,7 @@ const TYPES_PROPERTY = uuidToHex(SystemIds.TYPES_PROPERTY); type EntityVoteButtonsProps = { entityId: string; spaceId: string; + responseKind?: ResponseKind | null; claimResponderAvatarsPosition?: 'leading' | 'trailing'; showProcessingLabel?: boolean; }; @@ -70,6 +72,7 @@ type EntityVoteButtonsProps = { export function EntityVoteButtons({ entityId, spaceId, + responseKind: responseKindOverride, claimResponderAvatarsPosition = 'leading', showProcessingLabel = false, }: EntityVoteButtonsProps) { @@ -85,16 +88,24 @@ export function EntityVoteButtons({ v => uuidToHex(v.spaceId) === uuidToHex(spaceId) && uuidToHex(v.property.id) === CLAIM_IS_FACTUAL )?.value ) === true; - const responseKind = getEntityResponseKind({ isClaim, isFactual: isFactualClaim }); + const inferredResponseKind = getEntityResponseKind({ isClaim, isFactual: isFactualClaim }); + const responseKind = responseKindOverride === undefined ? inferredResponseKind : responseKindOverride; + const hasUnpublishedResponseKindEdit = + responseKindOverride === undefined && hasUnpublishedClaimResponseKindEdit(entity, spaceId); + const queryResponseKind = responseKind ?? 'stance'; + const isResponseKindLoading = responseKindOverride === undefined && isLoadingEntity; const variant: ResponseVariant = - responseKind === 'curation' ? 'default' : responseKind === 'veracity' ? 'chevrons' : 'thumbs'; - const responseCopy = ENTITY_RESPONSE_COPY[responseKind]; - - const { submitResponse, isProcessingResponse, isConnected, personalSpaceId } = useEntityResponse({ - entityId, - spaceId, - responseKind, - }); + queryResponseKind === 'curation' ? 'default' : queryResponseKind === 'veracity' ? 'chevrons' : 'thumbs'; + const responseCopy = ENTITY_RESPONSE_COPY[queryResponseKind]; + + const { + submitResponse, + isProcessingResponse, + isResponseIndexingDelayed, + retryResponseIndexing, + isConnected, + personalSpaceId, + } = useEntityResponse({ entityId, spaceId, responseKind }); const { smartAccount } = useSmartAccount(); const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); @@ -113,22 +124,28 @@ export function EntityVoteButtons({ const [respondersOpen, setRespondersOpen] = React.useState(false); const { data: responseCounts } = useQuery<{ positive: number; negative: number } | null>({ - queryKey: entityResponseCountsQueryKey(entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, responseKind), + queryKey: entityResponseCountsQueryKey(entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, queryResponseKind), queryFn: () => - Effect.runPromise(getEntityResponseCounts(entityId, spaceId, responseKind, ENTITY_RESPONSE_OBJECT_TYPE)), - enabled: !isLoadingEntity, + Effect.runPromise(getEntityResponseCounts(entityId, spaceId, queryResponseKind, ENTITY_RESPONSE_OBJECT_TYPE)), + enabled: !isResponseKindLoading && responseKind !== null, staleTime: 30_000, }); const { data: serverResponseDirection } = useQuery({ - queryKey: userEntityResponseQueryKey(personalSpaceId, entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, responseKind), + queryKey: userEntityResponseQueryKey( + personalSpaceId, + entityId, + spaceId, + ENTITY_RESPONSE_OBJECT_TYPE, + queryResponseKind + ), queryFn: async () => { if (!personalSpaceId) return null; return Effect.runPromise( - getUserEntityResponse(personalSpaceId, entityId, spaceId, responseKind, ENTITY_RESPONSE_OBJECT_TYPE) + getUserEntityResponse(personalSpaceId, entityId, spaceId, queryResponseKind, ENTITY_RESPONSE_OBJECT_TYPE) ); }, - enabled: !!personalSpaceId && !isLoadingEntity, + enabled: !!personalSpaceId && !isResponseKindLoading && responseKind !== null, staleTime: 30_000, }); @@ -298,17 +315,33 @@ export function EntityVoteButtons({ entityId={entityId} spaceId={spaceId} objectType={ENTITY_RESPONSE_OBJECT_TYPE} - responseKind={responseKind} + responseKind={queryResponseKind} totalResponders={totalResponders} /> ) : null; const claimResponderAvatarsClassName = 'inline-flex h-5 shrink-0 items-center'; - if (isLoadingEntity) { + if (isResponseKindLoading) { return ; } + if (hasUnpublishedResponseKindEdit) { + return ( + + Publish changes before responding + + ); + } + + if (responseKind === null) { + return ( + + Response unavailable + + ); + } + return (
{claimResponderAvatarsPosition === 'leading' && claimResponderAvatars ? ( @@ -322,13 +355,15 @@ export function EntityVoteButtons({ ? responseCopy.signIn : isAccountSetupPending ? 'Finishing account setup…' - : isProcessingResponse - ? 'Processing response…' - : isConnected - ? positiveActive - ? responseCopy.removePositive - : responseCopy.positiveAction - : responseCopy.connect + : isResponseIndexingDelayed + ? 'Response submitted. Indexing is delayed.' + : isProcessingResponse + ? 'Processing response…' + : isConnected + ? positiveActive + ? responseCopy.removePositive + : responseCopy.positiveAction + : responseCopy.connect } className={cx( 'group/vote flex h-5 w-5 items-center justify-center rounded transition-colors', @@ -375,13 +410,15 @@ export function EntityVoteButtons({ ? responseCopy.signIn : isAccountSetupPending ? 'Finishing account setup…' - : isProcessingResponse - ? 'Processing response…' - : isConnected - ? negativeActive - ? responseCopy.removeNegative - : responseCopy.negativeAction - : responseCopy.connect + : isResponseIndexingDelayed + ? 'Response submitted. Indexing is delayed.' + : isProcessingResponse + ? 'Processing response…' + : isConnected + ? negativeActive + ? responseCopy.removeNegative + : responseCopy.negativeAction + : responseCopy.connect } className={cx( 'group/vote flex h-5 w-5 items-center justify-center rounded transition-colors', @@ -397,7 +434,14 @@ export function EntityVoteButtons({ {claimResponderAvatarsPosition === 'trailing' && claimResponderAvatars ? ( {claimResponderAvatars} ) : null} - {isProcessingResponse ? ( + {isResponseIndexingDelayed ? ( + + Response submitted. Indexing is delayed. + + + ) : isProcessingResponse ? ( Processing response… From d66df70794dd3c58f04e88436f3006f35a083eae Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 03:12:37 +0200 Subject: [PATCH 07/15] fix: harden response indexing reconciliation --- apps/web/core/hooks/use-entity-vote.test.tsx | 267 +++++++++++++++++- apps/web/core/hooks/use-entity-vote.ts | 177 ++++++++++-- .../core/responses/entity-response.test.ts | 50 +++- apps/web/core/responses/entity-response.ts | 36 ++- 4 files changed, 502 insertions(+), 28 deletions(-) diff --git a/apps/web/core/hooks/use-entity-vote.test.tsx b/apps/web/core/hooks/use-entity-vote.test.tsx index 470d35e3fc..822f0769f5 100644 --- a/apps/web/core/hooks/use-entity-vote.test.tsx +++ b/apps/web/core/hooks/use-entity-vote.test.tsx @@ -5,17 +5,29 @@ import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useEntityResponse } from './use-entity-vote'; +import { useEntityResponse, useEntityResponseIndexingState } from './use-entity-vote'; const PERSONAL_SPACE_ID = 'd4bee0928fb5405baba3b1513f085835'; const TARGET_SPACE_ID = '1234567890abcdef1234567890abcdef'; const mocks = vi.hoisted(() => ({ - fetchResponse: vi.fn<() => 'positive' | 'negative' | null>(), + fetchResponse: + vi.fn<(signal?: AbortSignal) => 'positive' | 'negative' | null | Promise<'positive' | 'negative' | null>>(), runEffectEither: vi.fn(), responseAction: vi.fn(() => ({ to: '0x1234' as const, calldata: '0xabcd' as const })), + waitForIndexedEntityResponse: vi.fn(), })); +vi.mock('~/core/responses/entity-response', async importOriginal => { + const actual = await importOriginal(); + mocks.waitForIndexedEntityResponse.mockImplementation(actual.waitForIndexedEntityResponse); + return { + ...actual, + waitForIndexedEntityResponse: (...args: Parameters) => + mocks.waitForIndexedEntityResponse(...args), + }; +}); + vi.mock('~/core/hooks/use-personal-space-id', () => ({ usePersonalSpaceId: () => ({ personalSpaceId: PERSONAL_SPACE_ID, isRegistered: true }), })); @@ -27,7 +39,10 @@ vi.mock('~/core/hooks/use-smart-account-transaction', async () => { vi.mock('~/core/io/queries', async () => { const { Effect } = await import('effect'); - return { getUserEntityResponse: () => Effect.sync(() => mocks.fetchResponse()) }; + return { + getUserEntityResponse: (...args: unknown[]) => + Effect.tryPromise(() => Promise.resolve(mocks.fetchResponse(args[5] as AbortSignal | undefined))), + }; }); vi.mock('~/core/sdk/geo-client', () => ({ @@ -55,6 +70,7 @@ beforeEach(() => { mocks.fetchResponse.mockReset(); mocks.runEffectEither.mockReset(); mocks.responseAction.mockClear(); + mocks.waitForIndexedEntityResponse.mockClear(); mocks.runEffectEither.mockResolvedValue({ _tag: 'Right', right: '0xtransaction' }); }); @@ -127,8 +143,253 @@ describe('useEntityResponse indexing reconciliation', () => { expect(result.current.isResponseIndexingDelayed).toBe(false); expect(invalidateQueries).toHaveBeenCalledTimes(3); }); + + it('does not let an older control supersede shared indexing state', async () => { + const firstTransaction = deferred(); + const secondTransaction = deferred(); + mocks.runEffectEither.mockReturnValueOnce(firstTransaction.promise).mockReturnValueOnce(secondTransaction.promise); + mocks.fetchResponse.mockReturnValue(null); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + first: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + second: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexingStatus: useEntityResponseIndexingState({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.first.submitResponse('positive')); + await act(async () => Promise.resolve()); + act(() => result.current.second.submitResponse('negative')); + await act(async () => Promise.resolve()); + + await act(async () => { + secondTransaction.resolve({ _tag: 'Right', right: '0xsecond' }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + expect(result.current.indexingStatus).toBe('reconciling'); + + await act(async () => { + firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + expect(result.current.indexingStatus).toBe('reconciling'); + }); + + it('resumes an older successful submission when a newer transaction fails', async () => { + const firstTransaction = deferred(); + const secondTransaction = deferred(); + mocks.runEffectEither.mockReturnValueOnce(firstTransaction.promise).mockReturnValueOnce(secondTransaction.promise); + mocks.fetchResponse.mockReturnValue('positive'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + first: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + second: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexingStatus: useEntityResponseIndexingState({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.first.submitResponse('positive')); + await act(async () => Promise.resolve()); + act(() => result.current.second.submitResponse('negative')); + await act(async () => Promise.resolve()); + + await act(async () => { + secondTransaction.resolve({ _tag: 'Left', left: new Error('rejected') }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.indexingStatus).toBe('reconciling'); + + await act(async () => { + firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + expect(result.current.indexingStatus).toBe('idle'); + consoleError.mockRestore(); + }); + + it('resumes the oldest submission after two newer overlapping transactions fail', async () => { + const firstTransaction = deferred(); + const secondTransaction = deferred(); + const thirdTransaction = deferred(); + mocks.runEffectEither + .mockReturnValueOnce(firstTransaction.promise) + .mockReturnValueOnce(secondTransaction.promise) + .mockReturnValueOnce(thirdTransaction.promise); + mocks.fetchResponse.mockReturnValue('positive'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + first: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + second: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + third: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexingStatus: useEntityResponseIndexingState({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.first.submitResponse('positive')); + await act(async () => Promise.resolve()); + act(() => result.current.second.submitResponse('negative')); + await act(async () => Promise.resolve()); + act(() => result.current.third.submitResponse('positive')); + await act(async () => Promise.resolve()); + + await act(async () => { + secondTransaction.resolve({ _tag: 'Left', left: new Error('second rejected') }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.indexingStatus).toBe('reconciling'); + + await act(async () => { + thirdTransaction.resolve({ _tag: 'Left', left: new Error('third rejected') }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.indexingStatus).toBe('reconciling'); + + await act(async () => { + firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + expect(result.current.indexingStatus).toBe('idle'); + consoleError.mockRestore(); + }); + + it('aborts an active polling run when another control submits', async () => { + mocks.fetchResponse + .mockImplementationOnce( + signal => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }) + ) + .mockReturnValue('negative'); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + first: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + second: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexingStatus: useEntityResponseIndexingState({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.first.submitResponse('positive')); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.fetchResponse).toHaveBeenCalledOnce(); + + act(() => result.current.second.submitResponse('negative')); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.fetchResponse).toHaveBeenCalledTimes(2); + expect(result.current.indexingStatus).toBe('idle'); + }); + + it('does not let a cancelled attempt clobber a restarted reconciliation for the same submission', async () => { + const firstReconciliation = deferred(); + const restartedReconciliation = deferred(); + const secondTransaction = deferred(); + mocks.waitForIndexedEntityResponse + .mockImplementationOnce(() => firstReconciliation.promise) + .mockImplementationOnce(() => restartedReconciliation.promise); + mocks.runEffectEither + .mockResolvedValueOnce({ _tag: 'Right', right: '0xfirst' }) + .mockReturnValueOnce(secondTransaction.promise); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + first: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + second: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexingStatus: useEntityResponseIndexingState({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.first.submitResponse('positive')); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.waitForIndexedEntityResponse).toHaveBeenCalledOnce(); + + act(() => result.current.second.submitResponse('negative')); + await act(async () => Promise.resolve()); + await act(async () => { + secondTransaction.resolve({ _tag: 'Left', left: new Error('second rejected') }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.waitForIndexedEntityResponse).toHaveBeenCalledTimes(2); + + await act(async () => { + firstReconciliation.resolve(false); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.indexingStatus).toBe('reconciling'); + + await act(async () => { + restartedReconciliation.resolve(true); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.indexingStatus).toBe('idle'); + consoleError.mockRestore(); + }); }); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function createHarness() { const queryClient = new QueryClient({ defaultOptions: { diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index 5d3d981fcb..098d128749 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { Effect, Either } from 'effect'; @@ -39,11 +39,55 @@ type PendingEntityResponseIndex = { }; type EntityResponseIndexingState = - | { status: 'idle'; pending: null } - | { status: 'reconciling'; pending: PendingEntityResponseIndex | null } - | { status: 'delayed'; pending: PendingEntityResponseIndex }; + | { status: 'idle'; pending: null; runId: null } + | { status: 'reconciling'; pending: PendingEntityResponseIndex | null; runId: string } + | { status: 'delayed'; pending: PendingEntityResponseIndex; runId: string }; -const IDLE_INDEXING_STATE: EntityResponseIndexingState = { status: 'idle', pending: null }; +const IDLE_INDEXING_STATE: EntityResponseIndexingState = { status: 'idle', pending: null, runId: null }; +let responseIndexingRunSequence = 0; +type ResponseSubmissionRun = { + pending: PendingEntityResponseIndex | null; + previousState: EntityResponseIndexingState; + runId: string; + runOrder: number; + status: 'pending' | 'success' | 'failed'; +}; +type ResponseIndexingRegistry = { + activeReconciliations: Map; + submissionRuns: Map>; +}; +const responseIndexingRegistries = new WeakMap(); + +function createResponseIndexingRunId() { + responseIndexingRunSequence += 1; + return { + runId: `${Date.now()}-${responseIndexingRunSequence}`, + runOrder: responseIndexingRunSequence, + }; +} + +function getResponseIndexingRegistry(queryClient: object) { + let registry = responseIndexingRegistries.get(queryClient); + if (!registry) { + registry = { activeReconciliations: new Map(), submissionRuns: new Map() }; + responseIndexingRegistries.set(queryClient, registry); + } + return registry; +} + +function cancelActiveResponseReconciliation(registry: ResponseIndexingRegistry, indexingKeyId: string) { + registry.activeReconciliations.get(indexingKeyId)?.controller.abort(); + registry.activeReconciliations.delete(indexingKeyId); +} + +function getResponseSubmissionRuns(registry: ResponseIndexingRegistry, indexingKeyId: string) { + let runs = registry.submissionRuns.get(indexingKeyId); + if (!runs) { + runs = new Map(); + registry.submissionRuns.set(indexingKeyId, runs); + } + return runs; +} export function useEntityResponseIndexingState({ entityId, @@ -72,13 +116,19 @@ function useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }: export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { const queryClient = useQueryClient(); + const responseIndexingRegistry = getResponseIndexingRegistry(queryClient); const { personalSpaceId, isRegistered } = usePersonalSpaceId(); const indexingQueryKey = entityResponseIndexingQueryKey(entityId, spaceId, responseKind); + const indexingKeyId = JSON.stringify(indexingQueryKey); const indexingState = useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }); - const reconciliationRun = useRef(0); const tx = useSmartAccountTransaction(); + const isCurrentIndexingRun = useCallback( + (runId: string) => queryClient.getQueryData(indexingQueryKey)?.runId === runId, + [indexingQueryKey, queryClient] + ); + const invalidateResponseQueries = useCallback( (pending: PendingEntityResponseIndex) => Promise.all([ @@ -102,37 +152,66 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity ); const reconcileResponseIndexing = useCallback( - async (pending: PendingEntityResponseIndex) => { - const run = ++reconciliationRun.current; + async (pending: PendingEntityResponseIndex, runId: string) => { + if (!isCurrentIndexingRun(runId)) return; + cancelActiveResponseReconciliation(responseIndexingRegistry, indexingKeyId); + const controller = new AbortController(); + responseIndexingRegistry.activeReconciliations.set(indexingKeyId, { controller, runId }); queryClient.setQueryData(indexingQueryKey, { status: 'reconciling', pending, + runId, }); const indexed = await waitForIndexedEntityResponse( - () => + signal => Effect.runPromise( - getUserEntityResponse(pending.personalSpaceId, pending.entityId, pending.spaceId, pending.responseKind) + getUserEntityResponse( + pending.personalSpaceId, + pending.entityId, + pending.spaceId, + pending.responseKind, + 0, + signal + ) ), - pending.expectedResponse + pending.expectedResponse, + 30, + 2_000, + 5_000, + controller.signal ); - if (run !== reconciliationRun.current) return; + const activeReconciliation = responseIndexingRegistry.activeReconciliations.get(indexingKeyId); + if (activeReconciliation?.controller !== controller) return; + responseIndexingRegistry.activeReconciliations.delete(indexingKeyId); + + if (!isCurrentIndexingRun(runId)) return; if (!indexed) { queryClient.setQueryData(indexingQueryKey, { status: 'delayed', pending, + runId, }); + responseIndexingRegistry.submissionRuns.delete(indexingKeyId); return; } await invalidateResponseQueries(pending); - if (run !== reconciliationRun.current) return; + if (!isCurrentIndexingRun(runId)) return; queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + responseIndexingRegistry.submissionRuns.delete(indexingKeyId); }, - [indexingQueryKey, invalidateResponseQueries, queryClient] + [ + indexingKeyId, + indexingQueryKey, + invalidateResponseQueries, + isCurrentIndexingRun, + queryClient, + responseIndexingRegistry, + ] ); const executeResponse = useCallback( @@ -195,24 +274,80 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity const responseMutation = useMutation({ mutationFn: executeResponse, onMutate: () => { - reconciliationRun.current += 1; + const previousState = + queryClient.getQueryData(indexingQueryKey) ?? IDLE_INDEXING_STATE; + const { runId, runOrder } = createResponseIndexingRunId(); + getResponseSubmissionRuns(responseIndexingRegistry, indexingKeyId).set(runId, { + pending: null, + previousState, + runId, + runOrder, + status: 'pending', + }); + cancelActiveResponseReconciliation(responseIndexingRegistry, indexingKeyId); queryClient.setQueryData(indexingQueryKey, { status: 'reconciling', pending: null, + runId, }); + return { previousState, runId, runOrder }; }, - onSuccess: submission => { - void reconcileResponseIndexing(submission.pending); + onSuccess: (submission, _direction, context) => { + if (!context) return; + const run = responseIndexingRegistry.submissionRuns.get(indexingKeyId)?.get(context.runId); + if (!run) return; + run.pending = submission.pending; + run.status = 'success'; + if (isCurrentIndexingRun(context.runId)) { + void reconcileResponseIndexing(submission.pending, context.runId); + } }, - onError: () => { - queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + onError: (_error, _direction, context) => { + if (!context) return; + const runs = responseIndexingRegistry.submissionRuns.get(indexingKeyId); + const failedRun = runs?.get(context.runId); + if (!runs || !failedRun) return; + failedRun.status = 'failed'; + if (!isCurrentIndexingRun(context.runId)) return; + + const recoverableRun = [...runs.values()] + .filter(run => run.status !== 'failed' && run.runOrder < context.runOrder) + .sort((left, right) => right.runOrder - left.runOrder)[0]; + if (recoverableRun) { + queryClient.setQueryData(indexingQueryKey, { + status: 'reconciling', + pending: recoverableRun.pending, + runId: recoverableRun.runId, + }); + if (recoverableRun.status === 'success' && recoverableRun.pending) { + void reconcileResponseIndexing(recoverableRun.pending, recoverableRun.runId); + } + return; + } + + const previousState = [...runs.values()].sort((left, right) => left.runOrder - right.runOrder)[0]?.previousState; + responseIndexingRegistry.submissionRuns.delete(indexingKeyId); + if (!previousState || previousState.status === 'idle') { + queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + return; + } + queryClient.setQueryData(indexingQueryKey, previousState); + if (previousState.status === 'reconciling' && previousState.pending) { + void reconcileResponseIndexing(previousState.pending, previousState.runId); + } }, }); const retryResponseIndexing = useCallback(() => { if (!indexingState.pending || indexingState.status === 'reconciling') return; - void reconcileResponseIndexing(indexingState.pending); - }, [indexingState, reconcileResponseIndexing]); + const { runId } = createResponseIndexingRunId(); + queryClient.setQueryData(indexingQueryKey, { + status: 'reconciling', + pending: indexingState.pending, + runId, + }); + void reconcileResponseIndexing(indexingState.pending, runId); + }, [indexingKeyId, indexingQueryKey, indexingState, queryClient, reconcileResponseIndexing]); return { submitResponse: responseMutation.mutate, diff --git a/apps/web/core/responses/entity-response.test.ts b/apps/web/core/responses/entity-response.test.ts index ce6f8e2f2a..b96c190629 100644 --- a/apps/web/core/responses/entity-response.test.ts +++ b/apps/web/core/responses/entity-response.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CLAIM_IS_FACTUAL_PROPERTY_ID } from '~/core/claims/ontology'; @@ -140,6 +140,54 @@ describe('entity response semantics', () => { ).resolves.toBe(true); expect(attempt).toBe(2); }); + + it('aborts and retries a Gaia probe that never settles', async () => { + vi.useFakeTimers(); + let aborted = false; + const polling = waitForIndexedEntityResponse( + signal => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(new Error('aborted')); + }); + }), + 'positive', + 1, + 0, + 50 + ); + + await vi.advanceTimersByTimeAsync(50); + + await expect(polling).resolves.toBe(false); + expect(aborted).toBe(true); + vi.useRealTimers(); + }); + + it('stops polling when a reconciliation run is superseded', async () => { + const controller = new AbortController(); + let attempts = 0; + const polling = waitForIndexedEntityResponse( + signal => { + attempts += 1; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }, + 'positive', + 30, + 2_000, + 5_000, + controller.signal + ); + await Promise.resolve(); + + controller.abort(); + + await expect(polling).resolves.toBe(false); + expect(attempts).toBe(1); + }); }); describe('entity response query keys', () => { diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index beb6e650b1..6dee58972f 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -142,20 +142,50 @@ export function decodeActiveResponseDirection(voteType: unknown): ActiveResponse } export async function waitForIndexedEntityResponse( - fetchResponse: () => Promise, + fetchResponse: (signal: AbortSignal) => Promise, expectedResponse: ActiveResponseDirection | null, maxAttempts = 30, - intervalMs = 2_000 + intervalMs = 2_000, + probeTimeoutMs = 5_000, + signal?: AbortSignal ): Promise { + const deadline = Date.now() + Math.max(probeTimeoutMs, maxAttempts * intervalMs); for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal?.aborted) return false; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + const controller = new AbortController(); + let rejectTimeout: ((reason: Error) => void) | undefined; + const timeoutPromise = new Promise((_, reject) => { + rejectTimeout = reject; + }); + const abortProbe = () => { + controller.abort(); + rejectTimeout?.(new Error('Response indexing reconciliation was superseded')); + }; + signal?.addEventListener('abort', abortProbe, { once: true }); + const timeoutId = setTimeout( + () => { + controller.abort(); + rejectTimeout?.(new Error('Timed out waiting for Gaia response indexing')); + }, + Math.min(probeTimeoutMs, remainingMs) + ); + try { - if ((await fetchResponse()) === expectedResponse) return true; + if ((await Promise.race([fetchResponse(controller.signal), timeoutPromise])) === expectedResponse) { + return true; + } } catch { // Gaia can fail transiently while indexing. Keep the transaction in its // processing state and retry instead of presenting a false write error. + } finally { + clearTimeout(timeoutId); + signal?.removeEventListener('abort', abortProbe); } if (attempt < maxAttempts) { + if (signal?.aborted) return false; await sleep(intervalMs); } } From 4f2f254bbdb4f3728666a18fc3bcd04d74ab3c4b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 13:36:39 +0200 Subject: [PATCH 08/15] fix(debates): remove legacy position labels --- .../[sessionId]/rematch-page-client.test.tsx | 6 +- .../[sessionId]/rematch-page-client.tsx | 11 +--- .../debates/claim-debate-readiness.test.tsx | 15 +++++ .../core/debates/claim-debate-readiness.tsx | 56 +++++++++++++++---- apps/web/core/responses/entity-response.ts | 5 ++ 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index f9c628e68a..16fb9b1fc3 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -215,7 +215,7 @@ describe('DebateRematchPageClient', () => { expect(document.documentElement.style.overflow).toBe(''); }); - it('falls back to Yes and No for legacy incoming requests without response metadata', () => { + it('falls back to Agree and Disagree for incoming requests without response metadata', () => { mocks.session = session({ status: 'request_pending', request: { @@ -235,8 +235,8 @@ describe('DebateRematchPageClient', () => { render(); const dialog = screen.getByRole('dialog', { name: 'A claim both participants chose' }); - expect(within(within(dialog).getByText('You').parentElement!).getByText('Yes')).toBeInTheDocument(); - expect(within(within(dialog).getByText('Salina').parentElement!).getByText('No')).toBeInTheDocument(); + expect(within(within(dialog).getByText('You').parentElement!).getByText('Agree')).toBeInTheDocument(); + expect(within(within(dialog).getByText('Salina').parentElement!).getByText('Disagree')).toBeInTheDocument(); }); it('disables debate requests while a rematch request is pending', () => { diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index dff3ff8718..215ac8836e 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -26,6 +26,7 @@ import { useLeaveDebateRematch, useRejectDebateRematchRequest, } from '~/core/debates/hooks'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import { Avatar } from '~/design-system/avatar'; @@ -204,7 +205,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { return { ...participant, position, - position_label: positionLabel ?? (position ? 'Yes' : 'No'), + position_label: positionLabel ?? responsePositionLabel(incomingRequest.response_kind ?? null, position), }; }) : []; @@ -399,7 +400,7 @@ function RematchClaimCard({ participant => participant.position === position && participant.position_label ); const positionLabel = - positionSnapshot?.position_label ?? fallbackRematchPositionLabel(claim.response_kind, position); + positionSnapshot?.position_label ?? responsePositionLabel(claim.response_kind, position); const holders = session ? claim.participants .filter(participant => participant.position === position) @@ -452,12 +453,6 @@ function RematchClaimCard({ ); } -function fallbackRematchPositionLabel(responseKind: DebateRematchClaim['response_kind'], position: boolean) { - if (responseKind === 'veracity') return position ? 'Verify' : 'Dispute'; - if (responseKind === 'stance') return position ? 'Agree' : 'Disagree'; - return null; -} - function rematchCancellationMessage(reason: string) { switch (reason) { case 'claim_response_withdrawn': diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index 399487c705..478ab35f63 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -106,6 +106,21 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByText('This claim’s response type changed. Respond and join again.')).toBeInTheDocument(); }); + + it('keeps response actions available while debate matchmaking is disabled', () => { + renderReadiness( + claim({ + viewer_response: null, + readiness_disabled_reason: 'response_derived_positions_disabled', + }) + ); + + expect(screen.getByText('Debate matchmaking is temporarily unavailable.')).toBeInTheDocument(); + expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); + expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); + expect(screen.queryByText('Respond before joining')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); + }); }); function renderReadiness(debateClaim: DebateClaim, indexingStatus: 'idle' | 'reconciling' = 'idle') { diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index b548037827..df8dec26e2 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -3,6 +3,7 @@ import cx from 'classnames'; import { useEntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; +import { responsePositionLabel } from '~/core/responses/entity-response'; import { Avatar } from '~/design-system/avatar'; import { Text } from '~/design-system/text'; @@ -28,17 +29,55 @@ export function ClaimDebateReadiness({ className, textVariant = 'metadata', }: ClaimDebateReadinessProps) { + if (!debateClaim) return null; + + const isMatchmakingDisabled = debateClaim.readiness_disabled_reason === 'response_derived_positions_disabled'; + + if (isMatchmakingDisabled) { + return ( +
+
+ + {readinessReasonMessage(debateClaim.readiness_disabled_reason)} + + +
+
+ ); + } + + return ( + + ); +} + +function EnabledClaimDebateReadiness({ + debateClaim, + entityId, + spaceId, + canToggle, + className, + textVariant = 'metadata', +}: Omit & { debateClaim: DebateClaim }) { const joinQueue = useJoinDebateQueue(spaceId); const leaveQueue = useLeaveDebateQueue(spaceId); - const responseIndexingState = useEntityResponseIndexingState({ entityId, spaceId, - responseKind: debateClaim?.response_kind ?? null, + responseKind: debateClaim.response_kind, }); - if (!debateClaim) return null; - const isReady = debateClaim.viewer_debate_ready; const isResponseProcessing = responseIndexingState !== 'idle'; const isPending = joinQueue.isPending || leaveQueue.isPending; @@ -115,6 +154,8 @@ function readinessReasonMessage(reason: string | null) { return 'This claim’s response type changed. Respond and join again.'; case 'claim_response_validation_failed': return 'Your response could not be verified yet. You’ll remain ready while verification retries.'; + case 'response_derived_positions_disabled': + return 'Debate matchmaking is temporarily unavailable.'; default: return reason; } @@ -130,7 +171,7 @@ function OnlineChoices({ responseKind, choices }: { responseKind: DebateResponse {[true, false].map(position => { const choice = choices.find(candidate => candidate.position === position); const participantCount = choice?.participant_count ?? 0; - const label = choice?.position_label || fallbackPositionLabel(responseKind, position); + const label = choice?.position_label || responsePositionLabel(responseKind, position); return (
= { }, }; +export function responsePositionLabel(responseKind: 'stance' | 'veracity' | null, position: boolean) { + const copy = ENTITY_RESPONSE_COPY[responseKind ?? 'stance']; + return position ? copy.positiveAction : copy.negativeAction; +} + export function getEntityResponseKind({ isClaim, isFactual }: { isClaim: boolean; isFactual: boolean }): ResponseKind { if (!isClaim) return 'curation'; return isFactual ? 'veracity' : 'stance'; From dc494b54a0017b6d573e06ce49fde728038d5d2a Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Aug 2026 16:21:15 +0200 Subject: [PATCH 09/15] refactor(debates): remove disabled matchmaking state --- .../debates/claim-debate-readiness.test.tsx | 14 ----------- .../core/debates/claim-debate-readiness.tsx | 25 ++----------------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index 478ab35f63..c3ac81575d 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -107,20 +107,6 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByText('This claim’s response type changed. Respond and join again.')).toBeInTheDocument(); }); - it('keeps response actions available while debate matchmaking is disabled', () => { - renderReadiness( - claim({ - viewer_response: null, - readiness_disabled_reason: 'response_derived_positions_disabled', - }) - ); - - expect(screen.getByText('Debate matchmaking is temporarily unavailable.')).toBeInTheDocument(); - expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); - expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); - expect(screen.queryByText('Respond before joining')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); - }); }); function renderReadiness(debateClaim: DebateClaim, indexingStatus: 'idle' | 'reconciling' = 'idle') { diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index df8dec26e2..c3d0388ad7 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -31,27 +31,8 @@ export function ClaimDebateReadiness({ }: ClaimDebateReadinessProps) { if (!debateClaim) return null; - const isMatchmakingDisabled = debateClaim.readiness_disabled_reason === 'response_derived_positions_disabled'; - - if (isMatchmakingDisabled) { - return ( -
-
- - {readinessReasonMessage(debateClaim.readiness_disabled_reason)} - - -
-
- ); - } - return ( - Date: Fri, 7 Aug 2026 18:41:49 +0200 Subject: [PATCH 10/15] feat(debates): add persistent readiness toggle --- .../claims/claims-page-client.test.tsx | 89 ++--- .../(space)/claims/claims-page-client.tsx | 2 +- .../debates/browse/join-debate-panel.test.tsx | 54 ++- .../core/debates/browse/join-debate-panel.tsx | 2 +- .../core/debates/claim-debate-button.test.tsx | 57 +-- apps/web/core/debates/claim-debate-button.tsx | 102 +---- .../debates/claim-debate-readiness.test.tsx | 281 ++++++++++--- .../core/debates/claim-debate-readiness.tsx | 378 ++++++++++++------ .../debate-entity-response-controls.test.tsx | 19 +- .../debate-entity-response-controls.tsx | 1 - .../core/debates/debate-readiness-intent.ts | 60 +++ apps/web/core/hooks/use-entity-vote.test.tsx | 78 +++- apps/web/core/hooks/use-entity-vote.ts | 135 +++++-- .../core/responses/entity-response.test.ts | 25 +- apps/web/core/responses/entity-response.ts | 9 +- apps/web/core/sync/use-store.tsx | 5 +- .../entity-page-metadata-header.tsx | 2 +- .../entity-page/entity-vote-buttons.tsx | 126 ++---- 18 files changed, 889 insertions(+), 536 deletions(-) create mode 100644 apps/web/core/debates/debate-readiness-intent.ts diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx index c018eb7f36..a35929a48b 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx @@ -1,6 +1,9 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -36,11 +39,14 @@ vi.mock('~/core/state/feature-flags', () => ({ vi.mock('~/core/hooks/use-entity-vote', () => ({ useEntityResponseIndexingState: () => 'idle', + useEntityResponseIndexingSnapshot: () => ({ status: 'idle', pending: null, runId: null }), + useResetEntityResponseIndexingSnapshot: () => vi.fn(), })); vi.mock('~/core/debates/hooks', () => ({ + useGeoChatAuth: () => ({ authenticated: true, accountKey: 'account-1' }), useDebateClaims: () => ({ data: debateClaimsResponse, error: null }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: joinPending, error: null }), + useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: joinPending, error: null }), useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), useAcceptDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), useDeclineDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), @@ -93,7 +99,7 @@ afterEach(() => cleanup()); describe('ClaimsPageClient', () => { it('queries Claim entities and renders the empty state', () => { - render(); + renderClaims(); expect(screen.getByRole('heading', { name: 'Claims' })).toBeInTheDocument(); expect(screen.getByText('No claims yet')).toBeInTheDocument(); @@ -106,7 +112,7 @@ describe('ClaimsPageClient', () => { }); it('stages a claim with Claim and Topics relations only', () => { - render(); + renderClaims(); fireEvent.click(screen.getByRole('button', { name: 'Add claim' })); fireEvent.change(screen.getByLabelText('Claim'), { @@ -122,26 +128,28 @@ describe('ClaimsPageClient', () => { expect(mocks.setIsReviewOpen).toHaveBeenCalledWith(true); }); - it('shows no readiness toggle until a refetch exposes the viewer response', () => { + it('enables the readiness switch after a refetch exposes the viewer response', () => { claims = [publishedClaim()]; debateClaimsResponse = { claims: [debateClaim({ viewer_response: null })], }; - const { rerender } = render(); + const { rerender } = renderClaims(); - expect(screen.getByText('Respond before joining')).toBeInTheDocument(); expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); debateClaimsResponse = { claims: [debateClaim({ viewer_response: { position: true, position_label: 'Agree' } })], }; rerender(); - fireEvent.click(screen.getByRole('button', { name: 'Join debate' })); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + expect(mocks.joinMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); }); it('renders backend response labels and one leave-readiness toggle', () => { @@ -152,48 +160,20 @@ describe('ClaimsPageClient', () => { viewer_response: { position: true, position_label: 'Verify' }, viewer_debate_ready: true, response_kind: 'veracity', - online_choices: [ - { - position: true, - position_label: 'Verify', - participant_count: 4, - participants: [ - { - user_id: 'viewer-user', - profile_space_id: 'viewer-profile', - display_name: 'Current viewer', - avatar_cid: 'https://example.com/viewer.png', - }, - { - user_id: 'other-user', - profile_space_id: 'other-profile', - display_name: 'Other participant', - avatar_cid: null, - }, - ], - }, - { - position: false, - position_label: 'Dispute', - participant_count: 0, - participants: [], - }, - ], + online_choices: [], }), ], }; - render(); + renderClaims(); - const verifyAvailability = screen.getByLabelText('Verify, 4 participants available'); - expect(within(verifyAvailability).getByTitle('Current viewer')).toBeInTheDocument(); - expect(within(verifyAvailability).getByTitle('Other participant')).toBeInTheDocument(); - expect(within(verifyAvailability).getByText('+2')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Verify' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Dispute' })).not.toBeInTheDocument(); + expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Leave debate' })); - expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + expect(mocks.leaveMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); }); it('keeps readiness disabled while joining, unpublished, or matched', () => { @@ -204,9 +184,9 @@ describe('ClaimsPageClient', () => { }; joinPending = true; - const { rerender } = render(); + const { rerender } = renderClaims(); - expect(screen.getByRole('button', { name: 'Join debate' })).toBeDisabled(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); joinPending = false; claims = [ @@ -224,7 +204,7 @@ describe('ClaimsPageClient', () => { rerender(); expect(screen.getByText('Publish this claim before starting a debate.')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('switch', { name: 'Debate' })).not.toBeInTheDocument(); claims = [published]; debateClaimsResponse = { @@ -233,10 +213,19 @@ describe('ClaimsPageClient', () => { rerender(); expect(screen.getByText('Match found. Both speakers need to accept.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Join debate' })).toBeDisabled(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); }); }); +function renderClaims() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + function publishedClaim(): Entity { return { id: 'claim-1', diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx index 7346f687cf..3e18208969 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx @@ -316,7 +316,7 @@ function ClaimListItem({ debateClaim={debateClaim} entityId={claim.id} spaceId={spaceId} - canToggle={!activeDebate && !activeMatch && !debateJoinBlocked} + canEnable={!activeDebate && !activeMatch && !debateJoinBlocked} className="mt-3" textVariant="body" /> diff --git a/apps/web/core/debates/browse/join-debate-panel.test.tsx b/apps/web/core/debates/browse/join-debate-panel.test.tsx index 50b2592c24..dd757e3fe0 100644 --- a/apps/web/core/debates/browse/join-debate-panel.test.tsx +++ b/apps/web/core/debates/browse/join-debate-panel.test.tsx @@ -1,6 +1,9 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DebateClaim } from '../api'; @@ -14,15 +17,18 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('~/core/debates/hooks', () => ({ + useGeoChatAuth: () => ({ authenticated: true, accountKey: 'account-1' }), useDebateClaims: () => ({ data: { claims: mocks.claims }, isLoading: false, error: null }), useDebateActivity: () => ({ data: { available_to_debate: false }, isPending: false }), useUpdateDebateAvailability: () => ({ mutate: mocks.availabilityMutate, isPending: false }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), })); vi.mock('~/core/hooks/use-entity-vote', () => ({ useEntityResponseIndexingState: () => 'idle', + useEntityResponseIndexingSnapshot: () => ({ status: 'idle', pending: null, runId: null }), + useResetEntityResponseIndexingSnapshot: () => vi.fn(), })); vi.mock('~/core/sync/use-store', () => ({ @@ -41,14 +47,13 @@ beforeEach(() => { afterEach(cleanup); describe('JoinDebatePanel', () => { - it('prompts for a response without a readiness toggle', () => { + it('prompts for a response with a disabled readiness switch', () => { mocks.claims = [claim({ viewer_response: null })]; - render(); + renderPanel(); - expect(screen.getByText('Respond before joining')).toBeInTheDocument(); expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); }); it('renders backend labels and one bodyless join toggle after a response', () => { @@ -56,32 +61,43 @@ describe('JoinDebatePanel', () => { claim({ response_kind: 'veracity', viewer_response: { position: false, position_label: 'Dispute' }, - online_choices: [choice(true, 'Verified'), choice(false, 'Disputed')], + online_choices: [], }), ]; - render(); + renderPanel(); - expect(screen.getByText('Your response: Dispute')).toBeInTheDocument(); - expect(screen.getByText('Verified')).toBeInTheDocument(); - expect(screen.getByText('Disputed')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Verified' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Disputed' })).not.toBeInTheDocument(); + expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Join debate' })); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + expect(mocks.joinMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); }); it('leaves through the same readiness toggle', () => { mocks.claims = [claim({ viewer_debate_ready: true })]; - render(); + renderPanel(); - fireEvent.click(screen.getByRole('button', { name: 'Leave debate' })); - expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + expect(mocks.leaveMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); }); }); +function renderPanel() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + function claim(overrides: Partial = {}): DebateClaim { return { id: 'debate-claim-1', @@ -102,7 +118,3 @@ function claim(overrides: Partial = {}): DebateClaim { ...overrides, }; } - -function choice(position: boolean, positionLabel: string) { - return { position, position_label: positionLabel, participant_count: 0, participants: [] }; -} diff --git a/apps/web/core/debates/browse/join-debate-panel.tsx b/apps/web/core/debates/browse/join-debate-panel.tsx index 5a834eba96..dc679a7b30 100644 --- a/apps/web/core/debates/browse/join-debate-panel.tsx +++ b/apps/web/core/debates/browse/join-debate-panel.tsx @@ -155,7 +155,7 @@ function JoinDebateCard({ debateClaim={debateClaim} entityId={debateClaim.claim_entity_id} spaceId={spaceId} - canToggle={canJoin} + canEnable={canJoin} className="mt-3" /> diff --git a/apps/web/core/debates/claim-debate-button.test.tsx b/apps/web/core/debates/claim-debate-button.test.tsx index 2bb94d1c5e..3b407c6c5e 100644 --- a/apps/web/core/debates/claim-debate-button.test.tsx +++ b/apps/web/core/debates/claim-debate-button.test.tsx @@ -1,6 +1,9 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; @@ -22,6 +25,8 @@ vi.mock('~/core/state/feature-flags', () => ({ vi.mock('~/core/hooks/use-entity-vote', () => ({ useEntityResponseIndexingState: () => 'idle', + useEntityResponseIndexingSnapshot: () => ({ status: 'idle', pending: null, runId: null }), + useResetEntityResponseIndexingSnapshot: () => vi.fn(), })); vi.mock('~/core/sync/use-store', () => ({ @@ -29,9 +34,10 @@ vi.mock('~/core/sync/use-store', () => ({ })); vi.mock('./hooks', () => ({ + useGeoChatAuth: () => ({ authenticated: true, accountKey: 'account-1' }), useDebateClaims: () => mocks.debateClaims(), useDebateActivity: () => ({ data: null }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), })); @@ -77,38 +83,30 @@ function debateClaim(overrides: Partial = {}): DebateClaim { } as unknown as DebateClaim; } -function openPopover() { - fireEvent.click(screen.getByRole('button', { name: 'Debate' })); -} - describe('ClaimDebateButton', () => { it('renders nothing when the entity is not a Claim', () => { - render( + renderButton( ); - expect(screen.queryByRole('button', { name: 'Debate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('switch', { name: 'Debate' })).not.toBeInTheDocument(); }); - it('prompts to publish without rendering readiness controls for an unpublished claim', () => { - render(); - openPopover(); + it('renders a disabled compact switch for an unpublished claim', () => { + renderButton(); - expect(screen.getByText('Publish this claim before starting a debate.')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); }); - it('requires a claim response before showing the readiness toggle', () => { + it('shows the disabled Debate switch before a claim response', () => { mocks.debateClaims.mockReturnValue({ data: { claims: [debateClaim()] } }); - render(); - openPopover(); + renderButton(); - expect(screen.getByText('Respond before joining')).toBeInTheDocument(); - expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join debate' })).not.toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); + expect(screen.queryByText('Respond before debating', { selector: 'p' })).not.toBeInTheDocument(); }); - it('shows one readiness toggle for the backend response and leaves when ready', () => { + it('shows the inline checked Debate switch and leaves when ready', () => { mocks.debateClaims.mockReturnValue({ data: { claims: [ @@ -119,17 +117,22 @@ describe('ClaimDebateButton', () => { ], }, }); - render(); - openPopover(); + renderButton(); - expect(screen.getByText('Your response: Agree')).toBeInTheDocument(); - const leave = screen.getByRole('button', { name: 'Leave debate' }); - expect(leave).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getAllByRole('button', { name: /debate/i })).toHaveLength(2); - expect(screen.getByText('Waiting for someone with the opposite response.')).toBeInTheDocument(); + const leave = screen.getByRole('switch', { name: 'Debate' }); + expect(leave).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText('Waiting for someone with the opposite response.')).not.toBeInTheDocument(); fireEvent.click(leave); - expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-entity-1' }); + expect(mocks.leaveMutate).toHaveBeenCalledWith( + { claimId: 'claim-entity-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); expect(mocks.joinMutate).not.toHaveBeenCalled(); }); }); + +function renderButton(button: ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({button}); +} diff --git a/apps/web/core/debates/claim-debate-button.tsx b/apps/web/core/debates/claim-debate-button.tsx index cca42be93f..3b87aabc3d 100644 --- a/apps/web/core/debates/claim-debate-button.tsx +++ b/apps/web/core/debates/claim-debate-button.tsx @@ -1,128 +1,48 @@ 'use client'; -import * as Popover from '@radix-ui/react-popover'; - -import * as React from 'react'; - -import cx from 'classnames'; - import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { isClaimPublished } from '~/core/claims/publish'; import { useDebatesEnabled } from '~/core/state/feature-flags'; import { useQueryEntity } from '~/core/sync/use-store'; import type { Entity } from '~/core/types'; -import { Text } from '~/design-system/text'; - -import type { DebateClaim } from './api'; import { ClaimDebateReadiness } from './claim-debate-readiness'; import { useDebateActivity, useDebateClaims } from './hooks'; type ClaimDebateButtonProps = { entityId: string; spaceId: string; - /** - * The entity, if the parent already subscribes to it (e.g. the entity-page - * header). Passing it avoids a duplicate `useQueryEntity` subscription on the - * entity-page hot path; omit it to let the button fetch on its own. - */ + /** Avoids a duplicate entity subscription when the parent already has the entity. */ entity?: Entity | null; }; export function ClaimDebateButton({ entityId, spaceId, entity: providedEntity }: ClaimDebateButtonProps) { const isDebatesEnabled = useDebatesEnabled(); - const { entity: fetchedEntity } = useQueryEntity({ id: entityId, spaceId, enabled: isDebatesEnabled && providedEntity == null, }); const entity = providedEntity ?? fetchedEntity; - const isClaim = entity?.types.some(type => type.id === CLAIM_TYPE_ID) ?? false; const published = entity ? isClaimPublished(entity) : false; const debateClaimsQuery = useDebateClaims(spaceId, published ? [entityId] : [], isDebatesEnabled && isClaim); const debateClaim = debateClaimsQuery.data?.claims.find(claim => claim.claim_entity_id === entityId) ?? null; - - const activityQuery = useDebateActivity(isDebatesEnabled && isClaim); - const activity = activityQuery.data ?? null; + const activity = useDebateActivity(isDebatesEnabled && isClaim).data ?? null; const hasActiveFlowElsewhere = Boolean(activity?.match || activity?.debate || activity?.rematch); if (!isDebatesEnabled || !isClaim) return null; - const activeMatch = debateClaim?.active_match ?? null; - const activeDebate = debateClaim?.active_debate ?? null; - const canJoinDebate = published && !activeDebate && !activeMatch && !hasActiveFlowElsewhere; - return ( - - - - - - - - Debate this claim - - - Respond to the claim, then choose whether you’re ready to debate. - + const canEnable = published && !debateClaim?.active_debate && !debateClaim?.active_match && !hasActiveFlowElsewhere; - {published && ( - - )} - - - - - + return ( + ); } - -function ClaimDebateStatus({ debateClaim, published }: { debateClaim: DebateClaim | null; published: boolean }) { - if (!published) { - return ( - - Publish this claim before starting a debate. - - ); - } - - if (debateClaim?.active_debate) { - return ( - - Debate {debateClaim.active_debate.status.replace('_', ' ')} - - ); - } - - if (debateClaim?.active_match) { - return ( - - Match found. Both speakers need to accept. - - ); - } - - return null; -} diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index c3ac81575d..517563dc80 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -1,25 +1,35 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { entityResponseIndexingQueryKey } from '~/core/responses/entity-response'; -import type { DebateClaim } from './api'; +import { type DebateClaim, GeoChatRequestError } from './api'; import { ClaimDebateReadiness } from './claim-debate-readiness'; const mocks = vi.hoisted(() => ({ joinMutate: vi.fn(), + joinReset: vi.fn(), leaveMutate: vi.fn(), responseKinds: [] as Array<'stance' | 'veracity' | null>, + authenticated: true, + accountKey: 'account-1' as string | null, })); vi.mock('./hooks', () => ({ - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, isPending: false, error: null }), + useGeoChatAuth: () => ({ authenticated: mocks.authenticated, accountKey: mocks.accountKey }), + useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: mocks.joinReset, isPending: false, error: null }), useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), })); +vi.mock('~/core/hooks/use-personal-space-id', () => ({ + usePersonalSpaceId: () => ({ personalSpaceId: 'profile-space-1', isRegistered: true }), +})); + vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ EntityVoteButtons: ({ responseKind }: { responseKind: 'stance' | 'veracity' | null }) => { mocks.responseKinds.push(responseKind); @@ -29,106 +39,274 @@ vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ beforeEach(() => { mocks.joinMutate.mockReset(); + mocks.joinReset.mockReset(); mocks.leaveMutate.mockReset(); mocks.responseKinds.length = 0; + mocks.authenticated = true; + mocks.accountKey = 'account-1'; }); afterEach(cleanup); describe('ClaimDebateReadiness', () => { - it('prompts for a response without rendering an inert readiness toggle', () => { + it('shows the disabled Debate switch until the user responds', () => { renderReadiness(claim({ viewer_response: null })); - expect(screen.getByText('Respond before joining')).toBeInTheDocument(); expect(screen.getByTestId('entity-response-buttons')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /join debate/i })).not.toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); + expect(screen.queryByText(/join debate|cancel join|check again/i)).not.toBeInTheDocument(); }); - it('renders one readiness toggle after a response and joins without a position body', () => { - renderReadiness( - claim({ - viewer_response: { position: false, position_label: 'Disagree' }, - }) - ); + it('optimistically enables persisted readiness through the Debate switch', async () => { + renderReadiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } })); - expect(screen.getByText('Your response: Disagree')).toBeInTheDocument(); - const toggle = screen.getByRole('button', { name: 'Join debate' }); - expect(screen.getAllByRole('button')).toHaveLength(1); + const toggle = screen.getByRole('switch', { name: 'Debate' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); fireEvent.click(toggle); - expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(1)); + expect(mocks.joinMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); }); - it('leaves readiness through the same toggle', () => { + it('turns persisted readiness off through the same switch', () => { renderReadiness(claim({ viewer_debate_ready: true })); - fireEvent.click(screen.getByRole('button', { name: 'Leave debate' })); + const toggle = screen.getByRole('switch', { name: 'Debate' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(mocks.leaveMutate).toHaveBeenCalledWith( + { claimId: 'claim-1' }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + ); + }); + + it('allows optimistic readiness before indexing without calling geo-chat', () => { + renderReadiness(claim({ viewer_response: null }), indexing('positive')); - expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); + const toggle = screen.getByRole('switch', { name: 'Debate' }); + expect(toggle).toBeEnabled(); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-checked', 'true'); expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(screen.queryByText(/processing response|join debate|cancel join|check again/i)).not.toBeInTheDocument(); }); - it('blocks queue changes while the exact response is still indexing', () => { - renderReadiness(claim(), 'reconciling'); + it('submits readiness exactly once after geo-chat confirms the expected response', async () => { + const view = renderReadiness(claim({ viewer_response: null }), indexing('negative')); - const toggle = screen.getByRole('button', { name: 'Processing response…' }); - expect(toggle).toBeDisabled(); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + expect(mocks.joinMutate).not.toHaveBeenCalled(); + + view.rerender( + readiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } }), view.queryClient) + ); + + await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(1)); + view.rerender( + readiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } }), view.queryClient) + ); + expect(mocks.joinMutate).toHaveBeenCalledTimes(1); + }); + it('turns the optimistic switch off to cancel readiness before confirmation', () => { + renderReadiness(claim({ viewer_response: null }), indexing('positive')); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(mocks.joinMutate).not.toHaveBeenCalled(); + }); + + it('clears optimistic readiness when the response transaction fails', async () => { + const view = renderReadiness(claim({ viewer_response: null }), indexing('positive')); + const toggle = screen.getByRole('switch', { name: 'Debate' }); fireEvent.click(toggle); + + await act(async () => { + view.queryClient.setQueryData( + entityResponseIndexingQueryKey('profile-space-1', 'claim-1', 'space-1', 'stance'), + idleIndexingState() + ); + }); + + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toBeDisabled(); expect(mocks.joinMutate).not.toHaveBeenCalled(); }); - it.each([ - ['stance', 'Agree', 'Disagree'], - ['veracity', 'Verify', 'Dispute'], - ] as const)('falls back to %s response labels', (responseKind, positiveLabel, negativeLabel) => { - renderReadiness( - claim({ - response_kind: responseKind, - online_choices: [choice(true, ''), choice(false, '')], - }) + it('turns readiness off immediately when the response is optimistically withdrawn', () => { + renderReadiness(claim({ viewer_debate_ready: true }), indexingClear()); + + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText('Waiting for someone with the opposite response.')).not.toBeInTheDocument(); + expect(mocks.leaveMutate).not.toHaveBeenCalled(); + }); + + it('refetches and retries once when geo-chat has not observed the response yet', async () => { + mocks.joinMutate.mockImplementationOnce((_variables, options) => { + options.onError(new GeoChatRequestError('Respond before debating', 'claim_response_required', 409)); + }); + renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + + await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(2)); + expect(mocks.joinReset).toHaveBeenCalledOnce(); + expect(screen.queryByText('Respond before debating', { selector: '.text-red-01' })).not.toBeInTheDocument(); + }); + + it('clears readiness intent when a validation refetch removes the server response', async () => { + mocks.joinMutate.mockImplementationOnce((_variables, options) => { + options.onError(new GeoChatRequestError('Respond before debating', 'claim_response_required', 409)); + }); + const view = renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + view.rerender(readiness(claim({ viewer_response: null }), view.queryClient)); + + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false') + ); + expect(mocks.joinMutate).toHaveBeenCalledTimes(1); + }); + + it('retires an indexed response after the debate payload confirms it', async () => { + const view = renderReadiness(claim(), indexed('positive')); + + await waitFor(() => + expect( + view.queryClient.getQueryData(entityResponseIndexingQueryKey('profile-space-1', 'claim-1', 'space-1', 'stance')) + ).toEqual(idleIndexingState()) ); + }); + + it('clears optimistic readiness and surfaces non-transient queue errors', async () => { + mocks.joinMutate.mockImplementationOnce((_variables, options) => { + options.onError(new Error('Queue unavailable')); + }); + renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + + await waitFor(() => expect(screen.getByText('Queue unavailable')).toBeInTheDocument()); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('cancels pending readiness when the response kind changes', () => { + const view = renderReadiness(claim({ viewer_response: null }), indexing('positive')); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(screen.getByText(positiveLabel)).toBeInTheDocument(); - expect(screen.getByText(negativeLabel)).toBeInTheDocument(); - expect(mocks.responseKinds).toContain(responseKind); + view.rerender(readiness(claim({ response_kind: 'veracity', viewer_response: null }), view.queryClient)); + + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); + expect(mocks.joinMutate).not.toHaveBeenCalled(); + }); + + it('cancels pending readiness when the user signs out', () => { + const view = renderReadiness(claim({ viewer_response: null }), indexing('positive')); + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + + mocks.authenticated = false; + mocks.accountKey = null; + view.rerender(readiness(claim({ viewer_response: null }), view.queryClient)); + + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + expect(mocks.joinMutate).not.toHaveBeenCalled(); }); - it('shows the backend readiness-disabled reason after a refetch', () => { - renderReadiness(claim({ readiness_disabled_reason: 'Your response changed, so debate readiness was removed.' })); + it('renders only the compact Debate switch in claim action rows', () => { + renderReadiness(claim(), idleIndexingState(), true); - expect(screen.getByText('Your response changed, so debate readiness was removed.')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeInTheDocument(); + expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); + expect(screen.queryByText(/your response/i)).not.toBeInTheDocument(); }); it('turns backend lifecycle codes into actionable readiness messages', () => { renderReadiness(claim({ readiness_disabled_reason: 'claim_response_kind_changed' })); - expect(screen.getByText('This claim’s response type changed. Respond and join again.')).toBeInTheDocument(); + expect( + screen.getByText('This claim’s response type changed. Respond and enable Debate again.') + ).toBeInTheDocument(); }); - }); -function renderReadiness(debateClaim: DebateClaim, indexingStatus: 'idle' | 'reconciling' = 'idle') { +type IndexingState = + | ReturnType + | ReturnType + | ReturnType + | ReturnType; + +function renderReadiness( + debateClaim: DebateClaim, + indexingState: IndexingState = idleIndexingState(), + compact = false +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - queryClient.setQueryData(entityResponseIndexingQueryKey('claim-1', 'space-1', debateClaim.response_kind), { - status: indexingStatus, - pending: null, - }); + queryClient.setQueryData( + entityResponseIndexingQueryKey('profile-space-1', 'claim-1', 'space-1', debateClaim.response_kind), + indexingState + ); + + return { queryClient, ...render(readiness(debateClaim, queryClient, compact)) }; +} - return render( +function readiness(debateClaim: DebateClaim, queryClient: QueryClient, compact = false): ReactElement { + return ( ); } +function idleIndexingState() { + return { status: 'idle' as const, pending: null, runId: null }; +} + +function indexing(expectedResponse: 'positive' | 'negative') { + return { + status: 'reconciling' as const, + pending: { + entityId: 'claim-1', + expectedResponse, + personalSpaceId: 'profile-space-1', + responseKind: 'stance' as const, + spaceId: 'space-1', + }, + runId: 'response-run-1', + }; +} + +function indexingClear() { + return { + ...indexing('positive'), + pending: { ...indexing('positive').pending, expectedResponse: null }, + }; +} + +function indexed(expectedResponse: 'positive' | 'negative') { + return { ...indexing(expectedResponse), status: 'indexed' as const }; +} + function claim(overrides: Partial = {}): DebateClaim { return { id: 'debate-claim-1', @@ -149,12 +327,3 @@ function claim(overrides: Partial = {}): DebateClaim { ...overrides, }; } - -function choice(position: boolean, positionLabel: string) { - return { - position, - position_label: positionLabel, - participant_count: 0, - participants: [], - }; -} diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index c3d0388ad7..de02f9a617 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -1,44 +1,58 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; + +import * as React from 'react'; + import cx from 'classnames'; -import { useEntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; -import { responsePositionLabel } from '~/core/responses/entity-response'; +import { + useEntityResponseIndexingSnapshot, + useResetEntityResponseIndexingSnapshot, +} from '~/core/hooks/use-entity-vote'; -import { Avatar } from '~/design-system/avatar'; import { Text } from '~/design-system/text'; +import { Toggle } from '~/design-system/toggle'; -import type { DebateClaim, DebateOnlineChoice, DebateResponseKind } from './api'; +import { type DebateClaim, GeoChatRequestError } from './api'; import { DebateEntityResponseControls } from './debate-entity-response-controls'; -import { useJoinDebateQueue, useLeaveDebateQueue } from './hooks'; +import { useDebateReadinessIntent } from './debate-readiness-intent'; +import { useGeoChatAuth, useJoinDebateQueue, useLeaveDebateQueue } from './hooks'; type ClaimDebateReadinessProps = { debateClaim: DebateClaim | null; entityId: string; spaceId: string; - canToggle: boolean; + canEnable: boolean; className?: string; textVariant?: 'metadata' | 'body'; + compact?: boolean; }; export function ClaimDebateReadiness({ debateClaim, entityId, spaceId, - canToggle, + canEnable, className, textVariant = 'metadata', + compact = false, }: ClaimDebateReadinessProps) { - if (!debateClaim) return null; + if (!debateClaim) { + return compact ? ( + + ) : null; + } return ( ); } @@ -47,73 +61,213 @@ function ClaimDebateReadinessContent({ debateClaim, entityId, spaceId, - canToggle, + canEnable, className, textVariant = 'metadata', + compact = false, }: Omit & { debateClaim: DebateClaim }) { + const queryClient = useQueryClient(); const joinQueue = useJoinDebateQueue(spaceId); const leaveQueue = useLeaveDebateQueue(spaceId); - const responseIndexingState = useEntityResponseIndexingState({ + const { accountKey, authenticated } = useGeoChatAuth(); + const responseIndexing = useEntityResponseIndexingSnapshot({ + entityId, + spaceId, + responseKind: debateClaim.response_kind, + }); + const resetResponseIndexing = useResetEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind: debateClaim.response_kind, }); + const { + intent, + queryKey: intentQueryKey, + setIntent, + } = useDebateReadinessIntent(accountKey, spaceId, entityId, debateClaim.response_kind); + const [intentError, setIntentError] = React.useState(null); + const previousIntentKeyRef = React.useRef(intentQueryKey); + + React.useEffect(() => { + const previousKey = previousIntentKeyRef.current; + if (previousKey !== intentQueryKey) { + queryClient.setQueryData(previousKey, null); + previousIntentKeyRef.current = intentQueryKey; + } + }, [intentQueryKey, queryClient]); + + const pendingResponse = responseIndexing.status === 'idle' ? null : responseIndexing.pending; + const optimisticPosition = + pendingResponse?.expectedResponse == null ? null : pendingResponse.expectedResponse === 'positive'; + const viewerPosition = pendingResponse ? optimisticPosition : (debateClaim.viewer_response?.position ?? null); + const backendReady = debateClaim.viewer_debate_ready; + const responseWithdrawalPending = Boolean(pendingResponse && pendingResponse.expectedResponse === null); + const checked = responseWithdrawalPending ? false : (intent?.desiredReady ?? backendReady); + const isSaving = + intent?.status === 'submitting' || intent?.status === 'settling' || joinQueue.isPending || leaveQueue.isPending; + const canEnableToggle = viewerPosition !== null && authenticated && canEnable; + const disabled = isSaving || (!checked && !canEnableToggle); + + React.useEffect( + function reconcileReadinessIntent() { + if (!intent) return; + if (!authenticated || !accountKey) { + setIntent(null); + return; + } + + if (!intent.desiredReady) { + if (!backendReady) setIntent(null); + return; + } + + if (backendReady) { + setIntent(null); + return; + } + + if (intent.responseRunId && (intent.status === 'waiting' || intent.status === 'refreshing')) { + if (responseIndexing.status === 'idle' || responseIndexing.runId !== intent.responseRunId) { + setIntent(null); + return; + } + if ( + !responseIndexing.pending?.expectedResponse || + (responseIndexing.pending.expectedResponse === 'positive') !== intent.expectedPosition + ) { + setIntent(null); + return; + } + } + + if (debateClaim.viewer_response?.position !== intent.expectedPosition) { + if (!intent.responseRunId || responseIndexing.status === 'idle') setIntent(null); + return; + } + if (intent.status !== 'waiting') return; + + setIntent({ ...intent, status: 'submitting' }); + joinQueue.mutate( + { claimId: entityId }, + { + onSuccess: () => { + const current = queryClient.getQueryData(intentQueryKey); + if (current?.desiredReady) setIntent({ ...current, status: 'settling' }); + }, + onError: error => { + const current = queryClient.getQueryData(intentQueryKey); + if ( + current?.desiredReady && + error instanceof GeoChatRequestError && + error.code === 'claim_response_required' && + !current.hasRetried + ) { + setIntent({ ...current, hasRetried: true, status: 'refreshing' }); + joinQueue.reset(); + void queryClient.refetchQueries({ queryKey: ['debates', 'claims', spaceId] }).finally(() => { + const refreshed = queryClient.getQueryData(intentQueryKey); + if (refreshed?.desiredReady && refreshed.status === 'refreshing') { + setIntent({ ...refreshed, status: 'waiting' }); + } + }); + return; + } + + setIntent(null); + setIntentError(error instanceof Error ? error.message : 'Could not update debate readiness.'); + }, + } + ); + }, + [ + accountKey, + authenticated, + backendReady, + debateClaim.viewer_response?.position, + entityId, + intent, + intentQueryKey, + joinQueue, + queryClient, + responseIndexing, + setIntent, + spaceId, + ] + ); + + React.useEffect( + function retireConfirmedOptimisticResponse() { + if (responseIndexing.status !== 'indexed') return; + const expectedResponse = responseIndexing.pending.expectedResponse; + const confirmed = + expectedResponse === null + ? debateClaim.viewer_response === null + : debateClaim.viewer_response?.position === (expectedResponse === 'positive'); + if (confirmed) resetResponseIndexing(responseIndexing.runId); + }, + [debateClaim.viewer_response, resetResponseIndexing, responseIndexing] + ); + + const handleToggle = () => { + setIntentError(null); + + if (checked) { + if (intent?.desiredReady) { + setIntent(null); + return; + } + if (!backendReady) return; + + setIntent({ desiredReady: false, status: 'submitting' }); + leaveQueue.mutate( + { claimId: entityId }, + { + onSuccess: () => setIntent({ desiredReady: false, status: 'settling' }), + onError: error => { + setIntent(null); + setIntentError(error instanceof Error ? error.message : 'Could not update debate readiness.'); + }, + } + ); + return; + } + + if (viewerPosition === null || !canEnableToggle) return; + setIntent({ + desiredReady: true, + expectedPosition: viewerPosition, + responseRunId: pendingResponse ? responseIndexing.runId : null, + hasRetried: false, + status: 'waiting', + }); + }; + + const toggle = ( + + ); + + if (compact) return toggle; - const isReady = debateClaim.viewer_debate_ready; - const isResponseProcessing = responseIndexingState !== 'idle'; - const isPending = joinQueue.isPending || leaveQueue.isPending; const mutationError = - joinQueue.error instanceof Error + intentError ?? + (joinQueue.error instanceof Error ? joinQueue.error.message : leaveQueue.error instanceof Error ? leaveQueue.error.message - : null; + : null); return (
- - - {debateClaim.viewer_response ? ( -
-
- - Your response: {debateClaim.viewer_response.position_label} - - -
- -
- ) : ( -
- - Respond before joining - - -
- )} + +
{toggle}
- {isReady && ( + {backendReady && !responseWithdrawalPending && ( Waiting for someone with the opposite response. @@ -127,73 +281,59 @@ function ClaimDebateReadinessContent({ ); } +function DebateToggle({ + checked, + disabled, + busy = false, + className, + onClick, + title, +}: { + checked: boolean; + disabled: boolean; + busy?: boolean; + className?: string; + onClick?: () => void; + title?: string; +}) { + const descriptionId = React.useId(); + + return ( + + ); +} + function readinessReasonMessage(reason: string | null) { switch (reason) { case 'claim_response_withdrawn': - return 'Your response was withdrawn, so debate readiness was turned off.'; + return 'Your response was withdrawn, so Debate was turned off.'; case 'claim_response_kind_changed': - return 'This claim’s response type changed. Respond and join again.'; + return 'This claim’s response type changed. Respond and enable Debate again.'; case 'claim_response_validation_failed': - return 'Your response could not be verified yet. You’ll remain ready while verification retries.'; + return 'Your response could not be verified yet. Debate will remain enabled while verification retries.'; default: return reason; } } - -function OnlineChoices({ responseKind, choices }: { responseKind: DebateResponseKind; choices: DebateOnlineChoice[] }) { - return ( -
- - Ready to debate - -
- {[true, false].map(position => { - const choice = choices.find(candidate => candidate.position === position); - const participantCount = choice?.participant_count ?? 0; - const label = choice?.position_label || responsePositionLabel(responseKind, position); - - return ( -
- {label} - {choice && } -
- ); - })} -
-
- ); -} - -function OnlineChoiceParticipants({ choice }: { choice: DebateOnlineChoice }) { - const participants = choice.participants.slice(0, 2); - const overflowCount = Math.max(0, choice.participant_count - participants.length); - - if (participants.length === 0 && overflowCount === 0) return null; - - return ( - - ); -} diff --git a/apps/web/core/debates/debate-entity-response-controls.test.tsx b/apps/web/core/debates/debate-entity-response-controls.test.tsx index c8e1c2d35a..c755e4ce22 100644 --- a/apps/web/core/debates/debate-entity-response-controls.test.tsx +++ b/apps/web/core/debates/debate-entity-response-controls.test.tsx @@ -6,32 +6,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DebateEntityResponseControls } from './debate-entity-response-controls'; vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: ({ - responseKind, - showProcessingLabel, - }: { - responseKind?: 'stance' | 'veracity' | null; - showProcessingLabel?: boolean; - }) => ( - - {showProcessingLabel ? 'Visible processing label enabled' : 'Hidden processing label'}: - {responseKind ?? 'unavailable'} - + EntityVoteButtons: ({ responseKind }: { responseKind?: 'stance' | 'veracity' | null }) => ( + {responseKind ?? 'unavailable'} ), })); afterEach(cleanup); describe('DebateEntityResponseControls', () => { - it('requests a visible response-processing state in debate contexts', () => { + it('passes the backend response kind to the shared response controls', () => { render(); - expect(screen.getByText('Visible processing label enabled:veracity')).toBeInTheDocument(); + expect(screen.getByText('veracity')).toBeInTheDocument(); }); it('preserves an unavailable backend response kind instead of inferring curation', () => { render(); - expect(screen.getByText('Visible processing label enabled:unavailable')).toBeInTheDocument(); + expect(screen.getByText('unavailable')).toBeInTheDocument(); }); }); diff --git a/apps/web/core/debates/debate-entity-response-controls.tsx b/apps/web/core/debates/debate-entity-response-controls.tsx index e7f6ececc5..e56a8a7a5b 100644 --- a/apps/web/core/debates/debate-entity-response-controls.tsx +++ b/apps/web/core/debates/debate-entity-response-controls.tsx @@ -17,7 +17,6 @@ export function DebateEntityResponseControls({ spaceId={spaceId} responseKind={responseKind} claimResponderAvatarsPosition="trailing" - showProcessingLabel /> ); } diff --git a/apps/web/core/debates/debate-readiness-intent.ts b/apps/web/core/debates/debate-readiness-intent.ts new file mode 100644 index 0000000000..ba884a35b6 --- /dev/null +++ b/apps/web/core/debates/debate-readiness-intent.ts @@ -0,0 +1,60 @@ +'use client'; + +import { hashKey, useQueryClient } from '@tanstack/react-query'; + +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import type { DebateResponseKind } from './api'; + +type DebateReadinessIntent = + | { + desiredReady: true; + expectedPosition: boolean; + responseRunId: string | null; + hasRetried: boolean; + status: 'waiting' | 'refreshing' | 'submitting' | 'settling'; + } + | { + desiredReady: false; + status: 'submitting' | 'settling'; + }; + +function debateReadinessIntentQueryKey( + accountKey: string | null, + spaceId: string, + claimId: string, + responseKind: DebateResponseKind +) { + return ['debates', 'readiness-intent', accountKey, spaceId, claimId, responseKind] as const; +} + +export function useDebateReadinessIntent( + accountKey: string | null, + spaceId: string, + claimId: string, + responseKind: DebateResponseKind +) { + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => debateReadinessIntentQueryKey(accountKey, spaceId, claimId, responseKind), + [accountKey, claimId, responseKind, spaceId] + ); + const queryHash = useMemo(() => hashKey(queryKey), [queryKey]); + const getSnapshot = useCallback( + () => queryClient.getQueryData(queryKey) ?? null, + [queryClient, queryKey] + ); + const subscribe = useCallback( + (onStoreChange: () => void) => + queryClient.getQueryCache().subscribe(event => { + if (event.query.queryHash === queryHash) onStoreChange(); + }), + [queryClient, queryHash] + ); + const setIntent = useCallback( + (intent: DebateReadinessIntent | null) => queryClient.setQueryData(queryKey, intent), + [queryClient, queryKey] + ); + + return { intent: useSyncExternalStore(subscribe, getSnapshot, getSnapshot), queryKey, setIntent }; +} diff --git a/apps/web/core/hooks/use-entity-vote.test.tsx b/apps/web/core/hooks/use-entity-vote.test.tsx index 822f0769f5..17fc6d8414 100644 --- a/apps/web/core/hooks/use-entity-vote.test.tsx +++ b/apps/web/core/hooks/use-entity-vote.test.tsx @@ -5,7 +5,13 @@ import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useEntityResponse, useEntityResponseIndexingState } from './use-entity-vote'; +import { entityResponseIndexingQueryKey } from '~/core/responses/entity-response'; + +import { + useEntityResponse, + useEntityResponseIndexingSnapshot, + useEntityResponseIndexingState, +} from './use-entity-vote'; const PERSONAL_SPACE_ID = 'd4bee0928fb5405baba3b1513f085835'; const TARGET_SPACE_ID = '1234567890abcdef1234567890abcdef'; @@ -16,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runEffectEither: vi.fn(), responseAction: vi.fn(() => ({ to: '0x1234' as const, calldata: '0xabcd' as const })), waitForIndexedEntityResponse: vi.fn(), + personalSpaceId: 'd4bee0928fb5405baba3b1513f085835' as string | null, })); vi.mock('~/core/responses/entity-response', async importOriginal => { @@ -29,7 +36,7 @@ vi.mock('~/core/responses/entity-response', async importOriginal => { }); vi.mock('~/core/hooks/use-personal-space-id', () => ({ - usePersonalSpaceId: () => ({ personalSpaceId: PERSONAL_SPACE_ID, isRegistered: true }), + usePersonalSpaceId: () => ({ personalSpaceId: mocks.personalSpaceId, isRegistered: mocks.personalSpaceId !== null }), })); vi.mock('~/core/hooks/use-smart-account-transaction', async () => { @@ -72,6 +79,7 @@ beforeEach(() => { mocks.responseAction.mockClear(); mocks.waitForIndexedEntityResponse.mockClear(); mocks.runEffectEither.mockResolvedValue({ _tag: 'Right', right: '0xtransaction' }); + mocks.personalSpaceId = PERSONAL_SPACE_ID; }); afterEach(() => { @@ -80,7 +88,32 @@ afterEach(() => { }); describe('useEntityResponse indexing reconciliation', () => { - it('keeps a submitted response unsettled and exposes retry after reconciliation exhausts', async () => { + it('exposes the expected optimistic response before the transaction settles', async () => { + const transaction = deferred(); + mocks.runEffectEither.mockReturnValue(transaction.promise); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => ({ + response: useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + indexing: useEntityResponseIndexingSnapshot({ + entityId: 'claim-1', + spaceId: TARGET_SPACE_ID, + responseKind: 'stance', + }), + }), + { wrapper } + ); + + act(() => result.current.response.submitResponse('negative')); + await act(async () => Promise.resolve()); + + expect(result.current.indexing).toMatchObject({ + status: 'reconciling', + pending: { expectedResponse: 'negative' }, + }); + }); + + it('keeps a submitted response unsettled and retries automatically after reconciliation exhausts', async () => { mocks.fetchResponse.mockReturnValue(null); const onSuccess = vi.fn(); const onError = vi.fn(); @@ -98,26 +131,50 @@ describe('useEntityResponse indexing reconciliation', () => { expect(onError).not.toHaveBeenCalled(); expect(result.current.isProcessingResponse).toBe(true); expect(result.current.isResponseIndexingDelayed).toBe(false); + const initialRunId = queryClient.getQueryData<{ runId: string }>( + entityResponseIndexingQueryKey(PERSONAL_SPACE_ID, 'claim-1', TARGET_SPACE_ID, 'stance') + )?.runId; await act(async () => vi.advanceTimersByTimeAsync(58_000)); expect(mocks.fetchResponse).toHaveBeenCalledTimes(30); expect(result.current.isProcessingResponse).toBe(true); expect(result.current.isResponseIndexingDelayed).toBe(true); - expect(result.current.retryResponseIndexing).toEqual(expect.any(Function)); expect(invalidateQueries).not.toHaveBeenCalled(); + expect( + queryClient.getQueryData<{ runId: string }>( + entityResponseIndexingQueryKey(PERSONAL_SPACE_ID, 'claim-1', TARGET_SPACE_ID, 'stance') + )?.runId + ).toBe(initialRunId); mocks.fetchResponse.mockReturnValue('positive'); - await act(async () => { - result.current.retryResponseIndexing(); - await Promise.resolve(); - }); + await act(async () => vi.advanceTimersByTimeAsync(10_000)); expect(result.current.isProcessingResponse).toBe(false); expect(result.current.isResponseIndexingDelayed).toBe(false); + expect(result.current.optimisticResponse).toBeUndefined(); expect(invalidateQueries).toHaveBeenCalledTimes(3); }); + it('isolates optimistic response state by personal space', async () => { + const transaction = deferred(); + mocks.runEffectEither.mockReturnValue(transaction.promise); + const { wrapper } = createHarness(); + const { result, rerender } = renderHook( + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + { wrapper } + ); + + act(() => result.current.submitResponse('positive')); + await act(async () => Promise.resolve()); + expect(result.current.optimisticResponse).toBe('positive'); + + mocks.personalSpaceId = 'another-personal-space'; + rerender(); + + expect(result.current.optimisticResponse).toBeUndefined(); + }); + it('stays visibly processing after submission and invalidates when Gaia indexes late', async () => { let fetchCount = 0; mocks.fetchResponse.mockImplementation(() => (++fetchCount >= 2 ? 'positive' : null)); @@ -141,6 +198,7 @@ describe('useEntityResponse indexing reconciliation', () => { expect(mocks.fetchResponse).toHaveBeenCalledTimes(2); expect(result.current.isProcessingResponse).toBe(false); expect(result.current.isResponseIndexingDelayed).toBe(false); + expect(result.current.optimisticResponse).toBeUndefined(); expect(invalidateQueries).toHaveBeenCalledTimes(3); }); @@ -167,6 +225,7 @@ describe('useEntityResponse indexing reconciliation', () => { await act(async () => Promise.resolve()); act(() => result.current.second.submitResponse('negative')); await act(async () => Promise.resolve()); + expect(result.current.first.optimisticResponse).toBe('negative'); await act(async () => { secondTransaction.resolve({ _tag: 'Right', right: '0xsecond' }); @@ -175,6 +234,7 @@ describe('useEntityResponse indexing reconciliation', () => { }); expect(mocks.fetchResponse).toHaveBeenCalledOnce(); expect(result.current.indexingStatus).toBe('reconciling'); + expect(result.current.first.optimisticResponse).toBe('negative'); await act(async () => { firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); @@ -216,6 +276,7 @@ describe('useEntityResponse indexing reconciliation', () => { await Promise.resolve(); }); expect(result.current.indexingStatus).toBe('reconciling'); + expect(result.current.first.optimisticResponse).toBe('positive'); await act(async () => { firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); @@ -272,6 +333,7 @@ describe('useEntityResponse indexing reconciliation', () => { await Promise.resolve(); }); expect(result.current.indexingStatus).toBe('reconciling'); + expect(result.current.first.optimisticResponse).toBe('positive'); await act(async () => { firstTransaction.resolve({ _tag: 'Right', right: '0xfirst' }); diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index 098d128749..f69004e4dd 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -1,8 +1,8 @@ 'use client'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { hashKey, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'; import { Effect, Either } from 'effect'; @@ -30,7 +30,7 @@ interface UseEntityResponseArgs { responseKind: ResponseKind | null; } -type PendingEntityResponseIndex = { +export type PendingEntityResponseIndex = { entityId: string; expectedResponse: ActiveResponseDirection | null; personalSpaceId: string; @@ -38,10 +38,11 @@ type PendingEntityResponseIndex = { spaceId: string; }; -type EntityResponseIndexingState = +export type EntityResponseIndexingState = | { status: 'idle'; pending: null; runId: null } | { status: 'reconciling'; pending: PendingEntityResponseIndex | null; runId: string } - | { status: 'delayed'; pending: PendingEntityResponseIndex; runId: string }; + | { status: 'delayed'; pending: PendingEntityResponseIndex; runId: string } + | { status: 'indexed'; pending: PendingEntityResponseIndex; runId: string }; const IDLE_INDEXING_STATE: EntityResponseIndexingState = { status: 'idle', pending: null, runId: null }; let responseIndexingRunSequence = 0; @@ -93,37 +94,80 @@ export function useEntityResponseIndexingState({ entityId, spaceId, responseKind, -}: UseEntityResponseArgs): EntityResponseIndexingState['status'] { - return useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }).status; +}: UseEntityResponseArgs): 'idle' | 'reconciling' | 'delayed' { + const status = useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }).status; + return status === 'indexed' ? 'idle' : status; } -function useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { +export function useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { const queryClient = useQueryClient(); + const { personalSpaceId } = usePersonalSpaceId(); + const queryKey = useMemo( + () => entityResponseIndexingQueryKey(personalSpaceId, entityId, spaceId, responseKind), + [entityId, personalSpaceId, responseKind, spaceId] + ); + const queryHash = useMemo(() => hashKey(queryKey), [queryKey]); const getSnapshot = useCallback( - () => - queryClient.getQueryData( - entityResponseIndexingQueryKey(entityId, spaceId, responseKind) - ) ?? IDLE_INDEXING_STATE, - [entityId, queryClient, responseKind, spaceId] + () => queryClient.getQueryData(queryKey) ?? IDLE_INDEXING_STATE, + [queryClient, queryKey] ); const subscribe = useCallback( - (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), - [queryClient] + (onStoreChange: () => void) => + queryClient.getQueryCache().subscribe(event => { + if (event.query.queryHash === queryHash) onStoreChange(); + }), + [queryClient, queryHash] ); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } +export function useResetEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { + const queryClient = useQueryClient(); + const { personalSpaceId } = usePersonalSpaceId(); + const queryKey = useMemo( + () => entityResponseIndexingQueryKey(personalSpaceId, entityId, spaceId, responseKind), + [entityId, personalSpaceId, responseKind, spaceId] + ); + + return useCallback( + (runId: string) => { + queryClient.setQueryData(queryKey, current => + current?.runId === runId ? IDLE_INDEXING_STATE : current + ); + }, + [queryClient, queryKey] + ); +} + export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntityResponseArgs) { const queryClient = useQueryClient(); const responseIndexingRegistry = getResponseIndexingRegistry(queryClient); const { personalSpaceId, isRegistered } = usePersonalSpaceId(); - const indexingQueryKey = entityResponseIndexingQueryKey(entityId, spaceId, responseKind); - const indexingKeyId = JSON.stringify(indexingQueryKey); + const indexingQueryKey = useMemo( + () => entityResponseIndexingQueryKey(personalSpaceId, entityId, spaceId, responseKind), + [entityId, personalSpaceId, responseKind, spaceId] + ); + const indexingKeyId = useMemo(() => hashKey(indexingQueryKey), [indexingQueryKey]); const indexingState = useEntityResponseIndexingSnapshot({ entityId, spaceId, responseKind }); const tx = useSmartAccountTransaction(); + const pendingResponseIndex = useCallback( + (direction: ResponseDirection): PendingEntityResponseIndex | null => { + if (!responseKind || !personalSpaceId || !isRegistered || !validateSpaceId(spaceId)) return null; + + return { + entityId, + expectedResponse: direction === 'clear' ? null : direction, + personalSpaceId, + responseKind, + spaceId, + }; + }, + [entityId, isRegistered, personalSpaceId, responseKind, spaceId] + ); + const isCurrentIndexingRun = useCallback( (runId: string) => queryClient.getQueryData(indexingQueryKey)?.runId === runId, [indexingQueryKey, queryClient] @@ -157,11 +201,6 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity cancelActiveResponseReconciliation(responseIndexingRegistry, indexingKeyId); const controller = new AbortController(); responseIndexingRegistry.activeReconciliations.set(indexingKeyId, { controller, runId }); - queryClient.setQueryData(indexingQueryKey, { - status: 'reconciling', - pending, - runId, - }); const indexed = await waitForIndexedEntityResponse( signal => @@ -201,7 +240,11 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity await invalidateResponseQueries(pending); if (!isCurrentIndexingRun(runId)) return; - queryClient.setQueryData(indexingQueryKey, IDLE_INDEXING_STATE); + queryClient.setQueryData(indexingQueryKey, { + status: 'indexed', + pending, + runId, + }); responseIndexingRegistry.submissionRuns.delete(indexingKeyId); }, [ @@ -258,27 +301,22 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity throw error; } - const expectedResponse = direction === 'clear' ? null : direction; - const pending: PendingEntityResponseIndex = { - entityId, - expectedResponse, - personalSpaceId, - responseKind, - spaceId, - }; + const pending = pendingResponseIndex(direction); + if (!pending) throw new Error('Response indexing context is unavailable.'); return { pending, transaction: result.right }; }, - [personalSpaceId, isRegistered, spaceId, entityId, responseKind, tx] + [personalSpaceId, isRegistered, spaceId, entityId, responseKind, tx, pendingResponseIndex] ); const responseMutation = useMutation({ mutationFn: executeResponse, - onMutate: () => { + onMutate: direction => { const previousState = queryClient.getQueryData(indexingQueryKey) ?? IDLE_INDEXING_STATE; const { runId, runOrder } = createResponseIndexingRunId(); + const pending = pendingResponseIndex(direction); getResponseSubmissionRuns(responseIndexingRegistry, indexingKeyId).set(runId, { - pending: null, + pending, previousState, runId, runOrder, @@ -287,7 +325,7 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity cancelActiveResponseReconciliation(responseIndexingRegistry, indexingKeyId); queryClient.setQueryData(indexingQueryKey, { status: 'reconciling', - pending: null, + pending, runId, }); return { previousState, runId, runOrder }; @@ -339,21 +377,34 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity }); const retryResponseIndexing = useCallback(() => { - if (!indexingState.pending || indexingState.status === 'reconciling') return; - const { runId } = createResponseIndexingRunId(); + const current = queryClient.getQueryData(indexingQueryKey); + if (!current?.pending || current.status !== 'delayed') return; + const { runId } = current; queryClient.setQueryData(indexingQueryKey, { status: 'reconciling', - pending: indexingState.pending, + pending: current.pending, runId, }); - void reconcileResponseIndexing(indexingState.pending, runId); - }, [indexingKeyId, indexingQueryKey, indexingState, queryClient, reconcileResponseIndexing]); + void reconcileResponseIndexing(current.pending, runId); + }, [indexingQueryKey, queryClient, reconcileResponseIndexing]); + + useEffect(() => { + if (indexingState.status !== 'delayed') return; + const retryTimer = window.setTimeout(retryResponseIndexing, 10_000); + return () => window.clearTimeout(retryTimer); + }, [indexingState.status, retryResponseIndexing]); + + const optimisticResponse = + (indexingState.status !== 'reconciling' && indexingState.status !== 'delayed') || !indexingState.pending + ? undefined + : indexingState.pending.expectedResponse; return { submitResponse: responseMutation.mutate, - isProcessingResponse: responseMutation.isPending || indexingState.status !== 'idle', + optimisticResponse, + isProcessingResponse: + responseMutation.isPending || indexingState.status === 'reconciling' || indexingState.status === 'delayed', isResponseIndexingDelayed: indexingState.status === 'delayed', - retryResponseIndexing, isConnected: !!personalSpaceId && isRegistered, personalSpaceId, }; diff --git a/apps/web/core/responses/entity-response.test.ts b/apps/web/core/responses/entity-response.test.ts index b96c190629..3f341877a9 100644 --- a/apps/web/core/responses/entity-response.test.ts +++ b/apps/web/core/responses/entity-response.test.ts @@ -1,6 +1,8 @@ +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; + import { describe, expect, it, vi } from 'vitest'; -import { CLAIM_IS_FACTUAL_PROPERTY_ID } from '~/core/claims/ontology'; +import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { getChecked } from '~/design-system/checkbox'; @@ -108,6 +110,24 @@ describe('entity response semantics', () => { expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(false); }); + it('blocks responses for unpublished response-kind tombstones', () => { + const entity = { + values: [], + relations: [ + { + spaceId: SPACE_ID, + type: { id: SystemIds.TYPES_PROPERTY }, + toEntity: { id: CLAIM_TYPE_ID }, + isDeleted: true, + isLocal: true, + hasBeenPublished: false, + }, + ], + } as unknown as NonNullable[0]>; + + expect(hasUnpublishedClaimResponseKindEdit(entity, SPACE_ID)).toBe(true); + }); + it('keeps polling until Gaia serves the expected response', async () => { const indexedResponses = [null, null, 'positive'] as const; let attempt = 0; @@ -223,8 +243,9 @@ describe('entity response query keys', () => { 0, 'curation', ]); - expect(entityResponseIndexingQueryKey('entity', 'space', 'stance')).toEqual([ + expect(entityResponseIndexingQueryKey('profile-space', 'entity', 'space', 'stance')).toEqual([ 'entity-response-indexing', + 'profile-space', 'entity', 'space', 'stance', diff --git a/apps/web/core/responses/entity-response.ts b/apps/web/core/responses/entity-response.ts index 403f4558d3..9b927e25bd 100644 --- a/apps/web/core/responses/entity-response.ts +++ b/apps/web/core/responses/entity-response.ts @@ -231,8 +231,13 @@ export function userEntityResponseQueryKey( return ['user-entity-response', userId, entityId, spaceId, objectType, responseKind] as const; } -export function entityResponseIndexingQueryKey(entityId: string, spaceId: string, responseKind: ResponseKind | null) { - return ['entity-response-indexing', entityId, spaceId, responseKind] as const; +export function entityResponseIndexingQueryKey( + personalSpaceId: string | null, + entityId: string, + spaceId: string, + responseKind: ResponseKind | null +) { + return ['entity-response-indexing', personalSpaceId, entityId, spaceId, responseKind] as const; } export function entityRespondersQueryKey( diff --git a/apps/web/core/sync/use-store.tsx b/apps/web/core/sync/use-store.tsx index d8810b4e89..a3e9021055 100644 --- a/apps/web/core/sync/use-store.tsx +++ b/apps/web/core/sync/use-store.tsx @@ -25,6 +25,7 @@ import { useSyncEngine } from './use-sync-engine'; type QueryEntityOptions = { id?: string; spaceId?: string; + includeDeleted?: boolean; /** * By default we query the local store for the entity without * querying the remote server. This assumes that the entity @@ -204,7 +205,7 @@ export function useHydrateEntities({ ids, enabled = true, spaceId }: HydrateEnti * not stable (as of July 2025). Once it's stable we should just * migrate to @tanstack/db and use that instead. */ -export function useQueryEntity({ id, spaceId, enabled = true }: QueryEntityOptions) { +export function useQueryEntity({ id, spaceId, includeDeleted = false, enabled = true }: QueryEntityOptions) { const { store } = useSyncEngine(); const { isFetched } = useHydrateEntity({ id, enabled }); @@ -215,7 +216,7 @@ export function useQueryEntity({ id, spaceId, enabled = true }: QueryEntityOptio return null; } - return store.getEntity(id, { spaceId }) ?? null; + return store.getEntity(id, { includeDeleted, spaceId }) ?? null; }, equal ); diff --git a/apps/web/partials/entity-page/entity-page-metadata-header.tsx b/apps/web/partials/entity-page/entity-page-metadata-header.tsx index ef1f14c41e..3c98a4c7ea 100644 --- a/apps/web/partials/entity-page/entity-page-metadata-header.tsx +++ b/apps/web/partials/entity-page/entity-page-metadata-header.tsx @@ -290,8 +290,8 @@ export function EntityPageMetadataHeader({ id, spaceId, isVoteable = false }: En )}
- {isVoteable && } +
); diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index ebc3312dbe..f2ca170c9f 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -51,8 +51,6 @@ import { Skeleton } from '~/design-system/skeleton'; import { ClaimResponderAvatars } from '~/partials/entity-page/claim-voter-avatars'; import { avatarAtom, nameAtom, spaceIdAtom, stepAtom, topicIdAtom } from '~/partials/onboarding/dialog'; -type OptimisticResponse = ActiveResponseDirection | 'none' | null; - const ENTITY_RESPONSE_OBJECT_TYPE = 0; type ResponseVariant = 'default' | 'thumbs' | 'chevrons'; @@ -66,7 +64,6 @@ type EntityVoteButtonsProps = { spaceId: string; responseKind?: ResponseKind | null; claimResponderAvatarsPosition?: 'leading' | 'trailing'; - showProcessingLabel?: boolean; }; export function EntityVoteButtons({ @@ -74,17 +71,18 @@ export function EntityVoteButtons({ spaceId, responseKind: responseKindOverride, claimResponderAvatarsPosition = 'leading', - showProcessingLabel = false, }: EntityVoteButtonsProps) { - const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId }); + const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId, includeDeleted: true }); + const activeRelations = entity?.relations.filter(relation => !relation.isDeleted) ?? []; + const activeValues = entity?.values.filter(value => !value.isDeleted) ?? []; const isClaim = - entity?.relations.some( + activeRelations.some( relation => uuidToHex(relation.type.id) === TYPES_PROPERTY && uuidToHex(relation.toEntity.id) === CLAIM_TYPE ) ?? false; const isFactualClaim = isClaim && getChecked( - entity?.values.find( + activeValues.find( v => uuidToHex(v.spaceId) === uuidToHex(spaceId) && uuidToHex(v.property.id) === CLAIM_IS_FACTUAL )?.value ) === true; @@ -98,14 +96,8 @@ export function EntityVoteButtons({ queryResponseKind === 'curation' ? 'default' : queryResponseKind === 'veracity' ? 'chevrons' : 'thumbs'; const responseCopy = ENTITY_RESPONSE_COPY[queryResponseKind]; - const { - submitResponse, - isProcessingResponse, - isResponseIndexingDelayed, - retryResponseIndexing, - isConnected, - personalSpaceId, - } = useEntityResponse({ entityId, spaceId, responseKind }); + const { submitResponse, optimisticResponse, isResponseIndexingDelayed, isConnected, personalSpaceId } = + useEntityResponse({ entityId, spaceId, responseKind }); const { smartAccount } = useSmartAccount(); const { isPending: isAccountSetupPending } = usePendingPersonalSpace(); @@ -119,8 +111,6 @@ export function EntityVoteButtons({ onComplete: args => trackPrivyAuth(args, { auth_flow: 'manual_login' }), }); - const [optimisticResponse, setOptimisticResponse] = React.useState(null); - const [optimisticScore, setOptimisticScore] = React.useState(null); const [respondersOpen, setRespondersOpen] = React.useState(false); const { data: responseCounts } = useQuery<{ positive: number; negative: number } | null>({ @@ -149,27 +139,14 @@ export function EntityVoteButtons({ staleTime: 30_000, }); - React.useEffect( - function clearOptimisticScoreOnServerUpdate() { - setOptimisticScore(null); - }, - [responseCounts] - ); - - React.useEffect( - function clearOptimisticResponseOnServerUpdate() { - setOptimisticResponse(null); - }, - [serverResponseDirection] - ); - - const activeResponse = - optimisticResponse !== null ? (optimisticResponse === 'none' ? null : optimisticResponse) : serverResponseDirection; + const activeResponse = optimisticResponse === undefined ? serverResponseDirection : optimisticResponse; const positiveResponses = BigInt(responseCounts?.positive ?? 0); const negativeResponses = BigInt(responseCounts?.negative ?? 0); const netScore = positiveResponses - negativeResponses; - const displayScore = optimisticScore !== null ? optimisticScore : netScore; + const responseScore = (direction: ActiveResponseDirection | null | undefined) => + direction === 'positive' ? 1n : direction === 'negative' ? -1n : 0n; + const displayScore = netScore + responseScore(activeResponse) - responseScore(serverResponseDirection); function openPrivySignIn() { setName(''); @@ -186,24 +163,14 @@ export function EntityVoteButtons({ return; } if (!isConnected) return; - const base = optimisticScore !== null ? optimisticScore : netScore; if (activeResponse === 'positive') { - setOptimisticResponse('none'); - setOptimisticScore(base - 1n); submitResponse('clear', { onSuccess: () => { voteCast('none', voteProperties('remove', 'up')); }, - onError: () => { - setOptimisticResponse('positive'); - setOptimisticScore(null); - }, }); } else { - const delta = activeResponse === 'negative' ? 2n : 1n; const previousResponse = activeResponse ?? null; - setOptimisticResponse('positive'); - setOptimisticScore(base + delta); submitResponse('positive', { onSuccess: () => { upvoted( @@ -213,10 +180,6 @@ export function EntityVoteButtons({ ) ); }, - onError: () => { - setOptimisticResponse(previousResponse); - setOptimisticScore(null); - }, }); } } @@ -227,24 +190,14 @@ export function EntityVoteButtons({ return; } if (!isConnected) return; - const base = optimisticScore !== null ? optimisticScore : netScore; if (activeResponse === 'negative') { - setOptimisticResponse('none'); - setOptimisticScore(base + 1n); submitResponse('clear', { onSuccess: () => { voteCast('none', voteProperties('remove', 'down')); }, - onError: () => { - setOptimisticResponse('negative'); - setOptimisticScore(null); - }, }); } else { - const delta = activeResponse === 'positive' ? 2n : 1n; const previousResponse = activeResponse ?? null; - setOptimisticResponse('negative'); - setOptimisticScore(base - delta); submitResponse('negative', { onSuccess: () => { downvoted( @@ -254,10 +207,6 @@ export function EntityVoteButtons({ ) ); }, - onError: () => { - setOptimisticResponse(previousResponse); - setOptimisticScore(null); - }, }); } } @@ -280,9 +229,9 @@ export function EntityVoteButtons({ const totalResponders = (responseCounts?.positive ?? 0) + (responseCounts?.negative ?? 0); const optimisticPositiveDelta = - optimisticResponse !== null ? (positiveActive ? 1 : 0) - (serverResponseDirection === 'positive' ? 1 : 0) : 0; + optimisticResponse !== undefined ? (positiveActive ? 1 : 0) - (serverResponseDirection === 'positive' ? 1 : 0) : 0; const optimisticNegativeDelta = - optimisticResponse !== null ? (negativeActive ? 1 : 0) - (serverResponseDirection === 'negative' ? 1 : 0) : 0; + optimisticResponse !== undefined ? (negativeActive ? 1 : 0) - (serverResponseDirection === 'negative' ? 1 : 0) : 0; const effectivePositive = Math.max(0, (responseCounts?.positive ?? 0) + optimisticPositiveDelta); const effectiveNegative = Math.max(0, (responseCounts?.negative ?? 0) + optimisticNegativeDelta); const effectiveTotal = effectivePositive + effectiveNegative; @@ -349,29 +298,23 @@ export function EntityVoteButtons({ ) : null} - - ) : isProcessingResponse ? ( - - Processing response… + Response submitted. Waiting for confirmation. ) : null}
From 64061cd3beb3b64df855ec86e6ede406c8d4b234 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 10 Aug 2026 10:06:26 +0200 Subject: [PATCH 11/15] fixes --- .../claims/claims-page-client.test.tsx | 26 +- .../debates/debates-page-client.test.tsx | 2 +- .../debates/browse/join-debate-panel.test.tsx | 16 +- .../core/debates/claim-debate-button.test.tsx | 11 +- .../debates/claim-debate-readiness.test.tsx | 337 ++++++++++++++++-- .../core/debates/claim-debate-readiness.tsx | 217 ++++++----- .../core/debates/debate-readiness-intent.ts | 41 ++- 7 files changed, 472 insertions(+), 178 deletions(-) diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx index a35929a48b..e35c16bfc6 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx @@ -46,8 +46,13 @@ vi.mock('~/core/hooks/use-entity-vote', () => ({ vi.mock('~/core/debates/hooks', () => ({ useGeoChatAuth: () => ({ authenticated: true, accountKey: 'account-1' }), useDebateClaims: () => ({ data: debateClaimsResponse, error: null }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: joinPending, error: null }), - useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ + mutateAsync: mocks.joinMutate, + reset: vi.fn(), + isPending: joinPending, + error: null, + }), + useLeaveDebateQueue: () => ({ mutateAsync: mocks.leaveMutate, isPending: false, error: null }), useAcceptDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), useDeclineDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), })); @@ -93,6 +98,8 @@ beforeEach(() => { lastQueryEntitiesOptions = null; debateClaimsResponse = { claims: [] }; vi.clearAllMocks(); + mocks.joinMutate.mockReturnValue(new Promise(() => undefined)); + mocks.leaveMutate.mockReturnValue(new Promise(() => undefined)); }); afterEach(() => cleanup()); @@ -146,10 +153,7 @@ describe('ClaimsPageClient', () => { fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.joinMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); it('renders backend response labels and one leave-readiness toggle', () => { @@ -170,13 +174,10 @@ describe('ClaimsPageClient', () => { expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.leaveMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); - it('keeps readiness disabled while joining, unpublished, or matched', () => { + it('keeps readiness clickable while joining but unavailable for unpublished or matched claims', () => { const published = publishedClaim(); claims = [published]; debateClaimsResponse = { @@ -186,7 +187,8 @@ describe('ClaimsPageClient', () => { const { rerender } = renderClaims(); - expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); + expect(screen.getByRole('switch', { name: 'Debate' })).toBeEnabled(); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-busy', 'true'); joinPending = false; claims = [ diff --git a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx index bd1da77b37..5f14273e19 100644 --- a/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/debates-page-client.test.tsx @@ -48,7 +48,7 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateMediaArtifactUrl: () => ({ mutate: mocks.mediaArtifactMutate }), useDebateTranscript: () => ({ data: { segments: [] }, isLoading: false, error: null }), useDebateClaims: () => ({ data: { claims: [] } }), - useJoinDebateQueue: () => ({ mutate: vi.fn(), isPending: false }), + useJoinDebateQueue: () => ({ mutateAsync: vi.fn(), isPending: false }), })); vi.mock('~/core/hooks/use-space', () => ({ diff --git a/apps/web/core/debates/browse/join-debate-panel.test.tsx b/apps/web/core/debates/browse/join-debate-panel.test.tsx index dd757e3fe0..c4e3a26ef5 100644 --- a/apps/web/core/debates/browse/join-debate-panel.test.tsx +++ b/apps/web/core/debates/browse/join-debate-panel.test.tsx @@ -21,8 +21,8 @@ vi.mock('~/core/debates/hooks', () => ({ useDebateClaims: () => ({ data: { claims: mocks.claims }, isLoading: false, error: null }), useDebateActivity: () => ({ data: { available_to_debate: false }, isPending: false }), useUpdateDebateAvailability: () => ({ mutate: mocks.availabilityMutate, isPending: false }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), - useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ mutateAsync: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), + useLeaveDebateQueue: () => ({ mutateAsync: mocks.leaveMutate, isPending: false, error: null }), })); vi.mock('~/core/hooks/use-entity-vote', () => ({ @@ -42,6 +42,8 @@ vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ beforeEach(() => { mocks.claims = []; vi.clearAllMocks(); + mocks.joinMutate.mockReturnValue(new Promise(() => undefined)); + mocks.leaveMutate.mockReturnValue(new Promise(() => undefined)); }); afterEach(cleanup); @@ -70,10 +72,7 @@ describe('JoinDebatePanel', () => { expect(screen.queryByText('Ready to debate')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.joinMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); it('leaves through the same readiness toggle', () => { @@ -82,10 +81,7 @@ describe('JoinDebatePanel', () => { renderPanel(); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.leaveMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); }); diff --git a/apps/web/core/debates/claim-debate-button.test.tsx b/apps/web/core/debates/claim-debate-button.test.tsx index 3b407c6c5e..3e4190c139 100644 --- a/apps/web/core/debates/claim-debate-button.test.tsx +++ b/apps/web/core/debates/claim-debate-button.test.tsx @@ -37,8 +37,8 @@ vi.mock('./hooks', () => ({ useGeoChatAuth: () => ({ authenticated: true, accountKey: 'account-1' }), useDebateClaims: () => mocks.debateClaims(), useDebateActivity: () => ({ data: null }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), - useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ mutateAsync: mocks.joinMutate, reset: vi.fn(), isPending: false, error: null }), + useLeaveDebateQueue: () => ({ mutateAsync: mocks.leaveMutate, isPending: false, error: null }), })); vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ @@ -49,7 +49,9 @@ beforeEach(() => { mocks.debatesEnabled.mockReturnValue(true); mocks.debateClaims.mockReturnValue({ data: { claims: [] } }); mocks.joinMutate.mockReset(); + mocks.joinMutate.mockReturnValue(new Promise(() => undefined)); mocks.leaveMutate.mockReset(); + mocks.leaveMutate.mockReturnValue(new Promise(() => undefined)); }); afterEach(() => { @@ -124,10 +126,7 @@ describe('ClaimDebateButton', () => { expect(screen.queryByText('Waiting for someone with the opposite response.')).not.toBeInTheDocument(); fireEvent.click(leave); - expect(mocks.leaveMutate).toHaveBeenCalledWith( - { claimId: 'claim-entity-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.leaveMutate).toHaveBeenCalledWith({ claimId: 'claim-entity-1' }); expect(mocks.joinMutate).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/core/debates/claim-debate-readiness.test.tsx b/apps/web/core/debates/claim-debate-readiness.test.tsx index 517563dc80..2fd226e37b 100644 --- a/apps/web/core/debates/claim-debate-readiness.test.tsx +++ b/apps/web/core/debates/claim-debate-readiness.test.tsx @@ -12,9 +12,9 @@ import { type DebateClaim, GeoChatRequestError } from './api'; import { ClaimDebateReadiness } from './claim-debate-readiness'; const mocks = vi.hoisted(() => ({ - joinMutate: vi.fn(), + joinMutateAsync: vi.fn(), joinReset: vi.fn(), - leaveMutate: vi.fn(), + leaveMutateAsync: vi.fn(), responseKinds: [] as Array<'stance' | 'veracity' | null>, authenticated: true, accountKey: 'account-1' as string | null, @@ -22,8 +22,13 @@ const mocks = vi.hoisted(() => ({ vi.mock('./hooks', () => ({ useGeoChatAuth: () => ({ authenticated: mocks.authenticated, accountKey: mocks.accountKey }), - useJoinDebateQueue: () => ({ mutate: mocks.joinMutate, reset: mocks.joinReset, isPending: false, error: null }), - useLeaveDebateQueue: () => ({ mutate: mocks.leaveMutate, isPending: false, error: null }), + useJoinDebateQueue: () => ({ + mutateAsync: mocks.joinMutateAsync, + reset: mocks.joinReset, + isPending: false, + error: null, + }), + useLeaveDebateQueue: () => ({ mutateAsync: mocks.leaveMutateAsync, isPending: false, error: null }), })); vi.mock('~/core/hooks/use-personal-space-id', () => ({ @@ -38,9 +43,11 @@ vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ })); beforeEach(() => { - mocks.joinMutate.mockReset(); + mocks.joinMutateAsync.mockReset(); + mocks.joinMutateAsync.mockReturnValue(deferred().promise); mocks.joinReset.mockReset(); - mocks.leaveMutate.mockReset(); + mocks.leaveMutateAsync.mockReset(); + mocks.leaveMutateAsync.mockReturnValue(deferred(queueResponse(false)).promise); mocks.responseKinds.length = 0; mocks.authenticated = true; mocks.accountKey = 'account-1'; @@ -57,6 +64,14 @@ describe('ClaimDebateReadiness', () => { expect(screen.queryByText(/join debate|cancel join|check again/i)).not.toBeInTheDocument(); }); + it('places the response controls and Debate switch in one action row', () => { + renderReadiness(claim()); + + const actionRow = screen.getByTestId('entity-response-buttons').parentElement; + expect(actionRow).toBe(screen.getByRole('switch', { name: 'Debate' }).parentElement); + expect(actionRow).toHaveClass('flex', 'items-center', 'gap-4'); + }); + it('optimistically enables persisted readiness through the Debate switch', async () => { renderReadiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } })); @@ -66,11 +81,153 @@ describe('ClaimDebateReadiness', () => { fireEvent.click(toggle); expect(toggle).toHaveAttribute('aria-checked', 'true'); - await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(1)); - expect(mocks.joinMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledTimes(1)); + expect(mocks.joinMutateAsync).toHaveBeenCalledWith({ claimId: 'claim-1' }); + }); + + it('hands control back to a newer backend disable after the join is confirmed', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + const view = renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + + await act(() => join.resolve(queueResponse(true))); + view.rerender(readiness(claim({ viewer_debate_ready: true }), view.queryClient)); + await waitFor(() => expect(screen.getByRole('switch', { name: 'Debate' })).not.toHaveAttribute('aria-busy')); + + view.rerender(readiness(claim({ viewer_debate_ready: false }), view.queryClient)); + + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('stays clickable while joining and serializes a later leave', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle).toHaveAttribute('aria-busy', 'true'); + expect(toggle).toBeEnabled(); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(mocks.leaveMutateAsync).not.toHaveBeenCalled(); + + await act(() => join.resolve()); + + await waitFor(() => expect(mocks.leaveMutateAsync).toHaveBeenCalledOnce()); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('stays clickable while leaving and serializes a later join', async () => { + const leave = deferred(queueResponse(false)); + mocks.leaveMutateAsync.mockReturnValueOnce(leave.promise); + renderReadiness(claim({ viewer_debate_ready: true })); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + + expect(mocks.leaveMutateAsync).toHaveBeenCalledOnce(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toHaveAttribute('aria-busy', 'true'); + expect(toggle).toBeEnabled(); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); + + await act(() => leave.resolve()); + + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('collapses rapid reversals to the final desired readiness', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + + await act(() => join.resolve()); + + await waitFor(() => expect(toggle).not.toHaveAttribute('aria-busy')); + expect(mocks.joinMutateAsync).toHaveBeenCalledOnce(); + expect(mocks.leaveMutateAsync).not.toHaveBeenCalled(); + }); + + it('preserves an in-flight readiness intent across client-side navigation', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + const view = renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + view.unmount(); + + const remounted = render(readiness(claim(), view.queryClient)); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-busy', 'true'); + + await act(() => join.resolve()); + + await waitFor(() => expect(screen.getByRole('switch', { name: 'Debate' })).not.toHaveAttribute('aria-busy')); + expect(mocks.joinMutateAsync).toHaveBeenCalledOnce(); + remounted.rerender(readiness(claim({ viewer_debate_ready: true }), view.queryClient)); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('preserves the final reversal across client-side navigation', async () => { + const join = deferred(); + const leave = deferred(queueResponse(false)); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + mocks.leaveMutateAsync.mockReturnValueOnce(leave.promise); + const view = renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + fireEvent.click(toggle); + view.unmount(); + + render(readiness(claim(), view.queryClient)); + await act(() => join.resolve()); + + await waitFor(() => expect(mocks.leaveMutateAsync).toHaveBeenCalledOnce()); + await act(() => leave.resolve()); + await waitFor(() => expect(screen.getByRole('switch', { name: 'Debate' })).not.toHaveAttribute('aria-busy')); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('starts one request when duplicate controls share the same readiness intent', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + + ); + + fireEvent.click(screen.getAllByRole('switch', { name: 'Debate' })[0]); + + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + expect(screen.getAllByRole('switch', { name: 'Debate' })[0]).toHaveAttribute('aria-checked', 'true'); + expect(screen.getAllByRole('switch', { name: 'Debate' })[1]).toHaveAttribute('aria-checked', 'true'); }); it('turns persisted readiness off through the same switch', () => { @@ -78,13 +235,11 @@ describe('ClaimDebateReadiness', () => { const toggle = screen.getByRole('switch', { name: 'Debate' }); expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText('Waiting for someone with the opposite response.')).not.toBeInTheDocument(); fireEvent.click(toggle); expect(toggle).toHaveAttribute('aria-checked', 'false'); - expect(mocks.leaveMutate).toHaveBeenCalledWith( - { claimId: 'claim-1' }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }) - ); + expect(mocks.leaveMutateAsync).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); it('allows optimistic readiness before indexing without calling geo-chat', () => { @@ -96,7 +251,7 @@ describe('ClaimDebateReadiness', () => { fireEvent.click(toggle); expect(toggle).toHaveAttribute('aria-checked', 'true'); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); expect(screen.queryByText(/processing response|join debate|cancel join|check again/i)).not.toBeInTheDocument(); }); @@ -104,17 +259,17 @@ describe('ClaimDebateReadiness', () => { const view = renderReadiness(claim({ viewer_response: null }), indexing('negative')); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); view.rerender( readiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } }), view.queryClient) ); - await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledTimes(1)); view.rerender( readiness(claim({ viewer_response: { position: false, position_label: 'Disagree' } }), view.queryClient) ); - expect(mocks.joinMutate).toHaveBeenCalledTimes(1); + expect(mocks.joinMutateAsync).toHaveBeenCalledTimes(1); }); it('turns the optimistic switch off to cancel readiness before confirmation', () => { @@ -125,7 +280,7 @@ describe('ClaimDebateReadiness', () => { fireEvent.click(toggle); expect(toggle).toHaveAttribute('aria-checked', 'false'); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); }); it('clears optimistic readiness when the response transaction fails', async () => { @@ -142,7 +297,7 @@ describe('ClaimDebateReadiness', () => { expect(toggle).toHaveAttribute('aria-checked', 'false'); expect(toggle).toBeDisabled(); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); }); it('turns readiness off immediately when the response is optimistically withdrawn', () => { @@ -150,26 +305,44 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); expect(screen.queryByText('Waiting for someone with the opposite response.')).not.toBeInTheDocument(); - expect(mocks.leaveMutate).not.toHaveBeenCalled(); + expect(mocks.leaveMutateAsync).not.toHaveBeenCalled(); }); it('refetches and retries once when geo-chat has not observed the response yet', async () => { - mocks.joinMutate.mockImplementationOnce((_variables, options) => { - options.onError(new GeoChatRequestError('Respond before debating', 'claim_response_required', 409)); - }); + mocks.joinMutateAsync.mockRejectedValueOnce( + new GeoChatRequestError('Respond before debating', 'claim_response_required', 409) + ); renderReadiness(claim()); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); - await waitFor(() => expect(mocks.joinMutate).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledTimes(2)); expect(mocks.joinReset).toHaveBeenCalledOnce(); expect(screen.queryByText('Respond before debating', { selector: '.text-red-01' })).not.toBeInTheDocument(); }); + it('does not retry claim_response_required after the user switches Debate off', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + fireEvent.click(toggle); + + await act(() => join.reject(new GeoChatRequestError('Respond before debating', 'claim_response_required', 409))); + + await waitFor(() => expect(toggle).not.toHaveAttribute('aria-busy')); + expect(mocks.joinMutateAsync).toHaveBeenCalledOnce(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText('Respond before debating')).not.toBeInTheDocument(); + }); + it('clears readiness intent when a validation refetch removes the server response', async () => { - mocks.joinMutate.mockImplementationOnce((_variables, options) => { - options.onError(new GeoChatRequestError('Respond before debating', 'claim_response_required', 409)); - }); + mocks.joinMutateAsync.mockRejectedValueOnce( + new GeoChatRequestError('Respond before debating', 'claim_response_required', 409) + ); const view = renderReadiness(claim()); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); @@ -178,7 +351,7 @@ describe('ClaimDebateReadiness', () => { await waitFor(() => expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false') ); - expect(mocks.joinMutate).toHaveBeenCalledTimes(1); + expect(mocks.joinMutateAsync).toHaveBeenCalledTimes(1); }); it('retires an indexed response after the debate payload confirms it', async () => { @@ -192,9 +365,7 @@ describe('ClaimDebateReadiness', () => { }); it('clears optimistic readiness and surfaces non-transient queue errors', async () => { - mocks.joinMutate.mockImplementationOnce((_variables, options) => { - options.onError(new Error('Queue unavailable')); - }); + mocks.joinMutateAsync.mockRejectedValueOnce(new Error('Queue unavailable')); renderReadiness(claim()); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); @@ -203,6 +374,69 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); }); + it('preserves queue failure feedback across client-side navigation', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + const view = renderReadiness(claim()); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + view.unmount(); + await act(() => join.reject(new Error('Queue unavailable'))); + + render(readiness(claim(), view.queryClient)); + + expect(await screen.findByText('Queue unavailable')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('rolls a failed leave back to the confirmed ready state', async () => { + mocks.leaveMutateAsync.mockRejectedValueOnce(new Error('Leave unavailable')); + renderReadiness(claim({ viewer_debate_ready: true })); + + fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); + + await waitFor(() => expect(screen.getByText('Leave unavailable')).toBeInTheDocument()); + expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('rolls back to a newly confirmed join when the serialized leave fails', async () => { + const join = deferred(); + const leave = deferred(queueResponse(false)); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + mocks.leaveMutateAsync.mockReturnValueOnce(leave.promise); + renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + fireEvent.click(toggle); + await act(() => join.resolve()); + await waitFor(() => expect(mocks.leaveMutateAsync).toHaveBeenCalledOnce()); + + await act(() => leave.reject(new Error('Leave unavailable'))); + + await waitFor(() => expect(screen.getByText('Leave unavailable')).toBeInTheDocument()); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('suppresses an obsolete join error when the final desired state is already confirmed', async () => { + const join = deferred(); + mocks.joinMutateAsync.mockReturnValueOnce(join.promise); + renderReadiness(claim()); + + const toggle = screen.getByRole('switch', { name: 'Debate' }); + fireEvent.click(toggle); + await waitFor(() => expect(mocks.joinMutateAsync).toHaveBeenCalledOnce()); + fireEvent.click(toggle); + + await act(() => join.reject(new Error('Queue unavailable'))); + + await waitFor(() => expect(toggle).not.toHaveAttribute('aria-busy')); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText('Queue unavailable')).not.toBeInTheDocument(); + }); + it('cancels pending readiness when the response kind changes', () => { const view = renderReadiness(claim({ viewer_response: null }), indexing('positive')); fireEvent.click(screen.getByRole('switch', { name: 'Debate' })); @@ -211,7 +445,7 @@ describe('ClaimDebateReadiness', () => { expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); expect(screen.getByRole('switch', { name: 'Debate' })).toBeDisabled(); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); }); it('cancels pending readiness when the user signs out', () => { @@ -223,7 +457,7 @@ describe('ClaimDebateReadiness', () => { view.rerender(readiness(claim({ viewer_response: null }), view.queryClient)); expect(screen.getByRole('switch', { name: 'Debate' })).toHaveAttribute('aria-checked', 'false'); - expect(mocks.joinMutate).not.toHaveBeenCalled(); + expect(mocks.joinMutateAsync).not.toHaveBeenCalled(); }); it('renders only the compact Debate switch in claim action rows', () => { @@ -234,12 +468,23 @@ describe('ClaimDebateReadiness', () => { expect(screen.queryByText(/your response/i)).not.toBeInTheDocument(); }); - it('turns backend lifecycle codes into actionable readiness messages', () => { - renderReadiness(claim({ readiness_disabled_reason: 'claim_response_kind_changed' })); + it.each([ + ['claim_response_kind_changed', 'This claim’s response type changed. Respond and enable Debate again.'], + [ + 'claim_response_validation_failed', + 'Your response could not be verified yet. Debate will remain enabled while verification retries.', + ], + ])('turns %s into an actionable readiness message', (reason, message) => { + renderReadiness(claim({ readiness_disabled_reason: reason })); - expect( - screen.getByText('This claim’s response type changed. Respond and enable Debate again.') - ).toBeInTheDocument(); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it.each(['user_disabled', 'claim_response_withdrawn'])('does not expose %s lifecycle state', reason => { + renderReadiness(claim({ readiness_disabled_reason: reason })); + + expect(screen.queryByText(reason)).not.toBeInTheDocument(); + expect(screen.queryByText('Your response was withdrawn, so Debate was turned off.')).not.toBeInTheDocument(); }); }); @@ -327,3 +572,17 @@ function claim(overrides: Partial = {}): DebateClaim { ...overrides, }; } + +function queueResponse(viewerDebateReady: boolean) { + return { claim: claim({ viewer_debate_ready: viewerDebateReady }), match: null }; +} + +function deferred(defaultValue: unknown = queueResponse(true)) { + let resolve!: (value?: unknown) => void; + let reject!: (error: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = value => promiseResolve(value ?? defaultValue); + reject = promiseReject; + }); + return { promise, resolve, reject }; +} diff --git a/apps/web/core/debates/claim-debate-readiness.tsx b/apps/web/core/debates/claim-debate-readiness.tsx index de02f9a617..094689e3b3 100644 --- a/apps/web/core/debates/claim-debate-readiness.tsx +++ b/apps/web/core/debates/claim-debate-readiness.tsx @@ -14,7 +14,7 @@ import { import { Text } from '~/design-system/text'; import { Toggle } from '~/design-system/toggle'; -import { type DebateClaim, GeoChatRequestError } from './api'; +import { type DebateClaim, type DebateClaimsResponse, GeoChatRequestError } from './api'; import { DebateEntityResponseControls } from './debate-entity-response-controls'; import { useDebateReadinessIntent } from './debate-readiness-intent'; import { useGeoChatAuth, useJoinDebateQueue, useLeaveDebateQueue } from './hooks'; @@ -80,21 +80,12 @@ function ClaimDebateReadinessContent({ spaceId, responseKind: debateClaim.response_kind, }); - const { - intent, - queryKey: intentQueryKey, - setIntent, - } = useDebateReadinessIntent(accountKey, spaceId, entityId, debateClaim.response_kind); - const [intentError, setIntentError] = React.useState(null); - const previousIntentKeyRef = React.useRef(intentQueryKey); - - React.useEffect(() => { - const previousKey = previousIntentKeyRef.current; - if (previousKey !== intentQueryKey) { - queryClient.setQueryData(previousKey, null); - previousIntentKeyRef.current = intentQueryKey; - } - }, [intentQueryKey, queryClient]); + const { intent, setIntent, updateIntent } = useDebateReadinessIntent( + accountKey, + spaceId, + entityId, + debateClaim.response_kind + ); const pendingResponse = responseIndexing.status === 'idle' ? null : responseIndexing.pending; const optimisticPosition = @@ -103,10 +94,10 @@ function ClaimDebateReadinessContent({ const backendReady = debateClaim.viewer_debate_ready; const responseWithdrawalPending = Boolean(pendingResponse && pendingResponse.expectedResponse === null); const checked = responseWithdrawalPending ? false : (intent?.desiredReady ?? backendReady); - const isSaving = - intent?.status === 'submitting' || intent?.status === 'settling' || joinQueue.isPending || leaveQueue.isPending; + const intentRequestInFlight = intent?.inFlightReady !== null && intent?.inFlightReady !== undefined; + const isSaving = intentRequestInFlight || joinQueue.isPending || leaveQueue.isPending; const canEnableToggle = viewerPosition !== null && authenticated && canEnable; - const disabled = isSaving || (!checked && !canEnableToggle); + const disabled = !checked && !canEnableToggle; React.useEffect( function reconcileReadinessIntent() { @@ -116,17 +107,20 @@ function ClaimDebateReadinessContent({ return; } - if (!intent.desiredReady) { - if (!backendReady) setIntent(null); + if (responseWithdrawalPending) { + setIntent(null); return; } - if (backendReady) { - setIntent(null); + if (intent.inFlightReady !== null) return; + if (intent.refreshing) return; + + if (intent.desiredReady === intent.confirmedReady) { + if (backendReady === intent.confirmedReady && !intent.error) setIntent(null); return; } - if (intent.responseRunId && (intent.status === 'waiting' || intent.status === 'refreshing')) { + if (intent.desiredReady && intent.responseRunId) { if (responseIndexing.status === 'idle' || responseIndexing.runId !== intent.responseRunId) { setIntent(null); return; @@ -140,42 +134,98 @@ function ClaimDebateReadinessContent({ } } - if (debateClaim.viewer_response?.position !== intent.expectedPosition) { + if (intent.desiredReady && debateClaim.viewer_response?.position !== intent.expectedPosition) { if (!intent.responseRunId || responseIndexing.status === 'idle') setIntent(null); return; } - if (intent.status !== 'waiting') return; - - setIntent({ ...intent, status: 'submitting' }); - joinQueue.mutate( - { claimId: entityId }, - { - onSuccess: () => { - const current = queryClient.getQueryData(intentQueryKey); - if (current?.desiredReady) setIntent({ ...current, status: 'settling' }); - }, - onError: error => { - const current = queryClient.getQueryData(intentQueryKey); + + const submittedReady = intent.desiredReady; + let requestStarted = false; + updateIntent(current => { + if ( + !current || + current.inFlightReady !== null || + current.refreshing || + current.desiredReady !== submittedReady + ) { + return current; + } + requestStarted = true; + return { ...current, inFlightReady: submittedReady }; + }); + if (!requestStarted) return; + + const request = submittedReady + ? joinQueue.mutateAsync({ claimId: entityId }) + : leaveQueue.mutateAsync({ claimId: entityId }); + + void request.then( + result => { + queryClient.setQueriesData({ queryKey: ['debates', 'claims', spaceId] }, current => + current + ? { + ...current, + claims: current.claims.map(claim => + claim.claim_entity_id === result.claim.claim_entity_id ? result.claim : claim + ), + } + : current + ); + updateIntent(current => { + if (current?.inFlightReady !== submittedReady) return current; + return { + ...current, + confirmedReady: submittedReady, + inFlightReady: null, + error: null, + }; + }); + }, + error => { + let retryAfterRefetch = false; + updateIntent(current => { + if (current?.inFlightReady !== submittedReady) return current; if ( - current?.desiredReady && + submittedReady && + current.desiredReady && error instanceof GeoChatRequestError && error.code === 'claim_response_required' && !current.hasRetried ) { - setIntent({ ...current, hasRetried: true, status: 'refreshing' }); - joinQueue.reset(); - void queryClient.refetchQueries({ queryKey: ['debates', 'claims', spaceId] }).finally(() => { - const refreshed = queryClient.getQueryData(intentQueryKey); - if (refreshed?.desiredReady && refreshed.status === 'refreshing') { - setIntent({ ...refreshed, status: 'waiting' }); - } - }); - return; + retryAfterRefetch = true; + return { + ...current, + hasRetried: true, + inFlightReady: null, + refreshing: true, + error: null, + }; } - setIntent(null); - setIntentError(error instanceof Error ? error.message : 'Could not update debate readiness.'); - }, + const requestError = + current.desiredReady !== current.confirmedReady + ? error instanceof Error + ? error.message + : 'Could not update debate readiness.' + : null; + return { + ...current, + desiredReady: current.confirmedReady, + inFlightReady: null, + refreshing: false, + error: requestError, + }; + }); + + if (retryAfterRefetch) { + joinQueue.reset(); + void queryClient.refetchQueries({ queryKey: ['debates', 'claims', spaceId] }).finally(() => { + updateIntent(current => + current?.desiredReady && current.refreshing ? { ...current, refreshing: false } : current + ); + }); + return; + } } ); }, @@ -186,12 +236,13 @@ function ClaimDebateReadinessContent({ debateClaim.viewer_response?.position, entityId, intent, - intentQueryKey, joinQueue, queryClient, responseIndexing, + responseWithdrawalPending, setIntent, spaceId, + updateIntent, ] ); @@ -209,36 +260,18 @@ function ClaimDebateReadinessContent({ ); const handleToggle = () => { - setIntentError(null); - - if (checked) { - if (intent?.desiredReady) { - setIntent(null); - return; - } - if (!backendReady) return; - - setIntent({ desiredReady: false, status: 'submitting' }); - leaveQueue.mutate( - { claimId: entityId }, - { - onSuccess: () => setIntent({ desiredReady: false, status: 'settling' }), - onError: error => { - setIntent(null); - setIntentError(error instanceof Error ? error.message : 'Could not update debate readiness.'); - }, - } - ); - return; - } + const desiredReady = !checked; + if (desiredReady && (viewerPosition === null || !canEnableToggle)) return; - if (viewerPosition === null || !canEnableToggle) return; setIntent({ - desiredReady: true, - expectedPosition: viewerPosition, - responseRunId: pendingResponse ? responseIndexing.runId : null, - hasRetried: false, - status: 'waiting', + desiredReady, + confirmedReady: intent?.confirmedReady ?? backendReady, + inFlightReady: intent?.inFlightReady ?? null, + expectedPosition: desiredReady ? viewerPosition : (intent?.expectedPosition ?? viewerPosition), + responseRunId: desiredReady ? (pendingResponse ? responseIndexing.runId : null) : (intent?.responseRunId ?? null), + hasRetried: desiredReady ? false : (intent?.hasRetried ?? false), + refreshing: false, + error: null, }); }; @@ -254,27 +287,18 @@ function ClaimDebateReadinessContent({ if (compact) return toggle; - const mutationError = - intentError ?? - (joinQueue.error instanceof Error - ? joinQueue.error.message - : leaveQueue.error instanceof Error - ? leaveQueue.error.message - : null); + const readinessMessage = readinessReasonMessage(debateClaim.readiness_disabled_reason); return (
- -
{toggle}
+
+ + {toggle} +
- {backendReady && !responseWithdrawalPending && ( - - Waiting for someone with the opposite response. - - )} - {(mutationError || debateClaim.readiness_disabled_reason) && ( + {(intent?.error || readinessMessage) && ( - {mutationError ?? readinessReasonMessage(debateClaim.readiness_disabled_reason)} + {intent?.error ?? readinessMessage} )}
@@ -327,8 +351,9 @@ function DebateToggle({ function readinessReasonMessage(reason: string | null) { switch (reason) { + case 'user_disabled': case 'claim_response_withdrawn': - return 'Your response was withdrawn, so Debate was turned off.'; + return null; case 'claim_response_kind_changed': return 'This claim’s response type changed. Respond and enable Debate again.'; case 'claim_response_validation_failed': diff --git a/apps/web/core/debates/debate-readiness-intent.ts b/apps/web/core/debates/debate-readiness-intent.ts index ba884a35b6..732f18ad16 100644 --- a/apps/web/core/debates/debate-readiness-intent.ts +++ b/apps/web/core/debates/debate-readiness-intent.ts @@ -2,22 +2,20 @@ import { hashKey, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import type { DebateResponseKind } from './api'; -type DebateReadinessIntent = - | { - desiredReady: true; - expectedPosition: boolean; - responseRunId: string | null; - hasRetried: boolean; - status: 'waiting' | 'refreshing' | 'submitting' | 'settling'; - } - | { - desiredReady: false; - status: 'submitting' | 'settling'; - }; +type DebateReadinessIntent = { + desiredReady: boolean; + confirmedReady: boolean; + inFlightReady: boolean | null; + expectedPosition: boolean | null; + responseRunId: string | null; + hasRetried: boolean; + refreshing: boolean; + error: string | null; +}; function debateReadinessIntentQueryKey( accountKey: string | null, @@ -40,6 +38,7 @@ export function useDebateReadinessIntent( [accountKey, claimId, responseKind, spaceId] ); const queryHash = useMemo(() => hashKey(queryKey), [queryKey]); + const previousQueryKeyRef = useRef(queryKey); const getSnapshot = useCallback( () => queryClient.getQueryData(queryKey) ?? null, [queryClient, queryKey] @@ -51,10 +50,24 @@ export function useDebateReadinessIntent( }), [queryClient, queryHash] ); + + useEffect(() => { + const previousQueryKey = previousQueryKeyRef.current; + if (previousQueryKey !== queryKey) { + queryClient.setQueryData(previousQueryKey, null); + previousQueryKeyRef.current = queryKey; + } + }, [queryClient, queryKey]); + const setIntent = useCallback( (intent: DebateReadinessIntent | null) => queryClient.setQueryData(queryKey, intent), [queryClient, queryKey] ); + const updateIntent = useCallback( + (updater: (current: DebateReadinessIntent | null) => DebateReadinessIntent | null) => + queryClient.setQueryData(queryKey, current => updater(current ?? null)), + [queryClient, queryKey] + ); - return { intent: useSyncExternalStore(subscribe, getSnapshot, getSnapshot), queryKey, setIntent }; + return { intent: useSyncExternalStore(subscribe, getSnapshot, getSnapshot), setIntent, updateIntent }; } From 88cf2db71ce65e6155ee6097d786171a0c21d2ea Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 10 Aug 2026 11:26:17 +0200 Subject: [PATCH 12/15] perf(claims): batch response data --- .../claims/claims-page-client.test.tsx | 106 ++++++- .../(space)/claims/claims-page-client.tsx | 50 +++- apps/web/core/debates/hooks.test.tsx | 32 +- apps/web/core/debates/hooks.ts | 31 +- apps/web/core/gql/gql.ts | 6 + apps/web/core/gql/graphql.ts | 34 +++ apps/web/core/hooks/use-entity-vote.test.tsx | 72 ++++- apps/web/core/hooks/use-entity-vote.ts | 78 +++-- apps/web/core/hooks/use-spaces-by-ids.ts | 7 +- apps/web/core/io/queries.ts | 31 +- .../core/io/query-fragments.response.test.ts | 21 +- apps/web/core/io/query-fragments.tsx | 11 + apps/web/core/io/query-keys.ts | 11 + .../claim-response-summaries.test.ts | 250 ++++++++++++++++ .../responses/claim-response-summaries.ts | 275 ++++++++++++++++++ .../use-claim-response-summaries.test.tsx | 109 +++++++ .../responses/use-claim-response-summaries.ts | 58 ++++ .../blocks/table/ranking-period-metadata.tsx | 9 +- .../entity-page/claim-voter-avatars.tsx | 4 + .../entity-vote-buttons.batch.test.tsx | 125 ++++++++ .../entity-page/entity-vote-buttons.tsx | 19 +- 21 files changed, 1261 insertions(+), 78 deletions(-) create mode 100644 apps/web/core/io/query-keys.ts create mode 100644 apps/web/core/responses/claim-response-summaries.test.ts create mode 100644 apps/web/core/responses/claim-response-summaries.ts create mode 100644 apps/web/core/responses/use-claim-response-summaries.test.tsx create mode 100644 apps/web/core/responses/use-claim-response-summaries.ts create mode 100644 apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx index e35c16bfc6..f1184b5a7c 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.test.tsx @@ -21,13 +21,18 @@ const mocks = vi.hoisted(() => ({ setIsReviewOpen: vi.fn(), joinMutate: vi.fn(), leaveMutate: vi.fn(), + responseBatchCalls: [] as unknown[], + refetchResponseBatch: vi.fn(), })); let claims: Entity[] = []; +let claimsLoading = false; let featureEnabled = true; let joinPending = false; let lastQueryEntitiesOptions: unknown = null; let debateClaimsResponse: { claims: unknown[] } = { claims: [] }; +let responseBatchReady = true; +let responseBatchError = false; vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mocks.replace, push: vi.fn() }), @@ -57,8 +62,25 @@ vi.mock('~/core/debates/hooks', () => ({ useDeclineDebateMatch: () => ({ mutate: vi.fn(), isPending: false, error: null }), })); +vi.mock('~/core/responses/use-claim-response-summaries', () => ({ + ClaimResponseBatchBoundary: ({ children }: { children: ReactNode }) => <>{children}, + useClaimResponseSummaryBatch: (options: unknown) => { + mocks.responseBatchCalls.push(options); + return { + isSuccess: responseBatchReady, + isError: responseBatchError, + refetch: mocks.refetchResponseBatch, + }; + }, +})); + vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: () =>
Entity response buttons
, + EntityVoteButtons: () => + !responseBatchReady ? ( +
Response skeleton
+ ) : ( +
Entity response buttons
+ ), })); vi.mock('~/core/state/diff-store', () => ({ @@ -72,7 +94,8 @@ vi.mock('~/core/state/diff-store', () => ({ vi.mock('~/core/sync/use-store', () => ({ useQueryEntities: (options: unknown) => { lastQueryEntitiesOptions = options; - return { entities: claims, isLoading: false }; + const deferUntilFetched = (options as { deferUntilFetched?: boolean }).deferUntilFetched; + return { entities: claimsLoading && deferUntilFetched ? [] : claims, isLoading: claimsLoading }; }, })); @@ -93,13 +116,17 @@ vi.mock('~/design-system/select-entity-compact', () => ({ beforeEach(() => { claims = []; + claimsLoading = false; featureEnabled = true; joinPending = false; lastQueryEntitiesOptions = null; debateClaimsResponse = { claims: [] }; + responseBatchReady = true; + responseBatchError = false; vi.clearAllMocks(); mocks.joinMutate.mockReturnValue(new Promise(() => undefined)); mocks.leaveMutate.mockReturnValue(new Promise(() => undefined)); + mocks.responseBatchCalls.length = 0; }); afterEach(() => cleanup()); @@ -115,7 +142,34 @@ describe('ClaimsPageClient', () => { spaces: [{ equals: 'space-1' }], types: [{ id: { equals: CLAIM_TYPE_ID } }], }, + deferUntilFetched: true, + includeUnpublishedLocal: true, }); + expect(lastQueryEntitiesOptions).not.toHaveProperty('placeholderData'); + }); + + it('shows loading instead of locally cached claims before the authoritative query resolves', () => { + claims = [publishedClaim()]; + claimsLoading = true; + + renderClaims(); + + expect(screen.getByText('Loading claims...')).toBeInTheDocument(); + expect(screen.queryByText('Public transit should be free')).not.toBeInTheDocument(); + expect(screen.queryByTestId('entity-response-buttons')).not.toBeInTheDocument(); + expect(screen.queryByRole('switch', { name: 'Debate' })).not.toBeInTheDocument(); + }); + + it('does not retain the previous space claims while the next space loads', () => { + claims = [publishedClaim()]; + const view = renderClaims(); + expect(screen.getByText('Public transit should be free')).toBeInTheDocument(); + + claimsLoading = true; + view.rerender(); + + expect(screen.getByText('Loading claims...')).toBeInTheDocument(); + expect(screen.queryByText('Public transit should be free')).not.toBeInTheDocument(); }); it('stages a claim with Claim and Topics relations only', () => { @@ -156,6 +210,48 @@ describe('ClaimsPageClient', () => { expect(mocks.joinMutate).toHaveBeenCalledWith({ claimId: 'claim-1' }); }); + it('batches the active response kind for all visible claims and defers their individual requests', () => { + claims = Array.from({ length: 50 }, (_, index) => publishedClaim(`claim-${index}`, `Claim ${index}`)); + debateClaimsResponse = { + claims: claims.map((claim, index) => + debateClaim({ + claim_entity_id: claim.id, + response_kind: index % 2 === 0 ? 'stance' : 'veracity', + }) + ), + }; + responseBatchReady = false; + + renderClaims(); + + expect(mocks.responseBatchCalls).toHaveLength(1); + expect(mocks.responseBatchCalls[0]).toMatchObject({ + spaceId: 'space-1', + enabled: true, + targets: expect.arrayContaining([ + { entityId: 'claim-0', responseKind: 'stance' }, + { entityId: 'claim-1', responseKind: 'veracity' }, + ]), + }); + expect((mocks.responseBatchCalls[0] as { targets: unknown[] }).targets).toHaveLength(50); + expect(screen.getAllByTestId('entity-response-skeleton')).toHaveLength(50); + expect(screen.queryByTestId('entity-response-buttons')).not.toBeInTheDocument(); + expect(screen.getAllByRole('switch', { name: 'Debate' })).toHaveLength(50); + }); + + it('retries only the page response batch after its retries are exhausted', () => { + claims = [publishedClaim()]; + debateClaimsResponse = { claims: [debateClaim()] }; + responseBatchReady = false; + responseBatchError = true; + + renderClaims(); + + expect(screen.getByTestId('entity-response-skeleton')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(mocks.refetchResponseBatch).toHaveBeenCalledOnce(); + }); + it('renders backend response labels and one leave-readiness toggle', () => { claims = [publishedClaim()]; debateClaimsResponse = { @@ -228,10 +324,10 @@ function renderClaims() { }); } -function publishedClaim(): Entity { +function publishedClaim(id = 'claim-1', name = 'Public transit should be free'): Entity { return { - id: 'claim-1', - name: 'Public transit should be free', + id, + name, description: null, spaces: ['space-1'], types: [{ id: CLAIM_TYPE_ID, name: 'Claim' }], diff --git a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx index 3e18208969..dbad71a18b 100644 --- a/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/claims/claims-page-client.tsx @@ -1,7 +1,5 @@ 'use client'; -import { keepPreviousData } from '@tanstack/react-query'; - import * as React from 'react'; import cx from 'classnames'; @@ -13,6 +11,10 @@ import { isClaimPublished } from '~/core/claims/publish'; import type { DebateClaim } from '~/core/debates/api'; import { ClaimDebateReadiness } from '~/core/debates/claim-debate-readiness'; import { useDebateClaims } from '~/core/debates/hooks'; +import { + ClaimResponseBatchBoundary, + useClaimResponseSummaryBatch, +} from '~/core/responses/use-claim-response-summaries'; import { useDiff } from '~/core/state/diff-store'; import { useDebatesEnabled } from '~/core/state/feature-flags'; import { useMutate } from '~/core/sync/use-mutate'; @@ -69,7 +71,7 @@ function ClaimsTabSurface({ spaceId, debatesEnabled }: ClaimsPageClientProps & { types: [{ id: { equals: CLAIM_TYPE_ID } }], }, first: 50, - placeholderData: keepPreviousData, + deferUntilFetched: true, includeUnpublishedLocal: true, }); const publishedClaimIds = React.useMemo(() => claims.filter(isClaimPublished).map(claim => claim.id), [claims]); @@ -85,6 +87,20 @@ function ClaimsTabSurface({ spaceId, debatesEnabled }: ClaimsPageClientProps & { () => (debateClaimsQuery.data?.claims ?? []).flatMap(claim => (claim.active_match ? [claim.active_match] : [])), [debateClaimsQuery.data?.claims] ); + const responseTargets = React.useMemo( + () => + publishedClaimIds.flatMap(entityId => { + const responseKind = debateClaimsByEntityId.get(entityId)?.response_kind; + return responseKind ? [{ entityId, responseKind }] : []; + }), + [debateClaimsByEntityId, publishedClaimIds] + ); + const responseBatch = useClaimResponseSummaryBatch({ + spaceId, + targets: responseTargets, + enabled: debatesEnabled, + }); + const responseBatchReady = responseTargets.length === 0 || responseBatch.isSuccess; return (
@@ -101,15 +117,25 @@ function ClaimsTabSurface({ spaceId, debatesEnabled }: ClaimsPageClientProps & { {formOpen && setFormOpen(false)} />}
- 0} - debateClaimsByEntityId={debateClaimsByEntityId} - debateStatus={debateClaimsQuery.error instanceof Error ? debateClaimsQuery.error.message : null} - /> + + {responseBatch.isError ? ( +
+ Response data could not be loaded. + +
+ ) : null} + 0} + debateClaimsByEntityId={debateClaimsByEntityId} + debateStatus={debateClaimsQuery.error instanceof Error ? debateClaimsQuery.error.message : null} + /> +
); diff --git a/apps/web/core/debates/hooks.test.tsx b/apps/web/core/debates/hooks.test.tsx index a6e7459b71..1306c22ae4 100644 --- a/apps/web/core/debates/hooks.test.tsx +++ b/apps/web/core/debates/hooks.test.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { setCachedIdentityToken } from '~/core/auth/identity-token'; +import { entityResponseIndexingQueryKey } from '~/core/responses/entity-response'; import { type Debate, type DebateActivity, type DebateRematchSession, GeoChatRequestError } from './api'; import { DebateCoordinator } from './debate-coordinator'; @@ -189,7 +190,7 @@ describe('useGeoChatAuth', () => { expect(mocks.getIdentityToken).toHaveBeenCalledTimes(1); }); - it('refetches debate readiness when an indexed claim response query succeeds', async () => { + it('refetches debate readiness only when response reconciliation is fully indexed', async () => { mocks.identityToken.mockReturnValue(null); mocks.getIdentityToken.mockResolvedValue(null); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -205,11 +206,26 @@ describe('useGeoChatAuth', () => { act(() => { queryClient.setQueryData(['user-entity-response', 'profile-1', 'claim-1', 'space-1', 0, 'stance'], 'positive'); }); + expect(invalidateQueries).not.toHaveBeenCalled(); + + act(() => { + queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'stance'), { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'stance', + spaceId: 'space-1', + }, + runId: 'run-1', + }); + }); await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['debates', 'claims', 'space-1'] })); }); - it('refetches rematch snapshots when an indexed claim response query succeeds', async () => { + it('refetches rematch snapshots when response reconciliation is fully indexed', async () => { mocks.identityToken.mockReturnValue(null); mocks.getIdentityToken.mockResolvedValue(null); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -223,7 +239,17 @@ describe('useGeoChatAuth', () => { invalidateQueries.mockClear(); act(() => { - queryClient.setQueryData(['user-entity-response', 'profile-1', 'claim-1', 'space-1', 0, 'veracity'], 'negative'); + queryClient.setQueryData(entityResponseIndexingQueryKey('profile-1', 'claim-1', 'space-1', 'veracity'), { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'negative', + personalSpaceId: 'profile-1', + responseKind: 'veracity', + spaceId: 'space-1', + }, + runId: 'run-1', + }); }); await waitFor(() => diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index bcc3b2bfec..f3d3b28666 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -6,6 +6,7 @@ import { type UseQueryResult, useMutation, useQueries, useQuery, useQueryClient import * as React from 'react'; import { getCachedIdentityToken, useIdentityTokenSync } from '~/core/auth/identity-token'; +import type { EntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; import { type Debate, @@ -80,6 +81,19 @@ export const debateQueryKeys = { ['debates', 'account', accountKey, 'profile', profileSpaceId] as const, }; +function indexedResponseEvent(queryKey: readonly unknown[], data: unknown) { + const [scope, , entityId, spaceId, responseKind] = queryKey; + const indexingState = data as EntityResponseIndexingState | undefined; + if ( + scope !== 'entity-response-indexing' || + indexingState?.status !== 'indexed' || + (responseKind !== 'stance' && responseKind !== 'veracity') + ) { + return null; + } + return { entityId: String(entityId), spaceId: String(spaceId), responseKind }; +} + export function useGeoChatAuth() { const privy = usePrivy(); useIdentityTokenSync(); @@ -107,13 +121,8 @@ export function useDebateClaims(spaceId: string, claimIds: string[] | null, enab return queryClient.getQueryCache().subscribe(event => { if (event.type !== 'updated' || event.action.type !== 'success') return; - const [scope, , responseEntityId, responseSpaceId, , responseKind] = event.query.queryKey; - if ( - scope !== 'user-entity-response' || - responseSpaceId !== spaceId || - (responseKind !== 'stance' && responseKind !== 'veracity') || - (claimIds !== null && !claimIds.includes(String(responseEntityId))) - ) { + const response = indexedResponseEvent(event.query.queryKey, event.query.state.data); + if (!response || response.spaceId !== spaceId || (claimIds !== null && !claimIds.includes(response.entityId))) { return; } @@ -449,12 +458,8 @@ export function useDebateRematchClaims(sessionId: string, claimIds: string[] = [ return queryClient.getQueryCache().subscribe(event => { if (event.type !== 'updated' || event.action.type !== 'success') return; - const [scope, , responseEntityId, , , responseKind] = event.query.queryKey; - if ( - scope !== 'user-entity-response' || - (responseKind !== 'stance' && responseKind !== 'veracity') || - (claimIds.length > 0 && !claimIds.includes(String(responseEntityId))) - ) { + const response = indexedResponseEvent(event.query.queryKey, event.query.state.data); + if (!response || (claimIds.length > 0 && !claimIds.includes(response.entityId))) { return; } diff --git a/apps/web/core/gql/gql.ts b/apps/web/core/gql/gql.ts index 43f5de4975..00e83f3fad 100644 --- a/apps/web/core/gql/gql.ts +++ b/apps/web/core/gql/gql.ts @@ -54,6 +54,7 @@ type Documents = { "\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\n }\n }\n": typeof types.EntityResponseCountsDocument, "\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\n }\n }\n": typeof types.UserEntityResponseDocument, "\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n": typeof types.EntityRespondersDocument, + "\n query ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!) {\n userVotes(filter: $filter, first: $first, offset: $offset, orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]) {\n userId\n objectId\n voteType\n voteKind\n }\n }\n": typeof types.ClaimResponseSummariesDocument, "\n query UserHasEntityVote($userId: UUID!) {\n userVotes(condition: { userId: $userId }, first: 1) {\n userId\n }\n }\n": typeof types.UserHasEntityVoteDocument, }; const documents: Documents = { @@ -97,6 +98,7 @@ const documents: Documents = { "\n query EntityResponseCounts($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n positive\n negative\n voteKind\n }\n }\n": types.EntityResponseCountsDocument, "\n query UserEntityResponse($userId: UUID!, $objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind(\n userId: $userId\n objectId: $objectId\n objectType: $objectType\n spaceId: $spaceId\n voteKind: $voteKind\n ) {\n voteType\n }\n }\n": types.UserEntityResponseDocument, "\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n": types.EntityRespondersDocument, + "\n query ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!) {\n userVotes(filter: $filter, first: $first, offset: $offset, orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]) {\n userId\n objectId\n voteType\n voteKind\n }\n }\n": types.ClaimResponseSummariesDocument, "\n query UserHasEntityVote($userId: UUID!) {\n userVotes(condition: { userId: $userId }, first: 1) {\n userId\n }\n }\n": types.UserHasEntityVoteDocument, }; @@ -274,6 +276,10 @@ export function graphql(source: "\n query UserEntityResponse($userId: UUID!, $o * 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 EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\n }\n }\n"): (typeof documents)["\n query EntityResponders($objectId: UUID!, $objectType: Int!, $spaceId: UUID!, $voteKind: Int!) {\n userVotes(condition: { objectId: $objectId, objectType: $objectType, spaceId: $spaceId, voteKind: $voteKind }) {\n userId\n voteType\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 ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!) {\n userVotes(filter: $filter, first: $first, offset: $offset, orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]) {\n userId\n objectId\n voteType\n voteKind\n }\n }\n"): (typeof documents)["\n query ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!) {\n userVotes(filter: $filter, first: $first, offset: $offset, orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]) {\n userId\n objectId\n voteType\n voteKind\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/apps/web/core/gql/graphql.ts b/apps/web/core/gql/graphql.ts index 9d8c4c5e84..9b2e835797 100644 --- a/apps/web/core/gql/graphql.ts +++ b/apps/web/core/gql/graphql.ts @@ -1102,6 +1102,30 @@ export type UuidListFilter = { overlaps?: Array | null | undefined; }; +/** A filter to be used against `UserVote` object types. All fields are combined with a logical ‘and.’ */ +export type UserVoteFilter = { + /** Checks for all expressions in this list. */ + and?: Array | null | undefined; + /** Negates the expression. */ + not?: UserVoteFilter | null | undefined; + /** Filter by the object’s `objectId` field. */ + objectId?: UuidFilter | null | undefined; + /** Filter by the object’s `objectType` field. */ + objectType?: IntFilter | null | undefined; + /** Checks for any expressions in this list. */ + or?: Array | null | undefined; + /** Filter by the object’s `spaceId` field. */ + spaceId?: UuidFilter | null | undefined; + /** Filter by the object’s `userId` field. */ + userId?: UuidFilter | null | undefined; + /** Filter by the object’s `voteKind` field. */ + voteKind?: IntFilter | null | undefined; + /** Filter by the object’s `voteType` field. */ + voteType?: IntFilter | null | undefined; + /** Filter by the object’s `votedAt` field. */ + votedAt?: DatetimeFilter | null | undefined; +}; + /** A filter to be used against `Value` object types. All fields are combined with a logical ‘and.’ */ export type ValueFilter = { /** Checks for all expressions in this list. */ @@ -1521,6 +1545,15 @@ export type EntityRespondersQueryVariables = Exact<{ export type EntityRespondersQuery = { userVotes: Array<{ userId: any, voteType: number }> | null }; +export type ClaimResponseSummariesQueryVariables = Exact<{ + filter: UserVoteFilter; + first: number; + offset: number; +}>; + + +export type ClaimResponseSummariesQuery = { userVotes: Array<{ userId: any, objectId: any, voteType: number, voteKind: number }> | null }; + export type UserHasEntityVoteQueryVariables = Exact<{ userId: any; }>; @@ -1568,4 +1601,5 @@ export const RelationEntityMinimalDocument = {"kind":"Document","definitions":[{ export const EntityResponseCountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityResponseCounts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"votesCountByObjectIdAndObjectTypeAndSpaceIdAndVoteKind"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"Argument","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"positive"}},{"kind":"Field","name":{"kind":"Name","value":"negative"}},{"kind":"Field","name":{"kind":"Name","value":"voteKind"}}]}}]}}]} as unknown as DocumentNode; export const UserEntityResponseDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserEntityResponse"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVoteByUserIdAndObjectIdAndObjectTypeAndSpaceIdAndVoteKind"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"Argument","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; export const EntityRespondersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EntityResponders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"objectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"objectType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectType"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"spaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"voteKind"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voteKind"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"voteType"}}]}}]}}]} as unknown as DocumentNode; +export const ClaimResponseSummariesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ClaimResponseSummaries"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserVoteFilter"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"ListValue","values":[{"kind":"EnumValue","value":"OBJECT_ID_ASC"},{"kind":"EnumValue","value":"VOTE_KIND_ASC"},{"kind":"EnumValue","value":"USER_ID_ASC"}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"objectId"}},{"kind":"Field","name":{"kind":"Name","value":"voteType"}},{"kind":"Field","name":{"kind":"Name","value":"voteKind"}}]}}]}}]} as unknown as DocumentNode; export const UserHasEntityVoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserHasEntityVote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userVotes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"condition"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/apps/web/core/hooks/use-entity-vote.test.tsx b/apps/web/core/hooks/use-entity-vote.test.tsx index 17fc6d8414..64433b5e7a 100644 --- a/apps/web/core/hooks/use-entity-vote.test.tsx +++ b/apps/web/core/hooks/use-entity-vote.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runEffectEither: vi.fn(), responseAction: vi.fn(() => ({ to: '0x1234' as const, calldata: '0xabcd' as const })), waitForIndexedEntityResponse: vi.fn(), + loadResponseSummaryCaches: vi.fn(), personalSpaceId: 'd4bee0928fb5405baba3b1513f085835' as string | null, })); @@ -68,6 +69,15 @@ vi.mock('~/core/sdk/geo-client', () => ({ }, })); +vi.mock('~/core/responses/claim-response-summaries', () => ({ + claimResponseSummariesQueryKeyPrefix: (personalSpaceId: string | null, spaceId: string) => [ + 'claim-response-summaries', + personalSpaceId, + spaceId, + ], + loadClaimResponseSummaryCaches: (...args: unknown[]) => mocks.loadResponseSummaryCaches(...args), +})); + vi.mock('~/core/telemetry/effect-runtime', () => ({ runEffectEither: (...args: unknown[]) => mocks.runEffectEither(...args), })); @@ -78,6 +88,8 @@ beforeEach(() => { mocks.runEffectEither.mockReset(); mocks.responseAction.mockClear(); mocks.waitForIndexedEntityResponse.mockClear(); + mocks.loadResponseSummaryCaches.mockReset(); + mocks.loadResponseSummaryCaches.mockResolvedValue(new Map()); mocks.runEffectEither.mockResolvedValue({ _tag: 'Right', right: '0xtransaction' }); mocks.personalSpaceId = PERSONAL_SPACE_ID; }); @@ -119,6 +131,7 @@ describe('useEntityResponse indexing reconciliation', () => { const onError = vi.fn(); const { queryClient, wrapper } = createHarness(); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const cancelQueries = vi.spyOn(queryClient, 'cancelQueries'); const { result } = renderHook( () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), { wrapper } @@ -153,7 +166,18 @@ describe('useEntityResponse indexing reconciliation', () => { expect(result.current.isProcessingResponse).toBe(false); expect(result.current.isResponseIndexingDelayed).toBe(false); expect(result.current.optimisticResponse).toBeUndefined(); - expect(invalidateQueries).toHaveBeenCalledTimes(3); + expect(mocks.loadResponseSummaryCaches).toHaveBeenCalledWith( + expect.objectContaining({ + queryClient, + spaceId: TARGET_SPACE_ID, + personalSpaceId: PERSONAL_SPACE_ID, + targets: [{ entityId: 'claim-1', responseKind: 'stance' }], + }) + ); + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: ['claim-response-summaries', PERSONAL_SPACE_ID, TARGET_SPACE_ID], + }); + expect(invalidateQueries).not.toHaveBeenCalled(); }); it('isolates optimistic response state by personal space', async () => { @@ -199,6 +223,52 @@ describe('useEntityResponse indexing reconciliation', () => { expect(result.current.isProcessingResponse).toBe(false); expect(result.current.isResponseIndexingDelayed).toBe(false); expect(result.current.optimisticResponse).toBeUndefined(); + expect(mocks.loadResponseSummaryCaches).toHaveBeenCalledOnce(); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it('keeps reconciliation recoverable when the single-claim summary refresh fails', async () => { + mocks.fetchResponse.mockReturnValue('positive'); + mocks.loadResponseSummaryCaches.mockRejectedValueOnce(new Error('summary unavailable')); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => useEntityResponse({ entityId: 'claim-1', spaceId: TARGET_SPACE_ID, responseKind: 'stance' }), + { wrapper } + ); + + act(() => result.current.submitResponse('positive')); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.isResponseIndexingDelayed).toBe(true); + expect(mocks.loadResponseSummaryCaches).toHaveBeenCalledOnce(); + + await act(async () => vi.advanceTimersByTimeAsync(10_000)); + + expect(mocks.loadResponseSummaryCaches).toHaveBeenCalledTimes(2); + expect(result.current.isResponseIndexingDelayed).toBe(false); + }); + + it('preserves individual cache invalidation for non-Claim curation votes', async () => { + mocks.fetchResponse.mockReturnValue('positive'); + const { queryClient, wrapper } = createHarness(); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook( + () => useEntityResponse({ entityId: 'entity-1', spaceId: TARGET_SPACE_ID, responseKind: 'curation' }), + { wrapper } + ); + + act(() => result.current.submitResponse('positive')); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.loadResponseSummaryCaches).not.toHaveBeenCalled(); expect(invalidateQueries).toHaveBeenCalledTimes(3); }); diff --git a/apps/web/core/hooks/use-entity-vote.ts b/apps/web/core/hooks/use-entity-vote.ts index f69004e4dd..11abddd92f 100644 --- a/apps/web/core/hooks/use-entity-vote.ts +++ b/apps/web/core/hooks/use-entity-vote.ts @@ -9,6 +9,10 @@ import { Effect, Either } from 'effect'; import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; import { useSmartAccountTransaction } from '~/core/hooks/use-smart-account-transaction'; import { getUserEntityResponse } from '~/core/io/queries'; +import { + claimResponseSummariesQueryKeyPrefix, + loadClaimResponseSummaryCaches, +} from '~/core/responses/claim-response-summaries'; import { type ActiveResponseDirection, type ResponseDirection, @@ -173,28 +177,6 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity [indexingQueryKey, queryClient] ); - const invalidateResponseQueries = useCallback( - (pending: PendingEntityResponseIndex) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: entityResponseCountsQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), - }), - queryClient.invalidateQueries({ - queryKey: userEntityResponseQueryKey( - pending.personalSpaceId, - pending.entityId, - pending.spaceId, - 0, - pending.responseKind - ), - }), - queryClient.invalidateQueries({ - queryKey: entityRespondersQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), - }), - ]), - [queryClient] - ); - const reconcileResponseIndexing = useCallback( async (pending: PendingEntityResponseIndex, runId: string) => { if (!isCurrentIndexingRun(runId)) return; @@ -237,7 +219,48 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity return; } - await invalidateResponseQueries(pending); + try { + if (pending.responseKind === 'curation') { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: entityResponseCountsQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), + }), + queryClient.invalidateQueries({ + queryKey: userEntityResponseQueryKey( + pending.personalSpaceId, + pending.entityId, + pending.spaceId, + 0, + pending.responseKind + ), + }), + queryClient.invalidateQueries({ + queryKey: entityRespondersQueryKey(pending.entityId, pending.spaceId, 0, pending.responseKind), + }), + ]); + } else { + void queryClient.cancelQueries({ + queryKey: claimResponseSummariesQueryKeyPrefix(pending.personalSpaceId, pending.spaceId), + }); + await loadClaimResponseSummaryCaches({ + queryClient, + spaceId: pending.spaceId, + targets: [{ entityId: pending.entityId, responseKind: pending.responseKind }], + personalSpaceId: pending.personalSpaceId, + signal: controller.signal, + forceResponseRefresh: true, + }); + } + } catch { + if (!isCurrentIndexingRun(runId)) return; + queryClient.setQueryData(indexingQueryKey, { + status: 'delayed', + pending, + runId, + }); + responseIndexingRegistry.submissionRuns.delete(indexingKeyId); + return; + } if (!isCurrentIndexingRun(runId)) return; queryClient.setQueryData(indexingQueryKey, { @@ -247,14 +270,7 @@ export function useEntityResponse({ entityId, spaceId, responseKind }: UseEntity }); responseIndexingRegistry.submissionRuns.delete(indexingKeyId); }, - [ - indexingKeyId, - indexingQueryKey, - invalidateResponseQueries, - isCurrentIndexingRun, - queryClient, - responseIndexingRegistry, - ] + [indexingKeyId, indexingQueryKey, isCurrentIndexingRun, queryClient, responseIndexingRegistry] ); const executeResponse = useCallback( diff --git a/apps/web/core/hooks/use-spaces-by-ids.ts b/apps/web/core/hooks/use-spaces-by-ids.ts index becc5f18a2..d2e223691e 100644 --- a/apps/web/core/hooks/use-spaces-by-ids.ts +++ b/apps/web/core/hooks/use-spaces-by-ids.ts @@ -6,6 +6,7 @@ import { Effect } from 'effect'; import { Space } from '~/core/io/dto/spaces'; import { getSpaces } from '~/core/io/queries'; +import { spacesByIdsQueryKey } from '~/core/io/query-keys'; type UseSpacesByIdsResult = { spaces: Space[]; @@ -15,12 +16,12 @@ type UseSpacesByIdsResult = { type UseSpacesByIdsData = Omit; -export function useSpacesByIds(spaceIds: string[] = []): UseSpacesByIdsResult { +export function useSpacesByIds(spaceIds: string[] = [], enabled = true): UseSpacesByIdsResult { const requestedIds = [...new Set(spaceIds.filter(Boolean))]; const normalizedIds = [...requestedIds].sort(); const { data, isLoading } = useQuery({ - queryKey: ['spaces-by-ids', normalizedIds], + queryKey: spacesByIdsQueryKey(normalizedIds), queryFn: ({ signal }) => Effect.runPromise(getSpaces({ spaceIds: normalizedIds }, signal)), select: (fetchedSpaces): UseSpacesByIdsData => { const spacesById = new Map(fetchedSpaces.map(space => [space.id, space])); @@ -31,7 +32,7 @@ export function useSpacesByIds(spaceIds: string[] = []): UseSpacesByIdsResult { spacesById, }; }, - enabled: normalizedIds.length > 0, + enabled: enabled && normalizedIds.length > 0, }); return { diff --git a/apps/web/core/io/queries.ts b/apps/web/core/io/queries.ts index ac04a98e86..5ae9c1617d 100644 --- a/apps/web/core/io/queries.ts +++ b/apps/web/core/io/queries.ts @@ -16,6 +16,7 @@ import { type EntityFilter, type EntitySpacesBatchQuery, UserHasEntityVoteDocument, + type UserVoteFilter, type UuidFilter, } from '~/core/gql/graphql'; import { RANKING_BLOCK_TYPE_ID } from '~/core/ranking-block-ids'; @@ -41,6 +42,7 @@ import { Space } from './dto/spaces'; import { entitiesOrderedByPropertyConnectionDocument } from './entities-ordered-by-property-connection-document'; import { graphql } from './graphql-client'; import { + claimResponseSummariesQuery, entitiesBatchQuery, entitiesPageQuery, entityBacklinksQuery, @@ -71,8 +73,8 @@ import { userEntityResponseQuery, } from './query-fragments'; import { restFetch } from './rest'; -import { extractSingleSpaceIdFromFilter, extractSpaceIdsFromFilter, removeSpaceIdsFromFilter } from './space-filter'; import { type SortOrder } from './sort-order'; +import { extractSingleSpaceIdFromFilter, extractSpaceIdsFromFilter, removeSpaceIdsFromFilter } from './space-filter'; import { extractSingleTypeIdFromFilter, extractTypeIdsFromFilter, removeTypeIdsFromFilter } from './type-filter'; // `EntitiesBatch` has no `first` argument, so keep id.in calls under the API's default page size. @@ -1267,6 +1269,33 @@ export function getUserHasEntityVote(userId: string, signal?: AbortController['s export type EntityResponder = { userId: string; direction: ActiveResponseDirection }; +export type ClaimResponseSummaryRow = { + userId: string; + objectId: string; + voteType: number; + voteKind: number; +}; + +export function getClaimResponseSummaryPage( + filter: UserVoteFilter, + first: number, + offset: number, + signal?: AbortController['signal'] +) { + return graphql({ + query: claimResponseSummariesQuery, + decoder: data => + (data.userVotes ?? []).map((vote): ClaimResponseSummaryRow => ({ + userId: String(vote.userId), + objectId: String(vote.objectId), + voteType: vote.voteType, + voteKind: vote.voteKind, + })), + variables: { filter, first, offset }, + signal, + }); +} + export function getEntityResponders( entityId: string, spaceId: string, diff --git a/apps/web/core/io/query-fragments.response.test.ts b/apps/web/core/io/query-fragments.response.test.ts index 7a2c67c21d..826ce2c236 100644 --- a/apps/web/core/io/query-fragments.response.test.ts +++ b/apps/web/core/io/query-fragments.response.test.ts @@ -1,7 +1,12 @@ import { print } from 'graphql'; import { describe, expect, it } from 'vitest'; -import { entityRespondersQuery, entityResponseCountsQuery, userEntityResponseQuery } from './query-fragments'; +import { + claimResponseSummariesQuery, + entityRespondersQuery, + entityResponseCountsQuery, + userEntityResponseQuery, +} from './query-fragments'; describe('entity response query fragments', () => { it('reads counts for one exact object, space, and vote kind', () => { @@ -29,4 +34,18 @@ describe('entity response query fragments', () => { expect(query).toContain('spaceId: $spaceId'); expect(query).toContain('voteKind: $voteKind'); }); + + it('reads one deterministic page of response summaries for exact claim and kind filters', () => { + const query = print(claimResponseSummariesQuery); + + expect(query).toContain('query ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!)'); + expect(query).toContain('filter: $filter'); + expect(query).toContain('first: $first'); + expect(query).toContain('offset: $offset'); + expect(query).toContain('orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]'); + expect(query).toContain('userId'); + expect(query).toContain('objectId'); + expect(query).toContain('voteType'); + expect(query).toContain('voteKind'); + }); }); diff --git a/apps/web/core/io/query-fragments.tsx b/apps/web/core/io/query-fragments.tsx index 8eff4df86d..9d1eb270bd 100644 --- a/apps/web/core/io/query-fragments.tsx +++ b/apps/web/core/io/query-fragments.tsx @@ -791,6 +791,17 @@ export const entityRespondersQuery = graphql(/* GraphQL */ ` } `); +export const claimResponseSummariesQuery = graphql(/* GraphQL */ ` + query ClaimResponseSummaries($filter: UserVoteFilter!, $first: Int!, $offset: Int!) { + userVotes(filter: $filter, first: $first, offset: $offset, orderBy: [OBJECT_ID_ASC, VOTE_KIND_ASC, USER_ID_ASC]) { + userId + objectId + voteType + voteKind + } + } +`); + export const userHasEntityVoteQuery = graphql(/* GraphQL */ ` query UserHasEntityVote($userId: UUID!) { userVotes(condition: { userId: $userId }, first: 1) { diff --git a/apps/web/core/io/query-keys.ts b/apps/web/core/io/query-keys.ts new file mode 100644 index 0000000000..d5235f272d --- /dev/null +++ b/apps/web/core/io/query-keys.ts @@ -0,0 +1,11 @@ +function normalizeSpaceIds(spaceIds: string[]) { + return [...new Set(spaceIds.filter(Boolean))].sort(); +} + +export function profilesBySpaceIdsQueryKey(spaceIds: string[]) { + return ['profiles-by-space-ids', normalizeSpaceIds(spaceIds)] as const; +} + +export function spacesByIdsQueryKey(spaceIds: string[]) { + return ['spaces-by-ids', normalizeSpaceIds(spaceIds)] as const; +} diff --git a/apps/web/core/responses/claim-response-summaries.test.ts b/apps/web/core/responses/claim-response-summaries.test.ts new file mode 100644 index 0000000000..817ae74666 --- /dev/null +++ b/apps/web/core/responses/claim-response-summaries.test.ts @@ -0,0 +1,250 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { Space } from '~/core/io/dto/spaces'; +import { profilesBySpaceIdsQueryKey, spacesByIdsQueryKey } from '~/core/io/query-keys'; +import type { Profile } from '~/core/types'; + +import { + CLAIM_RESPONSE_SUMMARY_PAGE_SIZE, + buildClaimResponseSummaryFilter, + fetchClaimResponseSummaries, + groupClaimResponseSummaryRows, + loadClaimResponseSummaryCaches, + normalizeClaimResponseTargets, +} from './claim-response-summaries'; +import { + entityResponderProfilesQueryKey, + entityRespondersQueryKey, + entityResponseCountsQueryKey, + userEntityResponseQueryKey, +} from './entity-response'; + +const targets = [ + { entityId: 'claim-veracity', responseKind: 'veracity' as const }, + { entityId: 'claim-stance', responseKind: 'stance' as const }, +]; + +describe('claim response summaries', () => { + it('normalizes duplicate claim-kind pairs deterministically', () => { + expect(normalizeClaimResponseTargets([targets[0], targets[1], targets[0]])).toEqual([ + { entityId: 'claim-stance', responseKind: 'stance' }, + { entityId: 'claim-veracity', responseKind: 'veracity' }, + ]); + }); + + it('builds an exact-space entity filter with one exact branch per active vote kind', () => { + expect(buildClaimResponseSummaryFilter('space-1', targets)).toEqual({ + spaceId: { is: 'space-1' }, + objectType: { is: 0 }, + voteType: { in: [0, 1] }, + or: [ + { objectId: { is: 'claim-stance' }, voteKind: { is: 1 } }, + { objectId: { is: 'claim-veracity' }, voteKind: { is: 2 } }, + ], + }); + }); + + it('groups active positive and negative rows and ignores cleared or inactive-kind rows', () => { + const summaries = groupClaimResponseSummaryRows( + targets, + [ + row('claim-stance', 1, 'viewer', 0), + row('claim-stance', 1, 'negative-user', 1), + row('claim-stance', 1, 'cleared-user', 2), + row('claim-stance', 2, 'wrong-kind', 0), + row('claim-veracity', 2, 'verifier', 0), + row('another-claim', 1, 'wrong-claim', 0), + ], + 'viewer' + ); + + expect(summaries.get('claim-stance:stance')).toEqual({ + counts: { positive: 1, negative: 1 }, + viewerResponse: 'positive', + responders: [ + { userId: 'viewer', direction: 'positive' }, + { userId: 'negative-user', direction: 'negative' }, + ], + }); + expect(summaries.get('claim-veracity:veracity')).toEqual({ + counts: { positive: 1, negative: 0 }, + viewerResponse: null, + responders: [{ userId: 'verifier', direction: 'positive' }], + }); + }); + + it('does not assign a viewer response for anonymous requests', () => { + const summaries = groupClaimResponseSummaryRows( + [{ entityId: 'claim-stance', responseKind: 'stance' }], + [row('claim-stance', 1, 'user-1', 1)], + null + ); + + expect(summaries.get('claim-stance:stance')?.viewerResponse).toBeNull(); + }); + + it('paginates in deterministic 1,000-row pages and deduplicates rows repeated across pages', async () => { + const firstPage = Array.from({ length: CLAIM_RESPONSE_SUMMARY_PAGE_SIZE }, (_, index) => + row('claim-stance', 1, `user-${index}`, index % 2) + ); + const duplicate = firstPage.at(-1)!; + const fetchPage = vi + .fn() + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([duplicate, row('claim-stance', 1, 'user-1000', 0)]); + + const summaries = await fetchClaimResponseSummaries({ + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + personalSpaceId: 'user-1000', + fetchPage, + }); + + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(fetchPage.mock.calls.map(call => call[0].offset)).toEqual([0, 1000]); + expect(fetchPage.mock.calls[0]?.[0]).toMatchObject({ + first: 1000, + filter: { + spaceId: { is: 'space-1' }, + objectType: { is: 0 }, + or: [{ objectId: { is: 'claim-stance' }, voteKind: { is: 1 } }], + }, + }); + expect(summaries.get('claim-stance:stance')).toMatchObject({ + counts: { positive: 501, negative: 500 }, + viewerResponse: 'positive', + }); + expect(summaries.get('claim-stance:stance')?.responders).toHaveLength(1001); + }); + + it('deduplicates avatar metadata in chunks and seeds every existing per-claim cache', async () => { + const queryClient = new QueryClient(); + const responderIds = Array.from({ length: 205 }, (_, index) => `profile-${index}`); + const summary = { + counts: { positive: 103, negative: 102 }, + viewerResponse: 'negative' as const, + responders: responderIds.map((userId, index) => ({ + userId, + direction: index % 2 === 0 ? ('positive' as const) : ('negative' as const), + })), + }; + const fetchSummaries = vi.fn().mockResolvedValue(new Map([['claim-stance:stance', summary]])); + const fetchProfiles = vi.fn(async (ids: string[]) => + ids.map(id => ({ spaceId: id, address: `0x${id}` }) as Profile) + ); + const fetchSpaces = vi.fn(async (ids: string[]) => + ids.map(id => ({ id, entity: { id: `entity-${id}` } }) as Space) + ); + + await loadClaimResponseSummaryCaches({ + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + personalSpaceId: 'profile-1', + fetchSummaries, + fetchProfiles, + fetchSpaces, + }); + + expect(fetchProfiles.mock.calls.map(call => call[0].length)).toEqual([100, 100, 5]); + expect(fetchSpaces.mock.calls.map(call => call[0].length)).toEqual([100, 100, 5]); + expect(queryClient.getQueryData(entityResponseCountsQueryKey('claim-stance', 'space-1', 0, 'stance'))).toEqual( + summary.counts + ); + expect( + queryClient.getQueryData(userEntityResponseQueryKey('profile-1', 'claim-stance', 'space-1', 0, 'stance')) + ).toBe('negative'); + expect(queryClient.getQueryData(entityRespondersQueryKey('claim-stance', 'space-1', 0, 'stance'))).toEqual( + summary.responders + ); + expect( + queryClient.getQueryData([ + ...entityResponderProfilesQueryKey('claim-stance', 'space-1', 0, 'stance'), + responderIds, + ]) + ).toHaveLength(205); + expect(queryClient.getQueryData(profilesBySpaceIdsQueryKey(responderIds))).toBeInstanceOf(Map); + expect(queryClient.getQueryData(spacesByIdsQueryKey(responderIds))).toHaveLength(205); + }); + + it('reuses successful summary and profile metadata when a space-metadata retry is needed', async () => { + const queryClient = new QueryClient(); + const fetchSummaries = vi.fn().mockResolvedValue( + new Map([ + [ + 'claim-stance:stance', + { + counts: { positive: 1, negative: 0 }, + viewerResponse: null, + responders: [{ userId: 'profile-1', direction: 'positive' }], + }, + ], + ]) + ); + const fetchProfiles = vi.fn(async () => [{ spaceId: 'profile-1', address: '0x1' } as unknown as Profile]); + const fetchSpaces = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('space metadata unavailable')) + .mockResolvedValueOnce([{ id: 'profile-1', entity: { id: 'profile-1' } } as Space]); + const args = { + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' as const }], + personalSpaceId: null, + fetchSummaries, + fetchProfiles, + fetchSpaces, + }; + + await expect(loadClaimResponseSummaryCaches(args)).rejects.toThrow('space metadata unavailable'); + await expect(loadClaimResponseSummaryCaches(args)).resolves.toBeInstanceOf(Map); + + expect(fetchSummaries).toHaveBeenCalledOnce(); + expect(fetchProfiles).toHaveBeenCalledOnce(); + expect(fetchSpaces).toHaveBeenCalledTimes(2); + }); + + it('does not seed per-claim caches after its page batch is cancelled', async () => { + const queryClient = new QueryClient(); + const controller = new AbortController(); + const fetchSummaries = vi.fn().mockResolvedValue( + new Map([ + [ + 'claim-stance:stance', + { + counts: { positive: 1, negative: 0 }, + viewerResponse: 'positive' as const, + responders: [{ userId: 'profile-1', direction: 'positive' as const }], + }, + ], + ]) + ); + const fetchProfiles = vi.fn(async () => { + controller.abort(); + return [{ spaceId: 'profile-1', address: '0x1' } as unknown as Profile]; + }); + + await expect( + loadClaimResponseSummaryCaches({ + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + personalSpaceId: 'profile-1', + signal: controller.signal, + fetchSummaries, + fetchProfiles, + fetchSpaces: vi.fn(async () => [{ id: 'profile-1', entity: { id: 'profile-1' } } as Space]), + }) + ).rejects.toBeDefined(); + + expect(queryClient.getQueryData(entityResponseCountsQueryKey('claim-stance', 'space-1', 0, 'stance'))).toBe( + undefined + ); + }); +}); + +function row(objectId: string, voteKind: number, userId: string, voteType: number) { + return { objectId, voteKind, userId, voteType }; +} diff --git a/apps/web/core/responses/claim-response-summaries.ts b/apps/web/core/responses/claim-response-summaries.ts new file mode 100644 index 0000000000..8eb31c1ac7 --- /dev/null +++ b/apps/web/core/responses/claim-response-summaries.ts @@ -0,0 +1,275 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { Effect } from 'effect'; + +import type { UserVoteFilter } from '~/core/gql/graphql'; +import type { Space } from '~/core/io/dto/spaces'; +import { + type ClaimResponseSummaryRow, + type EntityResponder, + getClaimResponseSummaryPage, + getSpaces, +} from '~/core/io/queries'; +import { profilesBySpaceIdsQueryKey, spacesByIdsQueryKey } from '~/core/io/query-keys'; +import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; +import type { Profile } from '~/core/types'; + +import { + type ActiveResponseDirection, + type ResponseKind, + decodeActiveResponseDirection, + entityResponderProfilesQueryKey, + entityRespondersQueryKey, + entityResponseCountsQueryKey, + responseKindToVoteKind, + userEntityResponseQueryKey, +} from './entity-response'; + +export const CLAIM_RESPONSE_SUMMARY_PAGE_SIZE = 1_000; + +export type ClaimResponseTarget = { + entityId: string; + responseKind: ResponseKind; +}; + +export type ClaimResponseSummary = { + counts: { positive: number; negative: number }; + viewerResponse: ActiveResponseDirection | null; + responders: EntityResponder[]; +}; + +type FetchSummaryPageArgs = { + filter: UserVoteFilter; + first: number; + offset: number; + signal?: AbortSignal; +}; + +type FetchSummaryPage = (args: FetchSummaryPageArgs) => Promise; +type FetchSummaries = typeof fetchClaimResponseSummaries; +type FetchProfiles = (spaceIds: string[]) => Promise; +type FetchSpaces = (spaceIds: string[], signal?: AbortSignal) => Promise; + +export function claimResponseTargetKey(target: ClaimResponseTarget) { + return `${target.entityId}:${target.responseKind}`; +} + +export function claimResponseSummariesQueryKeyPrefix(personalSpaceId: string | null, spaceId: string) { + return ['claim-response-summaries', personalSpaceId, spaceId] as const; +} + +export function normalizeClaimResponseTargets(targets: ClaimResponseTarget[]) { + const byKey = new Map(targets.map(target => [claimResponseTargetKey(target), target])); + return [...byKey.values()].sort((left, right) => + claimResponseTargetKey(left).localeCompare(claimResponseTargetKey(right)) + ); +} + +export function buildClaimResponseSummaryFilter(spaceId: string, targets: ClaimResponseTarget[]): UserVoteFilter { + return { + spaceId: { is: spaceId }, + objectType: { is: 0 }, + voteType: { in: [0, 1] }, + or: normalizeClaimResponseTargets(targets).map(target => ({ + objectId: { is: target.entityId }, + voteKind: { is: responseKindToVoteKind(target.responseKind) }, + })), + }; +} + +export function groupClaimResponseSummaryRows( + targets: ClaimResponseTarget[], + rows: ClaimResponseSummaryRow[], + personalSpaceId: string | null +): Map { + const normalizedTargets = normalizeClaimResponseTargets(targets); + const targetByObjectAndKind = new Map( + normalizedTargets.map(target => [`${target.entityId}:${responseKindToVoteKind(target.responseKind)}`, target]) + ); + const activeRows = new Map(); + + for (const row of rows) { + const target = targetByObjectAndKind.get(`${row.objectId}:${row.voteKind}`); + const direction = decodeActiveResponseDirection(row.voteType); + if (!target || !direction) continue; + activeRows.set(`${claimResponseTargetKey(target)}:${row.userId}`, { target, row }); + } + + const summaries = new Map( + normalizedTargets.map(target => [ + claimResponseTargetKey(target), + { counts: { positive: 0, negative: 0 }, viewerResponse: null, responders: [] }, + ]) + ); + + for (const { target, row } of activeRows.values()) { + const direction = decodeActiveResponseDirection(row.voteType); + if (!direction) continue; + const summary = summaries.get(claimResponseTargetKey(target)); + if (!summary) continue; + summary.counts[direction] += 1; + summary.responders.push({ userId: row.userId, direction }); + if (personalSpaceId && row.userId === personalSpaceId) summary.viewerResponse = direction; + } + + return summaries; +} + +export async function fetchClaimResponseSummaries({ + spaceId, + targets, + personalSpaceId, + signal, + fetchPage = defaultFetchPage, +}: { + spaceId: string; + targets: ClaimResponseTarget[]; + personalSpaceId: string | null; + signal?: AbortSignal; + fetchPage?: FetchSummaryPage; +}) { + const normalizedTargets = normalizeClaimResponseTargets(targets); + if (normalizedTargets.length === 0) return new Map(); + + const filter = buildClaimResponseSummaryFilter(spaceId, normalizedTargets); + const rows: ClaimResponseSummaryRow[] = []; + let offset = 0; + + while (true) { + const page = await fetchPage({ filter, first: CLAIM_RESPONSE_SUMMARY_PAGE_SIZE, offset, signal }); + rows.push(...page); + if (page.length < CLAIM_RESPONSE_SUMMARY_PAGE_SIZE) break; + offset += CLAIM_RESPONSE_SUMMARY_PAGE_SIZE; + } + + return groupClaimResponseSummaryRows(normalizedTargets, rows, personalSpaceId); +} + +function defaultFetchPage({ filter, first, offset, signal }: FetchSummaryPageArgs) { + return Effect.runPromise(getClaimResponseSummaryPage(filter, first, offset, signal)); +} + +export async function loadClaimResponseSummaryCaches({ + queryClient, + spaceId, + targets, + personalSpaceId, + signal, + fetchSummaries = fetchClaimResponseSummaries, + fetchProfiles = defaultFetchProfiles, + fetchSpaces = defaultFetchSpaces, + forceResponseRefresh = false, +}: { + queryClient: QueryClient; + spaceId: string; + targets: ClaimResponseTarget[]; + personalSpaceId: string | null; + signal?: AbortSignal; + fetchSummaries?: FetchSummaries; + fetchProfiles?: FetchProfiles; + fetchSpaces?: FetchSpaces; + forceResponseRefresh?: boolean; +}) { + const normalizedTargets = normalizeClaimResponseTargets(targets); + const summaryQueryKey = [ + 'claim-response-summary-data', + personalSpaceId, + spaceId, + normalizedTargets.map(claimResponseTargetKey), + ] as const; + const summaries = await queryClient.fetchQuery({ + queryKey: summaryQueryKey, + queryFn: () => fetchSummaries({ spaceId, targets: normalizedTargets, personalSpaceId, signal }), + staleTime: forceResponseRefresh ? 0 : 30_000, + retry: false, + }); + const responderSpaceIds = [ + ...new Set( + [...summaries.values()].flatMap(summary => summary.responders.map(responder => responder.userId)).filter(Boolean) + ), + ].sort(); + const [profilesBySpaceId, responderSpaces] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: profilesBySpaceIdsQueryKey(responderSpaceIds), + queryFn: async () => { + const profileChunks = await Promise.all(chunk(responderSpaceIds, 100).map(ids => fetchProfiles(ids))); + return new Map(profileChunks.flat().map(profile => [profile.spaceId, profile])); + }, + staleTime: 60_000, + retry: false, + }), + queryClient.fetchQuery({ + queryKey: spacesByIdsQueryKey(responderSpaceIds), + queryFn: async () => { + const spaceChunks = await Promise.all(chunk(responderSpaceIds, 100).map(ids => fetchSpaces(ids, signal))); + return spaceChunks.flat(); + }, + staleTime: 60_000, + retry: false, + }), + ]); + const spacesById = new Map(responderSpaces.map(space => [space.id, space])); + + signal?.throwIfAborted(); + + for (const target of normalizedTargets) { + const summary = summaries.get(claimResponseTargetKey(target)) ?? emptySummary(); + const responderIds = summary.responders.map(responder => responder.userId); + const responderProfiles = responderIds.flatMap(id => { + const profile = profilesBySpaceId.get(id); + return profile ? [profile] : []; + }); + + queryClient.setQueryData( + entityResponseCountsQueryKey(target.entityId, spaceId, 0, target.responseKind), + summary.counts + ); + queryClient.setQueryData( + entityRespondersQueryKey(target.entityId, spaceId, 0, target.responseKind), + summary.responders + ); + if (personalSpaceId) { + queryClient.setQueryData( + userEntityResponseQueryKey(personalSpaceId, target.entityId, spaceId, 0, target.responseKind), + summary.viewerResponse + ); + } + if (responderIds.length > 0) { + queryClient.setQueryData( + [...entityResponderProfilesQueryKey(target.entityId, spaceId, 0, target.responseKind), responderIds], + responderProfiles + ); + queryClient.setQueryData( + profilesBySpaceIdsQueryKey(responderIds), + new Map(responderProfiles.map(profile => [profile.spaceId, profile])) + ); + queryClient.setQueryData( + spacesByIdsQueryKey(responderIds), + [...responderIds].sort().flatMap(id => { + const space = spacesById.get(id); + return space ? [space] : []; + }) + ); + } + } + + return summaries; +} + +function emptySummary(): ClaimResponseSummary { + return { counts: { positive: 0, negative: 0 }, viewerResponse: null, responders: [] }; +} + +function chunk(items: T[], size: number) { + const chunks: T[][] = []; + for (let start = 0; start < items.length; start += size) chunks.push(items.slice(start, start + size)); + return chunks; +} + +function defaultFetchProfiles(spaceIds: string[]) { + return Effect.runPromise(fetchProfilesBySpaceIds(spaceIds)); +} + +function defaultFetchSpaces(spaceIds: string[], signal?: AbortSignal) { + return Effect.runPromise(getSpaces({ spaceIds, limit: spaceIds.length }, signal)); +} diff --git a/apps/web/core/responses/use-claim-response-summaries.test.tsx b/apps/web/core/responses/use-claim-response-summaries.test.tsx new file mode 100644 index 0000000000..ff48a4e836 --- /dev/null +++ b/apps/web/core/responses/use-claim-response-summaries.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useClaimResponseSummaryBatch } from './use-claim-response-summaries'; + +const mocks = vi.hoisted(() => ({ + loadCaches: vi.fn(), + personalSpaceId: 'profile-1' as string | null, +})); + +vi.mock('~/core/hooks/use-personal-space-id', () => ({ + usePersonalSpaceId: () => ({ personalSpaceId: mocks.personalSpaceId, isRegistered: true }), +})); + +vi.mock('./claim-response-summaries', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + loadClaimResponseSummaryCaches: (...args: unknown[]) => mocks.loadCaches(...args), + }; +}); + +beforeEach(() => { + mocks.loadCaches.mockReset(); + mocks.loadCaches.mockResolvedValue(new Map()); + mocks.personalSpaceId = 'profile-1'; +}); + +describe('useClaimResponseSummaryBatch', () => { + it('loads all 50 visible claim-kind pairs through one batch query function', async () => { + const targets = Array.from({ length: 50 }, (_, index) => ({ + entityId: `claim-${index}`, + responseKind: index % 2 === 0 ? ('stance' as const) : ('veracity' as const), + })); + const { wrapper } = createHarness(); + + const { result } = renderHook(() => useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mocks.loadCaches).toHaveBeenCalledOnce(); + expect(mocks.loadCaches).toHaveBeenCalledWith( + expect.objectContaining({ + spaceId: 'space-1', + personalSpaceId: 'profile-1', + targets: expect.arrayContaining(targets), + }) + ); + }); + + it('isolates the batch cache by viewer account and exact space', async () => { + const { queryClient, wrapper } = createHarness(); + const targets = [{ entityId: 'claim-1', responseKind: 'stance' as const }]; + const first = renderHook(() => useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }), { + wrapper, + }); + await waitFor(() => expect(first.result.current.isSuccess).toBe(true)); + + mocks.personalSpaceId = 'profile-2'; + first.rerender(); + await waitFor(() => expect(mocks.loadCaches).toHaveBeenCalledTimes(2)); + + expect( + queryClient.getQueryState(['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']])?.status + ).toBe('success'); + expect( + queryClient.getQueryState(['claim-response-summaries', 'profile-2', 'space-1', ['claim-1:stance']])?.status + ).toBe('success'); + }); + + it('starts a separate batch when the exact space changes', async () => { + const { queryClient, wrapper } = createHarness(); + const targets = [{ entityId: 'claim-1', responseKind: 'stance' as const }]; + const { result, rerender } = renderHook( + ({ spaceId }) => useClaimResponseSummaryBatch({ spaceId, targets, enabled: true }), + { wrapper, initialProps: { spaceId: 'space-1' } } + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + rerender({ spaceId: 'space-2' }); + await waitFor(() => expect(mocks.loadCaches).toHaveBeenCalledTimes(2)); + + expect(mocks.loadCaches.mock.calls.map(call => (call[0] as { spaceId: string }).spaceId)).toEqual([ + 'space-1', + 'space-2', + ]); + expect( + queryClient.getQueryState(['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']])?.status + ).toBe('success'); + expect( + queryClient.getQueryState(['claim-response-summaries', 'profile-1', 'space-2', ['claim-1:stance']])?.status + ).toBe('success'); + }); +}); + +function createHarness() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + queryClient, + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} diff --git a/apps/web/core/responses/use-claim-response-summaries.ts b/apps/web/core/responses/use-claim-response-summaries.ts new file mode 100644 index 0000000000..18a1eed9c3 --- /dev/null +++ b/apps/web/core/responses/use-claim-response-summaries.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; + +import { + type ClaimResponseTarget, + claimResponseSummariesQueryKeyPrefix, + claimResponseTargetKey, + loadClaimResponseSummaryCaches, + normalizeClaimResponseTargets, +} from './claim-response-summaries'; + +const ClaimResponseBatchContext = React.createContext({ managed: false, ready: true }); + +export function ClaimResponseBatchBoundary({ ready, children }: { ready: boolean; children: React.ReactNode }) { + const value = React.useMemo(() => ({ managed: true, ready }), [ready]); + return React.createElement(ClaimResponseBatchContext.Provider, { value }, children); +} + +export function useClaimResponseBatchState() { + return React.useContext(ClaimResponseBatchContext); +} + +export function useClaimResponseSummaryBatch({ + spaceId, + targets, + enabled, +}: { + spaceId: string; + targets: ClaimResponseTarget[]; + enabled: boolean; +}) { + const queryClient = useQueryClient(); + const { personalSpaceId } = usePersonalSpaceId(); + const normalizedTargets = normalizeClaimResponseTargets(targets); + + return useQuery({ + queryKey: [ + ...claimResponseSummariesQueryKeyPrefix(personalSpaceId, spaceId), + normalizedTargets.map(claimResponseTargetKey), + ], + queryFn: ({ signal }) => + loadClaimResponseSummaryCaches({ + queryClient, + spaceId, + targets: normalizedTargets, + personalSpaceId, + signal, + }), + enabled: enabled && normalizedTargets.length > 0, + staleTime: 30_000, + retry: 2, + }); +} diff --git a/apps/web/partials/blocks/table/ranking-period-metadata.tsx b/apps/web/partials/blocks/table/ranking-period-metadata.tsx index 1bec139649..919ac1de62 100644 --- a/apps/web/partials/blocks/table/ranking-period-metadata.tsx +++ b/apps/web/partials/blocks/table/ranking-period-metadata.tsx @@ -11,6 +11,7 @@ import type { RankingPeriodState } from '~/core/blocks/ranking/ranking-period'; import type { RankingSubmissionRecord } from '~/core/blocks/ranking/ranking-submission-types'; import { PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; import { useSpacesByIds } from '~/core/hooks/use-spaces-by-ids'; +import { profilesBySpaceIdsQueryKey } from '~/core/io/query-keys'; import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; import { Avatar } from '~/design-system/avatar'; @@ -94,23 +95,25 @@ export function RankingAggregatedSubmitterAvatars({ totalCount, maxVisible = VISIBLE_RANKED_BY_AVATARS, size = RANKED_BY_AVATAR_SIZE, + queriesEnabled = true, }: { submitterSpaceIds: string[]; totalCount?: number; maxVisible?: number; size?: 12 | 20; + queriesEnabled?: boolean; }) { const uniqueSpaceIds = React.useMemo(() => dedupePreserveOrder(submitterSpaceIds), [submitterSpaceIds]); const { data: profilesBySpaceId = new Map() } = useQuery({ - queryKey: ['ranking-submitter-profiles', uniqueSpaceIds], - enabled: uniqueSpaceIds.length > 0, + queryKey: profilesBySpaceIdsQueryKey(uniqueSpaceIds), + enabled: queriesEnabled && uniqueSpaceIds.length > 0, staleTime: 60_000, queryFn: async () => { const profiles = await Effect.runPromise(fetchProfilesBySpaceIds(uniqueSpaceIds)); return new Map(uniqueSpaceIds.map((spaceId, index) => [spaceId, profiles[index]!])); }, }); - const { spacesById } = useSpacesByIds(uniqueSpaceIds); + const { spacesById } = useSpacesByIds(uniqueSpaceIds, queriesEnabled); const resolveAvatarUrl = React.useCallback( (spaceId: string) => { diff --git a/apps/web/partials/entity-page/claim-voter-avatars.tsx b/apps/web/partials/entity-page/claim-voter-avatars.tsx index 2b517eb04a..0ddfd7dda5 100644 --- a/apps/web/partials/entity-page/claim-voter-avatars.tsx +++ b/apps/web/partials/entity-page/claim-voter-avatars.tsx @@ -8,6 +8,7 @@ import { Effect } from 'effect'; import { getEntityResponders } from '~/core/io/queries'; import { type ResponseKind, type ResponseObjectType, entityRespondersQueryKey } from '~/core/responses/entity-response'; +import { useClaimResponseBatchState } from '~/core/responses/use-claim-response-summaries'; import { RankingAggregatedSubmitterAvatars } from '~/partials/blocks/table/ranking-period-metadata'; @@ -24,9 +25,11 @@ export function ClaimResponderAvatars({ responseKind: ResponseKind; totalResponders: number; }) { + const responseBatch = useClaimResponseBatchState(); const { data: responders } = useQuery({ queryKey: entityRespondersQueryKey(entityId, spaceId, objectType, responseKind), queryFn: () => Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)), + enabled: !responseBatch.managed, staleTime: 30_000, }); @@ -39,6 +42,7 @@ export function ClaimResponderAvatars({ submitterSpaceIds={responderSpaceIds} totalCount={Math.max(totalResponders, responderSpaceIds.length)} size={12} + queriesEnabled={!responseBatch.managed} /> ); } diff --git a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx new file mode 100644 index 0000000000..4174268bd1 --- /dev/null +++ b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx @@ -0,0 +1,125 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { Effect } from 'effect'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { entityResponseCountsQueryKey, userEntityResponseQueryKey } from '~/core/responses/entity-response'; +import { ClaimResponseBatchBoundary } from '~/core/responses/use-claim-response-summaries'; + +import { EntityVoteButtons } from './entity-vote-buttons'; + +const mocks = vi.hoisted(() => ({ + getCounts: vi.fn(), + getResponders: vi.fn(), + getViewerResponse: vi.fn(), + queryEntityOptions: [] as unknown[], +})); + +vi.mock('@geogenesis/auth', () => ({ + useGeoLogin: () => ({ login: vi.fn() }), +})); + +vi.mock('~/core/analytics', () => ({ + downvoted: vi.fn(), + trackPrivyAuth: vi.fn(), + upvoted: vi.fn(), + voteCast: vi.fn(), +})); + +vi.mock('~/core/hooks/use-entity-vote', () => ({ + useEntityResponse: () => ({ + submitResponse: vi.fn(), + optimisticResponse: undefined, + isResponseIndexingDelayed: false, + isConnected: true, + personalSpaceId: 'profile-1', + }), +})); + +vi.mock('~/core/hooks/use-smart-account', () => ({ + useSmartAccount: () => ({ smartAccount: null }), +})); + +vi.mock('~/core/io/queries', () => ({ + getEntityResponseCounts: (...args: unknown[]) => Effect.succeed(mocks.getCounts(...args)), + getEntityResponders: (...args: unknown[]) => Effect.succeed(mocks.getResponders(...args)), + getUserEntityResponse: (...args: unknown[]) => Effect.succeed(mocks.getViewerResponse(...args)), +})); + +vi.mock('~/core/io/subgraph/fetch-profile', () => ({ + fetchProfilesBySpaceIds: () => Effect.succeed([]), +})); + +vi.mock('~/core/state/pending-personal-space', () => ({ + usePendingPersonalSpace: () => ({ isPending: false }), +})); + +vi.mock('~/core/sync/use-store', () => ({ + useQueryEntity: (options: unknown) => { + mocks.queryEntityOptions.push(options); + return { entity: null, isLoading: false }; + }, +})); + +vi.mock('~/partials/entity-page/claim-voter-avatars', () => ({ + ClaimResponderAvatars: () => null, +})); + +beforeEach(() => { + mocks.getCounts.mockReset(); + mocks.getCounts.mockReturnValue({ positive: 2, negative: 1 }); + mocks.getResponders.mockReset(); + mocks.getResponders.mockReturnValue([]); + mocks.getViewerResponse.mockReset(); + mocks.getViewerResponse.mockReturnValue('positive'); + mocks.queryEntityOptions.length = 0; +}); + +afterEach(cleanup); + +describe('EntityVoteButtons claims-page batching', () => { + it('skips entity hydration and all individual response queries while the page batch is unresolved', async () => { + const view = renderButtons(false); + + expect(view.container.querySelector('.animate-pulse')).toBeInTheDocument(); + expect(mocks.queryEntityOptions.at(-1)).toMatchObject({ enabled: false }); + await Promise.resolve(); + expect(mocks.getCounts).not.toHaveBeenCalled(); + expect(mocks.getViewerResponse).not.toHaveBeenCalled(); + expect(mocks.getResponders).not.toHaveBeenCalled(); + }); + + it('uses the batch-seeded caches without fetching or hydrating Entity once batching resolves', async () => { + renderButtons(true, true); + + await waitFor(() => expect(mocks.queryEntityOptions).toHaveLength(1)); + expect(mocks.getCounts).not.toHaveBeenCalled(); + expect(mocks.getViewerResponse).not.toHaveBeenCalled(); + expect(mocks.queryEntityOptions.at(-1)).toMatchObject({ enabled: false }); + }); +}); + +function renderButtons(ready: boolean, seedCaches = false) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (seedCaches) { + queryClient.setQueryData(entityResponseCountsQueryKey('claim-1', 'space-1', 0, 'stance'), { + positive: 2, + negative: 1, + }); + queryClient.setQueryData(userEntityResponseQueryKey('profile-1', 'claim-1', 'space-1', 0, 'stance'), 'positive'); + } + return render( + + + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + } + ); +} diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index f2ca170c9f..c57bd8c52b 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -34,6 +34,7 @@ import { hasUnpublishedClaimResponseKindEdit, userEntityResponseQueryKey, } from '~/core/responses/entity-response'; +import { useClaimResponseBatchState } from '~/core/responses/use-claim-response-summaries'; import { usePendingPersonalSpace } from '~/core/state/pending-personal-space'; import { useQueryEntity } from '~/core/sync/use-store'; import { Profile } from '~/core/types'; @@ -72,7 +73,13 @@ export function EntityVoteButtons({ responseKind: responseKindOverride, claimResponderAvatarsPosition = 'leading', }: EntityVoteButtonsProps) { - const { entity, isLoading: isLoadingEntity } = useQueryEntity({ id: entityId, spaceId, includeDeleted: true }); + const responseBatch = useClaimResponseBatchState(); + const { entity, isLoading: isLoadingEntity } = useQueryEntity({ + id: entityId, + spaceId, + includeDeleted: true, + enabled: responseKindOverride === undefined, + }); const activeRelations = entity?.relations.filter(relation => !relation.isDeleted) ?? []; const activeValues = entity?.values.filter(value => !value.isDeleted) ?? []; const isClaim = @@ -117,7 +124,7 @@ export function EntityVoteButtons({ queryKey: entityResponseCountsQueryKey(entityId, spaceId, ENTITY_RESPONSE_OBJECT_TYPE, queryResponseKind), queryFn: () => Effect.runPromise(getEntityResponseCounts(entityId, spaceId, queryResponseKind, ENTITY_RESPONSE_OBJECT_TYPE)), - enabled: !isResponseKindLoading && responseKind !== null, + enabled: !responseBatch.managed && !isResponseKindLoading && responseKind !== null, staleTime: 30_000, }); @@ -135,7 +142,7 @@ export function EntityVoteButtons({ getUserEntityResponse(personalSpaceId, entityId, spaceId, queryResponseKind, ENTITY_RESPONSE_OBJECT_TYPE) ); }, - enabled: !!personalSpaceId && !isResponseKindLoading && responseKind !== null, + enabled: !responseBatch.managed && !!personalSpaceId && !isResponseKindLoading && responseKind !== null, staleTime: 30_000, }); @@ -271,7 +278,7 @@ export function EntityVoteButtons({ const claimResponderAvatarsClassName = 'inline-flex h-5 shrink-0 items-center'; - if (isResponseKindLoading) { + if ((responseBatch.managed && !responseBatch.ready) || isResponseKindLoading) { return ; } @@ -393,17 +400,19 @@ function RespondersPopoverContent({ objectType: 0 | 1; responseKind: ResponseKind; }) { + const responseBatch = useClaimResponseBatchState(); const copy = ENTITY_RESPONSE_COPY[responseKind]; const respondersQueryKey = entityRespondersQueryKey(entityId, spaceId, objectType, responseKind); const { data: responders, isLoading: isLoadingResponders } = useQuery({ queryKey: respondersQueryKey, queryFn: () => Effect.runPromise(getEntityResponders(entityId, spaceId, responseKind, objectType)), + enabled: !responseBatch.managed, staleTime: 30_000, }); const responderSpaceIds = React.useMemo(() => responders?.map(responder => responder.userId) ?? [], [responders]); const { data: profiles, isLoading: isLoadingProfiles } = useQuery({ queryKey: [...entityResponderProfilesQueryKey(entityId, spaceId, objectType, responseKind), responderSpaceIds], - enabled: responderSpaceIds.length > 0, + enabled: !responseBatch.managed && responderSpaceIds.length > 0, queryFn: () => Effect.runPromise(fetchProfilesBySpaceIds(responderSpaceIds)), staleTime: 30_000, }); From 916478482d2311f34d8671b0f35e8e5177a24ff1 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 10 Aug 2026 11:58:25 +0200 Subject: [PATCH 13/15] fix(review): harden batched claim responses --- .../claim-response-summaries.test.ts | 102 ++++++++++++++-- .../responses/claim-response-summaries.ts | 85 +++++++++---- .../use-claim-response-summaries.test.tsx | 64 +++++++++- .../responses/use-claim-response-summaries.ts | 31 ++++- .../entity-vote-buttons.batch.test.tsx | 113 ++++++++++++++++-- .../entity-page/entity-vote-buttons.tsx | 16 +-- 6 files changed, 351 insertions(+), 60 deletions(-) diff --git a/apps/web/core/responses/claim-response-summaries.test.ts b/apps/web/core/responses/claim-response-summaries.test.ts index 817ae74666..9502e3f1c9 100644 --- a/apps/web/core/responses/claim-response-summaries.test.ts +++ b/apps/web/core/responses/claim-response-summaries.test.ts @@ -11,6 +11,7 @@ import { buildClaimResponseSummaryFilter, fetchClaimResponseSummaries, groupClaimResponseSummaryRows, + loadClaimResponderMetadataCaches, loadClaimResponseSummaryCaches, normalizeClaimResponseTargets, } from './claim-response-summaries'; @@ -138,12 +139,18 @@ describe('claim response summaries', () => { ids.map(id => ({ id, entity: { id: `entity-${id}` } }) as Space) ); - await loadClaimResponseSummaryCaches({ + const summaries = await loadClaimResponseSummaryCaches({ queryClient, spaceId: 'space-1', targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], personalSpaceId: 'profile-1', fetchSummaries, + }); + await loadClaimResponderMetadataCaches({ + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + summaries, fetchProfiles, fetchSpaces, }); @@ -169,7 +176,7 @@ describe('claim response summaries', () => { expect(queryClient.getQueryData(spacesByIdsQueryKey(responderIds))).toHaveLength(205); }); - it('reuses successful summary and profile metadata when a space-metadata retry is needed', async () => { + it('keeps response caches usable when avatar metadata fails and reuses successful metadata on retry', async () => { const queryClient = new QueryClient(); const fetchSummaries = vi.fn().mockResolvedValue( new Map([ @@ -188,25 +195,35 @@ describe('claim response summaries', () => { .fn<() => Promise>() .mockRejectedValueOnce(new Error('space metadata unavailable')) .mockResolvedValueOnce([{ id: 'profile-1', entity: { id: 'profile-1' } } as Space]); - const args = { + const summaries = await loadClaimResponseSummaryCaches({ queryClient, spaceId: 'space-1', targets: [{ entityId: 'claim-stance', responseKind: 'stance' as const }], personalSpaceId: null, fetchSummaries, + }); + const metadataArgs = { + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' as const }], + summaries, fetchProfiles, fetchSpaces, }; - await expect(loadClaimResponseSummaryCaches(args)).rejects.toThrow('space metadata unavailable'); - await expect(loadClaimResponseSummaryCaches(args)).resolves.toBeInstanceOf(Map); + await expect(loadClaimResponderMetadataCaches(metadataArgs)).rejects.toThrow('space metadata unavailable'); + expect(queryClient.getQueryData(entityResponseCountsQueryKey('claim-stance', 'space-1', 0, 'stance'))).toEqual({ + positive: 1, + negative: 0, + }); + await expect(loadClaimResponderMetadataCaches(metadataArgs)).resolves.toBeUndefined(); expect(fetchSummaries).toHaveBeenCalledOnce(); expect(fetchProfiles).toHaveBeenCalledOnce(); expect(fetchSpaces).toHaveBeenCalledTimes(2); }); - it('does not seed per-claim caches after its page batch is cancelled', async () => { + it('does not seed avatar caches after metadata enrichment is cancelled', async () => { const queryClient = new QueryClient(); const controller = new AbortController(); const fetchSummaries = vi.fn().mockResolvedValue( @@ -226,22 +243,83 @@ describe('claim response summaries', () => { return [{ spaceId: 'profile-1', address: '0x1' } as unknown as Profile]; }); + const summaries = await loadClaimResponseSummaryCaches({ + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + personalSpaceId: 'profile-1', + fetchSummaries, + }); + await expect( - loadClaimResponseSummaryCaches({ + loadClaimResponderMetadataCaches({ queryClient, spaceId: 'space-1', targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], - personalSpaceId: 'profile-1', + summaries, signal: controller.signal, - fetchSummaries, fetchProfiles, fetchSpaces: vi.fn(async () => [{ id: 'profile-1', entity: { id: 'profile-1' } } as Space]), }) ).rejects.toBeDefined(); - expect(queryClient.getQueryData(entityResponseCountsQueryKey('claim-stance', 'space-1', 0, 'stance'))).toBe( - undefined - ); + expect(queryClient.getQueryData(entityResponseCountsQueryKey('claim-stance', 'space-1', 0, 'stance'))).toEqual({ + positive: 1, + negative: 0, + }); + expect( + queryClient.getQueryData([ + ...entityResponderProfilesQueryKey('claim-stance', 'space-1', 0, 'stance'), + ['profile-1'], + ]) + ).toBeUndefined(); + }); + + it('caps concurrent responder profile and space metadata requests', async () => { + const queryClient = new QueryClient(); + const responderIds = Array.from({ length: 505 }, (_, index) => `profile-${index}`); + const summaries = new Map([ + [ + 'claim-stance:stance', + { + counts: { positive: 505, negative: 0 }, + viewerResponse: null, + responders: responderIds.map(userId => ({ userId, direction: 'positive' as const })), + }, + ], + ]); + let activeProfiles = 0; + let activeSpaces = 0; + let maxProfiles = 0; + let maxSpaces = 0; + const fetchProfiles = vi.fn(async (ids: string[]) => { + activeProfiles += 1; + maxProfiles = Math.max(maxProfiles, activeProfiles); + await Promise.resolve(); + activeProfiles -= 1; + return ids.map(id => ({ spaceId: id, address: `0x${id}` }) as Profile); + }); + const fetchSpaces = vi.fn(async (ids: string[]) => { + activeSpaces += 1; + maxSpaces = Math.max(maxSpaces, activeSpaces); + await Promise.resolve(); + activeSpaces -= 1; + return ids.map(id => ({ id, entity: { id } }) as Space); + }); + + await loadClaimResponderMetadataCaches({ + queryClient, + spaceId: 'space-1', + targets: [{ entityId: 'claim-stance', responseKind: 'stance' }], + summaries, + fetchProfiles, + fetchSpaces, + }); + + expect(fetchProfiles).toHaveBeenCalledTimes(6); + expect(fetchSpaces).toHaveBeenCalledTimes(6); + expect(maxProfiles).toBeLessThanOrEqual(4); + expect(maxSpaces).toBeLessThanOrEqual(4); }); }); diff --git a/apps/web/core/responses/claim-response-summaries.ts b/apps/web/core/responses/claim-response-summaries.ts index 8eb31c1ac7..ec4360bd14 100644 --- a/apps/web/core/responses/claim-response-summaries.ts +++ b/apps/web/core/responses/claim-response-summaries.ts @@ -13,6 +13,7 @@ import { import { profilesBySpaceIdsQueryKey, spacesByIdsQueryKey } from '~/core/io/query-keys'; import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; import type { Profile } from '~/core/types'; +import { mapWithConcurrency } from '~/core/utils/map-with-concurrency'; import { type ActiveResponseDirection, @@ -26,6 +27,8 @@ import { } from './entity-response'; export const CLAIM_RESPONSE_SUMMARY_PAGE_SIZE = 1_000; +const RESPONDER_METADATA_CHUNK_SIZE = 100; +const RESPONDER_METADATA_CONCURRENCY = 4; export type ClaimResponseTarget = { entityId: string; @@ -156,8 +159,6 @@ export async function loadClaimResponseSummaryCaches({ personalSpaceId, signal, fetchSummaries = fetchClaimResponseSummaries, - fetchProfiles = defaultFetchProfiles, - fetchSpaces = defaultFetchSpaces, forceResponseRefresh = false, }: { queryClient: QueryClient; @@ -166,8 +167,6 @@ export async function loadClaimResponseSummaryCaches({ personalSpaceId: string | null; signal?: AbortSignal; fetchSummaries?: FetchSummaries; - fetchProfiles?: FetchProfiles; - fetchSpaces?: FetchSpaces; forceResponseRefresh?: boolean; }) { const normalizedTargets = normalizeClaimResponseTargets(targets); @@ -183,16 +182,68 @@ export async function loadClaimResponseSummaryCaches({ staleTime: forceResponseRefresh ? 0 : 30_000, retry: false, }); - const responderSpaceIds = [ + signal?.throwIfAborted(); + + for (const target of normalizedTargets) { + const summary = summaries.get(claimResponseTargetKey(target)) ?? emptySummary(); + + queryClient.setQueryData( + entityResponseCountsQueryKey(target.entityId, spaceId, 0, target.responseKind), + summary.counts + ); + queryClient.setQueryData( + entityRespondersQueryKey(target.entityId, spaceId, 0, target.responseKind), + summary.responders + ); + if (personalSpaceId) { + queryClient.setQueryData( + userEntityResponseQueryKey(personalSpaceId, target.entityId, spaceId, 0, target.responseKind), + summary.viewerResponse + ); + } + } + + return summaries; +} + +export function claimResponseSummaryResponderSpaceIds(summaries: Map) { + return [ ...new Set( [...summaries.values()].flatMap(summary => summary.responders.map(responder => responder.userId)).filter(Boolean) ), ].sort(); +} + +export async function loadClaimResponderMetadataCaches({ + queryClient, + spaceId, + targets, + summaries, + signal, + fetchProfiles = defaultFetchProfiles, + fetchSpaces = defaultFetchSpaces, +}: { + queryClient: QueryClient; + spaceId: string; + targets: ClaimResponseTarget[]; + summaries: Map; + signal?: AbortSignal; + fetchProfiles?: FetchProfiles; + fetchSpaces?: FetchSpaces; +}) { + const normalizedTargets = normalizeClaimResponseTargets(targets); + const responderSpaceIds = claimResponseSummaryResponderSpaceIds(summaries); + if (responderSpaceIds.length === 0) return; + const [profilesBySpaceId, responderSpaces] = await Promise.all([ queryClient.fetchQuery({ queryKey: profilesBySpaceIdsQueryKey(responderSpaceIds), queryFn: async () => { - const profileChunks = await Promise.all(chunk(responderSpaceIds, 100).map(ids => fetchProfiles(ids))); + const profileChunks = await mapWithConcurrency( + chunk(responderSpaceIds, RESPONDER_METADATA_CHUNK_SIZE), + RESPONDER_METADATA_CONCURRENCY, + ids => fetchProfiles(ids) + ); return new Map(profileChunks.flat().map(profile => [profile.spaceId, profile])); }, staleTime: 60_000, @@ -201,7 +252,11 @@ export async function loadClaimResponseSummaryCaches({ queryClient.fetchQuery({ queryKey: spacesByIdsQueryKey(responderSpaceIds), queryFn: async () => { - const spaceChunks = await Promise.all(chunk(responderSpaceIds, 100).map(ids => fetchSpaces(ids, signal))); + const spaceChunks = await mapWithConcurrency( + chunk(responderSpaceIds, RESPONDER_METADATA_CHUNK_SIZE), + RESPONDER_METADATA_CONCURRENCY, + ids => fetchSpaces(ids, signal) + ); return spaceChunks.flat(); }, staleTime: 60_000, @@ -220,20 +275,6 @@ export async function loadClaimResponseSummaryCaches({ return profile ? [profile] : []; }); - queryClient.setQueryData( - entityResponseCountsQueryKey(target.entityId, spaceId, 0, target.responseKind), - summary.counts - ); - queryClient.setQueryData( - entityRespondersQueryKey(target.entityId, spaceId, 0, target.responseKind), - summary.responders - ); - if (personalSpaceId) { - queryClient.setQueryData( - userEntityResponseQueryKey(personalSpaceId, target.entityId, spaceId, 0, target.responseKind), - summary.viewerResponse - ); - } if (responderIds.length > 0) { queryClient.setQueryData( [...entityResponderProfilesQueryKey(target.entityId, spaceId, 0, target.responseKind), responderIds], @@ -252,8 +293,6 @@ export async function loadClaimResponseSummaryCaches({ ); } } - - return summaries; } function emptySummary(): ClaimResponseSummary { diff --git a/apps/web/core/responses/use-claim-response-summaries.test.tsx b/apps/web/core/responses/use-claim-response-summaries.test.tsx index ff48a4e836..37624cd4e2 100644 --- a/apps/web/core/responses/use-claim-response-summaries.test.tsx +++ b/apps/web/core/responses/use-claim-response-summaries.test.tsx @@ -9,11 +9,17 @@ import { useClaimResponseSummaryBatch } from './use-claim-response-summaries'; const mocks = vi.hoisted(() => ({ loadCaches: vi.fn(), + loadMetadataCaches: vi.fn(), personalSpaceId: 'profile-1' as string | null, + personalSpaceLoading: false, })); vi.mock('~/core/hooks/use-personal-space-id', () => ({ - usePersonalSpaceId: () => ({ personalSpaceId: mocks.personalSpaceId, isRegistered: true }), + usePersonalSpaceId: () => ({ + personalSpaceId: mocks.personalSpaceId, + isRegistered: true, + isLoading: mocks.personalSpaceLoading, + }), })); vi.mock('./claim-response-summaries', async importOriginal => { @@ -21,13 +27,17 @@ vi.mock('./claim-response-summaries', async importOriginal => { return { ...actual, loadClaimResponseSummaryCaches: (...args: unknown[]) => mocks.loadCaches(...args), + loadClaimResponderMetadataCaches: (...args: unknown[]) => mocks.loadMetadataCaches(...args), }; }); beforeEach(() => { mocks.loadCaches.mockReset(); mocks.loadCaches.mockResolvedValue(new Map()); + mocks.loadMetadataCaches.mockReset(); + mocks.loadMetadataCaches.mockResolvedValue(undefined); mocks.personalSpaceId = 'profile-1'; + mocks.personalSpaceLoading = false; }); describe('useClaimResponseSummaryBatch', () => { @@ -96,6 +106,58 @@ describe('useClaimResponseSummaryBatch', () => { queryClient.getQueryState(['claim-response-summaries', 'profile-1', 'space-2', ['claim-1:stance']])?.status ).toBe('success'); }); + + it('waits for the viewer identity query before starting the batch', async () => { + mocks.personalSpaceId = null; + mocks.personalSpaceLoading = true; + const { wrapper } = createHarness(); + const targets = [{ entityId: 'claim-1', responseKind: 'stance' as const }]; + const { result, rerender } = renderHook( + () => useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }), + { wrapper } + ); + + expect(result.current.fetchStatus).toBe('idle'); + expect(mocks.loadCaches).not.toHaveBeenCalled(); + + mocks.personalSpaceId = 'profile-1'; + mocks.personalSpaceLoading = false; + rerender(); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mocks.loadCaches).toHaveBeenCalledOnce(); + expect(mocks.loadCaches).toHaveBeenCalledWith(expect.objectContaining({ personalSpaceId: 'profile-1' })); + }); + + it('keeps the response batch successful when avatar enrichment fails', async () => { + mocks.loadCaches.mockResolvedValue( + new Map([ + [ + 'claim-1:stance', + { + counts: { positive: 1, negative: 0 }, + viewerResponse: null, + responders: [{ userId: 'profile-2', direction: 'positive' }], + }, + ], + ]) + ); + mocks.loadMetadataCaches.mockRejectedValue(new Error('avatar metadata unavailable')); + const { wrapper } = createHarness(); + const { result } = renderHook( + () => + useClaimResponseSummaryBatch({ + spaceId: 'space-1', + targets: [{ entityId: 'claim-1', responseKind: 'stance' }], + enabled: true, + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(mocks.loadMetadataCaches).toHaveBeenCalled()); + expect(result.current.isError).toBe(false); + }); }); function createHarness() { diff --git a/apps/web/core/responses/use-claim-response-summaries.ts b/apps/web/core/responses/use-claim-response-summaries.ts index 18a1eed9c3..287889a1b4 100644 --- a/apps/web/core/responses/use-claim-response-summaries.ts +++ b/apps/web/core/responses/use-claim-response-summaries.ts @@ -9,7 +9,9 @@ import { usePersonalSpaceId } from '~/core/hooks/use-personal-space-id'; import { type ClaimResponseTarget, claimResponseSummariesQueryKeyPrefix, + claimResponseSummaryResponderSpaceIds, claimResponseTargetKey, + loadClaimResponderMetadataCaches, loadClaimResponseSummaryCaches, normalizeClaimResponseTargets, } from './claim-response-summaries'; @@ -35,10 +37,10 @@ export function useClaimResponseSummaryBatch({ enabled: boolean; }) { const queryClient = useQueryClient(); - const { personalSpaceId } = usePersonalSpaceId(); + const { personalSpaceId, isLoading: isPersonalSpaceLoading } = usePersonalSpaceId(); const normalizedTargets = normalizeClaimResponseTargets(targets); - return useQuery({ + const responseBatch = useQuery({ queryKey: [ ...claimResponseSummariesQueryKeyPrefix(personalSpaceId, spaceId), normalizedTargets.map(claimResponseTargetKey), @@ -51,8 +53,31 @@ export function useClaimResponseSummaryBatch({ personalSpaceId, signal, }), - enabled: enabled && normalizedTargets.length > 0, + enabled: enabled && !isPersonalSpaceLoading && normalizedTargets.length > 0, staleTime: 30_000, retry: 2, }); + const responderSpaceIds = responseBatch.data ? claimResponseSummaryResponderSpaceIds(responseBatch.data) : []; + + useQuery({ + queryKey: [ + 'claim-response-responder-metadata', + spaceId, + normalizedTargets.map(claimResponseTargetKey), + responderSpaceIds, + ], + queryFn: ({ signal }) => + loadClaimResponderMetadataCaches({ + queryClient, + spaceId, + targets: normalizedTargets, + summaries: responseBatch.data!, + signal, + }), + enabled: responseBatch.isSuccess && responderSpaceIds.length > 0, + staleTime: 60_000, + retry: 2, + }); + + return responseBatch; } diff --git a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx index 4174268bd1..0a1606a125 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/vitest'; -import { cleanup, render, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; @@ -8,15 +8,22 @@ import { Effect } from 'effect'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { entityResponseCountsQueryKey, userEntityResponseQueryKey } from '~/core/responses/entity-response'; -import { ClaimResponseBatchBoundary } from '~/core/responses/use-claim-response-summaries'; +import { + ClaimResponseBatchBoundary, + useClaimResponseSummaryBatch, +} from '~/core/responses/use-claim-response-summaries'; import { EntityVoteButtons } from './entity-vote-buttons'; const mocks = vi.hoisted(() => ({ getCounts: vi.fn(), getResponders: vi.fn(), + getSummaryPage: vi.fn(), getViewerResponse: vi.fn(), + optimisticResponse: undefined as 'positive' | 'negative' | null | undefined, queryEntityOptions: [] as unknown[], + smartAccount: null as object | null, + submitResponse: vi.fn(), })); vi.mock('@geogenesis/auth', () => ({ @@ -32,21 +39,27 @@ vi.mock('~/core/analytics', () => ({ vi.mock('~/core/hooks/use-entity-vote', () => ({ useEntityResponse: () => ({ - submitResponse: vi.fn(), - optimisticResponse: undefined, + submitResponse: mocks.submitResponse, + optimisticResponse: mocks.optimisticResponse, isResponseIndexingDelayed: false, isConnected: true, personalSpaceId: 'profile-1', }), })); +vi.mock('~/core/hooks/use-personal-space-id', () => ({ + usePersonalSpaceId: () => ({ personalSpaceId: 'profile-1', isRegistered: true, isLoading: false }), +})); + vi.mock('~/core/hooks/use-smart-account', () => ({ - useSmartAccount: () => ({ smartAccount: null }), + useSmartAccount: () => ({ smartAccount: mocks.smartAccount }), })); vi.mock('~/core/io/queries', () => ({ + getClaimResponseSummaryPage: (...args: unknown[]) => Effect.succeed(mocks.getSummaryPage(...args)), getEntityResponseCounts: (...args: unknown[]) => Effect.succeed(mocks.getCounts(...args)), getEntityResponders: (...args: unknown[]) => Effect.succeed(mocks.getResponders(...args)), + getSpaces: () => Effect.succeed([]), getUserEntityResponse: (...args: unknown[]) => Effect.succeed(mocks.getViewerResponse(...args)), })); @@ -74,9 +87,14 @@ beforeEach(() => { mocks.getCounts.mockReturnValue({ positive: 2, negative: 1 }); mocks.getResponders.mockReset(); mocks.getResponders.mockReturnValue([]); + mocks.getSummaryPage.mockReset(); + mocks.getSummaryPage.mockReturnValue([]); mocks.getViewerResponse.mockReset(); mocks.getViewerResponse.mockReturnValue('positive'); + mocks.optimisticResponse = undefined; mocks.queryEntityOptions.length = 0; + mocks.smartAccount = null; + mocks.submitResponse.mockReset(); }); afterEach(cleanup); @@ -101,20 +119,97 @@ describe('EntityVoteButtons claims-page batching', () => { expect(mocks.getViewerResponse).not.toHaveBeenCalled(); expect(mocks.queryEntityOptions.at(-1)).toMatchObject({ enabled: false }); }); + + it('renders factual claims with thumbs and the Is factual label', () => { + const view = renderButtons(true, true, 'veracity'); + + expect(view.getByText('Is factual')).toBeInTheDocument(); + const responseIcons = [...view.container.querySelectorAll('svg')]; + expect(responseIcons).toHaveLength(2); + expect(responseIcons.every(icon => icon.getAttribute('viewBox') === '0 0 12 12')).toBe(true); + }); + + it('wires optimistic claim response changes without blocking subsequent clicks', () => { + mocks.smartAccount = {}; + const view = renderButtons(true, true); + + fireEvent.click(view.getByTitle('Remove agreement')); + expect(mocks.submitResponse).toHaveBeenLastCalledWith('clear', expect.any(Object)); + + mocks.optimisticResponse = 'negative'; + view.rerender( + + + + ); + fireEvent.click(view.getByTitle('Agree')); + expect(mocks.submitResponse).toHaveBeenLastCalledWith('positive', expect.any(Object)); + + mocks.optimisticResponse = 'positive'; + view.rerender( + + + + ); + fireEvent.click(view.getByTitle('Disagree')); + expect(mocks.submitResponse).toHaveBeenLastCalledWith('negative', expect.any(Object)); + }); + + it('renders 50 batched claims with one summary request and no individual response requests', async () => { + const targets = Array.from({ length: 50 }, (_, index) => ({ + entityId: `claim-${index}`, + responseKind: index % 2 === 0 ? ('stance' as const) : ('veracity' as const), + })); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const view = render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + await waitFor(() => expect(view.container.querySelector('.animate-pulse')).not.toBeInTheDocument()); + expect(mocks.getSummaryPage).toHaveBeenCalledOnce(); + expect(mocks.getCounts).not.toHaveBeenCalled(); + expect(mocks.getViewerResponse).not.toHaveBeenCalled(); + expect(mocks.getResponders).not.toHaveBeenCalled(); + expect(mocks.queryEntityOptions.length).toBeGreaterThanOrEqual(50); + expect(mocks.queryEntityOptions.every(option => (option as { enabled: boolean }).enabled === false)).toBe(true); + expect(new Set(mocks.queryEntityOptions.map(option => (option as { id: string }).id)).size).toBe(50); + }); }); -function renderButtons(ready: boolean, seedCaches = false) { +function BatchedClaims({ targets }: { targets: Array<{ entityId: string; responseKind: 'stance' | 'veracity' }> }) { + const batch = useClaimResponseSummaryBatch({ spaceId: 'space-1', targets, enabled: true }); + return ( + + {targets.map(target => ( + + ))} + + ); +} + +function renderButtons(ready: boolean, seedCaches = false, responseKind: 'stance' | 'veracity' = 'stance') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); if (seedCaches) { - queryClient.setQueryData(entityResponseCountsQueryKey('claim-1', 'space-1', 0, 'stance'), { + queryClient.setQueryData(entityResponseCountsQueryKey('claim-1', 'space-1', 0, responseKind), { positive: 2, negative: 1, }); - queryClient.setQueryData(userEntityResponseQueryKey('profile-1', 'claim-1', 'space-1', 0, 'stance'), 'positive'); + queryClient.setQueryData( + userEntityResponseQueryKey('profile-1', 'claim-1', 'space-1', 0, responseKind), + 'positive' + ); } return render( - + , { wrapper: ({ children }: { children: ReactNode }) => ( diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index c57bd8c52b..05e0c89e1c 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -41,8 +41,6 @@ import { Profile } from '~/core/types'; import { Avatar } from '~/design-system/avatar'; import { getChecked } from '~/design-system/checkbox'; -import { ChevronDown } from '~/design-system/icons/chevron-down'; -import { ChevronUp } from '~/design-system/icons/chevron-up'; import { ThumbDown } from '~/design-system/icons/thumb-down'; import { ThumbUp } from '~/design-system/icons/thumb-up'; import { VoteArrow } from '~/design-system/icons/vote-arrow'; @@ -54,7 +52,7 @@ import { avatarAtom, nameAtom, spaceIdAtom, stepAtom, topicIdAtom } from '~/part const ENTITY_RESPONSE_OBJECT_TYPE = 0; -type ResponseVariant = 'default' | 'thumbs' | 'chevrons'; +type ResponseVariant = 'default' | 'thumbs'; const CLAIM_TYPE = uuidToHex(CLAIM_TYPE_ID); const CLAIM_IS_FACTUAL = uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID); @@ -99,8 +97,8 @@ export function EntityVoteButtons({ responseKindOverride === undefined && hasUnpublishedClaimResponseKindEdit(entity, spaceId); const queryResponseKind = responseKind ?? 'stance'; const isResponseKindLoading = responseKindOverride === undefined && isLoadingEntity; - const variant: ResponseVariant = - queryResponseKind === 'curation' ? 'default' : queryResponseKind === 'veracity' ? 'chevrons' : 'thumbs'; + const variant: ResponseVariant = queryResponseKind === 'curation' ? 'default' : 'thumbs'; + const isFactualResponse = queryResponseKind === 'veracity'; const responseCopy = ENTITY_RESPONSE_COPY[queryResponseKind]; const { submitResponse, optimisticResponse, isResponseIndexingDelayed, isConnected, personalSpaceId } = @@ -248,10 +246,6 @@ export function EntityVoteButtons({ const displayLabel = isClaimVariant ? percentLabel : scoreLabel; const renderResponseIcon = (direction: 'up' | 'down', active: boolean) => { - if (variant === 'chevrons') { - return direction === 'up' ? : ; - } - if (variant === 'thumbs') { return direction === 'up' ? : ; } @@ -260,9 +254,6 @@ export function EntityVoteButtons({ }; const claimResponseButtonColor = (active: boolean) => { - if (variant === 'chevrons') { - return active ? 'text-[#2A2B2E]' : 'text-grey-03 hover:text-grey-04'; - } return isClaimVariant && (active ? 'text-grey-04' : 'text-grey-03 hover:text-grey-04'); }; @@ -300,6 +291,7 @@ export function EntityVoteButtons({ return (
+ {isFactualResponse ? Is factual : null} {claimResponderAvatarsPosition === 'leading' && claimResponderAvatars ? ( {claimResponderAvatars} ) : null} From cd052087031aadc91b48984837784e0fa3face12 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Mon, 10 Aug 2026 12:40:26 +0200 Subject: [PATCH 14/15] fix(debates): make rematch responses interactive --- .../[sessionId]/rematch-page-client.test.tsx | 72 ++++++++------ .../[sessionId]/rematch-page-client.tsx | 95 ++++++++++--------- .../entity-page/claim-voter-avatars.test.tsx | 75 +++++++++++++++ .../entity-page/claim-voter-avatars.tsx | 19 +++- .../entity-vote-buttons.batch.test.tsx | 25 ++++- .../entity-page/entity-vote-buttons.tsx | 20 +++- 6 files changed, 223 insertions(+), 83 deletions(-) create mode 100644 apps/web/partials/entity-page/claim-voter-avatars.test.tsx diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 16fb9b1fc3..60e691b0a2 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -17,7 +17,8 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), acceptMutate: vi.fn(), rejectMutate: vi.fn(), - responseKinds: [] as Array<'stance' | 'veracity' | null>, + submitResponse: vi.fn(), + optimisticResponses: new Map(), })); vi.mock('next/navigation', () => ({ @@ -64,11 +65,12 @@ vi.mock('~/core/sync/use-store', () => ({ }), })); -vi.mock('~/partials/entity-page/entity-vote-buttons', () => ({ - EntityVoteButtons: ({ responseKind }: { responseKind: 'stance' | 'veracity' | null }) => { - mocks.responseKinds.push(responseKind); - return ; - }, +vi.mock('~/core/hooks/use-entity-vote', () => ({ + useEntityResponse: ({ entityId }: { entityId: string }) => ({ + submitResponse: (direction: 'positive' | 'negative' | 'clear') => mocks.submitResponse(entityId, direction), + optimisticResponse: mocks.optimisticResponses.get(entityId), + isConnected: true, + }), })); function mutation(mutate = mocks.mutate) { @@ -80,7 +82,8 @@ beforeEach(() => { mocks.mutate.mockReset(); mocks.acceptMutate.mockReset(); mocks.rejectMutate.mockReset(); - mocks.responseKinds.length = 0; + mocks.submitResponse.mockReset(); + mocks.optimisticResponses.clear(); mocks.session = session(); mocks.claims = [sharedClaim()]; document.body.style.overflow = ''; @@ -128,18 +131,18 @@ describe('DebateRematchPageClient', () => { expect(screen.getAllByRole('button', { name: 'Request debate' })[0]).toBeEnabled(); }); - it('shows backend response labels and holder avatars without position-update controls', () => { + it('renders active semantic response buttons with holder avatars', () => { render(); const sharedClaimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article'); expect(sharedClaimCard).not.toBeNull(); - expect(within(sharedClaimCard!).getByLabelText('Agree response').querySelector('img, svg')).not.toBeNull(); - expect(within(sharedClaimCard!).getByLabelText('Disagree response').querySelector('img, svg')).not.toBeNull(); - expect(within(sharedClaimCard!).queryByRole('button', { name: 'Agree' })).not.toBeInTheDocument(); - expect(within(sharedClaimCard!).queryByRole('button', { name: 'Disagree' })).not.toBeInTheDocument(); + expect(within(sharedClaimCard!).getByRole('button', { name: 'Agree' })).toBeEnabled(); + expect(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' })).toBeEnabled(); + expect(within(sharedClaimCard!).getByRole('button', { name: 'Agree' }).querySelector('img, svg')).not.toBeNull(); + expect(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' }).querySelector('img, svg')).not.toBeNull(); }); - it('shows response controls when the active responses are missing or on the same side', () => { + it('changes responses through the semantic buttons without rendering a second response area', () => { mocks.claims = [ { ...sharedClaim(), @@ -152,21 +155,36 @@ describe('DebateRematchPageClient', () => { render(); - expect( - screen.getByText('You both have the same response. Change yours to request this debate.') - ).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Change response' })).toHaveLength(1); + const sharedClaimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article'); + expect(sharedClaimCard).not.toBeNull(); + fireEvent.click(within(sharedClaimCard!).getByRole('button', { name: 'Disagree' })); + expect(mocks.submitResponse).toHaveBeenCalledWith('claim-shared', 'negative'); + expect(screen.queryByText('You both have the same response. Change yours to request this debate.')).toBeNull(); const syntheticClaimCard = screen.getByRole('heading', { name: 'A newly published claim' }).closest('article'); expect(syntheticClaimCard).not.toBeNull(); - expect(within(syntheticClaimCard!).getByText('Respond before requesting')).toBeInTheDocument(); - expect( - within(syntheticClaimCard!).getByText( - 'Response controls are unavailable while this claim is being prepared for rematches.' - ) - ).toBeInTheDocument(); - expect(within(syntheticClaimCard!).queryByRole('button', { name: 'Change response' })).not.toBeInTheDocument(); - expect(mocks.responseKinds).toContain('stance'); - expect(mocks.responseKinds).not.toContain(null); + expect(within(syntheticClaimCard!).queryByText('Respond before requesting')).toBeNull(); + expect(within(syntheticClaimCard!).getByRole('button', { name: 'Agree' })).toBeEnabled(); + expect(within(syntheticClaimCard!).getByRole('button', { name: 'Disagree' })).toBeEnabled(); + }); + + it('uses Verify and Dispute for factual claims', () => { + mocks.claims = [ + { + ...sharedClaim(), + response_kind: 'veracity', + participants: [ + { user_id: 'user-local', position: true, position_label: 'Verify' }, + { user_id: 'user-remote', position: false, position_label: 'Dispute' }, + ], + }, + ]; + + render(); + + const claimCard = screen.getByRole('heading', { name: 'A claim both participants chose' }).closest('article'); + expect(claimCard).not.toBeNull(); + expect(within(claimCard!).getByRole('button', { name: 'Verify' })).toBeEnabled(); + expect(within(claimCard!).getByRole('button', { name: 'Dispute' })).toBeEnabled(); }); it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', () => { @@ -262,7 +280,7 @@ describe('DebateRematchPageClient', () => { render(); expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: /^(Agree|Disagree)$/ })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /^(Agree|Disagree)$/ })).toHaveLength(4); }); it('explains when response changes cancel a rematch request', () => { diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 215ac8836e..8c04079b73 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -7,14 +7,13 @@ import * as React from 'react'; import cx from 'classnames'; import { useRouter } from 'next/navigation'; -import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; +import { CLAIM_IS_FACTUAL_PROPERTY_ID, CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; import { type DebateRematchClaim, type DebateRematchParticipant, type DebateRematchSession, getCurrentGeoChatUserId, } from '~/core/debates/api'; -import { DebateEntityResponseControls } from '~/core/debates/debate-entity-response-controls'; import { DebateRequestDialog } from '~/core/debates/debate-request-dialog'; import { defaultDebateFormatId } from '~/core/debates/formats'; import { @@ -26,11 +25,14 @@ import { useLeaveDebateRematch, useRejectDebateRematchRequest, } from '~/core/debates/hooks'; +import { useEntityResponse } from '~/core/hooks/use-entity-vote'; +import { uuidToHex } from '~/core/id/normalize'; import { responsePositionLabel } from '~/core/responses/entity-response'; import { useQueryEntities } from '~/core/sync/use-store'; import { Avatar } from '~/design-system/avatar'; import { Button } from '~/design-system/button'; +import { getChecked } from '~/design-system/checkbox'; import { ChevronDownSmall } from '~/design-system/icons/chevron-down-small'; import { Text } from '~/design-system/text'; @@ -98,7 +100,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { claim: claim.name!, description: claim.description, }, - response_kind: null, + response_kind: claimResponseKind(claim, claim.spaces[0]!), participants: (session?.participants ?? []).map(participant => ({ user_id: participant.user_id, position: null, @@ -366,14 +368,15 @@ function RematchClaimCard({ const localPosition = localResponse?.position ?? null; const remotePosition = remoteResponse?.position ?? null; const opposing = localPosition !== null && remotePosition !== null && localPosition !== remotePosition; - const responsePrompt = - localPosition === null - ? 'Respond before requesting' - : remotePosition === null - ? 'Waiting for the other participant to respond.' - : localPosition === remotePosition - ? 'You both have the same response. Change yours to request this debate.' - : null; + const { submitResponse, optimisticResponse, isConnected } = useEntityResponse({ + entityId: claim.claim.claim_entity_id, + spaceId: claim.claim.space_id, + responseKind: claim.response_kind, + }); + let displayedLocalPosition = localPosition; + if (optimisticResponse !== undefined) { + displayedLocalPosition = optimisticResponse === null ? null : optimisticResponse === 'positive'; + } const request = session?.request; const requesting = session?.status === 'request_pending' && request?.claim.claim_entity_id === claim.claim.claim_entity_id; @@ -396,49 +399,35 @@ function RematchClaimCard({
{[true, false].map(position => { - const positionSnapshot = claim.participants.find( - participant => participant.position === position && participant.position_label + const positionLabel = responsePositionLabel(claim.response_kind, position); + const holderIds = new Set( + claim.participants + .filter(participant => participant.user_id !== currentUserId && participant.position === position) + .map(participant => participant.user_id) ); - const positionLabel = - positionSnapshot?.position_label ?? responsePositionLabel(claim.response_kind, position); - const holders = session - ? claim.participants - .filter(participant => participant.position === position) - .map(participant => session.participants.find(sp => sp.user_id === participant.user_id)) - .filter((participant): participant is DebateRematchParticipant => Boolean(participant)) - : []; + if (currentUserId && displayedLocalPosition === position) holderIds.add(currentUserId); + const holders = session?.participants.filter(participant => holderIds.has(participant.user_id)) ?? []; + const selected = displayedLocalPosition === position; return ( -
submitResponse(selected ? 'clear' : position ? 'positive' : 'negative')} + className={cx( + 'flex min-h-9 items-center justify-between gap-2 rounded-full px-3 text-button transition-colors', + selected ? 'bg-text text-white' : 'bg-bg text-text hover:bg-grey-01', + (!claim.response_kind || !isConnected) && 'cursor-default opacity-50' + )} > - {positionLabel ?? 'No response'} + {positionLabel} {holders.length > 0 && } -
+ ); })}
- {responsePrompt && ( -
- - {responsePrompt} - - {claim.response_kind ? ( - - ) : ( - - Response controls are unavailable while this claim is being prepared for rematches. - - )} -
- )} - {(opposing || requesting) && (
{debatesEnabled && published && ( - +
+ + +
)} {debatesEnabled && } @@ -414,3 +433,13 @@ function RelationChipGroup({ function relationsForProperty(relations: Relation[], propertyId: string): Relation[] { return relations.filter(relation => relation.type.id === propertyId && relation.isDeleted !== true); } + +function claimResponseKind(claim: Entity, spaceId: string): 'stance' | 'veracity' { + const isFactual = claim.values.find( + value => + value.isDeleted !== true && + uuidToHex(value.spaceId) === uuidToHex(spaceId) && + uuidToHex(value.property.id) === uuidToHex(CLAIM_IS_FACTUAL_PROPERTY_ID) + )?.value; + return getChecked(isFactual) === true ? 'veracity' : 'stance'; +} diff --git a/apps/web/core/debates/api.test.ts b/apps/web/core/debates/api.test.ts index 66e78274c9..498c415762 100644 --- a/apps/web/core/debates/api.test.ts +++ b/apps/web/core/debates/api.test.ts @@ -7,6 +7,8 @@ import { getDebateActivity, getGeoChatSession, joinDebateQueue, + listDebateClaims, + notifyClaimResponseIndexed, resetGeoChatSession, retryDebatePhaseBoundaryRequest, updateDebateAvailability, @@ -183,6 +185,103 @@ describe('debate queue readiness', () => { }); }); +describe('debate claim hydration authentication', () => { + it('does not silently downgrade a signed-in claim request to anonymous access', async () => { + resetGeoChatSession(); + window.localStorage.removeItem('geo:chat-session'); + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ claims: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetch); + const getPrivyIdentityToken = vi.fn().mockRejectedValue(new Error('Identity token unavailable')); + + await expect(listDebateClaims('space-1', ['claim-1'], getPrivyIdentityToken, 'user-a')).rejects.toThrow( + 'Identity token unavailable' + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('keeps anonymous claim browsing available without an authorization header', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ claims: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetch); + + await expect(listDebateClaims('space-1', ['claim-1'])).resolves.toEqual({ claims: [] }); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8080/spaces/space-1/debate-claims?claim_ids=claim-1', + expect.objectContaining({ headers: {} }) + ); + }); +}); + +describe('claim response indexing notifications', () => { + it('posts the indexed response snapshot and accepts an empty success response', async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetch); + + await expect( + notifyClaimResponseIndexed('space-1', 'claim-1', 'veracity', false, vi.fn(), 'user-a') + ).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledWith('http://localhost:8080/spaces/space-1/claims/claim-1/response-indexed', { + method: 'POST', + headers: { + Authorization: 'Bearer access-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ response_kind: 'veracity', position: false }), + signal: undefined, + }); + }); + + it('retries transient failures twice before succeeding', async () => { + vi.useFakeTimers(); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503, statusText: 'Unavailable' })) + .mockResolvedValueOnce(new Response(null, { status: 503, statusText: 'Unavailable' })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetch); + + const notification = notifyClaimResponseIndexed('space-1', 'claim-1', 'stance', null, vi.fn(), 'user-a'); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + await vi.runAllTimersAsync(); + + await expect(notification).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('stops retrying when the notification is cancelled', async () => { + vi.useFakeTimers(); + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 503, statusText: 'Unavailable' })); + vi.stubGlobal('fetch', fetch); + const controller = new AbortController(); + + const notification = notifyClaimResponseIndexed( + 'space-1', + 'claim-1', + 'stance', + true, + vi.fn(), + 'user-a', + controller.signal + ); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + controller.abort(); + + await expect(notification).rejects.toMatchObject({ name: 'AbortError' }); + await vi.runAllTimersAsync(); + expect(fetch).toHaveBeenCalledOnce(); + }); +}); + describe('turn yields', () => { it('posts the client cutoff to the addressed turn', async () => { const debate = { id: 'debate-1', status: 'in_progress', turn_yields: [] }; diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 930a293ff9..48cd76bd2b 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -507,7 +507,7 @@ export async function listDebateClaims( ) { const query = claimIds.length > 0 ? `?claim_ids=${encodeURIComponent(claimIds.join(','))}` : ''; return geoChatRequest(`/spaces/${spaceId}/debate-claims${query}`, { - auth: 'optional', + auth: accountKey ? true : 'optional', getPrivyIdentityToken, accountKey, signal, @@ -542,6 +542,35 @@ export async function leaveDebateQueue( }); } +export async function notifyClaimResponseIndexed( + spaceId: string, + claimId: string, + responseKind: DebateResponseKind, + position: boolean | null, + getPrivyIdentityToken: GetPrivyIdentityToken, + accountKey: string | null, + signal?: AbortSignal +) { + const request = () => + geoChatRequest(`/spaces/${spaceId}/claims/${claimId}/response-indexed`, { + method: 'POST', + body: { response_kind: responseKind, position }, + auth: true, + getPrivyIdentityToken, + accountKey, + signal, + }); + + for (let attempt = 0; ; attempt += 1) { + try { + return await request(); + } catch (error) { + if (attempt >= 2 || !isTransientResponseNotificationError(error)) throw error; + await waitForResponseNotificationRetry(250 * 2 ** attempt, signal); + } + } +} + export async function acceptDebateMatch( matchId: string, getPrivyIdentityToken: GetPrivyIdentityToken, @@ -974,6 +1003,8 @@ async function geoChatRequest(path: string, options: RequestOptions = {}): Pr throw await requestError(response); } + if (response.status === 204) return undefined as T; + return response.json() as Promise; } @@ -995,6 +1026,31 @@ const debatePhaseBoundaryRetryCodes = new Set([ 'recording_not_ready', ]); +function isTransientResponseNotificationError(error: unknown) { + if (error instanceof GeoChatRequestError) { + return error.status === 429 || error.status >= 500; + } + return !(error instanceof DOMException && error.name === 'AbortError'); +} + +function waitForResponseNotificationRetry(delayMs: number, signal?: AbortSignal) { + if (signal?.aborted) + return Promise.reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); + + return new Promise((resolve, reject) => { + const finish = () => { + signal?.removeEventListener('abort', abort); + resolve(); + }; + const abort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError')); + }; + const timer = setTimeout(finish, delayMs); + signal?.addEventListener('abort', abort, { once: true }); + }); +} + export async function retryDebatePhaseBoundaryRequest(request: () => Promise): Promise { try { return await request(); diff --git a/apps/web/core/debates/claim-response-indexed-notifier.test.tsx b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx new file mode 100644 index 0000000000..b9dc87bd16 --- /dev/null +++ b/apps/web/core/debates/claim-response-indexed-notifier.test.tsx @@ -0,0 +1,217 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useClaimResponseIndexedNotifier } from './claim-response-indexed-notifier'; + +const mocks = vi.hoisted(() => ({ notify: vi.fn() })); + +vi.mock('./api', () => ({ + notifyClaimResponseIndexed: (...args: unknown[]) => mocks.notify(...args), +})); + +describe('useClaimResponseIndexedNotifier', () => { + beforeEach(() => { + mocks.notify.mockReset(); + mocks.notify.mockResolvedValue(undefined); + }); + + it('notifies geo-chat once when a claim response indexing run is confirmed', async () => { + const { queryClient, wrapper } = createHarness(); + const getPrivyIdentityToken = vi.fn(); + renderHook(() => useClaimResponseIndexedNotifier(true, getPrivyIdentityToken, 'account-1'), { wrapper }); + const queryKey = ['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'veracity'] as const; + const indexed = { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'negative', + personalSpaceId: 'profile-1', + responseKind: 'veracity', + spaceId: 'space-1', + }, + runId: 'run-1', + } as const; + + act(() => queryClient.setQueryData(queryKey, indexed)); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + 'space-1', + 'claim-1', + 'veracity', + false, + getPrivyIdentityToken, + 'account-1', + expect.any(AbortSignal) + ) + ); + + act(() => queryClient.setQueryData(queryKey, indexed)); + await Promise.resolve(); + expect(mocks.notify).toHaveBeenCalledOnce(); + }); + + it('reports cleared responses and ignores curation indexing', async () => { + const { queryClient, wrapper } = createHarness(); + renderHook(() => useClaimResponseIndexedNotifier(true, vi.fn(), 'account-1'), { wrapper }); + + act(() => { + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'stance'], { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: null, + personalSpaceId: 'profile-1', + responseKind: 'stance', + spaceId: 'space-1', + }, + runId: 'run-clear', + }); + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'entity-1', 'space-1', 'curation'], { + status: 'indexed', + pending: { + entityId: 'entity-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'curation', + spaceId: 'space-1', + }, + runId: 'run-curation', + }); + }); + + await waitFor(() => expect(mocks.notify).toHaveBeenCalledOnce()); + expect(mocks.notify).toHaveBeenCalledWith( + 'space-1', + 'claim-1', + 'stance', + null, + expect.any(Function), + 'account-1', + expect.any(AbortSignal) + ); + }); + + it('falls back to a silent local claim refresh when notification fails', async () => { + mocks.notify.mockRejectedValue(new Error('geo-chat unavailable')); + const { queryClient, wrapper } = createHarness(); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + renderHook(() => useClaimResponseIndexedNotifier(true, vi.fn(), 'account-1'), { wrapper }); + + act(() => { + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'stance'], { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'stance', + spaceId: 'space-1', + }, + runId: 'run-failure', + }); + }); + + await waitFor(() => expect(mocks.notify).toHaveBeenCalledOnce()); + await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['debates', 'claims', 'space-1'] })); + }); + + it('retries an interrupted notification when the same account is re-enabled', async () => { + let notificationSignal: AbortSignal | undefined; + mocks.notify.mockImplementation((...args: unknown[]) => { + notificationSignal = args.at(-1) as AbortSignal; + return new Promise((_, reject) => { + notificationSignal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + const { queryClient, wrapper } = createHarness(); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const getPrivyIdentityToken = vi.fn(); + const { rerender } = renderHook( + ({ enabled }) => useClaimResponseIndexedNotifier(enabled, getPrivyIdentityToken, 'account-1'), + { + initialProps: { enabled: true }, + wrapper, + } + ); + + act(() => { + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'stance'], { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'stance', + spaceId: 'space-1', + }, + runId: 'run-cancelled', + }); + }); + + await waitFor(() => expect(notificationSignal).toBeDefined()); + rerender({ enabled: false }); + await waitFor(() => expect(notificationSignal?.aborted).toBe(true)); + expect(invalidateQueries).not.toHaveBeenCalled(); + + mocks.notify.mockResolvedValue(undefined); + rerender({ enabled: true }); + await waitFor(() => expect(mocks.notify).toHaveBeenCalledTimes(2)); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it('does not replay an interrupted notification for another account', async () => { + let notificationSignal: AbortSignal | undefined; + mocks.notify.mockImplementation((...args: unknown[]) => { + notificationSignal = args.at(-1) as AbortSignal; + return new Promise((_, reject) => { + notificationSignal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + const { queryClient, wrapper } = createHarness(); + const { rerender } = renderHook(({ accountKey }) => useClaimResponseIndexedNotifier(true, vi.fn(), accountKey), { + initialProps: { accountKey: 'account-1' }, + wrapper, + }); + + act(() => { + queryClient.setQueryData(['entity-response-indexing', 'profile-1', 'claim-1', 'space-1', 'stance'], { + status: 'indexed', + pending: { + entityId: 'claim-1', + expectedResponse: 'positive', + personalSpaceId: 'profile-1', + responseKind: 'stance', + spaceId: 'space-1', + }, + runId: 'run-account-change', + }); + }); + + await waitFor(() => expect(notificationSignal).toBeDefined()); + rerender({ accountKey: 'account-2' }); + await waitFor(() => expect(notificationSignal?.aborted).toBe(true)); + expect(mocks.notify).toHaveBeenCalledOnce(); + }); +}); + +function createHarness() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + queryClient, + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} diff --git a/apps/web/core/debates/claim-response-indexed-notifier.ts b/apps/web/core/debates/claim-response-indexed-notifier.ts new file mode 100644 index 0000000000..c5b9ea9280 --- /dev/null +++ b/apps/web/core/debates/claim-response-indexed-notifier.ts @@ -0,0 +1,118 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; + +import * as React from 'react'; + +import type { EntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; + +import { type DebateResponseKind, type GetPrivyIdentityToken, notifyClaimResponseIndexed } from './api'; + +const MAX_NOTIFIED_RUNS = 256; +type IndexedClaimResponse = NonNullable>; +type InterruptedNotification = { + accountKey: string; + notificationKey: string; + response: IndexedClaimResponse; +}; + +export function claimResponseIndexedEvent(queryKey: readonly unknown[], data: unknown) { + const [scope, , entityId, spaceId, responseKind] = queryKey; + const indexingState = data as EntityResponseIndexingState | undefined; + if ( + scope !== 'entity-response-indexing' || + indexingState?.status !== 'indexed' || + !indexingState.pending || + (responseKind !== 'stance' && responseKind !== 'veracity') + ) { + return null; + } + return { + entityId: String(entityId), + position: + indexingState.pending.expectedResponse === null ? null : indexingState.pending.expectedResponse === 'positive', + responseKind: responseKind as DebateResponseKind, + runId: indexingState.runId, + spaceId: String(spaceId), + }; +} + +export function useClaimResponseIndexedNotifier( + enabled: boolean, + getPrivyIdentityToken: GetPrivyIdentityToken, + accountKey: string | null +) { + const queryClient = useQueryClient(); + const notifiedRuns = React.useRef(new Set()); + const notifiedRunOrder = React.useRef([]); + const interruptedNotifications = React.useRef(new Map()); + + React.useEffect(() => { + if (!enabled || !accountKey) return; + + const notificationControllers = new Map(); + + const forgetNotification = (notificationKey: string) => { + notifiedRuns.current.delete(notificationKey); + notifiedRunOrder.current = notifiedRunOrder.current.filter(key => key !== notificationKey); + }; + + const startNotification = (notificationKey: string, response: IndexedClaimResponse) => { + if (notifiedRuns.current.has(notificationKey)) return; + + notifiedRuns.current.add(notificationKey); + notifiedRunOrder.current.push(notificationKey); + if (notifiedRunOrder.current.length > MAX_NOTIFIED_RUNS) { + const expired = notifiedRunOrder.current.shift(); + if (expired) notifiedRuns.current.delete(expired); + } + + const controller = new AbortController(); + notificationControllers.set(controller, { accountKey, notificationKey, response }); + void notifyClaimResponseIndexed( + response.spaceId, + response.entityId, + response.responseKind, + response.position, + getPrivyIdentityToken, + accountKey, + controller.signal + ) + .catch(error => { + if (isAbortError(error)) return; + return queryClient.invalidateQueries({ queryKey: ['debates', 'claims', response.spaceId] }); + }) + .finally(() => notificationControllers.delete(controller)); + }; + + const unsubscribe = queryClient.getQueryCache().subscribe(event => { + if (event.type !== 'updated' || event.action.type !== 'success') return; + const response = claimResponseIndexedEvent(event.query.queryKey, event.query.state.data); + if (!response) return; + startNotification(`${event.query.queryHash}:${response.runId}`, response); + }); + + for (const [notificationKey, interrupted] of interruptedNotifications.current) { + interruptedNotifications.current.delete(notificationKey); + if (interrupted.accountKey === accountKey) { + startNotification(notificationKey, interrupted.response); + } else { + forgetNotification(notificationKey); + } + } + + return () => { + unsubscribe(); + for (const [controller, interrupted] of notificationControllers) { + interruptedNotifications.current.set(interrupted.notificationKey, interrupted); + forgetNotification(interrupted.notificationKey); + controller.abort(); + } + notificationControllers.clear(); + }; + }, [accountKey, enabled, getPrivyIdentityToken, queryClient]); +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === 'AbortError'; +} diff --git a/apps/web/core/debates/debate-coordinator.test.tsx b/apps/web/core/debates/debate-coordinator.test.tsx index fee9393cf5..c10fe7f8d0 100644 --- a/apps/web/core/debates/debate-coordinator.test.tsx +++ b/apps/web/core/debates/debate-coordinator.test.tsx @@ -56,6 +56,10 @@ vi.mock('./debate-gateway', () => ({ useDebateGateway: () => ({ status: mocks.gatewayPaused ? 'degraded' : 'ready', paused: mocks.gatewayPaused }), })); +vi.mock('./claim-response-indexed-notifier', () => ({ + useClaimResponseIndexedNotifier: vi.fn(), +})); + vi.mock('~/core/state/feature-flags', () => ({ useDebatesEnabled: () => true, })); diff --git a/apps/web/core/debates/debate-coordinator.tsx b/apps/web/core/debates/debate-coordinator.tsx index 2297d6e4b4..a8bf919ed6 100644 --- a/apps/web/core/debates/debate-coordinator.tsx +++ b/apps/web/core/debates/debate-coordinator.tsx @@ -12,8 +12,9 @@ import { Spinner } from '~/design-system/spinner'; import { Text } from '~/design-system/text'; import { type DebateMatch, type DebateSharePrompt, getCurrentGeoChatUserId } from './api'; -import { DebateChallengeDialog } from './debate-challenge-dialog'; +import { useClaimResponseIndexedNotifier } from './claim-response-indexed-notifier'; import { useDebateAttention } from './debate-attention'; +import { DebateChallengeDialog } from './debate-challenge-dialog'; import { useDebateGateway } from './debate-gateway'; import { clearDebateMatchTabOwnership, @@ -49,6 +50,11 @@ export function DebateCoordinator() { geoChatAuth.accountKey, debateAttention ); + useClaimResponseIndexedNotifier( + isDebatesEnabled && geoChatAuth.ready && geoChatAuth.authenticated, + geoChatAuth.getPrivyIdentityToken, + geoChatAuth.accountKey + ); const activityQuery = useDebateActivity(isDebatesEnabled); const currentUserId = getCurrentGeoChatUserId(); const activity = activityQuery.data ?? null; diff --git a/apps/web/core/debates/debate-gateway.test.ts b/apps/web/core/debates/debate-gateway.test.ts index 6a2cebeb57..c680c2cdc2 100644 --- a/apps/web/core/debates/debate-gateway.test.ts +++ b/apps/web/core/debates/debate-gateway.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from '@tanstack/react-query'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -150,7 +150,7 @@ describe('DebateGatewayClient', () => { sockets[0]!.open(); sockets[0]!.receive('READY', readyPayload([])); await flushInvalidations(); - expectInvalidated(invalidateQueries, { queryKey: ['debates'], refetchType: 'active' }); + expectBroadInvalidated(invalidateQueries); invalidateQueries.mockClear(); const event = { @@ -231,6 +231,10 @@ describe('DebateGatewayClient', () => { it('filters claim invalidations to active claim queries that intersect the event', async () => { queryClient.setQueryData(['debates', 'claims', 'space-1', ['claim-1']], {}); queryClient.setQueryData(['debates', 'claims', 'space-1', ['claim-2']], {}); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']], new Map()); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:veracity']], new Map()); + queryClient.setQueryData(['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:veracity']], new Map()); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:veracity']], new Map()); const refetchQueries = vi.spyOn(queryClient, 'refetchQueries').mockResolvedValue(); client.start( @@ -259,9 +263,156 @@ describe('DebateGatewayClient', () => { expect( predicate!(queryClient.getQueryCache().find({ queryKey: ['debates', 'claims', 'space-1', ['claim-2']] })!) ).toBe(true); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']] })! + ) + ).toBe(false); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-1', ['claim-2:veracity']] })! + ) + ).toBe(true); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summary-data', 'profile-1', 'space-1', ['claim-2:veracity']] })! + ) + ).toBe(true); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-2', ['claim-2:veracity']] })! + ) + ).toBe(false); expect(refetchQueries).not.toHaveBeenCalled(); }); + it('coalesces adjacent claim events into one active summary-data refresh', async () => { + client.start( + vi.fn(async () => 'privy-token'), + 'user-a' + ); + await vi.runAllTicks(); + sockets[0]!.open(); + sockets[0]!.receive('READY', readyPayload([])); + await flushInvalidations(); + invalidateQueries.mockRestore(); + + const responseTargets = ['claim-1:stance', 'claim-2:veracity']; + const summaryDataKey = ['claim-response-summary-data', 'profile-1', 'space-1', responseTargets] as const; + const responseBatchKey = ['claim-response-summaries', 'profile-1', 'space-1', responseTargets] as const; + const fetchSummaryData = vi.fn(async () => new Map([['claim-2:veracity', { negative: 1 }]])); + const observer = new QueryObserver(queryClient, { + queryKey: responseBatchKey, + queryFn: () => + queryClient.fetchQuery({ + queryKey: summaryDataKey, + queryFn: fetchSummaryData, + staleTime: 30_000, + }), + staleTime: 30_000, + }); + const unsubscribe = observer.subscribe(() => undefined); + await observer.refetch(); + expect(fetchSummaryData).toHaveBeenCalledOnce(); + + sockets[0]!.receive('EVENT', { + event_id: 'event-response-summary', + event_type: 'debate.claims_changed', + payload: { space_id: 'space-1', claim_entity_ids: ['claim-2'] }, + }); + sockets[0]!.receive('EVENT', { + event_id: 'event-response-summary-adjacent', + event_type: 'debate.claims_changed', + payload: { space_id: 'space-1', claim_entity_ids: ['claim-1'] }, + }); + await flushInvalidations(); + await vi.runAllTicks(); + + expect(fetchSummaryData).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it('broadly reconciles debate and active claim response batches', async () => { + queryClient.setQueryData(['debates', 'claims', 'space-1', ['claim-1']], {}); + queryClient.setQueryData(['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']], new Map()); + queryClient.setQueryData(['claim-response-summary-data', 'profile-1', 'space-1', ['claim-1:stance']], new Map()); + queryClient.setQueryData(['unrelated', 'space-1'], {}); + + client.start( + vi.fn(async () => 'privy-token'), + 'user-a' + ); + await vi.runAllTicks(); + sockets[0]!.open(); + sockets[0]!.receive('READY', readyPayload([])); + await flushInvalidations(); + + type InvalidationFilters = NonNullable[0]>; + const invalidationCalls = invalidateQueries.mock.calls as unknown as Array<[InvalidationFilters]>; + const predicate = invalidationCalls.map(call => call[0]).find(filters => 'predicate' in filters)?.predicate; + expect(predicate).toBeTypeOf('function'); + expect( + predicate!(queryClient.getQueryCache().find({ queryKey: ['debates', 'claims', 'space-1', ['claim-1']] })!) + ).toBe(true); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summaries', 'profile-1', 'space-1', ['claim-1:stance']] })! + ) + ).toBe(true); + expect( + predicate!( + queryClient + .getQueryCache() + .find({ queryKey: ['claim-response-summary-data', 'profile-1', 'space-1', ['claim-1:stance']] })! + ) + ).toBe(true); + expect(predicate!(queryClient.getQueryCache().find({ queryKey: ['unrelated', 'space-1'] })!)).toBe(false); + }); + + it('refetches an active response batch during broad reconnect reconciliation', async () => { + invalidateQueries.mockRestore(); + const responseTargets = ['claim-1:stance']; + const summaryDataKey = ['claim-response-summary-data', 'profile-1', 'space-1', responseTargets] as const; + const responseBatchKey = ['claim-response-summaries', 'profile-1', 'space-1', responseTargets] as const; + const fetchSummaryData = vi.fn(async () => new Map([['claim-1:stance', { positive: 1 }]])); + const observer = new QueryObserver(queryClient, { + queryKey: responseBatchKey, + queryFn: () => + queryClient.fetchQuery({ + queryKey: summaryDataKey, + queryFn: fetchSummaryData, + staleTime: 30_000, + }), + staleTime: 30_000, + }); + const unsubscribe = observer.subscribe(() => undefined); + await observer.refetch(); + expect(fetchSummaryData).toHaveBeenCalledOnce(); + + client.start( + vi.fn(async () => 'privy-token'), + 'user-a' + ); + await vi.runAllTicks(); + sockets[0]!.open(); + sockets[0]!.receive('READY', readyPayload([])); + await flushInvalidations(); + await vi.runAllTicks(); + + expect(fetchSummaryData).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + it('sends presence heartbeats and reconnects after two missed acknowledgements', async () => { client.start( vi.fn(async () => 'privy-token'), @@ -373,12 +524,12 @@ describe('DebateGatewayClient', () => { sockets[1]!.open(); sockets[1]!.receive('READY', readyPayload([])); await flushInvalidations(); - expectInvalidated(invalidateQueries, { queryKey: ['debates'], refetchType: 'active' }); + expectBroadInvalidated(invalidateQueries); invalidateQueries.mockClear(); sockets[1]!.receive('ERROR', { code: 'events_lagged', message: 'events skipped' }); await flushInvalidations(); - expectInvalidated(invalidateQueries, { queryKey: ['debates'], refetchType: 'active' }); + expectBroadInvalidated(invalidateQueries); }); it('abandons a socket that never completes the protocol handshake', async () => { @@ -426,7 +577,7 @@ describe('DebateGatewayClient', () => { sockets[0]!.receive('READY', readyPayload([])); await flushInvalidations(); - expect(cancelQueries).toHaveBeenCalledWith({ queryKey: ['debates'], refetchType: 'active' }); + expect(cancelQueries).toHaveBeenCalledWith({ predicate: expect.any(Function), refetchType: 'active' }); expect(cancelQueries.mock.invocationCallOrder[0]).toBeLessThan(invalidateQueries.mock.invocationCallOrder[0]!); }); @@ -621,3 +772,10 @@ async function flushInvalidations() { function expectInvalidated(invalidateQueries: ReturnType, filters: unknown) { expect(invalidateQueries.mock.calls).toContainEqual([filters, { throwOnError: true }]); } + +function expectBroadInvalidated(invalidateQueries: ReturnType) { + expect(invalidateQueries).toHaveBeenCalledWith( + { predicate: expect.any(Function), refetchType: 'active' }, + { throwOnError: true } + ); +} diff --git a/apps/web/core/debates/debate-gateway.ts b/apps/web/core/debates/debate-gateway.ts index 795606bdba..ac90e5eecb 100644 --- a/apps/web/core/debates/debate-gateway.ts +++ b/apps/web/core/debates/debate-gateway.ts @@ -5,6 +5,10 @@ import type { QueryClient, QueryKey } from '@tanstack/react-query'; import * as React from 'react'; import { queryClient } from '~/core/query-client'; +import { + claimResponseTargetKey, + isClaimResponseSummaryQueryKey, +} from '~/core/responses/claim-response-summary-query-keys'; import { type GeoChatSession, type GetPrivyIdentityToken, getGeoChatApiBaseUrl, getGeoChatSession } from './api'; @@ -83,6 +87,7 @@ export class DebateGatewayClient { private readonly recentEventIds = new Set(); private readonly recentEventIdOrder: string[] = []; private readonly pendingInvalidations = new Map(); + private readonly pendingChangedClaimsBySpace = new Map>(); private snapshot: DebateGatewaySnapshot = { status: 'idle', paused: false }; private getPrivyIdentityToken: GetPrivyIdentityToken | null = null; @@ -154,6 +159,7 @@ export class DebateGatewayClient { this.sentScopes.clear(); this.confirmedScopes.clear(); this.pendingInvalidations.clear(); + this.pendingChangedClaimsBySpace.clear(); if (accountKey) this.queryClient.removeQueries({ queryKey: ['debates'] }); this.setSnapshot({ status: 'idle', paused: false }); } @@ -367,7 +373,13 @@ export class DebateGatewayClient { } private queueBroadReconcile() { - this.queueInvalidation(BROAD_INVALIDATION_KEY, { queryKey: ['debates'], refetchType: 'active' }); + this.queueInvalidation(BROAD_INVALIDATION_KEY, { + predicate: query => { + const root = query.queryKey[0]; + return root === 'debates' || isClaimResponseSummaryQueryKey(query.queryKey); + }, + refetchType: 'active', + }); } /** A profile's `can_challenge` folds in the viewer's own activity, so it goes stale with it. */ @@ -388,17 +400,32 @@ export class DebateGatewayClient { this.queueQuery(['debates', 'claims', spaceId]); return; } - const changedClaims = new Set(claimEntityIds); - this.queueInvalidation(`claims:${spaceId}:${claimEntityIds.slice().sort().join(',')}`, { + let changedClaims = this.pendingChangedClaimsBySpace.get(spaceId); + if (!changedClaims) { + changedClaims = new Set(); + this.pendingChangedClaimsBySpace.set(spaceId, changedClaims); + } + for (const claimEntityId of claimEntityIds) changedClaims.add(claimEntityId); + const changedResponseTargets = new Set( + [...changedClaims].flatMap(entityId => + (['stance', 'veracity'] as const).map(responseKind => claimResponseTargetKey({ entityId, responseKind })) + ) + ); + this.queueInvalidation(`claims:${spaceId}`, { predicate: query => { const [root, kind, querySpaceId, queryClaimIds] = query.queryKey; - return ( + const isDebateClaimQuery = root === 'debates' && kind === 'claims' && querySpaceId === spaceId && Array.isArray(queryClaimIds) && - queryClaimIds.some(claimId => typeof claimId === 'string' && changedClaims.has(claimId)) - ); + queryClaimIds.some(claimId => typeof claimId === 'string' && changedClaims.has(claimId)); + if (isDebateClaimQuery) return true; + + return isClaimResponseSummaryQueryKey(query.queryKey, { + spaceId, + targetKeys: changedResponseTargets, + }); }, refetchType: 'active', }); @@ -411,6 +438,7 @@ export class DebateGatewayClient { private queueInvalidation(key: string, filters: InvalidationFilters) { if (key === BROAD_INVALIDATION_KEY) { this.pendingInvalidations.clear(); + this.pendingChangedClaimsBySpace.clear(); } else if (this.pendingInvalidations.has(BROAD_INVALIDATION_KEY)) { return; } @@ -420,6 +448,7 @@ export class DebateGatewayClient { this.invalidationTimer = null; const invalidations = [...this.pendingInvalidations.values()]; this.pendingInvalidations.clear(); + this.pendingChangedClaimsBySpace.clear(); void this.flushInvalidations(invalidations); }, INVALIDATION_COALESCE_MS); } diff --git a/apps/web/core/debates/hooks.test.tsx b/apps/web/core/debates/hooks.test.tsx index 1306c22ae4..d224ec407e 100644 --- a/apps/web/core/debates/hooks.test.tsx +++ b/apps/web/core/debates/hooks.test.tsx @@ -190,7 +190,7 @@ describe('useGeoChatAuth', () => { expect(mocks.getIdentityToken).toHaveBeenCalledTimes(1); }); - it('refetches debate readiness only when response reconciliation is fully indexed', async () => { + it('leaves indexed-response claim refreshes to the gateway notification path', async () => { mocks.identityToken.mockReturnValue(null); mocks.getIdentityToken.mockResolvedValue(null); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -222,7 +222,8 @@ describe('useGeoChatAuth', () => { }); }); - await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['debates', 'claims', 'space-1'] })); + await Promise.resolve(); + expect(invalidateQueries).not.toHaveBeenCalled(); }); it('refetches rematch snapshots when response reconciliation is fully indexed', async () => { diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index f3d3b28666..2f3b415590 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -6,7 +6,6 @@ import { type UseQueryResult, useMutation, useQueries, useQuery, useQueryClient import * as React from 'react'; import { getCachedIdentityToken, useIdentityTokenSync } from '~/core/auth/identity-token'; -import type { EntityResponseIndexingState } from '~/core/hooks/use-entity-vote'; import { type Debate, @@ -55,6 +54,7 @@ import { retryDebatePhaseBoundaryRequest, updateDebateAvailability, } from './api'; +import { claimResponseIndexedEvent } from './claim-response-indexed-notifier'; import { useDebateAttention } from './debate-attention'; import { useDebateGatewayScope } from './debate-gateway'; import { hasProcessedVideo } from './playback-utils'; @@ -81,19 +81,6 @@ export const debateQueryKeys = { ['debates', 'account', accountKey, 'profile', profileSpaceId] as const, }; -function indexedResponseEvent(queryKey: readonly unknown[], data: unknown) { - const [scope, , entityId, spaceId, responseKind] = queryKey; - const indexingState = data as EntityResponseIndexingState | undefined; - if ( - scope !== 'entity-response-indexing' || - indexingState?.status !== 'indexed' || - (responseKind !== 'stance' && responseKind !== 'veracity') - ) { - return null; - } - return { entityId: String(entityId), spaceId: String(spaceId), responseKind }; -} - export function useGeoChatAuth() { const privy = usePrivy(); useIdentityTokenSync(); @@ -110,28 +97,10 @@ export function useGeoChatAuth() { // claim in the space. geo-chat indexes them, so this skips the KG scan over all // the space's Claim entities that 504s on large spaces. export function useDebateClaims(spaceId: string, claimIds: string[] | null, enabled: boolean) { - const queryClient = useQueryClient(); const { accountKey, authenticated, getPrivyIdentityToken } = useGeoChatAuth(); const shouldFetch = enabled && (claimIds === null || claimIds.length > 0); useDebateGatewayScope({ scope: 'space', space_id: spaceId }, authenticated && shouldFetch); - React.useEffect( - function refetchReadinessAfterIndexedResponse() { - if (!shouldFetch) return; - - return queryClient.getQueryCache().subscribe(event => { - if (event.type !== 'updated' || event.action.type !== 'success') return; - const response = indexedResponseEvent(event.query.queryKey, event.query.state.data); - if (!response || response.spaceId !== spaceId || (claimIds !== null && !claimIds.includes(response.entityId))) { - return; - } - - void queryClient.invalidateQueries({ queryKey: ['debates', 'claims', spaceId] }); - }); - }, - [claimIds, queryClient, shouldFetch, spaceId] - ); - return useQuery({ ...debateQueryNetworkOptions, queryKey: debateQueryKeys.claims(spaceId, claimIds), @@ -458,7 +427,7 @@ export function useDebateRematchClaims(sessionId: string, claimIds: string[] = [ return queryClient.getQueryCache().subscribe(event => { if (event.type !== 'updated' || event.action.type !== 'success') return; - const response = indexedResponseEvent(event.query.queryKey, event.query.state.data); + const response = claimResponseIndexedEvent(event.query.queryKey, event.query.state.data); if (!response || (claimIds.length > 0 && !claimIds.includes(response.entityId))) { return; } diff --git a/apps/web/core/responses/claim-response-summaries.ts b/apps/web/core/responses/claim-response-summaries.ts index ec4360bd14..b10cc588b3 100644 --- a/apps/web/core/responses/claim-response-summaries.ts +++ b/apps/web/core/responses/claim-response-summaries.ts @@ -15,6 +15,7 @@ import { fetchProfilesBySpaceIds } from '~/core/io/subgraph/fetch-profile'; import type { Profile } from '~/core/types'; import { mapWithConcurrency } from '~/core/utils/map-with-concurrency'; +import { claimResponseTargetKey } from './claim-response-summary-query-keys'; import { type ActiveResponseDirection, type ResponseKind, @@ -26,6 +27,8 @@ import { userEntityResponseQueryKey, } from './entity-response'; +export { claimResponseSummariesQueryKeyPrefix, claimResponseTargetKey } from './claim-response-summary-query-keys'; + export const CLAIM_RESPONSE_SUMMARY_PAGE_SIZE = 1_000; const RESPONDER_METADATA_CHUNK_SIZE = 100; const RESPONDER_METADATA_CONCURRENCY = 4; @@ -53,14 +56,6 @@ type FetchSummaries = typeof fetchClaimResponseSummaries; type FetchProfiles = (spaceIds: string[]) => Promise; type FetchSpaces = (spaceIds: string[], signal?: AbortSignal) => Promise; -export function claimResponseTargetKey(target: ClaimResponseTarget) { - return `${target.entityId}:${target.responseKind}`; -} - -export function claimResponseSummariesQueryKeyPrefix(personalSpaceId: string | null, spaceId: string) { - return ['claim-response-summaries', personalSpaceId, spaceId] as const; -} - export function normalizeClaimResponseTargets(targets: ClaimResponseTarget[]) { const byKey = new Map(targets.map(target => [claimResponseTargetKey(target), target])); return [...byKey.values()].sort((left, right) => diff --git a/apps/web/core/responses/claim-response-summary-query-keys.ts b/apps/web/core/responses/claim-response-summary-query-keys.ts new file mode 100644 index 0000000000..71636fe2fa --- /dev/null +++ b/apps/web/core/responses/claim-response-summary-query-keys.ts @@ -0,0 +1,25 @@ +import type { ResponseKind } from './entity-response'; + +const CLAIM_RESPONSE_SUMMARY_QUERY_ROOTS = new Set(['claim-response-summaries', 'claim-response-summary-data']); + +export function claimResponseTargetKey(target: { entityId: string; responseKind: ResponseKind }) { + return `${target.entityId}:${target.responseKind}`; +} + +export function claimResponseSummariesQueryKeyPrefix(personalSpaceId: string | null, spaceId: string) { + return ['claim-response-summaries', personalSpaceId, spaceId] as const; +} + +export function isClaimResponseSummaryQueryKey( + queryKey: readonly unknown[], + filters: { spaceId?: string; targetKeys?: ReadonlySet } = {} +) { + const [root, , spaceId, targetKeys] = queryKey; + if (typeof root !== 'string' || !CLAIM_RESPONSE_SUMMARY_QUERY_ROOTS.has(root)) return false; + if (filters.spaceId !== undefined && spaceId !== filters.spaceId) return false; + if (!filters.targetKeys) return true; + return ( + Array.isArray(targetKeys) && + targetKeys.some(target => typeof target === 'string' && filters.targetKeys?.has(target)) + ); +}