diff --git a/blocksuite/affine/shared/src/services/user-service/user-service.ts b/blocksuite/affine/shared/src/services/user-service/user-service.ts index a9c38db645..129ac1dcf5 100644 --- a/blocksuite/affine/shared/src/services/user-service/user-service.ts +++ b/blocksuite/affine/shared/src/services/user-service/user-service.ts @@ -5,8 +5,9 @@ import type { Signal } from '@preact/signals-core'; import type { AffineUserInfo } from './types'; export interface UserService { - getCurrentUser(): AffineUserInfo; - getUserInfo(id: string): Signal; + getCurrentUser(): AffineUserInfo | null; + userInfo$(id: string): Signal; + revalidateUserInfo(id: string): void; } export const UserProvider = createIdentifier( diff --git a/blocksuite/tests-legacy/tsconfig.json b/blocksuite/tests-legacy/tsconfig.json index d4266719e5..d75000b864 100644 --- a/blocksuite/tests-legacy/tsconfig.json +++ b/blocksuite/tests-legacy/tsconfig.json @@ -7,8 +7,8 @@ }, "include": ["./e2e"], "references": [ - { "path": "../blocks" }, { "path": "../framework/block-std" }, + { "path": "../blocks" }, { "path": "../framework/global" }, { "path": "../framework/inline" }, { "path": "../integration-test" }, diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index ba7f903aab..daaf329907 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -7,6 +7,7 @@ import { type PageEditor, } from '@affine/core/blocksuite/editors'; import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai'; +import { AuthService, PublicUserService } from '@affine/core/modules/cloud'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import { DocService, DocsService } from '@affine/core/modules/doc'; import type { @@ -70,6 +71,7 @@ import { type ReferenceReactRenderer, } from '../extensions/reference-renderer'; import { patchSideBarService } from '../extensions/side-bar-service'; +import { patchUserExtensions } from '../extensions/user'; import { patchUserListExtensions } from '../extensions/user-list'; import { BiDirectionalLinkPanel } from './bi-directional-link-panel'; import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title'; @@ -93,6 +95,8 @@ const usePatchSpecs = (mode: DocMode) => { workspaceService, featureFlagService, memberSearchService, + publicUserService, + authService, } = useServices({ PeekViewService, DocService, @@ -101,7 +105,10 @@ const usePatchSpecs = (mode: DocMode) => { EditorService, FeatureFlagService, MemberSearchService, + PublicUserService, + AuthService, }); + const isCloud = workspaceService.workspace.flavour !== 'local'; const framework = useFramework(); const referenceRenderer: ReferenceReactRenderer = useMemo(() => { return function customReference(reference) { @@ -155,11 +162,16 @@ const usePatchSpecs = (mode: DocMode) => { patchPeekViewService(peekViewService), patchOpenDocExtension(), EdgelessClipboardWatcher, - patchUserListExtensions(memberSearchService), patchDocUrlExtensions(framework), patchQuickSearchService(framework), patchSideBarService(framework), patchDocModeService(docService, docsService, editorService), + isCloud + ? [ + patchUserListExtensions(memberSearchService), + patchUserExtensions(publicUserService, authService), + ] + : [], mode === 'edgeless' && enableTurboRenderer ? [ViewportTurboRendererExtension] : [], @@ -185,10 +197,13 @@ const usePatchSpecs = (mode: DocMode) => { referenceRenderer, confirmModal, peekViewService, - memberSearchService, docService, docsService, editorService, + isCloud, + memberSearchService, + publicUserService, + authService, enableTurboRenderer, featureFlagService.flags.enable_pdf_embed_preview.value, ]); diff --git a/packages/frontend/core/src/blocksuite/extensions/user.ts b/packages/frontend/core/src/blocksuite/extensions/user.ts new file mode 100644 index 0000000000..9beef1388f --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/user.ts @@ -0,0 +1,30 @@ +import type { + AuthService, + PublicUserService, +} from '@affine/core/modules/cloud'; +import { UserServiceExtension } from '@blocksuite/affine/blocks'; + +export function patchUserExtensions( + publicUserService: PublicUserService, + authService: AuthService +) { + return UserServiceExtension({ + getCurrentUser() { + const account = authService.session.account$.value; + return account + ? { + id: account.id, + avatar: account.avatar, + name: account.label, + } + : null; + }, + // eslint-disable-next-line rxjs/finnish + userInfo$(id) { + return publicUserService.publicUser$(id).signal; + }, + revalidateUserInfo(id) { + publicUserService.revalidate(id); + }, + }); +} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index a2a11f5a6c..60aa41663c 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -16,6 +16,7 @@ export { EventSourceService } from './services/eventsource'; export { FetchService } from './services/fetch'; export { GraphQLService } from './services/graphql'; export { InvoicesService } from './services/invoices'; +export { PublicUserService } from './services/public-user'; export { SelfhostGenerateLicenseService } from './services/selfhost-generate-license'; export { SelfhostLicenseService } from './services/selfhost-license'; export { ServerService } from './services/server'; @@ -63,6 +64,7 @@ import { EventSourceService } from './services/eventsource'; import { FetchService } from './services/fetch'; import { GraphQLService } from './services/graphql'; import { InvoicesService } from './services/invoices'; +import { PublicUserService } from './services/public-user'; import { SelfhostGenerateLicenseService } from './services/selfhost-generate-license'; import { SelfhostLicenseService } from './services/selfhost-license'; import { ServerService } from './services/server'; @@ -79,6 +81,7 @@ import { AuthStore } from './stores/auth'; import { CloudDocMetaStore } from './stores/cloud-doc-meta'; import { InviteInfoStore } from './stores/invite-info'; import { InvoicesStore } from './stores/invoices'; +import { PublicUserStore } from './stores/public-user'; import { SelfhostGenerateLicenseStore } from './stores/selfhost-generate-license'; import { SelfhostLicenseStore } from './stores/selfhost-license'; import { ServerConfigStore } from './stores/server-config'; @@ -147,7 +150,9 @@ export function configureCloudModule(framework: Framework) { .store(SelfhostGenerateLicenseStore, [GraphQLService]) .store(InviteInfoStore, [GraphQLService]) .service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore]) - .store(AcceptInviteStore, [GraphQLService]); + .store(AcceptInviteStore, [GraphQLService]) + .service(PublicUserService, [PublicUserStore]) + .store(PublicUserStore, [GraphQLService]); framework .scope(WorkspaceScope) diff --git a/packages/frontend/core/src/modules/cloud/services/public-user.ts b/packages/frontend/core/src/modules/cloud/services/public-user.ts new file mode 100644 index 0000000000..b97a1163ba --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/public-user.ts @@ -0,0 +1,75 @@ +import { + effect, + fromPromise, + LiveData, + Service, + smartRetry, +} from '@toeverything/infra'; +import { catchError, EMPTY, exhaustMap, groupBy, mergeMap } from 'rxjs'; + +import type { PublicUserStore } from '../stores/public-user'; + +type RemovedUserInfo = { + id: string; + removed: true; +}; + +type ExistedUserInfo = { + id: string; + name?: string | null; + avatar?: string | null; + removed?: false; +}; + +export type PublicUserInfo = RemovedUserInfo | ExistedUserInfo; + +export class PublicUserService extends Service { + constructor(private readonly store: PublicUserStore) { + super(); + } + + publicUsers$ = new LiveData>(new Map()); + + publicUser$(id: string) { + return this.publicUsers$.selector(map => map.get(id) ?? null); + } + + error$ = new LiveData(null); + + revalidate = effect( + groupBy((id: string) => id), + mergeMap(id$ => + id$.pipe( + exhaustMap(id => + fromPromise(async signal => { + const user = await this.store.getPublicUserById(id, signal); + if (!user) { + return { + id, + removed: true, + }; + } + return { + id, + name: user.name, + avatarUrl: user.avatarUrl, + }; + }).pipe( + smartRetry(), + catchError(error => { + console.error(error); + this.error$.next(error); + return EMPTY; + }), + mergeMap(user => { + const publicUsers = new Map(this.publicUsers$.value); + publicUsers.set(user.id, user); + this.publicUsers$.next(publicUsers); + return EMPTY; + }) + ) + ) + ) + ) + ); +} diff --git a/packages/frontend/core/src/modules/cloud/stores/public-user.ts b/packages/frontend/core/src/modules/cloud/stores/public-user.ts new file mode 100644 index 0000000000..59debcd19c --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/public-user.ts @@ -0,0 +1,24 @@ +import { getPublicUserByIdQuery } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../services/graphql'; + +export class PublicUserStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async getPublicUserById(id: string, signal?: AbortSignal) { + const result = await this.gqlService.gql({ + query: getPublicUserByIdQuery, + variables: { + id, + }, + context: { + signal, + }, + }); + + return result.publicUserById; + } +} diff --git a/packages/frontend/graphql/src/graphql/get-public-user-by-id.gql b/packages/frontend/graphql/src/graphql/get-public-user-by-id.gql new file mode 100644 index 0000000000..0a3ed0bfa5 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-public-user-by-id.gql @@ -0,0 +1,7 @@ +query getPublicUserById($id: String!) { + publicUserById(id: $id) { + id + avatarUrl + name + } +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index f2fee4d4b4..13faf7cb33 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -643,6 +643,18 @@ export const getPageGrantedUsersListQuery = { }`, }; +export const getPublicUserByIdQuery = { + id: 'getPublicUserByIdQuery' as const, + op: 'getPublicUserById', + query: `query getPublicUserById($id: String!) { + publicUserById(id: $id) { + id + avatarUrl + name + } +}`, +}; + export const getServerRuntimeConfigQuery = { id: 'getServerRuntimeConfigQuery' as const, op: 'getServerRuntimeConfig', diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index ab3046bc7f..e4049ffe7e 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -438,6 +438,7 @@ export type ErrorDataUnion = | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType + | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType @@ -453,6 +454,7 @@ export type ErrorDataUnion = | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType + | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType @@ -527,7 +529,11 @@ export enum ErrorNames { MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE', MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', + MENTION_USER_DOC_ACCESS_DENIED = 'MENTION_USER_DOC_ACCESS_DENIED', + MENTION_USER_ONESELF_DENIED = 'MENTION_USER_ONESELF_DENIED', MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER', + NETWORK_ERROR = 'NETWORK_ERROR', + NOTIFICATION_NOT_FOUND = 'NOTIFICATION_NOT_FOUND', NOT_FOUND = 'NOT_FOUND', NOT_IN_SPACE = 'NOT_IN_SPACE', NO_COPILOT_PROVIDER_AVAILABLE = 'NO_COPILOT_PROVIDER_AVAILABLE', @@ -557,6 +563,7 @@ export enum ErrorNames { UNSUPPORTED_SUBSCRIPTION_PLAN = 'UNSUPPORTED_SUBSCRIPTION_PLAN', USER_AVATAR_NOT_FOUND = 'USER_AVATAR_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND', + VALIDATION_ERROR = 'VALIDATION_ERROR', VERSION_REJECTED = 'VERSION_REJECTED', WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION', WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION', @@ -615,7 +622,7 @@ export interface GrantDocUserRolesInput { export interface GrantedDocUserType { __typename?: 'GrantedDocUserType'; role: DocRole; - user: PublicUserType; + user: WorkspaceUserType; } export interface GrantedDocUserTypeEdge { @@ -664,6 +671,36 @@ export interface InvalidRuntimeConfigTypeDataType { want: Scalars['String']['output']; } +export interface InvitationAcceptedNotificationBodyType { + __typename?: 'InvitationAcceptedNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + inviteId: Scalars['String']['output']; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + +export interface InvitationBlockedNotificationBodyType { + __typename?: 'InvitationBlockedNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + inviteId: Scalars['String']['output']; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + +export interface InvitationNotificationBodyType { + __typename?: 'InvitationNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + inviteId: Scalars['ID']['output']; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + export interface InvitationType { __typename?: 'InvitationType'; /** Invitee information */ @@ -804,6 +841,44 @@ export interface MemberNotFoundInSpaceDataType { spaceId: Scalars['String']['output']; } +export interface MentionDocInput { + /** The block id in the doc */ + blockId?: InputMaybe; + /** The element id in the doc */ + elementId?: InputMaybe; + id: Scalars['String']['input']; + title: Scalars['String']['input']; +} + +export interface MentionDocType { + __typename?: 'MentionDocType'; + blockId: Maybe; + elementId: Maybe; + id: Scalars['String']['output']; + title: Scalars['String']['output']; +} + +export interface MentionInput { + doc: MentionDocInput; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MentionNotificationBodyType { + __typename?: 'MentionNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + doc: MentionDocType; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + +export interface MentionUserDocAccessDeniedDataType { + __typename?: 'MentionUserDocAccessDeniedDataType'; + docId: Scalars['String']['output']; +} + export interface MissingOauthQueryParameterDataType { __typename?: 'MissingOauthQueryParameterDataType'; name: Scalars['String']['output']; @@ -856,9 +931,13 @@ export interface Mutation { invite: Scalars['String']['output']; inviteBatch: Array; leaveWorkspace: Scalars['Boolean']['output']; + /** mention user in a doc */ + mentionUser: Scalars['ID']['output']; publishDoc: DocType; /** @deprecated use publishDoc instead */ publishPage: DocType; + /** mark notification as read */ + readNotification: Scalars['Boolean']['output']; recoverDoc: Scalars['DateTime']['output']; releaseDeletedBlobs: Scalars['Boolean']['output']; /** Remove user avatar */ @@ -1047,6 +1126,10 @@ export interface MutationLeaveWorkspaceArgs { workspaceName?: InputMaybe; } +export interface MutationMentionUserArgs { + input: MentionInput; +} + export interface MutationPublishDocArgs { docId: Scalars['String']['input']; mode?: InputMaybe; @@ -1059,6 +1142,10 @@ export interface MutationPublishPageArgs { workspaceId: Scalars['String']['input']; } +export interface MutationReadNotificationArgs { + id: Scalars['String']['input']; +} + export interface MutationRecoverDocArgs { guid: Scalars['String']['input']; timestamp: Scalars['DateTime']['input']; @@ -1201,6 +1288,58 @@ export interface NotInSpaceDataType { spaceId: Scalars['String']['output']; } +/** Notification level */ +export enum NotificationLevel { + Default = 'Default', + High = 'High', + Low = 'Low', + Min = 'Min', + None = 'None', +} + +export interface NotificationObjectType { + __typename?: 'NotificationObjectType'; + /** Just a placeholder to export UnionNotificationBodyType, don't use it */ + _placeholderForUnionNotificationBodyType: UnionNotificationBodyType; + /** The body of the notification, different types have different fields, see UnionNotificationBodyType */ + body: Scalars['JSONObject']['output']; + /** The created at time of the notification */ + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + /** The level of the notification */ + level: NotificationLevel; + /** Whether the notification has been read */ + read: Scalars['Boolean']['output']; + /** The type of the notification */ + type: NotificationType; + /** The updated at time of the notification */ + updatedAt: Scalars['DateTime']['output']; +} + +export interface NotificationObjectTypeEdge { + __typename?: 'NotificationObjectTypeEdge'; + cursor: Scalars['String']['output']; + node: NotificationObjectType; +} + +/** Notification type */ +export enum NotificationType { + Invitation = 'Invitation', + InvitationAccepted = 'InvitationAccepted', + InvitationBlocked = 'InvitationBlocked', + InvitationRejected = 'InvitationRejected', + Mention = 'Mention', +} + +export interface NotificationWorkspaceType { + __typename?: 'NotificationWorkspaceType'; + /** Workspace avatar url */ + avatarUrl: Maybe; + id: Scalars['ID']['output']; + /** Workspace name */ + name: Scalars['String']['output']; +} + export enum OAuthProviderType { GitHub = 'GitHub', Google = 'Google', @@ -1222,6 +1361,13 @@ export interface PaginatedGrantedDocUserType { totalCount: Scalars['Int']['output']; } +export interface PaginatedNotificationObjectType { + __typename?: 'PaginatedNotificationObjectType'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +} + export interface PaginationInput { /** returns the elements in the list that come after the specified cursor. */ after?: InputMaybe; @@ -1254,7 +1400,6 @@ export enum PublicDocMode { export interface PublicUserType { __typename?: 'PublicUserType'; avatarUrl: Maybe; - email: Scalars['String']['output']; id: Scalars['String']['output']; name: Scalars['String']['output']; } @@ -1281,6 +1426,8 @@ export interface Query { /** List all copilot prompts */ listCopilotPrompts: Array; prices: Array; + /** Get public user by id */ + publicUserById: Maybe; /** server config */ serverConfig: ServerConfigType; /** get all server runtime configurable settings */ @@ -1323,6 +1470,10 @@ export interface QueryIsOwnerArgs { workspaceId: Scalars['String']['input']; } +export interface QueryPublicUserByIdArgs { + id: Scalars['String']['input']; +} + export interface QueryUserArgs { email: Scalars['String']['input']; } @@ -1566,6 +1717,12 @@ export enum SubscriptionVariant { Onetime = 'Onetime', } +export type UnionNotificationBodyType = + | InvitationAcceptedNotificationBodyType + | InvitationBlockedNotificationBodyType + | InvitationNotificationBodyType + | MentionNotificationBodyType; + export interface UnknownOauthProviderDataType { __typename?: 'UnknownOauthProviderDataType'; name: Scalars['String']['output']; @@ -1671,6 +1828,10 @@ export interface UserType { invoices: Array; /** User name */ name: Scalars['String']['output']; + /** Get user notification count */ + notificationCount: Scalars['Int']['output']; + /** Get current user notifications */ + notifications: PaginatedNotificationObjectType; quota: UserQuotaType; quotaUsage: UserQuotaUsageType; subscriptions: Array; @@ -1687,6 +1848,15 @@ export interface UserTypeInvoicesArgs { take?: InputMaybe; } +export interface UserTypeNotificationsArgs { + pagination: PaginationInput; +} + +export interface ValidationErrorDataType { + __typename?: 'ValidationErrorDataType'; + errors: Scalars['String']['output']; +} + export interface VersionRejectedDataType { __typename?: 'VersionRejectedDataType'; serverVersion: Scalars['String']['output']; @@ -1873,6 +2043,14 @@ export interface WorkspaceTypePublicPageArgs { pageId: Scalars['String']['input']; } +export interface WorkspaceUserType { + __typename?: 'WorkspaceUserType'; + avatarUrl: Maybe; + email: Scalars['String']['output']; + id: Scalars['String']['output']; + name: Scalars['String']['output']; +} + export interface WrongSignInCredentialsDataType { __typename?: 'WrongSignInCredentialsDataType'; email: Scalars['String']['output']; @@ -2599,7 +2777,7 @@ export type GetPageGrantedUsersListQuery = { __typename?: 'GrantedDocUserType'; role: DocRole; user: { - __typename?: 'PublicUserType'; + __typename?: 'WorkspaceUserType'; id: string; name: string; email: string; @@ -2612,6 +2790,20 @@ export type GetPageGrantedUsersListQuery = { }; }; +export type GetPublicUserByIdQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type GetPublicUserByIdQuery = { + __typename?: 'Query'; + publicUserById: { + __typename?: 'PublicUserType'; + id: string; + avatarUrl: string | null; + name: string; + } | null; +}; + export type GetServerRuntimeConfigQueryVariables = Exact<{ [key: string]: never; }>; @@ -3586,6 +3778,11 @@ export type Queries = variables: GetPageGrantedUsersListQueryVariables; response: GetPageGrantedUsersListQuery; } + | { + name: 'getPublicUserByIdQuery'; + variables: GetPublicUserByIdQueryVariables; + response: GetPublicUserByIdQuery; + } | { name: 'getServerRuntimeConfigQuery'; variables: GetServerRuntimeConfigQueryVariables;