diff --git a/README.md b/README.md index e064bc0e964ea66b1597861fbaaa5e17eb24ff87..86f73fa1fe9425c34f3b12b14363e0ac507c8c27 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Priority 1 - [ ] Network scan ? Priority 2 -- [ ] Login process (use a unique modal + a login method popover) +- [x] Login process should use a unique modal, and a method selector - issue #26 - [ ] Directory (aka wot) search using Data Pod (see [duniter-panel](https://duniter--vue-coinduf-eu.ipns.pagu.re/)) - [ ] Submit profile to Data pod (see ddd-ui) - [ ] TX comments diff --git a/codegen.yml b/codegen.yml deleted file mode 100644 index d1b476992cc4d16caac41ee527435365978bc693..0000000000000000000000000000000000000000 --- a/codegen.yml +++ /dev/null @@ -1,23 +0,0 @@ -overwrite: true -schema: "src/app/network/indexer-schema.graphql" -documents: "src/app/**/*!(.generated).{ts,graphql}" -generates: - src/app/network/indexer-types.generated.ts: - plugins: - - "add" - - "typescript" - - "typescript-operations" - - "typescript-apollo-angular" - - "fragment-matcher" - config: - content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" - nameSuffix: "Document" - sdkClass: true - serviceName: "IndexerGraphqlService" - namedClient: 'indexer' - src/app/network/indexer-helpers.generated.ts: - plugins: - - "add" - - "typescript-apollo-client-helpers" - config: - content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" diff --git a/graphql.config.yml b/graphql.config.yml index 90912ebe961b5d7f438f2766e000359808bf3478..5d96ef556aabd8e78d69fb49fda33cf6e7d74799 100644 --- a/graphql.config.yml +++ b/graphql.config.yml @@ -1,8 +1,65 @@ -schema: src/app/network/indexer-schema.graphql -extensions: - endpoints: - Gdev GraphQL Endpoint: - url: https://gdev-squid.axiom-team.fr/v1beta1/relay - headers: - user-agent: JS GraphQL - introspect: false +projects: + indexer: + schema: src/app/network/indexer/indexer-schema.graphql + documents: "src/app/network/indexer/indexer-*!(.generated).{ts,gql}" + extensions: + endpoints: + Gdev Indexer GraphQL Endpoint: + url: https://gdev-squid.axiom-team.fr/v1beta1/relay + headers: + user-agent: JS GraphQL + introspect: false + codegen: + generates: + src/app/network/indexer/indexer-types.generated.ts: + plugins: + - "add" + - "typescript" + - "typescript-operations" + - "typescript-apollo-angular" + - "fragment-matcher" + config: + content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" + nameSuffix: "Document" + sdkClass: true + serviceName: "IndexerGraphqlService" + namedClient: 'indexer' + src/app/network/indexer/indexer-helpers.generated.ts: + plugins: + - "add" + - "typescript-apollo-client-helpers" + config: + content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" + + pod: + schema: src/app/network/pod/pod-schema.graphql + documents: "src/app/network/pod/pod-*!(.generated).{ts,gql}" + extensions: + endpoints: + Gdev Pod GraphQL Endpoint: + url: https://datapod.coinduf.eu/v1/graphql + headers: + user-agent: JS GraphQL + introspect: false + codegen: + generates: + src/app/network/pod/pod-types.generated.ts: + plugins: + - "add" + - "typescript" + - "typescript-operations" + - "typescript-apollo-angular" + - "fragment-matcher" + config: + content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" + nameSuffix: "Document" + sdkClass: true + serviceName: "PodGraphqlService" + namedClient: 'pod' + src/app/network/pod/pod-helpers.generated.ts: + plugins: + - "add" + - "typescript-apollo-client-helpers" + config: + content: "// Auto-generated via `npx graphql-codegen`, do not edit\n/* eslint-disable */" + diff --git a/package.json b/package.json index 1c51128f2b46086ffaa78f10310142c8f3e2ea80..46976bbb71325f6482e66409c8401d954e93cf29 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,9 @@ "generate": "npm run generate:defs && npm run generate:meta && npm run generate:graphql", "generate:defs": "ts-node --skip-project node_modules/.bin/polkadot-types-from-defs --package @duniter/interfaces --input src/interfaces --endpoint src/interfaces/types.json", "generate:meta": "ts-node --skip-project node_modules/.bin/polkadot-types-from-chain --package @duniter/interfaces --output src/interfaces --endpoint src/interfaces/types.json", - "generate:graphql": "graphql-codegen", + "generate:graphql": "graphql-codegen --project indexer && graphql-codegen --project pod", + "generate:graphql:indexer": "graphql-codegen --project indexer", + "generate:graphql:pod": "graphql-codegen --project pod", "prepare": "husky install", "version:get": "node scripts/node/version.js", "version:set": "node scripts/node/version.js --set", diff --git a/src/app/account/account.converter.ts b/src/app/account/account.converter.ts index 77dfc8adac45cc93896517c7c325f3fa935661b5..34586a8224458365ae5d0bed4c04881ddbc8cba6 100644 --- a/src/app/account/account.converter.ts +++ b/src/app/account/account.converter.ts @@ -1,21 +1,22 @@ -import { LightAccountConnectionFragment, LightAccountFragment, LightIdentityFragment } from '@app/network/indexer-types.generated'; +import { LightAccountConnectionFragment, LightAccountFragment, LightIdentityFragment } from '@app/network/indexer/indexer-types.generated'; import { Account, parseAddressSquid } from '@app/account/account.model'; +import { ProfileFragment } from '@app/network/pod/pod-types.generated'; export class AccountConverter { - static connectionToAccounts(accountConnection: LightAccountConnectionFragment, debug?: boolean): Account[] { + static squidConnectionToAccounts(accountConnection: LightAccountConnectionFragment, debug?: boolean): Account[] { const inputs = accountConnection.edges?.map((edge) => edge.node) as LightAccountFragment[]; - const results = (inputs || []).map(this.toAccount); + const results = (inputs || []).map(this.squidToAccount); if (debug) console.debug('Results:', results); return results; } - static toAccounts(inputs: LightAccountFragment[], debug?: boolean): Account[] { - const results = (inputs || []).map(this.toAccount); + static squidToAccounts(inputs: LightAccountFragment[], debug?: boolean): Account[] { + const results = (inputs || []).map(this.squidToAccount); if (debug) console.debug('Results:', results); return results; } - static toAccount(input: LightAccountFragment): Account { + static squidToAccount(input: LightAccountFragment): Account { if (!input) return undefined; const addressSquid = parseAddressSquid(input.id); const identity = input.identity; @@ -31,6 +32,24 @@ export class AccountConverter { }, }; } + + static profileToAccounts(inputs: ProfileFragment[], opts?: { debug?: boolean; ipfsGateway?: string }): Account[] { + const results = (inputs || []).map((input) => this.profileToAccount(input, opts)); + if (opts?.debug) console.debug('Results:', results); + return results; + } + + static profileToAccount(input: ProfileFragment, opts?: { ipfsGateway?: string }): Account { + if (!input) return undefined; + const avatar = input.avatar_cid && opts.ipfsGateway ? opts.ipfsGateway + input.avatar_cid : undefined; + return <Account>{ + address: input.address, + meta: { + name: input.title, + avatar, + }, + }; + } } export class IdentityConverter { diff --git a/src/app/account/account.model.ts b/src/app/account/account.model.ts index d6df2325df55332ae07188335a9b62093eb00074..dde14f29ead85461cf1ed5af63c775a571bbc73d 100644 --- a/src/app/account/account.model.ts +++ b/src/app/account/account.model.ts @@ -1,7 +1,9 @@ import { HexString } from '@polkadot/util/types'; import { ListItem } from '@app/shared/popover/list.popover'; import { formatAddress } from '@app/shared/currencies'; -import { IdentityStatusEnum } from '@app/network/indexer-types.generated'; +import { IdentityStatusEnum } from '@app/network/indexer/indexer-types.generated'; +import { isEmptyArray } from '@app/shared/functions'; +import { combineLoadResults, LoadResult } from '@app/shared/services/service.model'; export interface AddressSquid { index: number; @@ -73,12 +75,43 @@ export class AccountUtils { } static getDisplayName(account: Partial<Account>) { - return account?.meta?.name || account?.meta?.uid || formatAddress(account?.address) || ''; + if (!account) return ''; + return account.meta?.name || account.meta?.uid || formatAddress(account.address) || ''; } static isEquals(a1: Account, a2: Account) { return a1 === a2 || (a1 && a1.address && a1.address === a2?.address); } + + static mergeAll(accounts: Account[]): Account[] { + if (isEmptyArray(accounts)) return accounts; // Nothing to merge + + const mapByAddress = {}; + return accounts.reduce((res, item) => { + const existingItem = mapByAddress[item.address]; + if (existingItem) { + AccountUtils.merge(existingItem, item); + return res; + } + mapByAddress[item.address] = item; + return res.concat(item); + }, []); + } + + static merge(target: Account, source: Account): Account { + if (target.address !== source.address) throw new Error('Both account should have same address!'); + if (source.meta) { + target.meta = { ...source.meta, ...target.meta }; + } + if (source.data) { + target.data = { ...source.data, ...target.data }; + } + return target; + } + + static combineAccountLoadResults(results: LoadResult<Account>[]): LoadResult<Account> { + return combineLoadResults(results, { reduce: AccountUtils.mergeAll }); + } } export interface UnlockOptions { diff --git a/src/app/account/accounts.service.ts b/src/app/account/accounts.service.ts index 1ac3c20c73ee47178d6717d681fbf884c31bc434..cc840a14fae497fd55b6fd080f964b2a82a3db60 100644 --- a/src/app/account/accounts.service.ts +++ b/src/app/account/accounts.service.ts @@ -28,7 +28,7 @@ import { RxStartableService } from '@app/shared/services/rx-startable-service.cl import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; import { ED25519_SEED_LENGTH, SCRYPT_PARAMS } from '@app/account/crypto.utils'; import { KeyringPair } from '@polkadot/keyring/types'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { AppEvent } from '@app/shared/types'; import { APP_AUTH_CONTROLLER, AuthData, IAuthController } from '@app/account/auth/auth.model'; import { ExtrinsicError, ExtrinsicUtils } from '@app/shared/substrate/extrinsic.utils'; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 306a2bce7f49070a19f1fd42dc4e62294dc1f03e..35c322af862815db97501561e7a99528b58f1a8b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -23,7 +23,8 @@ import { AccountsService } from '@app/account/accounts.service'; import { AppAccountModule } from '@app/account/account.module'; import { AppTransferModule } from '@app/transfer/send/transfer.module'; import { APP_GRAPHQL_TYPE_POLICIES } from '@app/shared/services/network/graphql/graphql.service'; -import { INDEXER_GRAPHQL_TYPE_POLICIES } from '@app/network/indexer.config'; +import { INDEXER_GRAPHQL_TYPE_POLICIES } from '@app/network/indexer/indexer.config'; +import { POD_GRAPHQL_TYPE_POLICIES } from '@app/network/pod/pod.config'; export function createTranslateLoader(http: HttpClient) { if (environment.production) { @@ -69,6 +70,7 @@ export function createTranslateLoader(http: HttpClient) { provide: APP_GRAPHQL_TYPE_POLICIES, useValue: { ...INDEXER_GRAPHQL_TYPE_POLICIES, + ...POD_GRAPHQL_TYPE_POLICIES, }, }, { provide: APP_STORAGE, useExisting: StorageService }, diff --git a/src/app/block/block.model.ts b/src/app/block/block.model.ts index 79987e59672e00034a51f060bd773ce5f3103585..80784b47eeba0aeeee8741df9693a33ded2b9163 100644 --- a/src/app/block/block.model.ts +++ b/src/app/block/block.model.ts @@ -1,6 +1,6 @@ import { Moment } from 'moment/moment'; import { equals, isNil, isNilOrBlank } from '@app/shared/functions'; -import { BlockBoolExp, BlockEdge } from '@app/network/indexer-types.generated'; +import { BlockBoolExp, BlockEdge } from '@app/network/indexer/indexer-types.generated'; import { fromDateISOString } from '@app/shared/dates'; export interface Block { diff --git a/src/app/block/block.page.ts b/src/app/block/block.page.ts index 1ad36fcbe88e5c098a70bd15d62a7ef886a13b92..342d46836eec64687d13f956bf3aa990b6635895 100644 --- a/src/app/block/block.page.ts +++ b/src/app/block/block.page.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { AppPage, AppPageState } from '@app/shared/pages/base-page.class'; import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; import { Promise } from '@rx-angular/cdk/zone-less/browser'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { Block } from '@app/block/block.model'; import { firstValueFrom, Observable } from 'rxjs'; import { isNotNil, isNotNilOrBlank } from '@app/shared/functions'; diff --git a/src/app/certification/history/cert-history.model.ts b/src/app/certification/history/cert-history.model.ts index 06969c90a2e761b7b8c148974f25e9bb96fb78e8..df12f4ccb2a312c05106538989d41fb12a3c190b 100644 --- a/src/app/certification/history/cert-history.model.ts +++ b/src/app/certification/history/cert-history.model.ts @@ -1,6 +1,6 @@ import { equals, isNilOrBlank } from '@app/shared/functions'; import { Account } from '@app/account/account.model'; -import { CertConnection, CertFragment } from '@app/network/indexer-types.generated'; +import { CertConnection, CertFragment } from '@app/network/indexer/indexer-types.generated'; import { IdentityConverter } from '@app/account/account.converter'; export interface Certification { diff --git a/src/app/certification/history/cert-history.page.ts b/src/app/certification/history/cert-history.page.ts index d2f931b1d4f572890d896f11c1636c950f89b4b8..7e6fea820b723342b1a01abb44ba5c7925f061d4 100644 --- a/src/app/certification/history/cert-history.page.ts +++ b/src/app/certification/history/cert-history.page.ts @@ -11,7 +11,7 @@ import { AccountsService } from '@app/account/accounts.service'; import { firstValueFrom, merge, Observable } from 'rxjs'; import { RxState } from '@rx-angular/state'; import { APP_TRANSFER_CONTROLLER, ITransferController } from '@app/transfer/transfer.model'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { FetchMoreFn, LoadResult } from '@app/shared/services/service.model'; import { Certification, CertificationSearchFilter, CertificationSearchFilterUtils } from './cert-history.model'; import { ListItems } from '@app/shared/types'; diff --git a/src/app/account/account.queries.graphql b/src/app/network/indexer/indexer-account.gql similarity index 100% rename from src/app/account/account.queries.graphql rename to src/app/network/indexer/indexer-account.gql diff --git a/src/app/block/block.queries.graphql b/src/app/network/indexer/indexer-block.gql similarity index 100% rename from src/app/block/block.queries.graphql rename to src/app/network/indexer/indexer-block.gql diff --git a/src/app/certification/history/cert-history.queries.graphql b/src/app/network/indexer/indexer-certification.gql similarity index 100% rename from src/app/certification/history/cert-history.queries.graphql rename to src/app/network/indexer/indexer-certification.gql diff --git a/src/app/network/indexer-helpers.generated.ts b/src/app/network/indexer/indexer-helpers.generated.ts similarity index 100% rename from src/app/network/indexer-helpers.generated.ts rename to src/app/network/indexer/indexer-helpers.generated.ts diff --git a/src/app/network/indexer-schema.graphql b/src/app/network/indexer/indexer-schema.graphql similarity index 100% rename from src/app/network/indexer-schema.graphql rename to src/app/network/indexer/indexer-schema.graphql diff --git a/src/app/transfer/history/transfer.queries.graphql b/src/app/network/indexer/indexer-transfer.gql similarity index 100% rename from src/app/transfer/history/transfer.queries.graphql rename to src/app/network/indexer/indexer-transfer.gql diff --git a/src/app/network/indexer-types.generated.ts b/src/app/network/indexer/indexer-types.generated.ts similarity index 100% rename from src/app/network/indexer-types.generated.ts rename to src/app/network/indexer/indexer-types.generated.ts diff --git a/src/app/wot/wot.queries.graphql b/src/app/network/indexer/indexer-wot.gql similarity index 100% rename from src/app/wot/wot.queries.graphql rename to src/app/network/indexer/indexer-wot.gql diff --git a/src/app/network/indexer.config.ts b/src/app/network/indexer/indexer.config.ts similarity index 100% rename from src/app/network/indexer.config.ts rename to src/app/network/indexer/indexer.config.ts diff --git a/src/app/network/indexer.service.ts b/src/app/network/indexer/indexer.service.ts similarity index 86% rename from src/app/network/indexer.service.ts rename to src/app/network/indexer/indexer.service.ts index bf842e819d21724af87b2c65d4adc14df9e9fca2..92f6ac997c4b6aefd0bb9aa944871b95b8743469 100644 --- a/src/app/network/indexer.service.ts +++ b/src/app/network/indexer/indexer.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, Optional } from '@angular/core'; -import { Peer, Peers } from '@app/shared/services/network/peer.model'; +import { Peers } from '@app/shared/services/network/peer.model'; import { Promise } from '@rx-angular/cdk/zone-less/browser'; import { SettingsService } from '@app/settings/settings.service'; import { arrayRandomPick, firstArrayValue, isNil, isNotNil, isNotNilOrBlank, toBoolean, toNumber } from '@app/shared/functions'; @@ -56,7 +56,7 @@ export class IndexerService extends GraphqlService<IndexerState> { constructor( storage: StorageService, private settings: SettingsService, - private indexerGraphqlService: IndexerGraphqlService, + private graphqlService: IndexerGraphqlService, @Optional() @Inject(APP_GRAPHQL_TYPE_POLICIES) typePolicies: TypePolicies, @Optional() @Inject(APP_GRAPHQL_FRAGMENTS) fragments: DocumentNode[] ) { @@ -95,7 +95,7 @@ export class IndexerService extends GraphqlService<IndexerState> { let data$: Observable<LightAccountConnectionFragment>; if (isNotNilOrBlank(filter.address)) { - data$ = this.indexerGraphqlService + data$ = this.graphqlService .wotSearchByAddress( { address: filter.address, @@ -109,7 +109,7 @@ export class IndexerService extends GraphqlService<IndexerState> { ) .pipe(map(({ data }) => data.accountConnection as LightAccountConnectionFragment)); } else if (isNotNilOrBlank(filter.searchText)) { - data$ = this.indexerGraphqlService + data$ = this.graphqlService .wotSearchByText( { searchText: `%${filter.searchText}%`, @@ -123,8 +123,8 @@ export class IndexerService extends GraphqlService<IndexerState> { ) .pipe(map(({ data }) => data.accountConnection as LightAccountConnectionFragment)); } else { - data$ = this.indexerGraphqlService - .wotSearchLastWatch( + data$ = this.graphqlService + .wotSearchLast( { after: options.after, first: options.first, @@ -135,12 +135,12 @@ export class IndexerService extends GraphqlService<IndexerState> { fetchPolicy: options.fetchPolicy || 'cache-first', } ) - .valueChanges.pipe(map(({ data }) => data.accountConnection as LightAccountConnectionFragment)); + .pipe(map(({ data }) => data.accountConnection as LightAccountConnectionFragment)); } return data$.pipe( map((connection: LightAccountConnectionFragment) => { - const data = AccountConverter.connectionToAccounts(connection); + const data = AccountConverter.squidConnectionToAccounts(connection); const result: LoadResult<Account> = { data }; if (connection.pageInfo.hasNextPage) { const endCursor = connection.pageInfo.endCursor; @@ -166,7 +166,7 @@ export class IndexerService extends GraphqlService<IndexerState> { }; if (filter?.address) { - return this.indexerGraphqlService + return this.graphqlService .transferConnectionByAddress( { address: filter.address, @@ -224,14 +224,14 @@ export class IndexerService extends GraphqlService<IndexerState> { return result; }; if (isNotNilOrBlank(filter.issuer)) { - return this.indexerGraphqlService.certsConnectionByIssuer(variables, fetchOptions).pipe( + return this.graphqlService.certsConnectionByIssuer(variables, fetchOptions).pipe( map(({ data }) => { const res = data.identityConnection.edges[0]?.node; return toEntities(res?.connection as CertConnection, res?.aggregate.aggregate.count || 0); }) ); } else { - return this.indexerGraphqlService.certsConnectionByReceiver(variables, fetchOptions).pipe( + return this.graphqlService.certsConnectionByReceiver(variables, fetchOptions).pipe( map(({ data }) => { const res = data.identityConnection.edges[0]?.node; return toEntities(res?.connection as CertConnection, res?.aggregate.aggregate.count || 0); @@ -255,7 +255,7 @@ export class IndexerService extends GraphqlService<IndexerState> { } if (isNotNilOrBlank(filter?.height)) { - return this.indexerGraphqlService + return this.graphqlService .blocks({ ...options, after: null, @@ -265,7 +265,7 @@ export class IndexerService extends GraphqlService<IndexerState> { .pipe(map(({ data: { blockConnection } }) => BlockConverter.toBlocks(blockConnection.edges as BlockEdge[], true))); } - return this.indexerGraphqlService + return this.graphqlService .blocks({ ...options, where: filter?.where, @@ -275,7 +275,7 @@ export class IndexerService extends GraphqlService<IndexerState> { blockById(id: string): Observable<Block> { console.info(`${this._logPrefix}Loading block #${id}`); - return this.indexerGraphqlService + return this.graphqlService .blockById({ id }) .pipe(map(({ data: { blockConnection } }) => BlockConverter.toBlock(blockConnection.edges[0] as BlockEdge))); } @@ -308,40 +308,4 @@ export class IndexerService extends GraphqlService<IndexerState> { minBlockHeight: currency?.minBlockHeight, }; } - - protected async ngOnStop(): Promise<void> { - super.ngOnStop(); - } - - protected async filterAlivePeers( - peers: string[], - opts?: { - timeout?: number; - } - ): Promise<Peer[]> { - const result: Peer[] = []; - await Promise.all( - peers - .map((peer) => Peers.fromUri(peer)) - .map((peer) => - this.isPeerAlive(peer, opts).then((alive) => { - if (!alive) return; - result.push(peer); - }) - ) - ); - return result; - } - - protected async isPeerAlive( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - peer: Peer, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - opts?: { - timeout?: number; - } - ): Promise<boolean> { - // TODO - return Promise.resolve(true); - } } diff --git a/src/app/network/ipfs/ipfs.service.ts b/src/app/network/ipfs/ipfs.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbcfb3b1993f75fec4a0683bf6df51d6c1515cb1 --- /dev/null +++ b/src/app/network/ipfs/ipfs.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import { Peer, Peers } from '@app/shared/services/network/peer.model'; +import { Promise } from '@rx-angular/cdk/zone-less/browser'; +import { SettingsService } from '@app/settings/settings.service'; +import { arrayRandomPick, isNotNil } from '@app/shared/functions'; +import { StorageService } from '@app/shared/services/storage/storage.service'; +import { Observable } from 'rxjs'; +import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; +import { RxStartableService } from '@app/shared/services/rx-startable-service.class'; + +export interface IpfsState { + peer: Peer; + gatewayBaseUrl: string; + offline: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class IpfsService extends RxStartableService<IpfsState> { + @RxStateSelect() peer$: Observable<Peer>; + @RxStateProperty() peer: Peer; + @RxStateProperty() gatewayBaseUrl: string; + + constructor( + storage: StorageService, + private settings: SettingsService + ) { + super(storage, { + name: 'ipfs-service', + startByReadyFunction: false, // Need an explicit call to start() + }); + } + + getGatewayUrl(cid: string): string { + return this.gatewayBaseUrl + cid; + } + + /* -- protected functions -- */ + + protected async ngOnStart(): Promise<IpfsState> { + // Wait settings and storage + const settings = await this.settings.ready(); + + let peer = Peers.fromUri(settings.ipfsGateway); + if (!peer) { + const peers = await this.filterAlivePeers(settings.preferredIpfsGateways || []); + if (!peers.length) { + throw { message: 'ERROR.CHECK_NETWORK_CONNECTION' }; + } + peer = arrayRandomPick(peers); + } + + const gatewayBaseUrl = Peers.getHttpUri(peer) + '/ipfs/'; + + return { + peer, + gatewayBaseUrl, + offline: false, + }; + } + + protected async filterAlivePeers( + peers: string[], + opts?: { + timeout?: number; + } + ): Promise<Peer[]> { + return ( + await Promise.all( + peers.map((peer) => Peers.fromUri(peer)).map((peer) => this.isPeerAlive(peer, opts).then((alive) => (alive ? peer : undefined))) + ) + ).filter(isNotNil); + } + + protected async isPeerAlive( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + peer: Peer, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + opts?: { + timeout?: number; + } + ): Promise<boolean> { + // TODO + console.log(`${this._logPrefix}TODO: implement ${this.constructor.name}.isPeerAlive()`, peer); + return Promise.resolve(true); + } +} diff --git a/src/app/network/network.service.ts b/src/app/network/network.service.ts index 1b026570f9fe2b6869a6c117e8de859094795f29..dc844e40f6b7974397b9e8d8921aa7a103614c4d 100644 --- a/src/app/network/network.service.ts +++ b/src/app/network/network.service.ts @@ -8,10 +8,11 @@ import { RxStartableService } from '@app/shared/services/rx-startable-service.cl import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; import { mergeMap, Observable, tap } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { arrayRandomPick, isNotNilOrBlank, toNumber } from '@app/shared/functions'; -import { IndexerService } from './indexer.service'; +import { arrayRandomPick, isNotNil, isNotNilOrBlank, toNumber } from '@app/shared/functions'; +import { IndexerService } from './indexer/indexer.service'; import { fromDateISOString } from '@app/shared/dates'; import { ContextService } from '@app/shared/services/storage/context.service'; +import { PodService } from '@app/network/pod/pod.service'; export interface NetworkState { peer: Peer; @@ -24,6 +25,7 @@ export interface NetworkState { @Injectable({ providedIn: 'root' }) export class NetworkService extends RxStartableService<NetworkState> { indexer = inject(IndexerService); + pod = inject(PodService); @RxStateProperty() peer: Peer; @RxStateProperty() currency: Currency; @@ -147,8 +149,10 @@ export class NetworkService extends RxStartableService<NetworkState> { const ud0 = toNumber(api.consts.universalDividend.unitsPerUd) / currency.powBase; + // Configure and start indexer and pod this.indexer.currency = currency; - await this.indexer.start(); + this.pod.currency = currency; + await Promise.all([this.indexer.start(), this.pod.start()]); return { api, @@ -172,18 +176,11 @@ export class NetworkService extends RxStartableService<NetworkState> { timeout?: number; } ): Promise<Peer[]> { - const result: Peer[] = []; - await Promise.all( - peers - .map((peer) => Peers.fromUri(peer)) - .map((peer) => - this.isPeerAlive(peer).then((alive) => { - if (!alive) return; - result.push(peer); - }) - ) - ); - return result; + return ( + await Promise.all( + peers.map((peer) => Peers.fromUri(peer)).map((peer) => this.isPeerAlive(peer, opts).then((alive) => (alive ? peer : undefined))) + ) + ).filter(isNotNil); } protected async isPeerAlive( diff --git a/src/app/network/pod/pod-helpers.generated.ts b/src/app/network/pod/pod-helpers.generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..04cd4a4992a0f5e23cf7ef3c80b0a326427e53a5 --- /dev/null +++ b/src/app/network/pod/pod-helpers.generated.ts @@ -0,0 +1,140 @@ +// Auto-generated via `npx graphql-codegen`, do not edit +/* eslint-disable */ +import { FieldPolicy, FieldReadFunction, TypePolicies, TypePolicy } from '@apollo/client/cache'; +export type AddTransactionResponseKeySpecifier = ('message' | 'success' | AddTransactionResponseKeySpecifier)[]; +export type AddTransactionResponseFieldPolicy = { + message?: FieldPolicy<any> | FieldReadFunction<any>, + success?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type DeleteProfileResponseKeySpecifier = ('message' | 'success' | DeleteProfileResponseKeySpecifier)[]; +export type DeleteProfileResponseFieldPolicy = { + message?: FieldPolicy<any> | FieldReadFunction<any>, + success?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type MigrateProfileResponseKeySpecifier = ('message' | 'success' | MigrateProfileResponseKeySpecifier)[]; +export type MigrateProfileResponseFieldPolicy = { + message?: FieldPolicy<any> | FieldReadFunction<any>, + success?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type UpdateProfileResponseKeySpecifier = ('message' | 'success' | UpdateProfileResponseKeySpecifier)[]; +export type UpdateProfileResponseFieldPolicy = { + message?: FieldPolicy<any> | FieldReadFunction<any>, + success?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type mutation_rootKeySpecifier = ('addTransaction' | 'deleteProfile' | 'migrateProfile' | 'updateProfile' | mutation_rootKeySpecifier)[]; +export type mutation_rootFieldPolicy = { + addTransaction?: FieldPolicy<any> | FieldReadFunction<any>, + deleteProfile?: FieldPolicy<any> | FieldReadFunction<any>, + migrateProfile?: FieldPolicy<any> | FieldReadFunction<any>, + updateProfile?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type profilesKeySpecifier = ('avatar' | 'city' | 'data_cid' | 'description' | 'geoloc' | 'index_request_cid' | 'pubkey' | 'socials' | 'time' | 'title' | profilesKeySpecifier)[]; +export type profilesFieldPolicy = { + avatar?: FieldPolicy<any> | FieldReadFunction<any>, + city?: FieldPolicy<any> | FieldReadFunction<any>, + data_cid?: FieldPolicy<any> | FieldReadFunction<any>, + description?: FieldPolicy<any> | FieldReadFunction<any>, + geoloc?: FieldPolicy<any> | FieldReadFunction<any>, + index_request_cid?: FieldPolicy<any> | FieldReadFunction<any>, + pubkey?: FieldPolicy<any> | FieldReadFunction<any>, + socials?: FieldPolicy<any> | FieldReadFunction<any>, + time?: FieldPolicy<any> | FieldReadFunction<any>, + title?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type profiles_aggregateKeySpecifier = ('aggregate' | 'nodes' | profiles_aggregateKeySpecifier)[]; +export type profiles_aggregateFieldPolicy = { + aggregate?: FieldPolicy<any> | FieldReadFunction<any>, + nodes?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type profiles_aggregate_fieldsKeySpecifier = ('count' | 'max' | 'min' | profiles_aggregate_fieldsKeySpecifier)[]; +export type profiles_aggregate_fieldsFieldPolicy = { + count?: FieldPolicy<any> | FieldReadFunction<any>, + max?: FieldPolicy<any> | FieldReadFunction<any>, + min?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type profiles_max_fieldsKeySpecifier = ('avatar' | 'city' | 'data_cid' | 'description' | 'index_request_cid' | 'pubkey' | 'time' | 'title' | profiles_max_fieldsKeySpecifier)[]; +export type profiles_max_fieldsFieldPolicy = { + avatar?: FieldPolicy<any> | FieldReadFunction<any>, + city?: FieldPolicy<any> | FieldReadFunction<any>, + data_cid?: FieldPolicy<any> | FieldReadFunction<any>, + description?: FieldPolicy<any> | FieldReadFunction<any>, + index_request_cid?: FieldPolicy<any> | FieldReadFunction<any>, + pubkey?: FieldPolicy<any> | FieldReadFunction<any>, + time?: FieldPolicy<any> | FieldReadFunction<any>, + title?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type profiles_min_fieldsKeySpecifier = ('avatar' | 'city' | 'data_cid' | 'description' | 'index_request_cid' | 'pubkey' | 'time' | 'title' | profiles_min_fieldsKeySpecifier)[]; +export type profiles_min_fieldsFieldPolicy = { + avatar?: FieldPolicy<any> | FieldReadFunction<any>, + city?: FieldPolicy<any> | FieldReadFunction<any>, + data_cid?: FieldPolicy<any> | FieldReadFunction<any>, + description?: FieldPolicy<any> | FieldReadFunction<any>, + index_request_cid?: FieldPolicy<any> | FieldReadFunction<any>, + pubkey?: FieldPolicy<any> | FieldReadFunction<any>, + time?: FieldPolicy<any> | FieldReadFunction<any>, + title?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type query_rootKeySpecifier = ('profiles' | 'profiles_aggregate' | 'profiles_by_pk' | query_rootKeySpecifier)[]; +export type query_rootFieldPolicy = { + profiles?: FieldPolicy<any> | FieldReadFunction<any>, + profiles_aggregate?: FieldPolicy<any> | FieldReadFunction<any>, + profiles_by_pk?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type subscription_rootKeySpecifier = ('profiles' | 'profiles_aggregate' | 'profiles_by_pk' | 'profiles_stream' | subscription_rootKeySpecifier)[]; +export type subscription_rootFieldPolicy = { + profiles?: FieldPolicy<any> | FieldReadFunction<any>, + profiles_aggregate?: FieldPolicy<any> | FieldReadFunction<any>, + profiles_by_pk?: FieldPolicy<any> | FieldReadFunction<any>, + profiles_stream?: FieldPolicy<any> | FieldReadFunction<any> +}; +export type StrictTypedTypePolicies = { + AddTransactionResponse?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | AddTransactionResponseKeySpecifier | (() => undefined | AddTransactionResponseKeySpecifier), + fields?: AddTransactionResponseFieldPolicy, + }, + DeleteProfileResponse?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | DeleteProfileResponseKeySpecifier | (() => undefined | DeleteProfileResponseKeySpecifier), + fields?: DeleteProfileResponseFieldPolicy, + }, + MigrateProfileResponse?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | MigrateProfileResponseKeySpecifier | (() => undefined | MigrateProfileResponseKeySpecifier), + fields?: MigrateProfileResponseFieldPolicy, + }, + UpdateProfileResponse?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | UpdateProfileResponseKeySpecifier | (() => undefined | UpdateProfileResponseKeySpecifier), + fields?: UpdateProfileResponseFieldPolicy, + }, + mutation_root?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | mutation_rootKeySpecifier | (() => undefined | mutation_rootKeySpecifier), + fields?: mutation_rootFieldPolicy, + }, + profiles?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | profilesKeySpecifier | (() => undefined | profilesKeySpecifier), + fields?: profilesFieldPolicy, + }, + profiles_aggregate?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | profiles_aggregateKeySpecifier | (() => undefined | profiles_aggregateKeySpecifier), + fields?: profiles_aggregateFieldPolicy, + }, + profiles_aggregate_fields?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | profiles_aggregate_fieldsKeySpecifier | (() => undefined | profiles_aggregate_fieldsKeySpecifier), + fields?: profiles_aggregate_fieldsFieldPolicy, + }, + profiles_max_fields?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | profiles_max_fieldsKeySpecifier | (() => undefined | profiles_max_fieldsKeySpecifier), + fields?: profiles_max_fieldsFieldPolicy, + }, + profiles_min_fields?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | profiles_min_fieldsKeySpecifier | (() => undefined | profiles_min_fieldsKeySpecifier), + fields?: profiles_min_fieldsFieldPolicy, + }, + query_root?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | query_rootKeySpecifier | (() => undefined | query_rootKeySpecifier), + fields?: query_rootFieldPolicy, + }, + subscription_root?: Omit<TypePolicy, "fields" | "keyFields"> & { + keyFields?: false | subscription_rootKeySpecifier | (() => undefined | subscription_rootKeySpecifier), + fields?: subscription_rootFieldPolicy, + } +}; +export type TypedTypePolicies = StrictTypedTypePolicies & TypePolicies; \ No newline at end of file diff --git a/src/app/network/pod/pod-profile.gql b/src/app/network/pod/pod-profile.gql new file mode 100644 index 0000000000000000000000000000000000000000..8fa6369250d854880f4d7c8b86729ae126ab5062 --- /dev/null +++ b/src/app/network/pod/pod-profile.gql @@ -0,0 +1,52 @@ +fragment LightProfile on profiles { + id: data_cid + __typename + address: pubkey + title + avatar_cid: avatar + time +} + +fragment Profile on profiles { + ...LightProfile + description + city + geoloc + socials + index_request_cid +} + +query ProfileSearchByText( + $searchText: String!, + $limit: Int!, + $offset: Int!, + $orderBy: [profiles_order_by!], + $withTotal: Boolean! +) { + profiles( + offset: $offset, + limit: $limit, + where: { title: { _ilike: $searchText } }, + order_by: $orderBy + ) { + ...LightProfile + } + + profiles_aggregate(where: { title: { _ilike: $searchText } }) @include (if: $withTotal) { + aggregate { + count + } + } +} + +query ProfileByAddress($address: String!) { + profiles_by_pk(pubkey: $address) { + ...Profile + } +} + +query ProfileSearchByAddresses($addresses: [String!]!) { + profiles(where: {pubkey: { _in: $addresses}}) { + ...LightProfile + } +} diff --git a/src/app/network/pod/pod-schema.graphql b/src/app/network/pod/pod-schema.graphql new file mode 100644 index 0000000000000000000000000000000000000000..8273276d3ca437130e48ed19b0e410b3594f5e28 --- /dev/null +++ b/src/app/network/pod/pod-schema.graphql @@ -0,0 +1,406 @@ +# This file was generated. Do not edit manually. + +schema { + query: query_root + mutation: mutation_root + subscription: subscription_root +} + +"whether this query should be cached (Hasura Cloud only)" +directive @cached( + "refresh the cache entry" + refresh: Boolean! = false, + "measured in seconds" + ttl: Int! = 60 +) on QUERY + +type AddTransactionResponse { + message: String! + success: Boolean! +} + +type DeleteProfileResponse { + message: String! + success: Boolean! +} + +type MigrateProfileResponse { + message: String! + success: Boolean! +} + +type UpdateProfileResponse { + message: String! + success: Boolean! +} + +"mutation root" +type mutation_root { + "addTransaction" + addTransaction(address: String!, comment: String!, hash: String!, id: String!, signature: String!): AddTransactionResponse + "deleteProfile" + deleteProfile(address: String!, hash: String!, signature: String!): DeleteProfileResponse + "migrateProfile" + migrateProfile(addressNew: String!, addressOld: String!, hash: String!, signature: String!): MigrateProfileResponse + "updateProfile" + updateProfile(address: String!, avatarBase64: String, city: String, description: String, geoloc: GeolocInput, hash: String!, signature: String!, socials: [SocialInput!], title: String): UpdateProfileResponse +} + +"columns and relationships of \"profiles\"" +type profiles { + "cid of avatar" + avatar: String + city: String + "CID of the latest data from which this document comes from" + data_cid: String + description: String + geoloc: point + "CID of the latest index request that modified this document" + index_request_cid: String! + "base58 pubkey of profile owner" + pubkey: String! + socials( + "JSON select path" + path: String + ): jsonb + "timestamp of the latest index request that modified this document" + time: timestamptz! + "title of c+ profile" + title: String +} + +"aggregated selection of \"profiles\"" +type profiles_aggregate { + aggregate: profiles_aggregate_fields + nodes: [profiles!]! +} + +"aggregate fields of \"profiles\"" +type profiles_aggregate_fields { + count(columns: [profiles_select_column!], distinct: Boolean): Int! + max: profiles_max_fields + min: profiles_min_fields +} + +"aggregate max on columns" +type profiles_max_fields { + "cid of avatar" + avatar: String + city: String + "CID of the latest data from which this document comes from" + data_cid: String + description: String + "CID of the latest index request that modified this document" + index_request_cid: String + "base58 pubkey of profile owner" + pubkey: String + "timestamp of the latest index request that modified this document" + time: timestamptz + "title of c+ profile" + title: String +} + +"aggregate min on columns" +type profiles_min_fields { + "cid of avatar" + avatar: String + city: String + "CID of the latest data from which this document comes from" + data_cid: String + description: String + "CID of the latest index request that modified this document" + index_request_cid: String + "base58 pubkey of profile owner" + pubkey: String + "timestamp of the latest index request that modified this document" + time: timestamptz + "title of c+ profile" + title: String +} + +type query_root { + "fetch data from the table: \"profiles\"" + profiles( + "distinct select on columns" + distinct_on: [profiles_select_column!], + "limit the number of rows returned" + limit: Int, + "skip the first n rows. Use only with order_by" + offset: Int, + "sort the rows by one or more columns" + order_by: [profiles_order_by!], + "filter the rows returned" + where: profiles_bool_exp + ): [profiles!]! + "fetch aggregated fields from the table: \"profiles\"" + profiles_aggregate( + "distinct select on columns" + distinct_on: [profiles_select_column!], + "limit the number of rows returned" + limit: Int, + "skip the first n rows. Use only with order_by" + offset: Int, + "sort the rows by one or more columns" + order_by: [profiles_order_by!], + "filter the rows returned" + where: profiles_bool_exp + ): profiles_aggregate! + "fetch data from the table: \"profiles\" using primary key columns" + profiles_by_pk( + "base58 pubkey of profile owner" + pubkey: String! + ): profiles +} + +type subscription_root { + "fetch data from the table: \"profiles\"" + profiles( + "distinct select on columns" + distinct_on: [profiles_select_column!], + "limit the number of rows returned" + limit: Int, + "skip the first n rows. Use only with order_by" + offset: Int, + "sort the rows by one or more columns" + order_by: [profiles_order_by!], + "filter the rows returned" + where: profiles_bool_exp + ): [profiles!]! + "fetch aggregated fields from the table: \"profiles\"" + profiles_aggregate( + "distinct select on columns" + distinct_on: [profiles_select_column!], + "limit the number of rows returned" + limit: Int, + "skip the first n rows. Use only with order_by" + offset: Int, + "sort the rows by one or more columns" + order_by: [profiles_order_by!], + "filter the rows returned" + where: profiles_bool_exp + ): profiles_aggregate! + "fetch data from the table: \"profiles\" using primary key columns" + profiles_by_pk( + "base58 pubkey of profile owner" + pubkey: String! + ): profiles + "fetch data from the table in a streaming manner: \"profiles\"" + profiles_stream( + "maximum number of rows returned in a single batch" + batch_size: Int!, + "cursor to stream the results returned by the query" + cursor: [profiles_stream_cursor_input]!, + "filter the rows returned" + where: profiles_bool_exp + ): [profiles!]! +} + +"ordering argument of a cursor" +enum cursor_ordering { + "ascending ordering of the cursor" + ASC + "descending ordering of the cursor" + DESC +} + +"column ordering options" +enum order_by { + "in ascending order, nulls last" + asc + "in ascending order, nulls first" + asc_nulls_first + "in ascending order, nulls last" + asc_nulls_last + "in descending order, nulls first" + desc + "in descending order, nulls first" + desc_nulls_first + "in descending order, nulls last" + desc_nulls_last +} + +"select columns of table \"profiles\"" +enum profiles_select_column { + "column name" + avatar + "column name" + city + "column name" + data_cid + "column name" + description + "column name" + geoloc + "column name" + index_request_cid + "column name" + pubkey + "column name" + socials + "column name" + time + "column name" + title +} + +scalar jsonb + +scalar point + +scalar timestamptz + +input GeolocInput { + latitude: Float! + longitude: Float! +} + +input SocialInput { + type: String + url: String! +} + +"Boolean expression to compare columns of type \"String\". All fields are combined with logical 'AND'." +input String_comparison_exp { + _eq: String + _gt: String + _gte: String + "does the column match the given case-insensitive pattern" + _ilike: String + _in: [String!] + "does the column match the given POSIX regular expression, case insensitive" + _iregex: String + _is_null: Boolean + "does the column match the given pattern" + _like: String + _lt: String + _lte: String + _neq: String + "does the column NOT match the given case-insensitive pattern" + _nilike: String + _nin: [String!] + "does the column NOT match the given POSIX regular expression, case insensitive" + _niregex: String + "does the column NOT match the given pattern" + _nlike: String + "does the column NOT match the given POSIX regular expression, case sensitive" + _nregex: String + "does the column NOT match the given SQL regular expression" + _nsimilar: String + "does the column match the given POSIX regular expression, case sensitive" + _regex: String + "does the column match the given SQL regular expression" + _similar: String +} + +input jsonb_cast_exp { + String: String_comparison_exp +} + +"Boolean expression to compare columns of type \"jsonb\". All fields are combined with logical 'AND'." +input jsonb_comparison_exp { + _cast: jsonb_cast_exp + "is the column contained in the given json value" + _contained_in: jsonb + "does the column contain the given json value at the top level" + _contains: jsonb + _eq: jsonb + _gt: jsonb + _gte: jsonb + "does the string exist as a top-level key in the column" + _has_key: String + "do all of these strings exist as top-level keys in the column" + _has_keys_all: [String!] + "do any of these strings exist as top-level keys in the column" + _has_keys_any: [String!] + _in: [jsonb!] + _is_null: Boolean + _lt: jsonb + _lte: jsonb + _neq: jsonb + _nin: [jsonb!] +} + +"Boolean expression to compare columns of type \"point\". All fields are combined with logical 'AND'." +input point_comparison_exp { + _eq: point + _gt: point + _gte: point + _in: [point!] + _is_null: Boolean + _lt: point + _lte: point + _neq: point + _nin: [point!] +} + +"Boolean expression to filter rows from the table \"profiles\". All fields are combined with a logical 'AND'." +input profiles_bool_exp { + _and: [profiles_bool_exp!] + _not: profiles_bool_exp + _or: [profiles_bool_exp!] + avatar: String_comparison_exp + city: String_comparison_exp + data_cid: String_comparison_exp + description: String_comparison_exp + geoloc: point_comparison_exp + index_request_cid: String_comparison_exp + pubkey: String_comparison_exp + socials: jsonb_comparison_exp + time: timestamptz_comparison_exp + title: String_comparison_exp +} + +"Ordering options when selecting data from \"profiles\"." +input profiles_order_by { + avatar: order_by + city: order_by + data_cid: order_by + description: order_by + geoloc: order_by + index_request_cid: order_by + pubkey: order_by + socials: order_by + time: order_by + title: order_by +} + +"Streaming cursor of the table \"profiles\"" +input profiles_stream_cursor_input { + "Stream column input with initial value" + initial_value: profiles_stream_cursor_value_input! + "cursor ordering" + ordering: cursor_ordering +} + +"Initial value of the column from where the streaming should start" +input profiles_stream_cursor_value_input { + "cid of avatar" + avatar: String + city: String + "CID of the latest data from which this document comes from" + data_cid: String + description: String + geoloc: point + "CID of the latest index request that modified this document" + index_request_cid: String + "base58 pubkey of profile owner" + pubkey: String + socials: jsonb + "timestamp of the latest index request that modified this document" + time: timestamptz + "title of c+ profile" + title: String +} + +"Boolean expression to compare columns of type \"timestamptz\". All fields are combined with logical 'AND'." +input timestamptz_comparison_exp { + _eq: timestamptz + _gt: timestamptz + _gte: timestamptz + _in: [timestamptz!] + _is_null: Boolean + _lt: timestamptz + _lte: timestamptz + _neq: timestamptz + _nin: [timestamptz!] +} diff --git a/src/app/network/pod/pod-types.generated.ts b/src/app/network/pod/pod-types.generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..67cf3a2c058f1b4bf4fae30d895815712b8b6ec9 --- /dev/null +++ b/src/app/network/pod/pod-types.generated.ts @@ -0,0 +1,734 @@ +// Auto-generated via `npx graphql-codegen`, do not edit +/* eslint-disable */ +import { gql } from 'apollo-angular'; +import { Injectable } from '@angular/core'; +import * as Apollo from 'apollo-angular'; +import * as ApolloCore from '@apollo/client/core'; +export type Maybe<T> = T | null; +export type InputMaybe<T> = Maybe<T>; +export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; +export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }; +export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }; +export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never }; +export type Incremental<T> = 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; } + jsonb: { input: any; output: any; } + point: { input: any; output: any; } + timestamptz: { input: any; output: any; } +}; + +export type AddTransactionResponse = { + __typename?: 'AddTransactionResponse'; + message: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type DeleteProfileResponse = { + __typename?: 'DeleteProfileResponse'; + message: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type GeolocInput = { + latitude: Scalars['Float']['input']; + longitude: Scalars['Float']['input']; +}; + +export type MigrateProfileResponse = { + __typename?: 'MigrateProfileResponse'; + message: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type SocialInput = { + type?: InputMaybe<Scalars['String']['input']>; + url: Scalars['String']['input']; +}; + +/** Boolean expression to compare columns of type "String". All fields are combined with logical 'AND'. */ +export type String_Comparison_Exp = { + _eq?: InputMaybe<Scalars['String']['input']>; + _gt?: InputMaybe<Scalars['String']['input']>; + _gte?: InputMaybe<Scalars['String']['input']>; + /** does the column match the given case-insensitive pattern */ + _ilike?: InputMaybe<Scalars['String']['input']>; + _in?: InputMaybe<Array<Scalars['String']['input']>>; + /** does the column match the given POSIX regular expression, case insensitive */ + _iregex?: InputMaybe<Scalars['String']['input']>; + _is_null?: InputMaybe<Scalars['Boolean']['input']>; + /** does the column match the given pattern */ + _like?: InputMaybe<Scalars['String']['input']>; + _lt?: InputMaybe<Scalars['String']['input']>; + _lte?: InputMaybe<Scalars['String']['input']>; + _neq?: InputMaybe<Scalars['String']['input']>; + /** does the column NOT match the given case-insensitive pattern */ + _nilike?: InputMaybe<Scalars['String']['input']>; + _nin?: InputMaybe<Array<Scalars['String']['input']>>; + /** does the column NOT match the given POSIX regular expression, case insensitive */ + _niregex?: InputMaybe<Scalars['String']['input']>; + /** does the column NOT match the given pattern */ + _nlike?: InputMaybe<Scalars['String']['input']>; + /** does the column NOT match the given POSIX regular expression, case sensitive */ + _nregex?: InputMaybe<Scalars['String']['input']>; + /** does the column NOT match the given SQL regular expression */ + _nsimilar?: InputMaybe<Scalars['String']['input']>; + /** does the column match the given POSIX regular expression, case sensitive */ + _regex?: InputMaybe<Scalars['String']['input']>; + /** does the column match the given SQL regular expression */ + _similar?: InputMaybe<Scalars['String']['input']>; +}; + +export type UpdateProfileResponse = { + __typename?: 'UpdateProfileResponse'; + message: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +/** ordering argument of a cursor */ +export enum Cursor_Ordering { + /** ascending ordering of the cursor */ + Asc = 'ASC', + /** descending ordering of the cursor */ + Desc = 'DESC' +} + +export type Jsonb_Cast_Exp = { + String?: InputMaybe<String_Comparison_Exp>; +}; + +/** Boolean expression to compare columns of type "jsonb". All fields are combined with logical 'AND'. */ +export type Jsonb_Comparison_Exp = { + _cast?: InputMaybe<Jsonb_Cast_Exp>; + /** is the column contained in the given json value */ + _contained_in?: InputMaybe<Scalars['jsonb']['input']>; + /** does the column contain the given json value at the top level */ + _contains?: InputMaybe<Scalars['jsonb']['input']>; + _eq?: InputMaybe<Scalars['jsonb']['input']>; + _gt?: InputMaybe<Scalars['jsonb']['input']>; + _gte?: InputMaybe<Scalars['jsonb']['input']>; + /** does the string exist as a top-level key in the column */ + _has_key?: InputMaybe<Scalars['String']['input']>; + /** do all of these strings exist as top-level keys in the column */ + _has_keys_all?: InputMaybe<Array<Scalars['String']['input']>>; + /** do any of these strings exist as top-level keys in the column */ + _has_keys_any?: InputMaybe<Array<Scalars['String']['input']>>; + _in?: InputMaybe<Array<Scalars['jsonb']['input']>>; + _is_null?: InputMaybe<Scalars['Boolean']['input']>; + _lt?: InputMaybe<Scalars['jsonb']['input']>; + _lte?: InputMaybe<Scalars['jsonb']['input']>; + _neq?: InputMaybe<Scalars['jsonb']['input']>; + _nin?: InputMaybe<Array<Scalars['jsonb']['input']>>; +}; + +/** mutation root */ +export type Mutation_Root = { + __typename?: 'mutation_root'; + /** addTransaction */ + addTransaction?: Maybe<AddTransactionResponse>; + /** deleteProfile */ + deleteProfile?: Maybe<DeleteProfileResponse>; + /** migrateProfile */ + migrateProfile?: Maybe<MigrateProfileResponse>; + /** updateProfile */ + updateProfile?: Maybe<UpdateProfileResponse>; +}; + + +/** mutation root */ +export type Mutation_RootAddTransactionArgs = { + address: Scalars['String']['input']; + comment: Scalars['String']['input']; + hash: Scalars['String']['input']; + id: Scalars['String']['input']; + signature: Scalars['String']['input']; +}; + + +/** mutation root */ +export type Mutation_RootDeleteProfileArgs = { + address: Scalars['String']['input']; + hash: Scalars['String']['input']; + signature: Scalars['String']['input']; +}; + + +/** mutation root */ +export type Mutation_RootMigrateProfileArgs = { + addressNew: Scalars['String']['input']; + addressOld: Scalars['String']['input']; + hash: Scalars['String']['input']; + signature: Scalars['String']['input']; +}; + + +/** mutation root */ +export type Mutation_RootUpdateProfileArgs = { + address: Scalars['String']['input']; + avatarBase64?: InputMaybe<Scalars['String']['input']>; + city?: InputMaybe<Scalars['String']['input']>; + description?: InputMaybe<Scalars['String']['input']>; + geoloc?: InputMaybe<GeolocInput>; + hash: Scalars['String']['input']; + signature: Scalars['String']['input']; + socials?: InputMaybe<Array<SocialInput>>; + title?: InputMaybe<Scalars['String']['input']>; +}; + +/** column ordering options */ +export enum Order_By { + /** in ascending order, nulls last */ + Asc = 'asc', + /** in ascending order, nulls first */ + AscNullsFirst = 'asc_nulls_first', + /** in ascending order, nulls last */ + AscNullsLast = 'asc_nulls_last', + /** in descending order, nulls first */ + Desc = 'desc', + /** in descending order, nulls first */ + DescNullsFirst = 'desc_nulls_first', + /** in descending order, nulls last */ + DescNullsLast = 'desc_nulls_last' +} + +/** Boolean expression to compare columns of type "point". All fields are combined with logical 'AND'. */ +export type Point_Comparison_Exp = { + _eq?: InputMaybe<Scalars['point']['input']>; + _gt?: InputMaybe<Scalars['point']['input']>; + _gte?: InputMaybe<Scalars['point']['input']>; + _in?: InputMaybe<Array<Scalars['point']['input']>>; + _is_null?: InputMaybe<Scalars['Boolean']['input']>; + _lt?: InputMaybe<Scalars['point']['input']>; + _lte?: InputMaybe<Scalars['point']['input']>; + _neq?: InputMaybe<Scalars['point']['input']>; + _nin?: InputMaybe<Array<Scalars['point']['input']>>; +}; + +/** columns and relationships of "profiles" */ +export type Profiles = { + __typename?: 'profiles'; + /** cid of avatar */ + avatar?: Maybe<Scalars['String']['output']>; + city?: Maybe<Scalars['String']['output']>; + /** CID of the latest data from which this document comes from */ + data_cid?: Maybe<Scalars['String']['output']>; + description?: Maybe<Scalars['String']['output']>; + geoloc?: Maybe<Scalars['point']['output']>; + /** CID of the latest index request that modified this document */ + index_request_cid: Scalars['String']['output']; + /** base58 pubkey of profile owner */ + pubkey: Scalars['String']['output']; + socials?: Maybe<Scalars['jsonb']['output']>; + /** timestamp of the latest index request that modified this document */ + time: Scalars['timestamptz']['output']; + /** title of c+ profile */ + title?: Maybe<Scalars['String']['output']>; +}; + + +/** columns and relationships of "profiles" */ +export type ProfilesSocialsArgs = { + path?: InputMaybe<Scalars['String']['input']>; +}; + +/** aggregated selection of "profiles" */ +export type Profiles_Aggregate = { + __typename?: 'profiles_aggregate'; + aggregate?: Maybe<Profiles_Aggregate_Fields>; + nodes: Array<Profiles>; +}; + +/** aggregate fields of "profiles" */ +export type Profiles_Aggregate_Fields = { + __typename?: 'profiles_aggregate_fields'; + count: Scalars['Int']['output']; + max?: Maybe<Profiles_Max_Fields>; + min?: Maybe<Profiles_Min_Fields>; +}; + + +/** aggregate fields of "profiles" */ +export type Profiles_Aggregate_FieldsCountArgs = { + columns?: InputMaybe<Array<Profiles_Select_Column>>; + distinct?: InputMaybe<Scalars['Boolean']['input']>; +}; + +/** Boolean expression to filter rows from the table "profiles". All fields are combined with a logical 'AND'. */ +export type Profiles_Bool_Exp = { + _and?: InputMaybe<Array<Profiles_Bool_Exp>>; + _not?: InputMaybe<Profiles_Bool_Exp>; + _or?: InputMaybe<Array<Profiles_Bool_Exp>>; + avatar?: InputMaybe<String_Comparison_Exp>; + city?: InputMaybe<String_Comparison_Exp>; + data_cid?: InputMaybe<String_Comparison_Exp>; + description?: InputMaybe<String_Comparison_Exp>; + geoloc?: InputMaybe<Point_Comparison_Exp>; + index_request_cid?: InputMaybe<String_Comparison_Exp>; + pubkey?: InputMaybe<String_Comparison_Exp>; + socials?: InputMaybe<Jsonb_Comparison_Exp>; + time?: InputMaybe<Timestamptz_Comparison_Exp>; + title?: InputMaybe<String_Comparison_Exp>; +}; + +/** aggregate max on columns */ +export type Profiles_Max_Fields = { + __typename?: 'profiles_max_fields'; + /** cid of avatar */ + avatar?: Maybe<Scalars['String']['output']>; + city?: Maybe<Scalars['String']['output']>; + /** CID of the latest data from which this document comes from */ + data_cid?: Maybe<Scalars['String']['output']>; + description?: Maybe<Scalars['String']['output']>; + /** CID of the latest index request that modified this document */ + index_request_cid?: Maybe<Scalars['String']['output']>; + /** base58 pubkey of profile owner */ + pubkey?: Maybe<Scalars['String']['output']>; + /** timestamp of the latest index request that modified this document */ + time?: Maybe<Scalars['timestamptz']['output']>; + /** title of c+ profile */ + title?: Maybe<Scalars['String']['output']>; +}; + +/** aggregate min on columns */ +export type Profiles_Min_Fields = { + __typename?: 'profiles_min_fields'; + /** cid of avatar */ + avatar?: Maybe<Scalars['String']['output']>; + city?: Maybe<Scalars['String']['output']>; + /** CID of the latest data from which this document comes from */ + data_cid?: Maybe<Scalars['String']['output']>; + description?: Maybe<Scalars['String']['output']>; + /** CID of the latest index request that modified this document */ + index_request_cid?: Maybe<Scalars['String']['output']>; + /** base58 pubkey of profile owner */ + pubkey?: Maybe<Scalars['String']['output']>; + /** timestamp of the latest index request that modified this document */ + time?: Maybe<Scalars['timestamptz']['output']>; + /** title of c+ profile */ + title?: Maybe<Scalars['String']['output']>; +}; + +/** Ordering options when selecting data from "profiles". */ +export type Profiles_Order_By = { + avatar?: InputMaybe<Order_By>; + city?: InputMaybe<Order_By>; + data_cid?: InputMaybe<Order_By>; + description?: InputMaybe<Order_By>; + geoloc?: InputMaybe<Order_By>; + index_request_cid?: InputMaybe<Order_By>; + pubkey?: InputMaybe<Order_By>; + socials?: InputMaybe<Order_By>; + time?: InputMaybe<Order_By>; + title?: InputMaybe<Order_By>; +}; + +/** select columns of table "profiles" */ +export enum Profiles_Select_Column { + /** column name */ + Avatar = 'avatar', + /** column name */ + City = 'city', + /** column name */ + DataCid = 'data_cid', + /** column name */ + Description = 'description', + /** column name */ + Geoloc = 'geoloc', + /** column name */ + IndexRequestCid = 'index_request_cid', + /** column name */ + Pubkey = 'pubkey', + /** column name */ + Socials = 'socials', + /** column name */ + Time = 'time', + /** column name */ + Title = 'title' +} + +/** Streaming cursor of the table "profiles" */ +export type Profiles_Stream_Cursor_Input = { + /** Stream column input with initial value */ + initial_value: Profiles_Stream_Cursor_Value_Input; + /** cursor ordering */ + ordering?: InputMaybe<Cursor_Ordering>; +}; + +/** Initial value of the column from where the streaming should start */ +export type Profiles_Stream_Cursor_Value_Input = { + /** cid of avatar */ + avatar?: InputMaybe<Scalars['String']['input']>; + city?: InputMaybe<Scalars['String']['input']>; + /** CID of the latest data from which this document comes from */ + data_cid?: InputMaybe<Scalars['String']['input']>; + description?: InputMaybe<Scalars['String']['input']>; + geoloc?: InputMaybe<Scalars['point']['input']>; + /** CID of the latest index request that modified this document */ + index_request_cid?: InputMaybe<Scalars['String']['input']>; + /** base58 pubkey of profile owner */ + pubkey?: InputMaybe<Scalars['String']['input']>; + socials?: InputMaybe<Scalars['jsonb']['input']>; + /** timestamp of the latest index request that modified this document */ + time?: InputMaybe<Scalars['timestamptz']['input']>; + /** title of c+ profile */ + title?: InputMaybe<Scalars['String']['input']>; +}; + +export type Query_Root = { + __typename?: 'query_root'; + /** fetch data from the table: "profiles" */ + profiles: Array<Profiles>; + /** fetch aggregated fields from the table: "profiles" */ + profiles_aggregate: Profiles_Aggregate; + /** fetch data from the table: "profiles" using primary key columns */ + profiles_by_pk?: Maybe<Profiles>; +}; + + +export type Query_RootProfilesArgs = { + distinct_on?: InputMaybe<Array<Profiles_Select_Column>>; + limit?: InputMaybe<Scalars['Int']['input']>; + offset?: InputMaybe<Scalars['Int']['input']>; + order_by?: InputMaybe<Array<Profiles_Order_By>>; + where?: InputMaybe<Profiles_Bool_Exp>; +}; + + +export type Query_RootProfiles_AggregateArgs = { + distinct_on?: InputMaybe<Array<Profiles_Select_Column>>; + limit?: InputMaybe<Scalars['Int']['input']>; + offset?: InputMaybe<Scalars['Int']['input']>; + order_by?: InputMaybe<Array<Profiles_Order_By>>; + where?: InputMaybe<Profiles_Bool_Exp>; +}; + + +export type Query_RootProfiles_By_PkArgs = { + pubkey: Scalars['String']['input']; +}; + +export type Subscription_Root = { + __typename?: 'subscription_root'; + /** fetch data from the table: "profiles" */ + profiles: Array<Profiles>; + /** fetch aggregated fields from the table: "profiles" */ + profiles_aggregate: Profiles_Aggregate; + /** fetch data from the table: "profiles" using primary key columns */ + profiles_by_pk?: Maybe<Profiles>; + /** fetch data from the table in a streaming manner: "profiles" */ + profiles_stream: Array<Profiles>; +}; + + +export type Subscription_RootProfilesArgs = { + distinct_on?: InputMaybe<Array<Profiles_Select_Column>>; + limit?: InputMaybe<Scalars['Int']['input']>; + offset?: InputMaybe<Scalars['Int']['input']>; + order_by?: InputMaybe<Array<Profiles_Order_By>>; + where?: InputMaybe<Profiles_Bool_Exp>; +}; + + +export type Subscription_RootProfiles_AggregateArgs = { + distinct_on?: InputMaybe<Array<Profiles_Select_Column>>; + limit?: InputMaybe<Scalars['Int']['input']>; + offset?: InputMaybe<Scalars['Int']['input']>; + order_by?: InputMaybe<Array<Profiles_Order_By>>; + where?: InputMaybe<Profiles_Bool_Exp>; +}; + + +export type Subscription_RootProfiles_By_PkArgs = { + pubkey: Scalars['String']['input']; +}; + + +export type Subscription_RootProfiles_StreamArgs = { + batch_size: Scalars['Int']['input']; + cursor: Array<InputMaybe<Profiles_Stream_Cursor_Input>>; + where?: InputMaybe<Profiles_Bool_Exp>; +}; + +/** Boolean expression to compare columns of type "timestamptz". All fields are combined with logical 'AND'. */ +export type Timestamptz_Comparison_Exp = { + _eq?: InputMaybe<Scalars['timestamptz']['input']>; + _gt?: InputMaybe<Scalars['timestamptz']['input']>; + _gte?: InputMaybe<Scalars['timestamptz']['input']>; + _in?: InputMaybe<Array<Scalars['timestamptz']['input']>>; + _is_null?: InputMaybe<Scalars['Boolean']['input']>; + _lt?: InputMaybe<Scalars['timestamptz']['input']>; + _lte?: InputMaybe<Scalars['timestamptz']['input']>; + _neq?: InputMaybe<Scalars['timestamptz']['input']>; + _nin?: InputMaybe<Array<Scalars['timestamptz']['input']>>; +}; + +export type LightProfileFragment = { __typename: 'profiles', title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null }; + +export type ProfileFragment = { __typename: 'profiles', description?: string | null, city?: string | null, geoloc?: any | null, socials?: any | null, index_request_cid: string, title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null }; + +export type ProfileSearchByTextQueryVariables = Exact<{ + searchText: Scalars['String']['input']; + limit: Scalars['Int']['input']; + offset: Scalars['Int']['input']; + orderBy?: InputMaybe<Array<Profiles_Order_By> | Profiles_Order_By>; + withTotal: Scalars['Boolean']['input']; +}>; + + +export type ProfileSearchByTextQuery = { __typename?: 'query_root', profiles: Array<{ __typename: 'profiles', title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null }>, profiles_aggregate?: { __typename?: 'profiles_aggregate', aggregate?: { __typename?: 'profiles_aggregate_fields', count: number } | null } }; + +export type ProfileByAddressQueryVariables = Exact<{ + address: Scalars['String']['input']; +}>; + + +export type ProfileByAddressQuery = { __typename?: 'query_root', profiles_by_pk?: { __typename: 'profiles', description?: string | null, city?: string | null, geoloc?: any | null, socials?: any | null, index_request_cid: string, title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null } | null }; + +export type ProfileSearchByAddressesQueryVariables = Exact<{ + addresses: Array<Scalars['String']['input']> | Scalars['String']['input']; +}>; + + +export type ProfileSearchByAddressesQuery = { __typename?: 'query_root', profiles: Array<{ __typename: 'profiles', title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null }> }; + +export type ProfileSearchByAddressQueryVariables = Exact<{ + address: Scalars['String']['input']; +}>; + + +export type ProfileSearchByAddressQuery = { __typename?: 'query_root', profiles_by_pk?: { __typename: 'profiles', description?: string | null, city?: string | null, geoloc?: any | null, socials?: any | null, index_request_cid: string, title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null } | null }; + +export type LightProfileByAddressesQueryVariables = Exact<{ + addresses: Array<Scalars['String']['input']> | Scalars['String']['input']; +}>; + + +export type LightProfileByAddressesQuery = { __typename?: 'query_root', profiles: Array<{ __typename: 'profiles', title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null }> }; + +export type LightProfileByAddressQueryVariables = Exact<{ + address: Scalars['String']['input']; +}>; + + +export type LightProfileByAddressQuery = { __typename?: 'query_root', profiles_by_pk?: { __typename: 'profiles', title?: string | null, time: any, id?: string | null, address: string, avatar_cid?: string | null } | null }; + +export const LightProfileFragmentDoc = gql` + fragment LightProfile on profiles { + id: data_cid + __typename + address: pubkey + title + avatar_cid: avatar + time +} + `; +export const ProfileFragmentDoc = gql` + fragment Profile on profiles { + ...LightProfile + description + city + geoloc + socials + index_request_cid +} + ${LightProfileFragmentDoc}`; +export const ProfileSearchByTextDocument = gql` + query ProfileSearchByText($searchText: String!, $limit: Int!, $offset: Int!, $orderBy: [profiles_order_by!], $withTotal: Boolean!) { + profiles( + offset: $offset + limit: $limit + where: {title: {_ilike: $searchText}} + order_by: $orderBy + ) { + ...LightProfile + } + profiles_aggregate(where: {title: {_ilike: $searchText}}) @include(if: $withTotal) { + aggregate { + count + } + } +} + ${LightProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class ProfileSearchByTextGQL extends Apollo.Query<ProfileSearchByTextQuery, ProfileSearchByTextQueryVariables> { + document = ProfileSearchByTextDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const ProfileByAddressDocument = gql` + query ProfileByAddress($address: String!) { + profiles_by_pk(pubkey: $address) { + ...Profile + } +} + ${ProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class ProfileByAddressGQL extends Apollo.Query<ProfileByAddressQuery, ProfileByAddressQueryVariables> { + document = ProfileByAddressDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const ProfileSearchByAddressesDocument = gql` + query ProfileSearchByAddresses($addresses: [String!]!) { + profiles(where: {pubkey: {_in: $addresses}}) { + ...LightProfile + } +} + ${LightProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class ProfileSearchByAddressesGQL extends Apollo.Query<ProfileSearchByAddressesQuery, ProfileSearchByAddressesQueryVariables> { + document = ProfileSearchByAddressesDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const ProfileSearchByAddressDocument = gql` + query ProfileSearchByAddress($address: String!) { + profiles_by_pk(pubkey: $address) { + ...Profile + } +} + ${ProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class ProfileSearchByAddressGQL extends Apollo.Query<ProfileSearchByAddressQuery, ProfileSearchByAddressQueryVariables> { + document = ProfileSearchByAddressDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const LightProfileByAddressesDocument = gql` + query LightProfileByAddresses($addresses: [String!]!) { + profiles(where: {pubkey: {_in: $addresses}}) { + ...LightProfile + } +} + ${LightProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class LightProfileByAddressesGQL extends Apollo.Query<LightProfileByAddressesQuery, LightProfileByAddressesQueryVariables> { + document = LightProfileByAddressesDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const LightProfileByAddressDocument = gql` + query LightProfileByAddress($address: String!) { + profiles_by_pk(pubkey: $address) { + ...LightProfile + } +} + ${LightProfileFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class LightProfileByAddressGQL extends Apollo.Query<LightProfileByAddressQuery, LightProfileByAddressQueryVariables> { + document = LightProfileByAddressDocument; + client = 'pod'; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } + + type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; + + interface WatchQueryOptionsAlone<V> extends Omit<ApolloCore.WatchQueryOptions<V>, 'query' | 'variables'> {} + + interface QueryOptionsAlone<V> extends Omit<ApolloCore.QueryOptions<V>, 'query' | 'variables'> {} + + @Injectable({ providedIn: 'root' }) + export class PodGraphqlService { + constructor( + private profileSearchByTextGql: ProfileSearchByTextGQL, + private profileByAddressGql: ProfileByAddressGQL, + private profileSearchByAddressesGql: ProfileSearchByAddressesGQL, + private profileSearchByAddressGql: ProfileSearchByAddressGQL, + private lightProfileByAddressesGql: LightProfileByAddressesGQL, + private lightProfileByAddressGql: LightProfileByAddressGQL + ) {} + + profileSearchByText(variables: ProfileSearchByTextQueryVariables, options?: QueryOptionsAlone<ProfileSearchByTextQueryVariables>) { + return this.profileSearchByTextGql.fetch(variables, options) + } + + profileSearchByTextWatch(variables: ProfileSearchByTextQueryVariables, options?: WatchQueryOptionsAlone<ProfileSearchByTextQueryVariables>) { + return this.profileSearchByTextGql.watch(variables, options) + } + + profileByAddress(variables: ProfileByAddressQueryVariables, options?: QueryOptionsAlone<ProfileByAddressQueryVariables>) { + return this.profileByAddressGql.fetch(variables, options) + } + + profileByAddressWatch(variables: ProfileByAddressQueryVariables, options?: WatchQueryOptionsAlone<ProfileByAddressQueryVariables>) { + return this.profileByAddressGql.watch(variables, options) + } + + profileSearchByAddresses(variables: ProfileSearchByAddressesQueryVariables, options?: QueryOptionsAlone<ProfileSearchByAddressesQueryVariables>) { + return this.profileSearchByAddressesGql.fetch(variables, options) + } + + profileSearchByAddressesWatch(variables: ProfileSearchByAddressesQueryVariables, options?: WatchQueryOptionsAlone<ProfileSearchByAddressesQueryVariables>) { + return this.profileSearchByAddressesGql.watch(variables, options) + } + + profileSearchByAddress(variables: ProfileSearchByAddressQueryVariables, options?: QueryOptionsAlone<ProfileSearchByAddressQueryVariables>) { + return this.profileSearchByAddressGql.fetch(variables, options) + } + + profileSearchByAddressWatch(variables: ProfileSearchByAddressQueryVariables, options?: WatchQueryOptionsAlone<ProfileSearchByAddressQueryVariables>) { + return this.profileSearchByAddressGql.watch(variables, options) + } + + lightProfileByAddresses(variables: LightProfileByAddressesQueryVariables, options?: QueryOptionsAlone<LightProfileByAddressesQueryVariables>) { + return this.lightProfileByAddressesGql.fetch(variables, options) + } + + lightProfileByAddressesWatch(variables: LightProfileByAddressesQueryVariables, options?: WatchQueryOptionsAlone<LightProfileByAddressesQueryVariables>) { + return this.lightProfileByAddressesGql.watch(variables, options) + } + + lightProfileByAddress(variables: LightProfileByAddressQueryVariables, options?: QueryOptionsAlone<LightProfileByAddressQueryVariables>) { + return this.lightProfileByAddressGql.fetch(variables, options) + } + + lightProfileByAddressWatch(variables: LightProfileByAddressQueryVariables, options?: WatchQueryOptionsAlone<LightProfileByAddressQueryVariables>) { + return this.lightProfileByAddressGql.watch(variables, options) + } + } + + export interface PossibleTypesResultData { + possibleTypes: { + [key: string]: string[] + } + } + const result: PossibleTypesResultData = { + "possibleTypes": {} +}; + export default result; + \ No newline at end of file diff --git a/src/app/network/pod/pod.config.ts b/src/app/network/pod/pod.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..f69ba56ebcf8e774c92946ab0145b5b5ed992b8d --- /dev/null +++ b/src/app/network/pod/pod.config.ts @@ -0,0 +1,3 @@ +import { StrictTypedTypePolicies } from './pod-helpers.generated'; + +export const POD_GRAPHQL_TYPE_POLICIES = <StrictTypedTypePolicies>{}; diff --git a/src/app/network/pod/pod.service.ts b/src/app/network/pod/pod.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a27a24bd157bcac5ad1077ddc55a952db7d680a7 --- /dev/null +++ b/src/app/network/pod/pod.service.ts @@ -0,0 +1,165 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { Peers } from '@app/shared/services/network/peer.model'; +import { Promise } from '@rx-angular/cdk/zone-less/browser'; +import { SettingsService } from '@app/settings/settings.service'; +import { arrayRandomPick, isNil, isNotEmptyArray, isNotNil, isNotNilOrBlank, toNumber } from '@app/shared/functions'; +import { TypePolicies } from '@apollo/client/core'; +import { + APP_GRAPHQL_FRAGMENTS, + APP_GRAPHQL_TYPE_POLICIES, + GraphqlService, + GraphqlServiceState, +} from '@app/shared/services/network/graphql/graphql.service'; +import { DocumentNode } from 'graphql/index'; +import { StorageService } from '@app/shared/services/storage/storage.service'; +import { firstValueFrom, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Currency } from '@app/currency/currency.model'; +import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; +import { firstNotNilPromise } from '@app/shared/observables'; +import { Order_By, PodGraphqlService, ProfileFragment } from './pod-types.generated'; +import { WotSearchFilter } from '@app/wot/wot.model'; +import { LoadResult } from '@app/shared/services/service.model'; +import { Account } from '@app/account/account.model'; +import { FetchPolicy } from '@apollo/client'; +import { AccountConverter } from '@app/account/account.converter'; +import { IpfsService } from '@app/network/ipfs/ipfs.service'; + +export interface PodState extends GraphqlServiceState { + currency: Currency; +} + +@Injectable({ providedIn: 'root' }) +export class PodService extends GraphqlService<PodState> { + @RxStateSelect() currency$: Observable<Currency>; + @RxStateProperty() currency: Currency; + + @RxStateSelect() minBlockHeight$: Observable<number>; + @RxStateProperty() minBlockHeight: number; + + constructor( + storage: StorageService, + private settings: SettingsService, + private graphqlService: PodGraphqlService, + private ipfsService: IpfsService, + @Optional() @Inject(APP_GRAPHQL_TYPE_POLICIES) typePolicies: TypePolicies, + @Optional() @Inject(APP_GRAPHQL_FRAGMENTS) fragments: DocumentNode[] + ) { + super(storage, typePolicies, fragments, { + name: 'pod-service', + startByReadyFunction: false, // Need an explicit call to start() + }); + } + + profileSearch( + filter: WotSearchFilter, + options: { after?: string; first?: number; fetchPolicy?: FetchPolicy; total?: number; withTotal?: boolean } + ): Observable<LoadResult<Account>> { + console.info(`${this._logPrefix}Searching profile by filter...`, filter); + + const offset = toNumber(+options?.after, 0); + const limit = toNumber(+options?.first, this.fetchSize); + const withTotal = offset === 0 && options?.withTotal !== false; + let data$: Observable<LoadResult<ProfileFragment>>; + + // Load by unique address + if (isNotNilOrBlank(filter.address)) { + data$ = this.graphqlService + .profileByAddress( + { + address: filter.address, + }, + options + ) + .pipe( + map(({ data }) => { + return { + data: [data.profiles_by_pk as ProfileFragment], + total: isNil(data.profiles_by_pk) ? 0 : 1, + }; + }) + ); + } + + // Load by adresses + else if (isNotEmptyArray(filter.addresses)) { + data$ = this.graphqlService + .profileSearchByAddresses( + { + addresses: filter.addresses, + }, + options + ) + .pipe( + map(({ data }) => { + return { + data: data.profiles as ProfileFragment[], + }; + }) + ); + } else if (isNotNilOrBlank(filter.searchText)) { + data$ = this.graphqlService + .profileSearchByText({ + offset, + limit, + searchText: `%${filter.searchText}%`, + orderBy: [{ time: Order_By.Asc }, { title: Order_By.Asc }], + withTotal, + }) + .pipe( + map(({ data }) => { + return { + data: data.profiles as ProfileFragment[], + total: data.profiles_aggregate?.aggregate.count, + }; + }) + ); + } else { + return of(<LoadResult<Account>>{}); + } + + return data$.pipe( + map((res) => { + const data = AccountConverter.profileToAccounts(res.data, { ipfsGateway: this.ipfsService.gatewayBaseUrl }); + const total = toNumber(res?.total, options?.total); + const result: LoadResult<Account> = { data, total }; + const nextOffset = offset + limit; + if (isNotNil(total) && nextOffset < total) { + result.fetchMore = (first) => { + console.debug(`${this._logPrefix}Fetching more profiles - offset: ${nextOffset}`); + return firstValueFrom( + this.profileSearch(filter, { ...options, after: nextOffset.toString(), first: toNumber(first, options.first), total }) + ); + }; + } + return result; + }) + ); + } + + protected async ngOnStart(): Promise<PodState> { + if (!this.ipfsService.started) this.ipfsService.start(); + + // Wait settings and ipfs service + const [settings, currency] = await Promise.all([this.settings.ready(), firstNotNilPromise(this.currency$)]); + + let peer = Peers.fromUri(settings.pod); + if (!peer) { + const peers = await this.filterAlivePeers(settings.preferredPods); + if (!peers.length) { + throw { message: 'ERROR.CHECK_NETWORK_CONNECTION' }; + } + peer = arrayRandomPick(peers); + } + + const client = await super.createClient(peer, 'pod'); + + return { + peer, + client, + currency, + offline: false, + fetchSize: this.defaultFetchSize, + }; + } +} diff --git a/src/app/settings/settings.model.ts b/src/app/settings/settings.model.ts index 21e160ab6aad573c947f9b6bde9a832f8822cff4..2414d4cac4b772433c903847aa7ebdec2e347b17 100644 --- a/src/app/settings/settings.model.ts +++ b/src/app/settings/settings.model.ts @@ -15,6 +15,10 @@ export interface Settings { preferredPeers?: string[]; indexer: string; preferredIndexers?: string[]; + pod: string; + preferredPods?: string[]; + ipfsGateway: string; + preferredIpfsGateways?: string[]; pages?: any; locale?: string; mobile?: boolean; diff --git a/src/app/settings/settings.page.html b/src/app/settings/settings.page.html index efbd40eebe5db312f5adbd64219d4342ab8e6af6..1f3f12b4de7ec3d6eceefa33073642a11628fa2f 100644 --- a/src/app/settings/settings.page.html +++ b/src/app/settings/settings.page.html @@ -60,6 +60,7 @@ </ion-button> </ion-item> + <!-- indexer --> <ion-item> <ion-icon slot="start" name="cloud-done"></ion-icon> <ion-label> @@ -74,6 +75,36 @@ </ion-button> </ion-item> + <!-- pod --> + <ion-item> + <ion-icon slot="start" name="cloud-done"></ion-icon> + <ion-label> + <h2 color="dark" translate>SETTINGS.POD</h2> + <p> + {{ pod }} + </p> + </ion-label> + + <ion-button slot="end" (click)="selectPodModal.present()" [title]="'SETTINGS.POPUP_PEER.BTN_SHOW_LIST' | translate"> + <ion-label>...</ion-label> + </ion-button> + </ion-item> + + <!-- ipfs --> + <ion-item> + <ion-icon slot="start" name="cloud-done"></ion-icon> + <ion-label> + <h2 color="dark" translate>SETTINGS.IPFS</h2> + <p> + {{ ipfsGateway }} + </p> + </ion-label> + + <ion-button slot="end" (click)="selectIpfsGatewayModal.present()" [title]="'SETTINGS.POPUP_PEER.BTN_SHOW_LIST' | translate"> + <ion-label>...</ion-label> + </ion-button> + </ion-item> + <ion-item-divider translate>SETTINGS.AUTHENTICATION_SETTINGS</ion-item-divider> <ion-item> @@ -112,47 +143,76 @@ <!-- Select peers modal --> <ion-modal #selectPeerModal [backdropDismiss]="true"> <ng-template> - <ion-header> - <ion-toolbar color="secondary"> - <ion-buttons slot="start"> - <ion-button (click)="selectPeerModal.dismiss()" *ngIf="mobile"> - <ion-icon slot="icon-only" name="arrow-back"></ion-icon> - </ion-button> - </ion-buttons> - - <ion-title translate>SETTINGS.POPUP_PEER.BTN_SHOW_LIST</ion-title> - </ion-toolbar> - </ion-header> - <ion-content> - <ion-list> - <ion-item *rxFor="let peer of preferredPeers$" tappable (click)="selectPeer(peer)"> - <ion-label>{{ peer }}</ion-label> - </ion-item> - </ion-list> - </ion-content> + <ng-container *ngTemplateOutlet="peerList; context: { $implicit: preferredPeers$, modal: selectPeerModal, property: 'peer' }"></ng-container> </ng-template> </ion-modal> <!-- select indexer modal --> <ion-modal #selectIndexerModal [backdropDismiss]="true"> <ng-template> - <ion-header> - <ion-toolbar color="secondary"> - <ion-buttons slot="start"> - <ion-button (click)="selectIndexerModal.dismiss()" *ngIf="mobile"> - <ion-icon slot="icon-only" name="arrow-back"></ion-icon> - </ion-button> - </ion-buttons> - - <ion-title translate>SETTINGS.POPUP_PEER.BTN_SHOW_LIST</ion-title> - </ion-toolbar> - </ion-header> - <ion-content> - <ion-list> - <ion-item *rxFor="let peer of preferredIndexers$" tappable (click)="selectIndexer(peer)"> - <ion-label>{{ peer }}</ion-label> - </ion-item> - </ion-list> - </ion-content> + <ng-container + *ngTemplateOutlet="peerList; context: { $implicit: preferredIndexers$, modal: selectIndexerModal, property: 'indexer' }" + ></ng-container> + </ng-template> +</ion-modal> + +<!-- select pod modal --> +<ion-modal #selectPodModal [backdropDismiss]="true"> + <ng-template> + <ng-container *ngTemplateOutlet="peerList; context: { $implicit: preferredPods$, modal: selectPodModal, property: 'pod' }"></ng-container> + </ng-template> +</ion-modal> + +<!-- select IPFS modal --> +<ion-modal #selectIpfsGatewayModal [backdropDismiss]="true"> + <ng-template> + <ng-container + *ngTemplateOutlet="peerList; context: { $implicit: preferredIpfsGateways$, modal: selectIpfsGatewayModal, property: 'ipfsGateway' }" + ></ng-container> </ng-template> </ion-modal> + +<ng-template #peerList let-peers$ let-property="property" let-modal="modal"> + <ion-header> + <ion-toolbar color="secondary"> + <ion-buttons slot="start"> + <ion-button (click)="modal.dismiss()" *ngIf="mobile"> + <ion-icon slot="icon-only" name="arrow-back"></ion-icon> + </ion-button> + </ion-buttons> + + <ion-title translate>SETTINGS.POPUP_PEER.BTN_SHOW_LIST</ion-title> + </ion-toolbar> + </ion-header> + <ion-content> + <ion-list> + <ion-item + *rxFor="let peer of peers$" + tappable + (click)="setStateValue(peer, property) && modal.dismiss()" + [class.selected]="peer === this[property]" + > + <ion-label>{{ peer }}</ion-label> + @if (property && peer === this[property]) { + <ion-icon slot="end" name="checkmark"></ion-icon> + } + </ion-item> + </ion-list> + </ion-content> + @if (!mobile) { + <ion-footer> + <ion-toolbar> + <ion-row class="ion-no-padding"> + <ion-col></ion-col> + + <!-- buttons --> + <ion-col size="auto"> + <ion-button fill="clear" color="dark" (click)="modal.dismiss()"> + <ion-label translate>COMMON.BTN_CANCEL</ion-label> + </ion-button> + </ion-col> + </ion-row> + </ion-toolbar> + </ion-footer> + } +</ng-template> diff --git a/src/app/settings/settings.page.scss b/src/app/settings/settings.page.scss index 5fdf1824ec6e13554ebc39e3cb271dff7e57ea7b..77f11754471800b8d00ca8c3b3adbc6d34e0b919 100644 --- a/src/app/settings/settings.page.scss +++ b/src/app/settings/settings.page.scss @@ -1,2 +1,9 @@ #container { } + +ion-list { + ion-item.selected { + --background: rgba(var(--ion-color-primary-rgb), 0.14); + --color: var(--ion-color-primary); + } +} diff --git a/src/app/settings/settings.page.ts b/src/app/settings/settings.page.ts index 0262e8214d1e7a8bdb6e89cf0ec5e2635b41f4c1..dc8ac6326b1bd1932c217011a2367d016bc2086e 100644 --- a/src/app/settings/settings.page.ts +++ b/src/app/settings/settings.page.ts @@ -55,6 +55,10 @@ export class SettingsPage extends AppPage<SettingsPageState> implements OnInit { @RxStateSelect() peer$: Observable<string>; @RxStateSelect() preferredIndexers$: Observable<string[]>; @RxStateSelect() indexer$: Observable<string>; + @RxStateSelect() preferredPods$: Observable<string[]>; + @RxStateSelect() pod$: Observable<string>; + @RxStateSelect() preferredIpfsGateways$: Observable<string[]>; + @RxStateSelect() ipfsGateway$: Observable<string>; @RxStateSelect() dirty$: Observable<boolean>; @RxStateProperty() darkMode: boolean; @@ -62,11 +66,15 @@ export class SettingsPage extends AppPage<SettingsPageState> implements OnInit { @RxStateProperty() useRelativeUnit: boolean; @RxStateProperty() peer: string; @RxStateProperty() indexer: string; + @RxStateProperty() pod: string; + @RxStateProperty() ipfsGateway: string; @RxStateProperty() unAuthDelayMs: number; @RxStateProperty() dirty: boolean; @ViewChild('selectPeerModal') selectPeerModal: IonModal; @ViewChild('selectIndexerModal') selectIndexerModal: IonModal; + @ViewChild('selectPodModal') selectPodModal: IonModal; + @ViewChild('selectIpfsGatewayModal') selectIpfsGatewayModal: IonModal; constructor( protected networkService: NetworkService, @@ -79,13 +87,16 @@ export class SettingsPage extends AppPage<SettingsPageState> implements OnInit { this._state.connect('useRelativeUnit', this._state.select('displayUnit').pipe(map((unit) => unit === 'du'))); // Detect changes - this._state.hold(this._state.select(['locale', 'peer', 'indexer', 'unAuthDelayMs', 'displayUnit'], (s) => s).pipe(skip(1)), () => { - if (this.mobile) { - this.save(); - } else { - this.markAsDirty(); + this._state.hold( + this._state.select(['locale', 'peer', 'indexer', 'pod', 'ipfsGateway', 'unAuthDelayMs', 'displayUnit'], (s) => s).pipe(skip(1)), + () => { + if (this.mobile) { + this.save(); + } else { + this.markAsDirty(); + } } - }); + ); } protected async ngOnLoad() { @@ -106,14 +117,9 @@ export class SettingsPage extends AppPage<SettingsPageState> implements OnInit { this.dirty = false; } - selectPeer(peer: string) { - this.peer = peer; - this.selectPeerModal.dismiss(); - } - - selectIndexer(peer: string) { - this.indexer = peer; - this.selectIndexerModal.dismiss(); + setStateValue(value: string, property: keyof Settings) { + this._state.set(property, () => value); + return true; } markAsDirty() { diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index ae9578bacdeaf9fbad1fc6c761ce1950519e6f81..caced262851f9a5a5168f94f42ef9979d5c16011 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -81,9 +81,13 @@ export class SettingsService extends RxStartableService<SettingsState> { locale: environment.defaultLocale, peer: environment.dev?.peer || environment.defaultPeers?.[0], indexer: environment.dev?.indexer || environment.defaultIndexers?.[0], + pod: environment.dev?.pod || environment.defaultPods?.[0], + ipfsGateway: environment.dev?.ipfsGateway || environment.defaultIfpsGateways?.[0], ...this.get(), preferredPeers: arrayDistinct([...environment.defaultPeers, ...(data?.preferredPeers || [])]), preferredIndexers: arrayDistinct([...environment.defaultIndexers, ...(data?.preferredIndexers || [])]), + preferredPods: arrayDistinct([...environment.defaultPods, ...(data?.preferredPods || [])]), + preferredIpfsGateways: arrayDistinct([...environment.defaultIfpsGateways, ...(data?.preferredIpfsGateways || [])]), }; } @@ -98,6 +102,8 @@ export class SettingsService extends RxStartableService<SettingsState> { // Merge default and restored data preferredPeers: arrayDistinct([...environment.defaultPeers, ...(data?.preferredPeers || [])]), preferredIndexers: arrayDistinct([...environment.defaultIndexers, ...(data?.preferredIndexers || [])]), + preferredPods: arrayDistinct([...environment.defaultPods, ...(data?.preferredPods || [])]), + preferredIpfsGateways: arrayDistinct([...environment.defaultIfpsGateways, ...(data?.preferredIpfsGateways || [])]), }; } diff --git a/src/app/shared/pipes/account.pipes.ts b/src/app/shared/pipes/account.pipes.ts index db2c22e1186562d175ba87baf82c1f555354a46b..3ab04af15ee050f48c089c0f47f4e0802058a4f9 100644 --- a/src/app/shared/pipes/account.pipes.ts +++ b/src/app/shared/pipes/account.pipes.ts @@ -4,12 +4,15 @@ import { equals, getPropertyByPath } from '@app/shared/functions'; import { Subscription } from 'rxjs'; import { AccountsService, LoadAccountDataOptions } from '@app/account/accounts.service'; +export interface AccountAbstractPipeOptions { + listenChanges?: boolean; +} // @dynamic /** * A common pipe, that will subscribe to all account changes, to refresh its value */ @Injectable() -export abstract class AccountAbstractPipe<T, O> implements PipeTransform { +export abstract class AccountAbstractPipe<T, O extends Object = AccountAbstractPipeOptions> implements PipeTransform { private value: T = null; private _lastAccount: Partial<Account> | null = null; private _lastOptions: O = null; @@ -22,11 +25,11 @@ export abstract class AccountAbstractPipe<T, O> implements PipeTransform { private _watchOptions?: LoadAccountDataOptions ) {} - transform(account: Partial<Account>, opts: O): T { + transform(account: Partial<Account>, opts?: O): T { // Not a user account (e.g. any wot identity) if (!account?.address) { this._dispose(); - return this._transform(account); + return this._transform(account, opts); } // if we ask another time for the same account and opts, return the last value @@ -46,8 +49,8 @@ export abstract class AccountAbstractPipe<T, O> implements PipeTransform { // if there is a subscription to onLangChange, clean it this._dispose(); - // subscribe to onTranslationChange event, in case the translations change - if (!this._changesSubscription) { + // subscribe to account changes + if (!this._changesSubscription && opts?.['listenChanges'] !== false) { this._changesSubscription = this._accountsService.watchByAddress(account.address, this._watchOptions).subscribe((updatedAccount) => { this.value = this._transform(updatedAccount, opts); this._cd.markForCheck(); @@ -76,7 +79,7 @@ export abstract class AccountAbstractPipe<T, O> implements PipeTransform { } } -export declare type AccountPropertyPipeOptions<T> = string | { key?: string; defaultValue?: T }; +export declare type AccountPropertyPipeOptions<T> = string | (AccountAbstractPipeOptions & { key?: string; defaultValue?: T }); @Pipe({ name: 'accountProperty', @@ -106,7 +109,7 @@ export class AccountPropertyPipe<T = never, O extends AccountPropertyPipeOptions name: 'balance', pure: false, }) -export class AccountBalancePipe extends AccountAbstractPipe<number, void> implements PipeTransform { +export class AccountBalancePipe extends AccountAbstractPipe<number> implements PipeTransform { constructor(cd: ChangeDetectorRef) { super(cd, { withBalance: true }); } @@ -120,7 +123,7 @@ export class AccountBalancePipe extends AccountAbstractPipe<number, void> implem name: 'accountName', pure: false, }) -export class AccountNamePipe extends AccountAbstractPipe<string, void> implements PipeTransform { +export class AccountNamePipe extends AccountAbstractPipe<string> implements PipeTransform { constructor(cd: ChangeDetectorRef) { super(cd, { withBalance: false }); } @@ -134,13 +137,13 @@ export class AccountNamePipe extends AccountAbstractPipe<string, void> implement name: 'isMemberAccount', pure: false, }) -export class IsMemberAccountPipe extends AccountAbstractPipe<boolean, void> implements PipeTransform { +export class IsMemberAccountPipe extends AccountAbstractPipe<boolean> implements PipeTransform { constructor(cd: ChangeDetectorRef) { super(cd, { withBalance: false, withMembership: true }); } protected _transform(account: Partial<Account>): boolean { - return (account && account.meta && account.meta.isMember === true) || false; + return (account?.meta && account.meta.isMember === true) || false; } } diff --git a/src/app/shared/pipes/block-number.pipe.ts b/src/app/shared/pipes/block-number.pipe.ts index 6d6cd1f40fa10e6f6ebf1649528dab965513c696..45d249b8dcbb0b1214c1c0b8e5671307a5641e0e 100644 --- a/src/app/shared/pipes/block-number.pipe.ts +++ b/src/app/shared/pipes/block-number.pipe.ts @@ -1,5 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { isNil } from '@app/shared/functions'; import { SettingsService } from '@app/settings/settings.service'; diff --git a/src/app/shared/pipes/block-timestamp.pipe.ts b/src/app/shared/pipes/block-timestamp.pipe.ts index 5b4009245e537e6709400a30302301a372d31f86..1c2c5d42df246bd5a84c8cd71d850c78ec3507cb 100644 --- a/src/app/shared/pipes/block-timestamp.pipe.ts +++ b/src/app/shared/pipes/block-timestamp.pipe.ts @@ -3,7 +3,7 @@ import { Moment } from 'moment'; import { DateUtils } from '@app/shared/dates'; import { NetworkService } from '@app/network/network.service'; import { isNil } from '@app/shared/functions'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; @Pipe({ name: 'blockTime', diff --git a/src/app/shared/services/network/graphql/graphql.service.ts b/src/app/shared/services/network/graphql/graphql.service.ts index 2997ae12f6cc5732215f538e288ad3cfc8fd4529..3578345b8f43ce9b702b0659a5d13240a55baf17 100644 --- a/src/app/shared/services/network/graphql/graphql.service.ts +++ b/src/app/shared/services/network/graphql/graphql.service.ts @@ -52,6 +52,7 @@ import { environment } from '@environments/environment'; import { RxStartableService, RxStartableServiceOptions } from '@app/shared/services/rx-startable-service.class'; import { Peer, Peers } from '../peer.model'; import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; +import { Promise } from '@rx-angular/cdk/zone-less/browser'; // Workaround for issue https://github.com/ng-packagr/ng-packagr/issues/2215 const QueueLink = unwrapESModule(queueLinkImported); const SerializingLink = unwrapESModule(serializingLinkImported); @@ -929,4 +930,30 @@ export abstract class GraphqlService< } return undefined; } + + protected async filterAlivePeers( + peers: string[], + opts?: { + timeout?: number; + } + ): Promise<Peer[]> { + return ( + await Promise.all( + peers.map((peer) => Peers.fromUri(peer)).map((peer) => this.isPeerAlive(peer, opts).then((alive) => (alive ? peer : undefined))) + ) + ).filter(isNotNil); + } + + protected async isPeerAlive( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + peer: Peer, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + opts?: { + timeout?: number; + } + ): Promise<boolean> { + // TODO + console.log(`${this._logPrefix}TODO: implement ${this.constructor.name}.isPeerAlive()`, peer); + return Promise.resolve(true); + } } diff --git a/src/app/shared/services/service.model.ts b/src/app/shared/services/service.model.ts index 185beeb51b2b69f3378937259f39162a0f05b150..5cd1fa151e38d56a5ddff2277e71c12bd4773d68 100644 --- a/src/app/shared/services/service.model.ts +++ b/src/app/shared/services/service.model.ts @@ -1,4 +1,5 @@ import { getPropertyByPathAsString, isNotNil, isNotNilOrBlank, matchUpperCase, startsWithUpperCase } from '../functions'; +import { Promise } from '@rx-angular/cdk/zone-less/browser'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export declare type ReadyAsyncFunction<T = any> = () => Promise<T>; @@ -64,6 +65,59 @@ export function suggestFromArray<T = any>( }; } +export function combineLoadResults<T>( + results: LoadResult<T>[], + options?: { + reduce: (value: T[]) => T[]; + data?: T[]; + total?: number; + } +): LoadResult<T> { + let data = options?.data || []; + const offset = data?.length || 0; + + // Compute data + data = data.concat(results.map((r) => r?.data || []).flat()); + + // Reduce (e.g. remove duplicated) + if (typeof options?.reduce === 'function') { + data = options.reduce(data); + } + + // Truncate data + const newData = offset > 0 ? data.slice(offset) : data; + + // Compute total + let total = isNotNil(options?.total) + ? options.total + : results + .map((r) => r?.total) + .filter(isNotNil) + .reduce((max, total) => Math.max(max || 0, total), -1); + if (total === -1) total = undefined; + + // Compute fetch more + const fetchMoreFns = results.map((r) => r?.fetchMore).filter(isNotNil); + const fetchMore = combineFetchMore(fetchMoreFns, { ...options, data, total }); + + return { data: newData, total, fetchMore }; +} + +export function combineFetchMore<T>( + fetchMoreFns: FetchMoreFn<LoadResult<T>>[], + options?: { + reduce: (value: T[]) => T[]; + data?: T[]; + total?: number; + } +): FetchMoreFn<LoadResult<T>> { + if (!fetchMoreFns?.length || (options?.data && !options.data.length)) return undefined; + return async (first) => { + const results = await Promise.all(fetchMoreFns.map((fetchMoreFn) => fetchMoreFn(first))); + return combineLoadResults(results, options); + }; +} + export interface IStartableService<T> { started: boolean; diff --git a/src/app/transfer/history/transfer-history.page.ts b/src/app/transfer/history/transfer-history.page.ts index cc60c09aec194979f0fa7782c80b3f78312ac6eb..f4e421c66e766de7f1b6d7ebe81a0774a0a182a6 100644 --- a/src/app/transfer/history/transfer-history.page.ts +++ b/src/app/transfer/history/transfer-history.page.ts @@ -18,7 +18,7 @@ import { TransferSearchFilter, TransferSearchFilterUtils, } from '@app/transfer/transfer.model'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { FetchMoreFn, LoadResult } from '@app/shared/services/service.model'; export interface TransferHistoryPageState extends AppPageState { diff --git a/src/app/transfer/transfer.model.ts b/src/app/transfer/transfer.model.ts index a00ad97e0649f4f64999898430d2d0e26b77cc95..b214e87da01c58fbb8f1992fc67055df1c26d463 100644 --- a/src/app/transfer/transfer.model.ts +++ b/src/app/transfer/transfer.model.ts @@ -2,7 +2,7 @@ import { InjectionToken } from '@angular/core'; import { Account, parseAddressSquid } from '@app/account/account.model'; import { Moment } from 'moment/moment'; import { equals, isNil, isNilOrBlank } from '@app/shared/functions'; -import { TransferFragment } from '@app/network/indexer-types.generated'; +import { TransferFragment } from '@app/network/indexer/indexer-types.generated'; import { fromDateISOString } from '@app/shared/dates'; import { AccountConverter } from '@app/account/account.converter'; @@ -53,10 +53,10 @@ export class TransferConverter { const toAddress = parseAddressSquid(item.to?.id).address; // Account is the issuer if (fromAddress === accountAddress) { - to = AccountConverter.toAccount(item.to); + to = AccountConverter.squidToAccount(item.to); amount = -1 * item.amount; } else if (toAddress === accountAddress) { - from = AccountConverter.toAccount(item.from); + from = AccountConverter.squidToAccount(item.from); amount = item.amount; } return <Transfer>{ diff --git a/src/app/wot/wot-details.page.ts b/src/app/wot/wot-details.page.ts index 023a2c625ad54c77bc80114bdce3ba220da0a915..5dd7ec3b281c3939f073dd1aa15f8b6c2d080bcc 100644 --- a/src/app/wot/wot-details.page.ts +++ b/src/app/wot/wot-details.page.ts @@ -10,7 +10,7 @@ import { RxState } from '@rx-angular/state'; import { APP_TRANSFER_CONTROLLER, ITransferController } from '@app/transfer/transfer.model'; import { filter, map } from 'rxjs/operators'; import { firstArrayValue, isNotNilOrBlank } from '@app/shared/functions'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { address2PubkeyV1, pubkeyV1Checksum } from '@app/shared/currencies'; export interface WotDetailsPageState extends AppPageState { diff --git a/src/app/wot/wot-lookup.page.html b/src/app/wot/wot-lookup.page.html index 0cdc591d1228e5728ebe58b24f47f1ebbbc93d59..68df96ce6a3f822281805046b1a6f42f86da3d93 100644 --- a/src/app/wot/wot-lookup.page.html +++ b/src/app/wot/wot-lookup.page.html @@ -112,18 +112,37 @@ </ion-avatar> <ion-label> <h2> - <ion-text [color]="item.meta?.isMember ? 'primary' : 'dark'"> - <small><ion-icon name="person"></ion-icon></small> + <ion-text [color]="item.meta?.isMember && !item.meta.name ? 'primary' : 'dark'"> + @if (!item.meta?.name) { + <ion-icon name="person"></ion-icon> + } @if (filter?.last) { - {{ item.meta?.uid }} + <span>{{ item | accountName: { listenChanges: false } }}</span> } @else { - <span [innerHTML]="item.meta?.uid | highlight: { search: searchText }"></span> + <span [innerHTML]="item | accountName: { listenChanges: false } | highlight: { search: searchText }"></span> } </ion-text> </h2> <p> - <ion-icon name="key"></ion-icon> - {{ item.address | addressFormat }} + @if (item.meta.name && item.meta.uid) { + <ion-text [color]="item.meta?.isMember ? 'primary' : 'dark'"> + <ion-icon name="person"></ion-icon> + @if (filter?.last) { + <span>{{ item.meta.uid }}</span> + } @else { + <span [innerHTML]="item.meta.uid | highlight: { search: searchText }"></span> + } + </ion-text> + } + <!-- address --> + <ion-text> + <ion-icon name="key"></ion-icon> + <span>{{ item.address | addressFormat }}</span> + </ion-text> + <!-- not member --> + @if (!item.meta?.isMember) { + <ion-text color="danger" translate>WOT.NOT_MEMBER_PARENTHESIS</ion-text> + } </p> </ion-label> <ion-button diff --git a/src/app/wot/wot-lookup.page.scss b/src/app/wot/wot-lookup.page.scss index 3fa413da5fcdc18866f08a19a23a7d1dc876e549..068b4d1217b51e524c6c4c84c47123b94ac80ba8 100644 --- a/src/app/wot/wot-lookup.page.scss +++ b/src/app/wot/wot-lookup.page.scss @@ -1,3 +1,25 @@ -ion-list.list-md { - padding-top: 0; +ion-list { + &.list-md { + padding-top: 0; + } + + --text-padding: 4px; + p { + --text-padding: 2px; + } + + ion-item { + ion-text { + ion-icon { + width: 0.9em; + height: 0.9em; + vertical-align: baseline; + } + padding-inline-end: calc(var(--text-padding) * 2); + } + + ion-text:has(ion-icon) span { + margin-inline-start: var(--text-padding); + } + } } diff --git a/src/app/wot/wot-lookup.page.ts b/src/app/wot/wot-lookup.page.ts index 1cf4cea6b422ad49f0176850a3c9c35026d88c90..9debd33cb377488421282c1162f2c802cb90005b 100644 --- a/src/app/wot/wot-lookup.page.ts +++ b/src/app/wot/wot-lookup.page.ts @@ -13,9 +13,10 @@ import { RxState } from '@rx-angular/state'; import { InfiniteScrollCustomEvent, IonPopover, ModalController } from '@ionic/angular'; import { APP_TRANSFER_CONTROLLER, ITransferController } from '@app/transfer/transfer.model'; -import { IndexerService } from '@app/network/indexer.service'; +import { IndexerService } from '@app/network/indexer/indexer.service'; import { FetchMoreFn, LoadResult } from '@app/shared/services/service.model'; import { environment } from '@environments/environment'; +import { WotService } from '@app/wot/wot.service'; export interface WotLookupState extends AppPageState { searchText: string; @@ -70,6 +71,7 @@ export class WotLookupPage extends AppPage<WotLookupState> implements OnInit, Wo constructor( private indexerService: IndexerService, + private wotService: WotService, private modalCtrl: ModalController, @Inject(APP_TRANSFER_CONTROLLER) private transferController: ITransferController ) { @@ -149,7 +151,7 @@ export class WotLookupPage extends AppPage<WotLookupState> implements OnInit, Wo search(searchFilter?: WotSearchFilter, options?: { first: number; after: string }): Observable<LoadResult<Account>> { try { - return this.indexerService.wotSearch(searchFilter, options).pipe( + return this.wotService.wotSearch(searchFilter, options).pipe( filter(() => WotSearchFilterUtils.isEquals(this.filter, searchFilter)), tap(() => this.markAsLoaded()) ); @@ -202,7 +204,9 @@ export class WotLookupPage extends AppPage<WotLookupState> implements OnInit, Wo await this.waitIdle(); if (this.canFetchMore) { - console.debug(this._logPrefix + 'Fetching more items, from offset: ' + this.count, event); + // DEBUG + //console.debug(this._logPrefix + 'Fetching more items, from offset: ' + this.count, event); + const { data, fetchMore } = await this.fetchMoreFn(); if (data?.length) { diff --git a/src/app/wot/wot.model.ts b/src/app/wot/wot.model.ts index 61a70e22df77d01a36ca1d52c6a29d07ba4f3bd6..2e1b5ebf5c364960ff3d8959b80e46654f8f6967 100644 --- a/src/app/wot/wot.model.ts +++ b/src/app/wot/wot.model.ts @@ -14,6 +14,7 @@ export interface WotLookupOptions { export interface WotSearchFilter { address?: string; + addresses?: string[]; searchText?: string; last?: boolean; pending?: boolean; diff --git a/src/app/wot/wot.service.ts b/src/app/wot/wot.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..33cf2846b18f30893054112c24830332db4399b3 --- /dev/null +++ b/src/app/wot/wot.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { StartableService } from '@app/shared/services/startable-service.class'; +import { IndexerService } from '@app/network/indexer/indexer.service'; +import { PodService } from '@app/network/pod/pod.service'; +import { WotSearchFilter } from '@app/wot/wot.model'; +import { FetchPolicy } from '@apollo/client'; +import { Promise } from '@rx-angular/cdk/zone-less/browser'; +import { combineLatestAll, concat, firstValueFrom, mergeMap, Observable, toArray } from 'rxjs'; +import { LoadResult } from '@app/shared/services/service.model'; +import { Account, AccountUtils } from '@app/account/account.model'; +import { map } from 'rxjs/operators'; +import { isNotEmptyArray, isNotNil } from '@app/shared/functions'; + +@Injectable({ providedIn: 'root' }) +export class WotService extends StartableService { + constructor( + private indexer: IndexerService, + private pod: PodService + ) { + super(); + } + + protected async ngOnStart(): Promise<void> { + await Promise.all([this.indexer.ready(), this.pod.ready()]); + } + + wotSearch(filter: WotSearchFilter, options: { after?: string; first?: number; fetchPolicy?: FetchPolicy }): Observable<LoadResult<Account>> { + const search1$ = this.indexer.wotSearch(filter, options).pipe(toArray()); + const search2$ = this.pod.profileSearch(filter, options).pipe(toArray()); + + return concat(search1$, search2$).pipe( + combineLatestAll(), + map(AccountUtils.combineAccountLoadResults), + mergeMap((res) => this.decorateWithProfiles(res)) + ); + } + + async decorateWithProfiles(result: LoadResult<Account>): Promise<LoadResult<Account>> { + // Get addresses without profiles + const noProfilesAddresses = (result?.data || []) + .filter((account) => !account.meta?.name) + .map((account) => account.address) + .filter(isNotNil); + + // Load profiles from addresses + if (isNotEmptyArray(noProfilesAddresses)) { + console.debug(`${this._logPrefix}Loading profiles from ${noProfilesAddresses.length} account's addresses...`); + const profiles = (await firstValueFrom(this.pod.profileSearch({ addresses: noProfilesAddresses }, { withTotal: false })))?.data; + if (isNotEmptyArray(profiles)) { + AccountUtils.mergeAll(result.data.concat(profiles)); + } + } + + // Decorate fetchMore + if (result.fetchMore) { + const inheritedFetchMore = result.fetchMore; + result.fetchMore = async (first) => { + const moreResult = await inheritedFetchMore(first); + return this.decorateWithProfiles(moreResult); + }; + } + + return result; + } +} diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index ed976ebe9e8282963efd3d9510506c2ff957cdf0..7a405c784ab26dc9d48e3be544ffba33b3fe1247 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -146,6 +146,8 @@ "PEER": "Nœud Duniter", "PEER_SHORT": "Nœud Duniter", "INDEXER": "Indexeur de données", + "POD": "Serveur de données", + "IPFS": "Passerelle IPFS", "PEER_CHANGED_TEMPORARY": "Adresse utilisée temporairement", "PERSIST_CACHE": "Conserver les données de navigation (expérimental)", "PERSIST_CACHE_HELP": "Permet une navigation plus rapide, en conservant localement les données reçues, pour les utiliser d'une session à l'autre.", diff --git a/src/environments/environment.class.ts b/src/environments/environment.class.ts index 3e7b41824932984bfa4af1fcd9f6086d319e0022..bf0a8c400c656a12aeba6b12e837be3b60050f09 100644 --- a/src/environments/environment.class.ts +++ b/src/environments/environment.class.ts @@ -15,6 +15,8 @@ export interface Environment { defaultPeers: string[]; defaultIndexers: string[]; + defaultPods: string[]; + defaultIfpsGateways: string[]; // GraphQL graphql: { @@ -37,6 +39,8 @@ export interface Environment { // Default peer peer?: string; indexer?: string; + pod?: string; + ipfsGateway?: string; // Load polkadot default account (alice, etc.) testingAccounts?: boolean; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 49f97e23f36169184492d967e202e314361b0bc6..7b72aa070c0fbb7eed114f044212a8e992deaa0d 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -50,6 +50,7 @@ export const environment = <Environment>{ }, defaultPeers: [ + /* Local endpoint */ //'ws://127.0.0.1:9944', /* GDev endpoints */ 'wss://gdev.coinduf.eu/ws', @@ -61,8 +62,25 @@ export const environment = <Environment>{ ], defaultIndexers: [ + /* Local endpoint */ + //'http://localhost:8080/v1/graphql', + /* GDev endpoints */ 'https://gdev-squid.axiom-team.fr/v1beta1/relay', 'https://squid.gdev.coinduf.eu/v1beta1/relay', //'https://gdev-squid.axiom-team.fr/graphql' ], + + defaultPods: [ + /* Local endpoint */ + // 'http://localhost:8081/v1/graphql' + /* GDev endpoints */ + 'https://datapod.coinduf.eu/v1/graphql', + ], + + defaultIfpsGateways: [ + /* Local endpoint */ + // 'http://localhost:8080' + /* GDev endpoints */ + 'https://pagu.re', + ], }; diff --git a/src/theme/_cesium.scss b/src/theme/_cesium.scss index e397052be0b1fc9b290806489dbf6ad03cb40cff..a1f3690755e9c6f8884ae91de42f9e645add2aa6 100644 --- a/src/theme/_cesium.scss +++ b/src/theme/_cesium.scss @@ -57,6 +57,8 @@ ion-list { --border-radius: 5px !important; --border-width: 1px !important; --border-color: var(--ion-color-step-150) !important; + overflow: hidden; + border: var(--border-width, 1px) solid var(--border-color, grey); } a {