Merge branch 'canary' into zzj/feat/database-block/upgrade-prompt

This commit is contained in:
3720
2025-05-14 01:28:25 +08:00
committed by GitHub
352 changed files with 7012 additions and 6659 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ai_prompts_metadata" ADD COLUMN "optional_models" VARCHAR[] DEFAULT ARRAY[]::VARCHAR[];
+1
View File
@@ -397,6 +397,7 @@ model AiPrompt {
// it is only used in the frontend and does not affect the backend
action String? @db.VarChar
model String @db.VarChar
optionalModels String[] @default([]) @db.VarChar @map("optional_models")
config Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
@@ -62,7 +62,7 @@ export interface ChatEvent {
}
type CheckResult = {
model: string | undefined;
model: string;
hasAttachment?: boolean;
};
@@ -94,7 +94,8 @@ export class CopilotController implements BeforeApplicationShutdown {
private async checkRequest(
userId: string,
sessionId: string,
messageId?: string
messageId?: string,
modelId?: string
): Promise<CheckResult> {
await this.chatSession.checkQuota(userId);
const session = await this.chatSession.get(sessionId);
@@ -102,7 +103,13 @@ export class CopilotController implements BeforeApplicationShutdown {
throw new CopilotSessionNotFound();
}
const ret: CheckResult = { model: session.model };
const ret: CheckResult = {
model: session.model,
};
if (modelId && session.optionalModels.includes(modelId)) {
ret.model = modelId;
}
if (messageId && typeof messageId === 'string') {
const message = await session.getMessageById(messageId);
@@ -116,13 +123,16 @@ export class CopilotController implements BeforeApplicationShutdown {
private async chooseTextProvider(
userId: string,
sessionId: string,
messageId?: string
): Promise<CopilotTextProvider> {
messageId?: string,
modelId?: string
): Promise<{ provider: CopilotTextProvider; model: string }> {
const { hasAttachment, model } = await this.checkRequest(
userId,
sessionId,
messageId
messageId,
modelId
);
let provider = await this.provider.getProviderByCapability(
CopilotCapability.TextToText,
{ model }
@@ -138,7 +148,7 @@ export class CopilotController implements BeforeApplicationShutdown {
throw new NoCopilotProviderAvailable();
}
return provider;
return { provider, model };
}
private async appendSessionMessage(
@@ -182,13 +192,17 @@ export class CopilotController implements BeforeApplicationShutdown {
const webSearch = Array.isArray(params.webSearch)
? Boolean(params.webSearch[0])
: Boolean(params.webSearch);
const modelId = Array.isArray(params.modelId)
? params.modelId[0]
: params.modelId;
delete params.messageId;
delete params.retry;
delete params.reasoning;
delete params.webSearch;
delete params.modelId;
return { messageId, retry, reasoning, webSearch, params };
return { messageId, retry, reasoning, webSearch, modelId, params };
}
private getSignal(req: Request) {
@@ -236,13 +250,14 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params };
try {
const { messageId, retry, reasoning, webSearch } =
const { messageId, retry, reasoning, webSearch, modelId } =
this.prepareParams(params);
const provider = await this.chooseTextProvider(
const { provider, model } = await this.chooseTextProvider(
user.id,
sessionId,
messageId
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
@@ -251,8 +266,8 @@ export class CopilotController implements BeforeApplicationShutdown {
retry
);
info.model = session.model;
metrics.ai.counter('chat_calls').add(1, { model: session.model });
info.model = model;
metrics.ai.counter('chat_calls').add(1, { model });
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
@@ -264,7 +279,7 @@ export class CopilotController implements BeforeApplicationShutdown {
const finalMessage = session.finish(params);
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
const content = await provider.generateText(finalMessage, session.model, {
const content = await provider.generateText(finalMessage, model, {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
@@ -302,13 +317,14 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params, throwInStream: false };
try {
const { messageId, retry, reasoning, webSearch } =
const { messageId, retry, reasoning, webSearch, modelId } =
this.prepareParams(params);
const provider = await this.chooseTextProvider(
const { provider, model } = await this.chooseTextProvider(
user.id,
sessionId,
messageId
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
@@ -317,8 +333,8 @@ export class CopilotController implements BeforeApplicationShutdown {
retry
);
info.model = session.model;
metrics.ai.counter('chat_stream_calls').add(1, { model: session.model });
info.model = model;
metrics.ai.counter('chat_stream_calls').add(1, { model });
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
@@ -332,7 +348,7 @@ export class CopilotController implements BeforeApplicationShutdown {
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
const source$ = from(
provider.generateTextStream(finalMessage, session.model, {
provider.generateTextStream(finalMessage, model, {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
@@ -41,6 +41,7 @@ export class ChatPrompt {
options.name,
options.action || undefined,
options.model,
options.optionalModels,
options.config,
options.messages
);
@@ -50,6 +51,7 @@ export class ChatPrompt {
public readonly name: string,
public readonly action: string | undefined,
public readonly model: string,
public readonly optionalModels: string[],
public readonly config: PromptConfig | undefined,
private readonly messages: PromptMessage[]
) {
@@ -5,8 +5,15 @@ import { PromptConfig, PromptMessage } from '../providers';
type Prompt = Omit<
AiPrompt,
'id' | 'createdAt' | 'updatedAt' | 'modified' | 'action' | 'config'
| 'id'
| 'createdAt'
| 'updatedAt'
| 'modified'
| 'action'
| 'config'
| 'optionalModels'
> & {
optionalModels?: string[];
action?: string;
messages: PromptMessage[];
config?: PromptConfig;
@@ -1037,7 +1044,13 @@ Finally, please only send us the content of your continuation in Markdown Format
const chat: Prompt[] = [
{
name: 'Chat With AFFiNE AI',
model: 'o4-mini',
model: 'gpt-4.1',
optionalModels: [
'o3',
'o4-mini',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
],
messages: [
{
role: 'system',
@@ -1161,14 +1174,15 @@ export async function refreshPrompts(db: PrismaClient) {
create: {
name: prompt.name,
action: prompt.action,
config: prompt.config || undefined,
config: prompt.config ?? undefined,
model: prompt.model,
optionalModels: prompt.optionalModels,
messages: {
create: prompt.messages.map((message, idx) => ({
idx,
role: message.role,
content: message.content,
params: message.params || undefined,
params: message.params ?? undefined,
})),
},
},
@@ -1177,6 +1191,7 @@ export async function refreshPrompts(db: PrismaClient) {
action: prompt.action,
config: prompt.config ?? undefined,
model: prompt.model,
optionalModels: prompt.optionalModels,
updatedAt: new Date(),
messages: {
deleteMany: {},
@@ -1184,7 +1199,7 @@ export async function refreshPrompts(db: PrismaClient) {
idx,
role: message.role,
content: message.content,
params: message.params || undefined,
params: message.params ?? undefined,
})),
},
},
@@ -64,6 +64,7 @@ export class PromptService implements OnApplicationBootstrap {
name: true,
action: true,
model: true,
optionalModels: true,
config: true,
messages: {
select: {
@@ -34,7 +34,10 @@ export class AnthropicProvider
{
override readonly type = CopilotProviderType.Anthropic;
override readonly capabilities = [CopilotCapability.TextToText];
override readonly models = ['claude-3-7-sonnet-20250219'];
override readonly models = [
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
];
private readonly MAX_STEPS = 20;
@@ -74,6 +74,7 @@ export class OpenAIProvider
'gpt-4.1-2025-04-14',
'gpt-4.1-mini',
'o1',
'o3',
'o4-mini',
// embeddings
'text-embedding-3-large',
@@ -110,12 +110,12 @@ export type CopilotImageOptions = z.infer<typeof CopilotImageOptionsSchema>;
export interface CopilotTextToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model?: string,
model: string,
options?: CopilotChatOptions
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model?: string,
model: string,
options?: CopilotChatOptions
): AsyncIterable<string>;
}
@@ -136,7 +136,7 @@ export interface CopilotTextToImageProvider extends CopilotProvider {
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
model: string,
options?: CopilotImageOptions
): AsyncIterable<string>;
}
@@ -145,12 +145,12 @@ export interface CopilotImageToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model: string,
options?: CopilotChatOptions
options: CopilotChatOptions
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model: string,
options?: CopilotChatOptions
options: CopilotChatOptions
): AsyncIterable<string>;
}
@@ -162,7 +162,7 @@ export interface CopilotImageToImageProvider extends CopilotProvider {
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
model: string,
options?: CopilotImageOptions
): AsyncIterable<string>;
}
@@ -45,6 +45,10 @@ export class ChatSession implements AsyncDisposable {
return this.state.prompt.model;
}
get optionalModels() {
return this.state.prompt.optionalModels;
}
get config() {
const {
sessionId,
-17
View File
@@ -55,23 +55,6 @@ export const collectionSchema = z.object({
createDate: z.union([z.date(), z.number()]).optional(),
updateDate: z.union([z.date(), z.number()]).optional(),
});
export const deletedCollectionSchema = z.object({
userId: z.string().optional(),
userName: z.string(),
collection: collectionSchema,
});
export type DeprecatedCollection = {
id: string;
name: string;
workspaceId: string;
filterList: z.infer<typeof filterSchema>[];
allowList?: string[];
};
export type Collection = z.input<typeof collectionSchema>;
export type DeleteCollectionInfo = {
userId: string;
userName: string;
} | null;
export type DeletedCollection = z.input<typeof deletedCollectionSchema>;
export type PropertiesMeta = DocsPropertiesMeta;
@@ -1,3 +0,0 @@
query getIsAdmin($workspaceId: String!) {
isAdmin(workspaceId: $workspaceId)
}
@@ -1,3 +0,0 @@
query getIsOwner($workspaceId: String!) {
isOwner(workspaceId: $workspaceId)
}
@@ -1,7 +1,6 @@
query getWorkspaceInfo($workspaceId: String!) {
isAdmin(workspaceId: $workspaceId)
isOwner(workspaceId: $workspaceId)
workspace(id: $workspaceId) {
role
team
}
}
+1 -21
View File
@@ -1036,24 +1036,6 @@ export const getInviteInfoQuery = {
}`,
};
export const getIsAdminQuery = {
id: 'getIsAdminQuery' as const,
op: 'getIsAdmin',
query: `query getIsAdmin($workspaceId: String!) {
isAdmin(workspaceId: $workspaceId)
}`,
deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead"],
};
export const getIsOwnerQuery = {
id: 'getIsOwnerQuery' as const,
op: 'getIsOwner',
query: `query getIsOwner($workspaceId: String!) {
isOwner(workspaceId: $workspaceId)
}`,
deprecations: ["'isOwner' is deprecated: use WorkspaceType[role] instead"],
};
export const getMemberCountByWorkspaceIdQuery = {
id: 'getMemberCountByWorkspaceIdQuery' as const,
op: 'getMemberCountByWorkspaceId',
@@ -1185,13 +1167,11 @@ export const getWorkspaceInfoQuery = {
id: 'getWorkspaceInfoQuery' as const,
op: 'getWorkspaceInfo',
query: `query getWorkspaceInfo($workspaceId: String!) {
isAdmin(workspaceId: $workspaceId)
isOwner(workspaceId: $workspaceId)
workspace(id: $workspaceId) {
role
team
}
}`,
deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead","'isOwner' is deprecated: use WorkspaceType[role] instead"],
};
export const getWorkspacePageByIdQuery = {
+1 -25
View File
@@ -3665,18 +3665,6 @@ export type GetInviteInfoQuery = {
};
};
export type GetIsAdminQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetIsAdminQuery = { __typename?: 'Query'; isAdmin: boolean };
export type GetIsOwnerQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean };
export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
@@ -3829,9 +3817,7 @@ export type GetWorkspaceInfoQueryVariables = Exact<{
export type GetWorkspaceInfoQuery = {
__typename?: 'Query';
isAdmin: boolean;
isOwner: boolean;
workspace: { __typename?: 'WorkspaceType'; team: boolean };
workspace: { __typename?: 'WorkspaceType'; role: Permission; team: boolean };
};
export type GetWorkspacePageByIdQueryVariables = Exact<{
@@ -4825,16 +4811,6 @@ export type Queries =
variables: GetInviteInfoQueryVariables;
response: GetInviteInfoQuery;
}
| {
name: 'getIsAdminQuery';
variables: GetIsAdminQueryVariables;
response: GetIsAdminQuery;
}
| {
name: 'getIsOwnerQuery';
variables: GetIsOwnerQueryVariables;
response: GetIsOwnerQuery;
}
| {
name: 'getMemberCountByWorkspaceIdQuery';
variables: GetMemberCountByWorkspaceIdQueryVariables;
@@ -182,7 +182,7 @@ export function yjsObservePath(yjs?: any, path?: string) {
* observable will automatically update when yjs data changed.
*
* @example
* yjsObserveDeep(yjs) -> emit when any of children changed
* yjsObserve(yjs) -> emit when yjs type changed
*/
export function yjsObserve(yjs?: any) {
return new Observable(subscriber => {
@@ -170,24 +170,27 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
onSelect={openResetPasswordDialog}
>
<LockIcon fontSize={20} /> Reset Password
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleEdit}
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
>
<EditIcon fontSize={20} /> Edit
<EditIcon fontSize={20} />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
onSelect={openResetPasswordDialog}
>
<LockIcon fontSize={20} />
{user.hasPassword ? 'Reset Password' : 'Setup Account'}
</DropdownMenuItem>
{user.disabled && (
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
onSelect={openEnableDialog}
>
<AccountBanIcon fontSize={20} /> Enable Email
<AccountBanIcon fontSize={20} />
Enable Email
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -196,14 +199,16 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
onSelect={openDisableDialog}
>
<AccountBanIcon fontSize={20} /> Disable & Delete data
<AccountBanIcon fontSize={20} />
Disable & Delete data
</DropdownMenuItem>
)}
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
onSelect={openDeleteDialog}
>
<DeleteIcon fontSize={20} /> Delete
<DeleteIcon fontSize={20} />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema';
import { DiscardChanges } from './discard-changes';
import { ExportUsersDialog } from './export-users-dialog';
import { ImportUsersDialog } from './import-users-dialog';
import { ImportUsersDialog } from './import-users';
import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> {
@@ -1,314 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
downloadCsvTemplate,
exportImportResults,
getValidUsersToImport,
ImportStatus,
type ParsedUser,
processCSVFile,
} from '../utils/csv-utils';
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
import {
useImportUsers,
type UserImportReturnType,
} from './use-user-management';
import { UserTable } from './user-table';
interface ImportUsersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImportUsersDialog({
open,
onOpenChange,
}: ImportUsersDialogProps) {
const [isImporting, setIsImporting] = useState(false);
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [isFormatError, setIsFormatError] = useState(false);
const importUsers = useImportUsers();
const fileUploadRef = useRef<FileUploadAreaRef>(null);
const handleUpload = useCallback(
() => fileUploadRef.current?.triggerFileUpload(),
[]
);
// Reset all states when dialog is closed
useEffect(() => {
if (open) {
setIsPreviewMode(false);
setParsedUsers([]);
setIsImporting(false);
setIsFormatError(false);
}
}, [open]);
const importUsersCallback = useCallback(
(result: UserImportReturnType) => {
const successfulUsers = result.filter(
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
user.__typename === 'UserType'
);
const failedUsers = result.filter(
(
user
): user is Extract<
typeof user,
{ __typename: 'UserImportFailedType' }
> => user.__typename === 'UserImportFailedType'
);
const successCount = successfulUsers.length;
const failedCount = parsedUsers.length - successCount;
if (failedCount > 0) {
toast.info(
`Successfully imported ${successCount} users, ${failedCount} failed`
);
} else {
toast.success(`Successfully imported ${successCount} users`);
}
const successfulUserEmails = new Set(
successfulUsers.map(user => user.email)
);
const failedUserErrorMap = new Map(
failedUsers.map(user => [user.email, user.error])
);
setParsedUsers(prev => {
return prev.map(user => {
if (successfulUserEmails.has(user.email)) {
return {
...user,
importStatus: ImportStatus.Success,
};
}
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
return {
...user,
importStatus: ImportStatus.Failed,
importError: errorMessage,
};
});
});
setIsImporting(false);
},
[parsedUsers.length, setIsImporting]
);
const handleFileSelected = useCallback(async (file: File) => {
setIsImporting(true);
try {
await processCSVFile(
file,
validatedUsers => {
setParsedUsers(validatedUsers);
setIsPreviewMode(true);
setIsImporting(false);
},
() => {
setIsImporting(false);
setIsFormatError(true);
}
);
} catch (error) {
console.error('Failed to process file', error);
setIsImporting(false);
setIsFormatError(true);
}
}, []);
const confirmImport = useAsyncCallback(async () => {
setIsImporting(true);
try {
const validUsersToImport = getValidUsersToImport(parsedUsers);
setParsedUsers(prev =>
prev.map(user =>
user.valid ? { ...user, importStatus: ImportStatus.Processing } : user
)
);
await importUsers({ users: validUsersToImport }, importUsersCallback);
// Note: setIsImporting(false) is now handled in importUsersCallback
} catch (error) {
console.error('Failed to import users', error);
toast.error('Failed to import users');
setIsImporting(false);
}
}, [importUsers, importUsersCallback, parsedUsers]);
const cancelImport = useCallback(() => {
setIsPreviewMode(false);
setParsedUsers([]);
}, []);
const resetFormatError = useCallback(() => {
setIsFormatError(false);
}, []);
// Handle closing the dialog after import is complete
const handleDone = useCallback(() => {
// Reset all states and close the dialog
setIsPreviewMode(false);
setParsedUsers([]);
setIsImporting(false);
setIsFormatError(false);
onOpenChange(false);
}, [onOpenChange]);
// Export failed imports to CSV
const exportResult = useCallback(() => {
exportImportResults(parsedUsers);
}, [parsedUsers]);
const isImported = useMemo(() => {
return parsedUsers.some(
user => user.importStatus && user.importStatus !== ImportStatus.Processing
);
}, [parsedUsers]);
const handleConfirm = useCallback(() => {
if (isImported) {
exportResult();
handleDone();
} else {
confirmImport();
}
}, [confirmImport, exportResult, handleDone, isImported]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={
isPreviewMode ? 'sm:max-w-[600px] flex-col' : 'sm:max-w-[425px]'
}
>
<DialogHeader>
<DialogTitle>
{isFormatError
? 'Incorrect import format'
: isPreviewMode
? isImported
? 'Import results'
: 'Confirm import'
: 'Import'}
</DialogTitle>
</DialogHeader>
<DialogDescription className="text-[15px] mt-3">
{isFormatError ? (
'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.'
) : isPreviewMode ? (
<div className="grid gap-3">
{isImported ? null : (
<p>
{parsedUsers.length} users detected from the CSV file. Please
confirm the user list below and import.
</p>
)}
<UserTable users={parsedUsers} />
</div>
) : (
<div className="grid gap-3">
<p>
You need to import the accounts by importing a CSV file in the
correct format. Please download the CSV template.
</p>
<FileUploadArea
ref={fileUploadRef}
onFileSelected={handleFileSelected}
/>
</div>
)}
</DialogDescription>
<DialogFooter
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'}`}
>
{isFormatError ? (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 text-[15px] px-0 py-2 h-10 underline cursor-pointer"
>
CSV template
</div>
<Button
type="button"
variant="outline"
onClick={resetFormatError}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
>
Done
</Button>
</>
) : isPreviewMode ? (
<>
<Button
variant="outline"
onClick={cancelImport}
className="w-full mb-2 sm:mb-0 sm:w-auto"
disabled={isImporting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={
isImporting ||
parsedUsers.some(
user => user.importStatus === ImportStatus.Processing
)
}
>
{isImporting
? 'Importing...'
: isImported
? 'Export'
: 'Confirm Import'}
</Button>
</>
) : (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
>
CSV template
</div>
<Button
type="button"
onClick={handleUpload}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={isImporting}
>
{isImporting ? 'Parsing...' : 'Choose a file'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,51 @@
import { WarningIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import type { FC } from 'react';
interface CsvFormatGuidanceProps {
passwordLimits: {
minLength: number;
maxLength: number;
};
}
/**
* Component that displays CSV format guidelines
*/
export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
passwordLimits,
}) => {
return (
<div
className="flex p-1.5 gap-1"
style={{
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
backgroundColor: cssVarV2('layer/background/secondary'),
}}
>
<div className="flex justify-center py-0.5">
<WarningIcon fontSize={16} color={cssVarV2('icon/primary')} />
</div>
<div>
<p>CSV file includes username, email, and password.</p>
<ul>
{[
`Username (optional): any text.`,
`Email (required): e.g., user@example.com.`,
`Password (optional): ${passwordLimits.minLength}${passwordLimits.maxLength} characters.`,
].map((text, index) => (
<li
key={`guidance-${index}`}
className="relative pl-2 leading-normal"
>
<span className="absolute left-0 top-2 w-1 h-1 rounded-full bg-current" />
{text}
</li>
))}
</ul>
</div>
</div>
);
};
@@ -0,0 +1,73 @@
import type { FC, RefObject } from 'react';
import type { ParsedUser } from '../../utils/csv-utils';
import { UserTable } from '../user-table';
import { CsvFormatGuidance } from './csv-format-guidance';
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
interface ImportPreviewContentProps {
parsedUsers: ParsedUser[];
isImported: boolean;
}
/**
* Component for the preview mode content
*/
export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
parsedUsers,
isImported,
}) => {
return (
<div className="grid gap-3">
{!isImported && (
<p>
{parsedUsers.length} users detected from the CSV file. Please confirm
the user list below and import.
</p>
)}
<UserTable users={parsedUsers} />
</div>
);
};
interface ImportInitialContentProps {
passwordLimits: {
minLength: number;
maxLength: number;
};
fileUploadRef: RefObject<FileUploadAreaRef | null>;
onFileSelected: (file: File) => Promise<void>;
}
/**
* Component for the initial import screen
*/
export const ImportInitialContent: FC<ImportInitialContentProps> = ({
passwordLimits,
fileUploadRef,
onFileSelected,
}) => {
return (
<div className="grid gap-3">
<p>
You need to import the accounts by importing a CSV file in the correct
format. Please download the CSV template.
</p>
<CsvFormatGuidance passwordLimits={passwordLimits} />
<FileUploadArea ref={fileUploadRef} onFileSelected={onFileSelected} />
</div>
);
};
interface ImportErrorContentProps {
message?: string;
}
/**
* Component for displaying import errors
*/
export const ImportErrorContent: FC<ImportErrorContentProps> = ({
message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.',
}) => {
return message;
};
@@ -0,0 +1,168 @@
import { Button } from '@affine/admin/components/ui/button';
import { DialogFooter } from '@affine/admin/components/ui/dialog';
import type { FC } from 'react';
import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils';
interface ImportUsersFooterProps {
isFormatError: boolean;
isPreviewMode: boolean;
isImporting: boolean;
isImported: boolean;
resetFormatError: () => void;
cancelImport: () => void;
handleConfirm: () => void;
handleUpload: () => void;
parsedUsers: {
importStatus?: ImportStatus;
}[];
}
/**
* Component for the dialog footer with appropriate buttons based on the import state
*/
export const ImportUsersFooter: FC<ImportUsersFooterProps> = ({
isFormatError,
isPreviewMode,
isImporting,
isImported,
resetFormatError,
cancelImport,
handleConfirm,
handleUpload,
parsedUsers,
}) => {
return (
<DialogFooter
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${
isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'
}`}
>
{isFormatError ? (
<FormatErrorFooter
downloadCsvTemplate={downloadCsvTemplate}
resetFormatError={resetFormatError}
/>
) : isPreviewMode ? (
<PreviewModeFooter
isImporting={isImporting}
isImported={isImported}
cancelImport={cancelImport}
handleConfirm={handleConfirm}
parsedUsers={parsedUsers}
/>
) : (
<InitialFooter
isImporting={isImporting}
downloadCsvTemplate={downloadCsvTemplate}
handleUpload={handleUpload}
/>
)}
</DialogFooter>
);
};
interface FormatErrorFooterProps {
downloadCsvTemplate: () => void;
resetFormatError: () => void;
}
/**
* Footer component when there's a format error
*/
const FormatErrorFooter: FC<FormatErrorFooterProps> = ({
downloadCsvTemplate,
resetFormatError,
}) => (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 text-[15px] px-0 py-2 h-10 underline cursor-pointer"
>
CSV template
</div>
<Button
type="button"
variant="outline"
onClick={resetFormatError}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
>
Done
</Button>
</>
);
interface PreviewModeFooterProps {
isImporting: boolean;
isImported: boolean;
cancelImport: () => void;
handleConfirm: () => void;
parsedUsers: {
importStatus?: ImportStatus;
}[];
}
/**
* Footer component for preview mode
*/
const PreviewModeFooter: FC<PreviewModeFooterProps> = ({
isImporting,
isImported,
cancelImport,
handleConfirm,
parsedUsers,
}) => (
<>
<Button
variant="outline"
onClick={cancelImport}
className="w-full mb-2 sm:mb-0 sm:w-auto"
disabled={isImporting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={
isImporting ||
parsedUsers.some(user => user.importStatus === ImportStatus.Processing)
}
>
{isImporting ? 'Importing...' : isImported ? 'Export' : 'Confirm Import'}
</Button>
</>
);
interface InitialFooterProps {
isImporting: boolean;
downloadCsvTemplate: () => void;
handleUpload: () => void;
}
/**
* Footer component for initial state
*/
const InitialFooter: FC<InitialFooterProps> = ({
isImporting,
downloadCsvTemplate,
handleUpload,
}) => (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
>
CSV template
</div>
<Button
type="button"
onClick={handleUpload}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={isImporting}
>
{isImporting ? 'Parsing...' : 'Choose a file'}
</Button>
</>
);
@@ -0,0 +1,109 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { useEffect, useRef } from 'react';
import { useServerConfig } from '../../../common';
import type { FileUploadAreaRef } from './file-upload-area';
import {
ImportErrorContent,
ImportInitialContent,
ImportPreviewContent,
} from './import-content';
import { ImportUsersFooter } from './import-footer';
import { useImportUsersState } from './use-import-users-state';
export interface ImportUsersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Dialog for importing users from a CSV file
*/
export function ImportUsersDialog({
open,
onOpenChange,
}: ImportUsersDialogProps) {
const fileUploadRef = useRef<FileUploadAreaRef>(null);
const serverConfig = useServerConfig();
const passwordLimits = serverConfig.credentialsRequirement.password;
const handleUpload = () => fileUploadRef.current?.triggerFileUpload();
const {
isImporting,
parsedUsers,
isPreviewMode,
isFormatError,
isImported,
handleFileSelected,
cancelImport,
resetFormatError,
handleConfirm,
resetState,
} = useImportUsersState({
passwordLimits,
onClose: () => onOpenChange(false),
});
// Reset all states when dialog is opened
useEffect(() => {
if (open) {
resetState();
}
}, [open, resetState]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={
isPreviewMode ? 'sm:max-w-[720px] flex-col' : 'sm:max-w-[480px]'
}
>
<DialogHeader>
<DialogTitle>
{isFormatError
? 'Incorrect import format'
: isPreviewMode
? isImported
? 'Import results'
: 'Confirm import'
: 'Import'}
</DialogTitle>
</DialogHeader>
<div className="text-[15px] mt-3">
{isFormatError ? (
<ImportErrorContent />
) : isPreviewMode ? (
<ImportPreviewContent
parsedUsers={parsedUsers}
isImported={isImported}
/>
) : (
<ImportInitialContent
passwordLimits={passwordLimits}
fileUploadRef={fileUploadRef}
onFileSelected={handleFileSelected}
/>
)}
</div>
<ImportUsersFooter
isFormatError={isFormatError}
isPreviewMode={isPreviewMode}
isImporting={isImporting}
isImported={isImported}
resetFormatError={resetFormatError}
cancelImport={cancelImport}
handleConfirm={handleConfirm}
handleUpload={handleUpload}
parsedUsers={parsedUsers}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,193 @@
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import {
exportImportResults,
getValidUsersToImport,
ImportStatus,
type ParsedUser,
processCSVFile,
validateUsers,
} from '../../utils/csv-utils';
import {
useImportUsers,
type UserImportReturnType,
} from '../use-user-management';
export interface ImportUsersStateProps {
passwordLimits: {
minLength: number;
maxLength: number;
};
onClose: () => void;
}
export function useImportUsersState({
passwordLimits,
onClose,
}: ImportUsersStateProps) {
const [isImporting, setIsImporting] = useState(false);
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [isFormatError, setIsFormatError] = useState(false);
const importUsers = useImportUsers();
// Reset all states when dialog is closed
const resetState = useCallback(() => {
setIsPreviewMode(false);
setParsedUsers([]);
setIsImporting(false);
setIsFormatError(false);
}, []);
const importUsersCallback = useCallback(
(result: UserImportReturnType) => {
const successfulUsers = result.filter(
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
user.__typename === 'UserType'
);
const failedUsers = result.filter(
(
user
): user is Extract<
typeof user,
{ __typename: 'UserImportFailedType' }
> => user.__typename === 'UserImportFailedType'
);
const successCount = successfulUsers.length;
const failedCount = parsedUsers.length - successCount;
if (failedCount > 0) {
toast.info(
`Successfully imported ${successCount} users, ${failedCount} failed`
);
} else {
toast.success(`Successfully imported ${successCount} users`);
}
const successfulUserEmails = new Set(
successfulUsers.map(user => user.email)
);
const failedUserErrorMap = new Map(
failedUsers.map(user => [user.email, user.error])
);
setParsedUsers(prev => {
return prev.map(user => {
if (successfulUserEmails.has(user.email)) {
return {
...user,
importStatus: ImportStatus.Success,
};
}
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
return {
...user,
importStatus: ImportStatus.Failed,
importError: errorMessage,
};
});
});
setIsImporting(false);
},
[parsedUsers.length]
);
const handleFileSelected = useCallback(
async (file: File) => {
setIsImporting(true);
try {
await processCSVFile(
file,
usersFromCsv => {
const validatedUsers = validateUsers(usersFromCsv, passwordLimits);
setParsedUsers(validatedUsers);
setIsPreviewMode(true);
setIsImporting(false);
},
() => {
setIsImporting(false);
setIsFormatError(true);
}
);
} catch (error) {
console.error('Failed to process file', error);
setIsImporting(false);
setIsFormatError(true);
}
},
[passwordLimits]
);
const confirmImport = useAsyncCallback(async () => {
setIsImporting(true);
try {
const validUsersToImport = getValidUsersToImport(parsedUsers);
setParsedUsers(prev =>
prev.map(user => ({
...user,
importStatus: ImportStatus.Processing,
}))
);
await importUsers({ users: validUsersToImport }, importUsersCallback);
} catch (error) {
console.error('Failed to import users', error);
toast.error('Failed to import users');
setIsImporting(false);
}
}, [importUsers, importUsersCallback, parsedUsers]);
const cancelImport = useCallback(() => {
setIsPreviewMode(false);
setParsedUsers([]);
}, []);
const resetFormatError = useCallback(() => {
setIsFormatError(false);
}, []);
// Handle closing the dialog after import is complete
const handleDone = useCallback(() => {
resetState();
onClose();
}, [onClose, resetState]);
// Export failed imports to CSV
const exportResult = useCallback(() => {
exportImportResults(parsedUsers);
}, [parsedUsers]);
const isImported = !!parsedUsers.some(
user => user.importStatus && user.importStatus !== ImportStatus.Processing
);
const handleConfirm = useCallback(() => {
if (isImported) {
exportResult();
handleDone();
} else {
confirmImport();
}
}, [confirmImport, exportResult, handleDone, isImported]);
return {
isImporting,
parsedUsers,
isPreviewMode,
isFormatError,
isImported,
handleFileSelected,
cancelImport,
resetFormatError,
handleConfirm,
resetState,
};
}
@@ -47,12 +47,13 @@ export const useCreateUser = () => {
const revalidate = useMutateQueryResource();
const create = useAsyncCallback(
async ({ name, email, features }: UserInput) => {
async ({ name, email, password, features }: UserInput) => {
try {
const account = await createAccount({
input: {
name,
email,
password: password === '' ? undefined : password,
},
});
@@ -4,13 +4,16 @@ import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { ChevronRightIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import type { UserInput, UserType } from '../schema';
import { validateEmails, validatePassword } from '../utils/csv-utils';
import { useCreateUser, useUpdateUser } from './use-user-management';
type UserFormProps = {
@@ -20,6 +23,7 @@ type UserFormProps = {
onConfirm: (user: UserInput) => void;
onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode;
showOption?: boolean;
};
function UserForm({
@@ -29,6 +33,7 @@ function UserForm({
onConfirm,
onValidate,
actions,
showOption,
}: UserFormProps) {
const serverConfig = useServerConfig();
@@ -36,9 +41,10 @@ function UserForm({
() => ({
name: defaultValue?.name ?? '',
email: defaultValue?.email ?? '',
password: defaultValue?.password ?? '',
features: defaultValue?.features ?? [],
}),
[defaultValue?.email, defaultValue?.features, defaultValue?.name]
[defaultValue]
);
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
@@ -68,7 +74,8 @@ function UserForm({
// @ts-expect-error checked
onConfirm(changes);
}, [canSave, changes, onConfirm]);
setChanges(defaultUser);
}, [canSave, changes, defaultUser, onConfirm]);
const onFeatureChanged = useCallback(
(feature: FeatureType, checked: boolean) => {
@@ -116,6 +123,19 @@ function UserForm({
onChange={setField}
placeholder="Enter email address"
/>
{showOption && (
<>
<Separator />
<InputItem
label="Password"
field="password"
value={changes.password}
onChange={setField}
optional
placeholder="Enter password"
/>
</>
)}
</div>
<div className="border rounded-md">
@@ -167,12 +187,14 @@ function ToggleItem({
function InputItem({
label,
field,
optional,
value,
onChange,
placeholder,
}: {
label: string;
field: keyof UserInput;
optional?: boolean;
value?: string;
onChange: (field: keyof UserInput, value: string) => void;
placeholder?: string;
@@ -185,9 +207,20 @@ function InputItem({
);
return (
<div className="p-3">
<Label className="text-[15px] leading-6 font-medium mb-1.5">
<div className="flex flex-col gap-1.5 p-3">
<Label
className="text-[15px] font-medium flex-wrap flex"
style={{ lineHeight: '1.6rem' }}
>
{label}
{optional && (
<span
className="font-normal ml-1"
style={{ color: cssVarV2('text/secondary') }}
>
(optional)
</span>
)}
</Label>
<Input
type="text"
@@ -210,6 +243,8 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
const { create, creating } = useCreateUser();
const serverConfig = useServerConfig();
const passwordLimits = serverConfig.credentialsRequirement.password;
useEffect(() => {
if (creating) {
return () => {
@@ -219,12 +254,30 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
return;
}, [creating, onComplete]);
const handleCreateUser = useCallback(
(user: UserInput) => {
const emailValidation = validateEmails([user]);
const passwordValidation = validatePassword(
user.password,
passwordLimits
);
if (!passwordValidation.valid || !emailValidation[0].valid) {
toast.error(passwordValidation.error || emailValidation[0].error);
return;
}
create(user);
},
[create, passwordLimits]
);
return (
<UserForm
title="Create User"
onClose={onComplete}
onConfirm={create}
onConfirm={handleCreateUser}
onValidate={validateCreateUser}
showOption={true}
/>
);
}
@@ -11,7 +11,7 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
return (
<div className="max-h-[300px] overflow-y-auto border rounded-md">
<table className="w-full border-collapse">
<thead className="bg-gray-50 sticky top-0">
<thead className="bg-white sticky top-0">
<tr>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
@@ -19,6 +19,9 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Password
</th>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
@@ -37,10 +40,26 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
{user.name || '-'}
</td>
<td
className={`py-2 px-4 text-sm truncate max-w-[200px] ${user.valid === false ? 'text-red-500' : 'text-gray-900'}`}
className={`py-2 px-4 text-sm truncate max-w-[200px] ${
user.valid === false &&
(user.error?.toLowerCase().includes('email') ||
!user.error?.toLowerCase().includes('password'))
? 'text-red-500'
: 'text-gray-900'
}`}
>
{user.email}
</td>
<td
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
user.valid === false &&
user.error?.toLowerCase().includes('password')
? 'text-red-500'
: 'text-gray-900'
}`}
>
{user.password || '-'}
</td>
<td className="py-2 px-4 text-sm">
{user.importStatus === ImportStatus.Success ? (
<span className="text-gray-900">
@@ -4,5 +4,6 @@ export type UserType = ListUsersQuery['users'][0];
export type UserInput = {
name: string;
email: string;
password?: string;
features: FeatureType[];
};
@@ -5,6 +5,7 @@ import { emailRegex } from '../../../utils';
export interface ParsedUser {
name: string | null;
email: string;
password?: string;
valid?: boolean;
error?: string;
importStatus?: ImportStatus;
@@ -17,6 +18,41 @@ export enum ImportStatus {
Processing = 'processing',
}
export const validatePassword = (
password: string | undefined,
passwordLimits: { minLength: number; maxLength: number }
): { valid: boolean; error?: string } => {
// if password is empty, it is valid
if (!password || password.trim() === '') {
return { valid: true };
}
// check password length
if (
password.length < passwordLimits.minLength ||
password.length > passwordLimits.maxLength
) {
return {
valid: false,
error: 'Invalid password format',
};
}
// TODO(@Jimmfly): check password contains at least one letter and one number
// const hasLetter = /[a-zA-Z]/.test(password);
// const hasNumber = /[0-9]/.test(password);
// if (!hasLetter || !hasNumber) {
// return {
// valid: false,
// error: 'Invalid password format',
// };
// }
return { valid: true };
};
/**
* Validates email addresses for duplicates and format
*/
@@ -53,6 +89,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
.map(user => ({
name: user.name || undefined,
email: user.email,
password: user.password,
}));
};
@@ -60,7 +97,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
* Downloads a CSV template for user import
*/
export const downloadCsvTemplate = () => {
const csvContent = 'Username,Email\n,example@example.com';
const csvContent = 'Username,Email,Password\n,example@example.com,';
downloadCsv(csvContent, 'user_import_template.csv');
};
@@ -69,10 +106,10 @@ export const downloadCsvTemplate = () => {
*/
export const exportImportResults = (results: ParsedUser[]) => {
const csvContent = [
'Username,Email,status',
'Username,Email,Password,Status',
...results.map(
user =>
`${user.name || ''},${user.email},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
`${user.name || ''},${user.email},${user.password || ''},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
),
].join('\n');
@@ -138,6 +175,7 @@ export const processCSVFile = async (
const users = dataRows.map(row => ({
name: row[0]?.trim() || null,
email: row[1]?.trim() || '',
password: row[2]?.trim() || undefined,
}));
const usersWithEmail = users.filter(user => user.email);
@@ -164,3 +202,34 @@ export const processCSVFile = async (
onError();
}
};
/**
* Validate users
*/
export const validateUsers = (
users: ParsedUser[],
passwordLimits: { minLength: number; maxLength: number }
): ParsedUser[] => {
// validate emails
const emailValidatedUsers = validateEmails(users);
// validate password
return emailValidatedUsers.map(user => {
// if email is invalid, return
if (user.valid === false) {
return user;
}
// validate password
const passwordValidation = validatePassword(user.password, passwordLimits);
if (!passwordValidation.valid) {
return {
...user,
valid: false,
error: passwordValidation.error,
};
}
return user;
});
};
@@ -20,7 +20,7 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
res.arrayBuffer()
);
const blob = new Blob([opusBuffer], {
type: 'audio/webm',
type: 'audio/mp4',
});
const blobId = await blobEngine.set(blob);
logger.debug('Recording saved', blobId);
@@ -51,33 +51,35 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
const docProps: DocProps = {
onStoreLoad: (doc, { noteId }) => {
(async () => {
// name + timestamp(readable) + extension
const attachmentName =
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
// add size and sourceId to the attachment later
const attachmentId = doc.addBlock(
'affine:attachment',
{
name: attachmentName,
type: 'audio/opus',
},
noteId
);
const model = doc.getBlock(attachmentId)
?.model as AttachmentBlockModel;
if (model && status.filepath) {
if (status.filepath) {
// it takes a while to save the blob, so we show the attachment first
const { blobId, blob } = await saveRecordingBlob(
doc.workspace.blobSync,
status.filepath
);
model.props.size = blob.size;
model.props.sourceId = blobId;
model.props.embed = true;
// name + timestamp(readable) + extension
const attachmentName =
(status.appName ?? 'System Audio') +
' ' +
timestamp +
'.opus';
// add size and sourceId to the attachment later
const attachmentId = doc.addBlock(
'affine:attachment',
{
name: attachmentName,
type: 'audio/opus',
size: blob.size,
sourceId: blobId,
embed: true,
},
noteId
);
const model = doc.getBlock(attachmentId)
?.model as AttachmentBlockModel;
if (!aiEnabled) {
return;
@@ -5,7 +5,7 @@ import {
createStreamEncoder,
encodeRawBufferToOpus,
type OpusStreamEncoder,
} from '@affine/core/utils/webm-encoding';
} from '@affine/core/utils/opus-encoding';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
@@ -148,6 +148,7 @@ export function Recording() {
);
useEffect(() => {
let removed = false;
let currentStreamEncoder: OpusStreamEncoder | undefined;
apis?.recording
@@ -161,6 +162,9 @@ export function Recording() {
.catch(console.error);
const handleRecordingStatusChanged = async (status: Status) => {
if (removed) {
return;
}
if (status?.status === 'new') {
track.popup.$.recordingBar.toggleRecordingBar({
type: 'Meeting record',
@@ -196,6 +200,7 @@ export function Recording() {
});
return () => {
removed = true;
unsubscribe?.();
currentStreamEncoder?.close();
};
@@ -80,7 +80,12 @@ export const registerHandlers = () => {
};
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
return handleIpcMessage(e, ...args);
try {
return await handleIpcMessage(e, ...args);
} catch (error) {
logger.error(`error in ipc handler when calling ${args[0]}`, error);
return null;
}
});
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
@@ -520,10 +520,13 @@ export function newRecording(
export function startRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
const state = recordingStateMachine.dispatch({
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
const state = recordingStateMachine.dispatch(
{
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
},
false
);
if (state?.status === 'recording') {
createRecording(state);
@@ -542,6 +545,8 @@ export function startRecording(
}
}, MAX_DURATION_FOR_TRANSCRIPTION);
recordingStateMachine.status$.next(state);
return state;
}
@@ -654,6 +659,8 @@ export async function getRawAudioBuffers(
}
export async function readyRecording(id: number, buffer: Buffer) {
logger.info('readyRecording', id);
const recordingStatus = recordingStatus$.value;
const recording = recordings.get(id);
if (!recordingStatus || recordingStatus.id !== id || !recording) {
@@ -63,7 +63,7 @@ export class RecordingStateMachine {
* @param event The event to dispatch
* @returns The new recording status after the event is processed
*/
dispatch(event: RecordingEvent): RecordingStatus | null {
dispatch(event: RecordingEvent, emit = true): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
let newStatus: RecordingStatus | null = null;
@@ -105,7 +105,9 @@ export class RecordingStateMachine {
return currentStatus;
}
this.recordingStatus$.next(newStatus);
if (emit) {
this.recordingStatus$.next(newStatus);
}
return newStatus;
}
@@ -220,15 +220,17 @@ class TrayState implements Disposable {
);
}
}
items.push({
label: `Meetings Settings...`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
},
});
if (checkRecordingAvailable()) {
items.push({
label: `Meetings Settings...`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
},
});
}
return items;
};
@@ -61,10 +61,7 @@ abstract class PopupWindow {
abstract windowOptions: Partial<BrowserWindowConstructorOptions>;
resolveReady: () => void = () => {};
ready = new Promise<void>(resolve => {
this.resolveReady = resolve;
});
ready = Promise.withResolvers<void>();
private readonly showing$ = new BehaviorSubject<boolean>(false);
@@ -109,12 +106,12 @@ abstract class PopupWindow {
visibleOnFullScreen: true,
});
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
browserWindow.on('ready-to-show', () => {
browserWindow.webContents.on('did-finish-load', () => {
this.resolveReady();
});
logger.info('loading popup', this.name, popupViewUrl);
browserWindow.webContents.on('did-finish-load', () => {
this.ready.resolve();
logger.info('popup ready', this.name);
});
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
return browserWindow;
}
@@ -126,7 +123,7 @@ abstract class PopupWindow {
const workArea = getCurrentDisplay(browserWindow).workArea;
const popupSize = browserWindow.getSize();
await this.ready;
await this.ready.promise;
this.showing$.next(true);
@@ -141,6 +138,8 @@ abstract class PopupWindow {
// Set initial position
browserWindow.setPosition(startX, y);
logger.info('showing popup', this.name);
// First fade in, then slide
await Promise.all([
// Slide in animation
@@ -169,6 +168,7 @@ abstract class PopupWindow {
if (!this.browserWindow) {
return;
}
logger.info('hiding popup', this.name);
this.showing$.next(false);
await animate(this.browserWindow.getOpacity(), 0, opacity => {
this.browserWindow?.setOpacity(opacity);
+2 -1
View File
@@ -20,6 +20,7 @@
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@blocksuite/icons": "^2.2.13",
"@blocksuite/std": "workspace:*",
"@dotlottie/player-component": "^2.7.12",
"@emotion/cache": "^11.14.0",
"@emotion/css": "^11.13.5",
@@ -66,6 +67,7 @@
"lit": "^3.2.1",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"mp4-muxer": "^5.2.1",
"nanoid": "^5.0.9",
"next-themes": "^0.4.4",
"query-string": "^9.1.1",
@@ -80,7 +82,6 @@
"socket.io-client": "^4.8.1",
"swr": "2.3.3",
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
"webm-muxer": "^5.1.0",
"y-protocols": "^1.0.6",
"yjs": "^13.6.21",
"zod": "^3.24.1"
@@ -135,6 +135,7 @@ declare global {
isRootSession?: boolean;
webSearch?: boolean;
reasoning?: boolean;
modelId?: string;
contexts?: {
docs: AIDocContextOption[];
files: AIFileContextOption[];
@@ -1,9 +1,11 @@
import { Peekable } from '@blocksuite/affine/components/peek';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
import { BlockComponent } from '@blocksuite/affine/std';
import { computed } from '@preact/signals-core';
import { html } from 'lit';
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
import type { TextRendererOptions } from '../../components/text-renderer';
import { ChatWithAIIcon } from './components/icon';
import { type AIChatBlockModel } from './model';
import { AIChatBlockStyles } from './styles';
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
static override styles = AIChatBlockStyles;
private _textRendererOptions: TextRendererOptions = {};
// Deserialize messages from JSON string and verify the type using zod
private readonly _deserializeChatMessages = computed(() => {
const messages = this.model.props.messages$.value;
@@ -32,18 +36,23 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
}
});
override connectedCallback() {
super.connectedCallback();
this._textRendererOptions = {
customHeading: true,
extensions: this.previewExtensions,
};
}
override renderBlock() {
const messages = this._deserializeChatMessages.value.slice(-2);
const textRendererOptions = {
customHeading: true,
};
return html`<div class="affine-ai-chat-block-container">
<div class="ai-chat-messages-container">
<ai-chat-messages
.host=${this.host}
.messages=${messages}
.textRendererOptions=${textRendererOptions}
.textRendererOptions=${this._textRendererOptions}
.withMask=${true}
></ai-chat-messages>
</div>
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
</div>
</div> `;
}
get previewExtensions() {
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
}
}
declare global {
@@ -1,9 +1,11 @@
import { Bound } from '@blocksuite/affine/global/gfx';
import { Bound, clamp } from '@blocksuite/affine/global/gfx';
import { toGfxBlockComponent } from '@blocksuite/affine/std';
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { AIChatBlockComponent } from './ai-chat-block';
import { AIChatBlockSchema } from './model';
export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
AIChatBlockComponent
@@ -31,6 +33,69 @@ export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
}
}
export const EdgelessAIChatBlockInteraction =
GfxViewInteractionExtension<EdgelessAIChatBlockComponent>(
AIChatBlockSchema.model.flavour,
{
resizeConstraint: {
minWidth: 260,
minHeight: 160,
maxWidth: 320,
maxHeight: 300,
},
handleRotate() {
return {
beforeRotate(context) {
context.set({
rotatable: false,
});
},
};
},
handleResize({ model }) {
const initialScale = model.props.scale$.peek();
return {
onResizeStart(context) {
context.default(context);
model.stash('scale');
},
onResizeMove(context) {
const { newBound, originalBound, lockRatio, constraint } = context;
const { minWidth, maxWidth, minHeight, maxHeight } = constraint;
let scale = initialScale;
const originalRealWidth = originalBound.w / scale;
// update scale if resize is proportional
if (lockRatio) {
scale = newBound.w / originalRealWidth;
}
let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth);
let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight);
newBound.w = newRealWidth * scale;
newBound.h = newRealHeight * scale;
model.props.xywh = newBound.serialize();
if (scale !== initialScale) {
model.props.scale = scale;
}
},
onResizeEnd(context) {
context.default(context);
model.pop('scale');
},
};
},
}
);
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-ai-chat': EdgelessAIChatBlockComponent;
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
.status=${isLast ? status : 'idle'}
.error=${isLast ? error : null}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.getSessionId=${this.getSessionId}
.retry=${() => this.retry()}
></chat-message-assistant>`;
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
protected override render() {
const { text, host } = this;
return html`${createTextRenderer(host, {
customHeading: true,
extensions: this.extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
})(text, this.state)}`;
}
}
@@ -1,5 +1,6 @@
import './chat-panel-messages';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor isLoading = false;
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
.host=${this.host}
.isLoading=${this.isLoading}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-panel-messages>
<ai-chat-composer
.host=${this.host}
@@ -1,6 +1,7 @@
import '../content/assistant-avatar';
import '../content/rich-text';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor getSessionId!: () => Promise<string | undefined>;
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.text=${item.content}
.state=${state}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-content-rich-text>
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
${this.renderEditorActions()}
@@ -1,8 +1,6 @@
import { toast } from '@affine/component';
import type {
CollectionMeta,
TagMeta,
} from '@affine/core/components/page-list';
import type { TagMeta } from '@affine/core/components/page-list';
import type { CollectionMeta } from '@affine/core/modules/collection';
import track from '@affine/track';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
@@ -1,5 +1,4 @@
import type { TagMeta } from '@affine/core/components/page-list';
import type { Collection } from '@affine/env/filter';
import { createLitPortal } from '@blocksuite/affine/components/portal';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -128,7 +127,7 @@ export class ChatPanelChips extends SignalWatcher(
private _tags: Signal<TagMeta[]> = signal([]);
private _collections: Signal<Collection[]> = signal([]);
private _collections: Signal<{ id: string; name: string }[]> = signal([]);
private _cleanup: (() => void) | null = null;
@@ -1,4 +1,3 @@
import type { Collection } from '@affine/env/filter';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { CollectionsIcon } from '@blocksuite/icons/lit';
@@ -18,7 +17,7 @@ export class ChatPanelCollectionChip extends SignalWatcher(
accessor removeChip!: (chip: CollectionChip) => void;
@property({ attribute: false })
accessor collection!: Collection;
accessor collection!: { id: string; name: string };
override render() {
const { state } = this.chip;
@@ -4,7 +4,6 @@ import type {
SearchDocMenuAction,
SearchTagMenuAction,
} from '@affine/core/modules/search-menu/services';
import type { Collection } from '@affine/env/filter';
import type { DocMeta, Store } from '@blocksuite/affine/store';
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
import type { Signal } from '@preact/signals-core';
@@ -71,7 +70,7 @@ export interface DocDisplayConfig {
getTagTitle: (tagId: string) => string;
getTagPageIds: (tagId: string) => string[];
getCollections: () => {
signal: Signal<Collection[]>;
signal: Signal<{ id: string; name: string }[]>;
cleanup: () => void;
};
getCollectionPageIds: (collectionId: string) => string[];
@@ -1,4 +1,5 @@
import { createReactComponentFromLit } from '@affine/component';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import {
@@ -6,10 +7,7 @@ import {
defaultImageProxyMiddleware,
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import {
LinkPreviewerService,
ThemeProvider,
} from '@blocksuite/affine/shared/services';
import { ThemeProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import {
BlockStdScope,
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
extensions?: ExtensionType[];
additionalMiddlewares?: TransformerMiddleware[];
testId?: string;
affineFeatureFlagService?: FeatureFlagService;
};
// todo: refactor it for more general purpose usage instead of AI only?
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
codeBlockWrapMiddleware(true),
...(this.options.additionalMiddlewares ?? []),
];
markDownToDoc(provider, schema, latestAnswer, middlewares)
const affineFeatureFlagService = this.options.affineFeatureFlagService;
markDownToDoc(
provider,
schema,
latestAnswer,
middlewares,
affineFeatureFlagService
)
.then(doc => {
this.disposeDoc();
this._doc = doc.doc.getStore({
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
this._doc.readonly = true;
this.requestUpdate();
if (this.state !== 'generating') {
// LinkPreviewerService & ImageProxyService config should read from host settings
const linkPreviewerService =
this.host?.std.store.get(LinkPreviewerService);
this._doc.load();
// LinkPreviewService & ImageProxyService config should read from host settings
const imageProxyService =
this.host?.std.store.get(ImageProxyService);
if (linkPreviewerService) {
this._doc
?.get(LinkPreviewerService)
.setEndpoint(linkPreviewerService.endpoint);
}
if (imageProxyService) {
this._doc
?.get(ImageProxyService)
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import {
CanvasElementType,
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
this._textRendererOptions = { extensions };
this._textRendererOptions = {
extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
};
this._historyMessages = this._deserializeHistoryChatMessages(
this.historyMessagesString
);
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
docDisplayConfig: DocDisplayConfig,
searchMenuConfig: SearchMenuConfig,
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig
reasoningConfig: AIReasoningConfig,
affineFeatureFlagService: FeatureFlagService
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
.docDisplayConfig=${docDisplayConfig}
.searchMenuConfig=${searchMenuConfig}
.reasoningConfig=${reasoningConfig}
.affineFeatureFlagService=${affineFeatureFlagService}
></ai-chat-block-peek-view>`;
};
@@ -352,12 +352,14 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
signal,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
signal?: AbortSignal;
}) {
let url = `/api/copilot/chat/${sessionId}`;
@@ -365,6 +367,7 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
});
if (queryString) {
url += `?${queryString}`;
@@ -380,11 +383,13 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
},
endpoint = 'stream'
) {
@@ -393,6 +398,7 @@ export class CopilotClient {
messageId,
reasoning,
webSearch,
modelId,
});
if (queryString) {
url += `?${queryString}`;
@@ -21,6 +21,7 @@ export type TextToTextOptions = {
postfix?: (text: string) => string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
};
export type ToImageOptions = TextToTextOptions & {
@@ -117,6 +118,7 @@ export function textToText({
postfix,
reasoning,
webSearch,
modelId,
}: TextToTextOptions) {
let messageId: string | undefined;
@@ -138,6 +140,7 @@ export function textToText({
messageId,
reasoning,
webSearch,
modelId,
},
workflow ? 'workflow' : undefined
);
@@ -199,6 +202,7 @@ export function textToText({
messageId,
reasoning,
webSearch,
modelId,
});
})(),
]);
@@ -82,7 +82,7 @@ export function setupAIProvider(
//#region actions
AIProvider.provide('chat', async options => {
const { input, contexts, webSearch } = options;
const { input, contexts, webSearch, reasoning } = options;
const sessionId = await createSession({
promptName: 'Chat With AFFiNE AI',
@@ -90,6 +90,7 @@ export function setupAIProvider(
});
return textToText({
...options,
modelId: options.modelId ?? (reasoning ? 'o4-mini' : undefined),
client,
sessionId,
content: input,
@@ -165,11 +165,13 @@ const usePreviewExtensions = () => {
const extensions = useMemo(() => {
const manager = getViewManager()
.config.init()
.common(framework, enableAI)
.foundation(framework)
.ai(enableAI, framework)
.theme(framework)
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI).value;
.paragraph(enableAI)
.linkPreview(framework).value;
const specs = manager.get('preview-page');
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
}, [reactToLit, referenceRenderer, framework, enableAI]);
@@ -19,7 +19,6 @@ import {
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
server.baseUrl
).toString();
const linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl,
server.baseUrl
).toString();
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
editor.updateComplete
@@ -80,7 +80,8 @@ const usePatchSpecs = (mode: DocMode) => {
const patchedSpecs = useMemo(() => {
const manager = getViewManager()
.config.init()
.common(framework, enableAI)
.foundation(framework)
.ai(enableAI, framework)
.theme(framework)
.editorConfig(framework)
.editorView({
@@ -98,7 +99,10 @@ const usePatchSpecs = (mode: DocMode) => {
})
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI).value;
.paragraph(enableAI)
.mobile(framework)
.electron(framework)
.linkPreview(framework).value;
if (BUILD_CONFIG.isMobileEdition) {
if (mode === 'page') {
@@ -0,0 +1,147 @@
import { Avatar, uniReactRoot } from '@affine/component';
import {
createGroupByConfig,
type GroupRenderProps,
t,
ungroups,
} from '@blocksuite/affine/blocks/database';
import type { UserService } from '@blocksuite/affine-shared/services';
import { useMemberInfo } from '../hooks/use-member-info';
import {
avatar,
memberName,
memberPreviewContainer,
} from '../properties/member/style.css';
const MemberPreview = ({
memberId,
userService,
}: {
memberId: string;
userService: UserService | null | undefined;
}) => {
const userInfo = useMemberInfo(memberId, userService);
if (!userInfo) {
return null;
}
return (
<div className={memberPreviewContainer}>
<Avatar
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
className={avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={20}
/>
<div className={memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
</div>
);
};
const MemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.user.is(tType)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.data?.userService}
></MemberPreview>
);
};
const MultiMemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.array.is(tType) || !t.user.is(tType.element)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.element.data?.userService}
></MemberPreview>
);
};
export const groupByConfigList = [
createGroupByConfig({
name: 'member',
matchType: t.user.instance(),
groupName: (type, value: string | null) => {
if (t.user.is(type) && typeof value === 'string') {
const userService = type.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: () => {
return [ungroups];
},
valuesGroup: value => {
if (typeof value !== 'string') {
return [ungroups];
}
return [
{
key: value,
value: value,
},
];
},
view: uniReactRoot.createUniComponent(MemberGroupView),
}),
createGroupByConfig({
name: 'multi-member',
matchType: t.array.instance(t.user.instance()),
groupName: (_type, value: string | null) => {
if (
t.array.is(_type) &&
t.user.is(_type.element) &&
typeof value === 'string'
) {
const userService = _type.element.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value, _type) => {
if (!Array.isArray(value)) {
return [ungroups];
}
return value.map(id => ({
key: id,
value: id,
}));
},
addToGroup: (value, old) => {
if (value == null) {
return old;
}
return Array.isArray(old) ? [...old, value] : [value];
},
removeFromGroup: (value, old) => {
if (Array.isArray(old)) {
return old.filter(v => v !== value);
}
return old;
},
view: uniReactRoot.createUniComponent(MultiMemberGroupView),
}),
];
@@ -0,0 +1,14 @@
import type { UserService } from '@blocksuite/affine-shared/services';
import { useEffect } from 'react';
import { useSignalValue } from '../../../modules/doc-info/utils';
export const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const createdByColumnType = propertyType('created-by');
@@ -26,6 +34,19 @@ export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
jsonValue: {
schema: zod.string().nullable(),
isEmpty: () => false,
type: () => t.string.instance(),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.user.instance(
userListService && userService
? {
userService,
userListService,
}
: undefined
);
},
},
});
@@ -3,7 +3,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserProvider,
@@ -14,11 +14,11 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import { createdByPropertyModelConfig } from './define';
const cellContainer = css({
@@ -64,7 +64,7 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
}),
[]
);
const host = props.cell.view.contextGet(HostContextKey);
const host = props.cell.view.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const memberId = useSignalValue(props.cell.value$);
if (!memberId) {
@@ -83,16 +83,6 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
userService,
@@ -10,7 +10,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import type { BlobEngine } from '@blocksuite/affine/sync';
@@ -226,8 +226,8 @@ class FileCellManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
this.blobSync = this.cell?.view?.contextGet
? this.cell.view.contextGet(HostContextKey)?.store.blobSync
this.blobSync = this.cell?.view?.serviceGet
? this.cell.view.serviceGet(EditorHostKey)?.store.blobSync
: undefined;
this.fileUploadManager = this.blobSync
@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const memberColumnType = propertyType('member');
@@ -31,7 +39,21 @@ export const memberPropertyModelConfig = memberColumnType.modelConfig({
},
jsonValue: {
schema: MemberCellJsonValueTypeSchema,
type: () => t.array.instance(t.string.instance()),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.array.instance(
t.user.instance(
userListService && userService
? {
userService: userService,
userListService: userListService,
}
: undefined
)
);
},
isEmpty: ({ value }) => value.length === 0,
},
});
@@ -16,6 +16,7 @@ import {
import { useSignalValue } from '../../../../../modules/doc-info/utils';
import { Spinner } from '../../../components/loading';
import { useMemberInfo } from '../../../hooks/use-member-info';
import * as styles from './style.css';
type BaseOptions = {
@@ -172,13 +173,6 @@ class MemberManager {
};
}
export const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
export const MemberListItem = (props: {
member: ExistedUserInfo;
memberManager: MemberManager;
@@ -225,7 +219,7 @@ export const MemberPreview = ({
memberManager: MemberManager;
onDelete?: () => void;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}
@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberPopoverContainer = style({
@@ -10,45 +9,10 @@ export const memberPopoverContent = style({
padding: '0',
});
export const searchContainer = style({
padding: '12px 12px 8px 12px',
});
export const searchInput = style({
width: '100%',
});
export const memberListContainer = style({
display: 'flex',
flexDirection: 'column',
maxHeight: '300px',
overflow: 'auto',
});
export const memberItem = style({
display: 'flex',
justifyContent: 'space-between',
padding: '8px 12px',
gap: '8px',
overflow: 'hidden',
cursor: 'pointer',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
':active': {
backgroundColor: cssVarV2.layer.background.secondary,
},
});
export const memberItemContent = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
});
export const memberName = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -70,21 +34,6 @@ export const avatar = style({
flexShrink: 0,
});
export const loadingContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
});
export const noResultContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
color: cssVarV2.text.secondary,
});
export const memberPreviewContainer = style({
display: 'flex',
alignItems: 'center',
@@ -4,7 +4,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
@@ -17,12 +17,12 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import type {
MemberCellJsonValueType,
MemberCellRawValueType,
@@ -55,7 +55,7 @@ class MemberManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
const host = this.cell.view.contextGet(HostContextKey);
const host = this.cell.view.serviceGet(EditorHostKey);
this.userService = host?.std.getOptional(UserProvider);
this.userListService = host?.std.getOptional(UserListProvider);
}
@@ -140,13 +140,6 @@ const MemberCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
memberManager,
@@ -154,7 +147,7 @@ const MemberPreview = ({
memberId: string;
memberManager: MemberManager;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}
@@ -10,12 +10,6 @@ import { AiSlashMenuConfigExtension } from '@affine/core/blocksuite/ai/extension
import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool';
import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel';
import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot';
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
import { getFontConfigExtension } from '@affine/core/blocksuite/extensions/font-config';
import { patchPeekViewService } from '@affine/core/blocksuite/extensions/peek-view-service';
import { getTelemetryExtension } from '@affine/core/blocksuite/extensions/telemetry';
import { PeekViewService } from '@affine/core/modules/peek-view';
import {
type ViewExtensionContext,
ViewExtensionProvider,
@@ -25,31 +19,37 @@ import { BlockFlavourIdentifier } from '@blocksuite/affine/std';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard';
const optionsSchema = z.object({
enableAI: z.boolean().optional(),
enable: z.boolean().optional(),
framework: z.instanceof(FrameworkProvider).optional(),
});
export class AffineCommonViewExtension extends ViewExtensionProvider<
z.infer<typeof optionsSchema>
> {
override name = 'affine-view-extensions';
type AIViewOptions = z.infer<typeof optionsSchema>;
export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
override name = 'affine-ai-view-extension';
override schema = optionsSchema;
private _setupAI(
context: ViewExtensionContext,
framework: FrameworkProvider
) {
context.register(AIChatBlockSpec);
context.register(AITranscriptionBlockSpec);
context.register([
AICodeBlockWatcher,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:image'),
config: imageToolbarAIEntryConfig(),
}),
]);
override setup(context: ViewExtensionContext, options?: AIViewOptions) {
super.setup(context, options);
if (!options?.enable) return;
const framework = options.framework;
if (!framework) return;
context
.register(AIChatBlockSpec)
.register(AITranscriptionBlockSpec)
.register(EdgelessClipboardAIChatConfig)
.register(AICodeBlockWatcher)
.register(
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:image'),
config: imageToolbarAIEntryConfig(),
})
);
if (context.scope === 'edgeless' || context.scope === 'page') {
context.register([
aiPanelWidget,
@@ -76,26 +76,4 @@ export class AffineCommonViewExtension extends ViewExtensionProvider<
context.register(getAIPageRootWatcher(framework));
}
}
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
super.setup(context, options);
const { framework, enableAI } = options || {};
if (framework) {
context.register(patchPeekViewService(framework.get(PeekViewService)));
context.register([
getFontConfigExtension(),
buildDocDisplayMetaExtension(framework),
]);
context.register(getTelemetryExtension());
if (context.scope === 'edgeless' || context.scope === 'page') {
context.register(patchFileSizeLimitExtension(framework));
}
if (enableAI) {
this._setupAI(context, framework);
}
}
}
}
@@ -6,6 +6,10 @@ import type { ExtensionType } from '@blocksuite/store';
export function patchForAudioEmbedView(reactToLit: ReactToLit): ExtensionType {
return {
setup: di => {
// do not show audio block on mobile
if (BUILD_CONFIG.isMobileEdition) {
return;
}
di.override(AttachmentEmbedConfigIdentifier('audio'), () => ({
name: 'audio',
check: (model, maxFileSize) =>
@@ -1,12 +1,20 @@
import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database';
import {
DatabaseBlockDataSource,
ExternalGroupByConfigProvider,
} from '@blocksuite/affine/blocks/database';
import type { ExtensionType } from '@blocksuite/affine/store';
import { groupByConfigList } from '../database-block/group-by';
import { propertiesPresets } from '../database-block/properties';
export function patchDatabaseBlockConfigService(): ExtensionType {
//TODO use service
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
return {
setup: () => {},
setup: di => {
groupByConfigList.forEach(config => {
di.addValue(ExternalGroupByConfigProvider(config.name), config);
});
},
};
}
@@ -0,0 +1,33 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchForClipboardInElectron } from './electron-clipboard';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type ElectronViewExtensionOptions = z.infer<typeof optionsSchema>;
export class ElectronViewExtension extends ViewExtensionProvider<ElectronViewExtensionOptions> {
override name = 'electron-view-extensions';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: ElectronViewExtensionOptions
) {
super.setup(context, options);
if (!BUILD_CONFIG.isElectron) return;
const framework = options?.framework;
if (!framework) return;
context.register(patchForClipboardInElectron(framework));
}
}
@@ -1,13 +0,0 @@
import {
AffineCanvasTextFonts,
FontConfigExtension,
} from '@blocksuite/affine/shared/services';
export function getFontConfigExtension() {
return FontConfigExtension(
AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
}))
);
}
@@ -0,0 +1,32 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchLinkPreviewService } from './link-preview-service';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type AffineLinkPreviewViewOptions = z.infer<typeof optionsSchema>;
export class AffineLinkPreviewExtension extends ViewExtensionProvider<AffineLinkPreviewViewOptions> {
override name = 'affine-link-preview-extension';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: AffineLinkPreviewViewOptions
) {
super.setup(context, options);
if (!options?.framework) {
return;
}
const { framework } = options;
context.register(patchLinkPreviewService(framework));
}
}
@@ -0,0 +1,61 @@
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
LinkPreviewService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine/shared/services';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/affine/std';
import { type ExtensionType } from '@blocksuite/affine/store';
import type { Container } from '@blocksuite/global/di';
import type { FrameworkProvider } from '@toeverything/infra';
import { ServerService } from '../../../modules/cloud/services/server';
class AffineLinkPreviewService extends LinkPreviewService {
constructor(
endpoint: string,
std: BlockStdScope,
cache: LinkPreviewCacheProvider
) {
super(std, cache);
this.setEndpoint(endpoint);
}
}
/**
* Patch the link preview service, set the endpoint and cache
* @param framework
* @returns
*/
export function patchLinkPreviewService(
framework: FrameworkProvider
): ExtensionType {
// get link preview service endpoint from server and BUILD_CONFIG
let linkPreviewUrl: string;
try {
const server = framework.get(ServerService).server;
linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl || '/',
server.baseUrl
).toString();
} catch (err) {
console.error(
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
err
);
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
}
return {
setup: (di: Container) => {
di.override(LinkPreviewServiceIdentifier, provider => {
return new AffineLinkPreviewService(
linkPreviewUrl,
provider.get(StdIdentifier),
provider.get(LinkPreviewCacheIdentifier)
);
});
},
};
}
@@ -0,0 +1,33 @@
import { KeyboardToolbarExtension } from '@affine/core/blocksuite/extensions/mobile/keyboard-toolbar-extension';
import { MobileFeatureFlagControl } from '@affine/core/blocksuite/extensions/mobile/mobile-feature-flag-control';
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type MobileViewOptions = z.infer<typeof optionsSchema>;
export class MobileViewExtension extends ViewExtensionProvider<MobileViewOptions> {
override name = 'mobile-view-extension';
override schema = optionsSchema;
override setup(context: ViewExtensionContext, options?: MobileViewOptions) {
super.setup(context, options);
const isMobile = BUILD_CONFIG.isMobileEdition;
if (!isMobile) return;
const framework = options?.framework;
if (framework) {
context.register(KeyboardToolbarExtension(framework));
}
context.register(MobileFeatureFlagControl);
}
}
@@ -1,50 +1,15 @@
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { CodeBlockConfigExtension } from '@blocksuite/affine/blocks/code';
import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph';
import type { Container } from '@blocksuite/affine/global/di';
import { DisposableGroup } from '@blocksuite/affine/global/disposable';
import {
FeatureFlagService,
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
type VirtualKeyboardProviderWithAction,
} from '@blocksuite/affine/shared/services';
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
import { LifeCycleWatcher } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import { batch, signal } from '@preact/signals-core';
import type { FrameworkProvider } from '@toeverything/infra';
export class MobileSpecsPatches extends LifeCycleWatcher {
static override key = 'mobile-patches';
constructor(std: BlockStdScope) {
super(std);
const featureFlagService = std.get(FeatureFlagService);
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
}
}
export const mobileParagraphConfig = ParagraphBlockConfigExtension({
getPlaceholder: model => {
const placeholders = {
text: '',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
return placeholders[model.props.type];
},
});
export const mobileCodeConfig = CodeBlockConfigExtension({
showLineNumbers: false,
});
export function KeyboardToolbarExtension(
framework: FrameworkProvider
): ExtensionType {
@@ -89,6 +54,7 @@ export function KeyboardToolbarExtension(
if ('show' in affineVirtualKeyboardProvider) {
const providerWithAction = affineVirtualKeyboardProvider;
class BSVirtualKeyboardServiceWithShowAndHide
extends BSVirtualKeyboardService
implements VirtualKeyboardProviderWithAction
@@ -96,6 +62,7 @@ export function KeyboardToolbarExtension(
show() {
providerWithAction.show();
}
hide() {
providerWithAction.hide();
}
@@ -0,0 +1,14 @@
import { FeatureFlagService } from '@blocksuite/affine/shared/services';
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
export class MobileFeatureFlagControl extends LifeCycleWatcher {
static override key = 'mobile-patches';
constructor(std: BlockStdScope) {
super(std);
const featureFlagService = std.get(FeatureFlagService);
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
}
}
@@ -1,36 +0,0 @@
import type { PeekViewService } from '@affine/core/modules/peek-view';
import { DebugLogger } from '@affine/debug';
import type {
PeekOptions,
PeekViewService as BSPeekViewService,
} from '@blocksuite/affine/components/peek';
import { PeekViewExtension } from '@blocksuite/affine/components/peek';
import type { TemplateResult } from 'lit';
const logger = new DebugLogger('affine::patch-peek-view-service');
export function patchPeekViewService(service: PeekViewService) {
return PeekViewExtension({
peek: (
element: {
target: HTMLElement;
docId: string;
blockIds?: string[];
template?: TemplateResult;
},
options?: PeekOptions
) => {
logger.debug('center peek', element);
const { template, target, ...props } = element;
return service.peekView.open(
{
element: target,
docRef: props,
},
template,
options?.abortSignal
);
},
} satisfies BSPeekViewService);
}
@@ -1,21 +0,0 @@
import { mixpanel } from '@affine/track';
import {
type TelemetryEventMap,
TelemetryProvider,
} from '@blocksuite/affine/shared/services';
import type { ExtensionType } from '@blocksuite/affine/store';
export function getTelemetryExtension(): ExtensionType {
return {
setup: di => {
di.addImpl(TelemetryProvider, () => ({
track: <T extends keyof TelemetryEventMap>(
eventName: T,
props: TelemetryEventMap[T]
) => {
mixpanel.track(eventName as string, props as Record<string, unknown>);
},
}));
},
};
}
@@ -1,10 +1,10 @@
import type { ConfirmModalProps, ElementOrFactory } from '@affine/component';
import { patchForAudioEmbedView } from '@affine/core/blocksuite/extensions/audio/audio-view';
import { patchDatabaseBlockConfigService } from '@affine/core/blocksuite/extensions/database-block-config-service';
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
import { patchDocModeService } from '@affine/core/blocksuite/extensions/doc-mode-service';
import { patchDocUrlExtensions } from '@affine/core/blocksuite/extensions/doc-url';
import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard';
import { patchForClipboardInElectron } from '@affine/core/blocksuite/extensions/electron-clipboard';
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
import { patchNotificationService } from '@affine/core/blocksuite/extensions/notification-service';
import { patchOpenDocExtension } from '@affine/core/blocksuite/extensions/open-doc';
import { patchQuickSearchService } from '@affine/core/blocksuite/extensions/quick-search-service';
@@ -29,13 +29,6 @@ import { FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import { z } from 'zod';
import {
KeyboardToolbarExtension,
mobileCodeConfig,
mobileParagraphConfig,
MobileSpecsPatches,
} from '../extensions/mobile-config';
const optionsSchema = z.object({
// services
framework: z.instanceof(FrameworkProvider),
@@ -106,8 +99,6 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
reactToLit,
confirmModal,
} = options;
const isMobileEdition = BUILD_CONFIG.isMobileEdition;
const isElectron = BUILD_CONFIG.isElectron;
const docService = framework.get(DocService);
const docsService = framework.get(DocsService);
@@ -115,30 +106,21 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
const referenceRenderer = this._getCustomReferenceRenderer(framework);
context.register([
patchReferenceRenderer(reactToLit, referenceRenderer),
patchNotificationService(confirmModal),
patchOpenDocExtension(),
EdgelessClipboardAIChatConfig,
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
]);
context.register(patchDocUrlExtensions(framework));
context.register(patchQuickSearchService(framework));
context.register([
patchDatabaseBlockConfigService(),
patchForAudioEmbedView(reactToLit),
]);
if (isMobileEdition) {
context.register([
KeyboardToolbarExtension(framework),
MobileSpecsPatches,
mobileParagraphConfig,
mobileCodeConfig,
context
.register([
patchReferenceRenderer(reactToLit, referenceRenderer),
patchNotificationService(confirmModal),
patchOpenDocExtension(),
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
patchFileSizeLimitExtension(framework),
buildDocDisplayMetaExtension(framework),
])
.register(patchDocUrlExtensions(framework))
.register(patchQuickSearchService(framework))
.register([
patchDatabaseBlockConfigService(),
patchForAudioEmbedView(reactToLit),
]);
}
if (isElectron) {
context.register(patchForClipboardInElectron(framework));
}
}
}
@@ -22,7 +22,9 @@ class MigratingAffineStoreExtension extends StoreExtensionProvider {
interface Configure {
init: () => Configure;
featureFlag: (featureFlagService?: FeatureFlagService) => Configure;
value: StoreExtensionManager;
}
@@ -1,4 +1,5 @@
import type { ReactToLit } from '@affine/component';
import { AIViewExtension } from '@affine/core/blocksuite/extensions/ai';
import { CloudViewExtension } from '@affine/core/blocksuite/extensions/cloud';
import {
EdgelessBlockHeaderConfigViewExtension,
@@ -7,23 +8,59 @@ import {
import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensions/editor-config';
import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/extensions/link-preview-service';
import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
import { TurboRendererViewExtension } from '@affine/core/blocksuite/extensions/turbo-renderer';
import { AffineCommonViewExtension } from '@affine/core/blocksuite/manager/common-view';
import {
AffineEditorViewExtension,
type AffineEditorViewOptions,
} from '@affine/core/blocksuite/manager/editor-view';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { DebugLogger } from '@affine/debug';
import { mixpanel } from '@affine/track';
import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view';
import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view';
import type {
PeekOptions,
PeekViewService as BSPeekViewService,
} from '@blocksuite/affine/components/peek';
import { ViewExtensionManager } from '@blocksuite/affine/ext-loader';
import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
import { FoundationViewExtension } from '@blocksuite/affine/foundation/view';
import { AffineCanvasTextFonts } from '@blocksuite/affine/shared/services';
import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view';
import type { FrameworkProvider } from '@toeverything/infra';
import type { TemplateResult } from 'lit';
import { CodeBlockPreviewViewExtension } from './code-block-preview';
type Configure = {
init: () => Configure;
foundation: (framework?: FrameworkProvider) => Configure;
editorView: (options?: AffineEditorViewOptions) => Configure;
theme: (framework?: FrameworkProvider) => Configure;
editorConfig: (framework?: FrameworkProvider) => Configure;
edgelessBlockHeader: (options?: EdgelessBlockHeaderViewOptions) => Configure;
database: (framework?: FrameworkProvider) => Configure;
linkedDoc: (framework?: FrameworkProvider) => Configure;
paragraph: (enableAI?: boolean) => Configure;
cloud: (framework?: FrameworkProvider, enableCloud?: boolean) => Configure;
turboRenderer: (enableTurboRenderer?: boolean) => Configure;
pdf: (enablePDFEmbedPreview?: boolean, reactToLit?: ReactToLit) => Configure;
mobile: (framework?: FrameworkProvider) => Configure;
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
value: ViewExtensionManager;
};
const peekViewLogger = new DebugLogger('affine::patch-peek-view-service');
class ViewProvider {
static instance: ViewProvider | null = null;
static getInstance() {
@@ -40,7 +77,6 @@ class ViewProvider {
...getInternalViewExtensions(),
AffineThemeViewExtension,
AffineCommonViewExtension,
AffineEditorViewExtension,
AffineEditorConfigViewExtension,
CodeBlockPreviewViewExtension,
@@ -48,6 +84,10 @@ class ViewProvider {
TurboRendererViewExtension,
CloudViewExtension,
PdfViewExtension,
MobileViewExtension,
AIViewExtension,
ElectronViewExtension,
AffineLinkPreviewExtension,
]);
}
@@ -55,10 +95,10 @@ class ViewProvider {
return this._manager;
}
get config() {
get config(): Configure {
return {
init: this._initDefaultConfig,
common: this._configureCommon,
foundation: this._configureFoundation,
editorView: this._configureEditorView,
theme: this._configureTheme,
editorConfig: this._configureEditorConfig,
@@ -69,13 +109,17 @@ class ViewProvider {
cloud: this._configureCloud,
turboRenderer: this._configureTurboRenderer,
pdf: this._configurePdf,
mobile: this._configureMobile,
ai: this._configureAI,
electron: this._configureElectron,
linkPreview: this._configureLinkPreview,
value: this._manager,
};
}
private readonly _initDefaultConfig = () => {
this.config
.common()
.foundation()
.theme()
.editorView()
.editorConfig()
@@ -85,19 +129,55 @@ class ViewProvider {
.paragraph()
.cloud()
.turboRenderer()
.pdf();
.pdf()
.mobile()
.ai()
.electron()
.linkPreview();
return this.config;
};
private readonly _configureCommon = (
framework?: FrameworkProvider,
enableAI?: boolean
) => {
this._manager.configure(AffineCommonViewExtension, {
framework,
enableAI,
private readonly _configureFoundation = (framework?: FrameworkProvider) => {
const peekViewService = framework?.get(PeekViewService);
this._manager.configure(FoundationViewExtension, {
telemetry: {
track: (eventName, props) => {
mixpanel.track(eventName, props);
},
},
fontConfig: AffineCanvasTextFonts.map(font => ({
...font,
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
})),
peekView: !peekViewService
? undefined
: ({
peek: (
element: {
target: HTMLElement;
docId: string;
blockIds?: string[];
template?: TemplateResult;
},
options?: PeekOptions
) => {
peekViewLogger.debug('center peek', element);
const { template, target, ...props } = element;
return peekViewService.peekView.open(
{
element: target,
docRef: props,
},
template,
options?.abortSignal
);
},
} satisfies BSPeekViewService),
});
return this.config;
};
@@ -146,7 +226,23 @@ class ViewProvider {
};
private readonly _configureParagraph = (enableAI?: boolean) => {
if (enableAI) {
if (BUILD_CONFIG.isMobileEdition) {
this._manager.configure(ParagraphViewExtension, {
getPlaceholder: model => {
const placeholders = {
text: '',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
quote: '',
};
return placeholders[model.props.type] ?? '';
},
});
} else if (enableAI) {
this._manager.configure(ParagraphViewExtension, {
getPlaceholder: model => {
const placeholders = {
@@ -193,6 +289,29 @@ class ViewProvider {
});
return this.config;
};
private readonly _configureMobile = (framework?: FrameworkProvider) => {
this._manager.configure(MobileViewExtension, { framework });
return this.config;
};
private readonly _configureAI = (
enable?: boolean,
framework?: FrameworkProvider
) => {
this._manager.configure(AIViewExtension, { framework, enable });
return this.config;
};
private readonly _configureElectron = (framework?: FrameworkProvider) => {
this._manager.configure(ElectronViewExtension, { framework });
return this.config;
};
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
this._manager.configure(AffineLinkPreviewExtension, { framework });
return this.config;
};
}
export function getViewManager() {
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import type { ServiceProvider } from '@blocksuite/affine/global/di';
import {
@@ -161,11 +162,13 @@ export async function markDownToDoc(
provider: ServiceProvider,
schema: Schema,
answer: string,
middlewares?: TransformerMiddleware[]
middlewares?: TransformerMiddleware[],
affineFeatureFlagService?: FeatureFlagService
) {
// Should not create a new doc in the original collection
const collection = new WorkspaceImpl({
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
featureFlagService: affineFeatureFlagService,
});
collection.meta.initialize();
const transformer = new Transformer({
@@ -1,5 +1,5 @@
import type { Collection } from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
@@ -5,10 +5,8 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection } from '../../page-list';
import { ActionButton } from './action-button';
import collectionListDark from './assets/collection-list.dark.png';
import collectionListLight from './assets/collection-list.light.png';
@@ -39,8 +37,7 @@ export const EmptyCollections = (props: UniversalEmptyProps) => {
variant: 'primary',
},
onConfirm(name) {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
const id = collectionService.createCollection({ name });
navigateHelper.jumpToCollection(currentWorkspace.id, id);
},
});
@@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
@@ -24,6 +24,36 @@ export const AddFilterMenu = ({
{t['com.affine.filter']()}
</div>
<MenuSeparator />
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'favorite'}
onClick={() => {
onAdd({
type: 'system',
key: 'favorite',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>{t['Favorited']()}</span>
</MenuItem>
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
key={'shared'}
onClick={() => {
onAdd({
type: 'system',
key: 'shared',
method: 'is',
value: 'true',
});
}}
>
<span className={styles.filterTypeItemName}>
{t['com.affine.filter.is-public']()}
</span>
</MenuItem>
{workspaceProperties.map(property => {
const type = WorkspacePropertyTypes[property.type];
const defaultFilter = type?.defaultFilter;
@@ -81,13 +81,13 @@ export function useAIChatConfig() {
return tag$.value?.pageIds$.value ?? [];
},
getCollections: () => {
const collections$ = collectionService.collections$;
return createSignalFromObservable(collections$, []);
const collectionMetas$ = collectionService.collectionMetas$;
return createSignalFromObservable(collectionMetas$, []);
},
getCollectionPageIds: (collectionId: string) => {
const collection$ = collectionService.collection$(collectionId);
// TODO: lack of documents that meet the collection rules
return collection$?.value?.allowList ?? [];
return collection$?.value?.info$.value.allowList ?? [];
},
};
@@ -1,15 +0,0 @@
import { AuthService } from '@affine/core/modules/cloud';
import type { DeleteCollectionInfo } from '@affine/env/filter';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
export const useDeleteCollectionInfo = () => {
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
return useMemo<DeleteCollectionInfo | null>(
() => (user ? { userName: user.label, userId: user.id } : null),
[user]
);
};
@@ -1,201 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type {
Filter,
LiteralValue,
PropertiesMeta,
Ref,
VariableMap,
} from '@affine/env/filter';
import { getOrCreateI18n, I18nextProvider } from '@affine/i18n';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
'Is Favourited': false,
'Is Public': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
if (!before) {
throw new Error('before is not found');
}
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
if (!after) {
throw new Error('after is not found');
}
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
if (!is) {
throw new Error('is is not found');
}
const filter1 = filter(is, ref('Is Favourited'), [false]);
const filter2 = filter(is, ref('Is Favourited'), [true]);
const varMap = mockVariableMap({
'Is Favourited': true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const is = filterMatcher.match(tBoolean.create());
const i18n = getOrCreateI18n();
if (!is) {
throw new Error('is is not found');
}
const Wrapper = () => {
const [value, onChange] = useState(
filter(is, ref('Is Favourited'), [true])
);
return (
<I18nextProvider i18n={i18n}>
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
const WrapperCreator = (fn: FilterMatcherDataType) =>
function Wrapper(): ReactElement {
const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
};
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
if (!dateFunction) {
throw new Error('dateFunction is not found');
}
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});
@@ -1,51 +1,25 @@
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
import {
type CollectionMeta,
CollectionService,
} from '../../../modules/collection';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { collectionHeaderColsDef } from '../header-col-def';
import { CollectionOperationCell } from '../operation-cell';
import { CollectionListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
import type { ItemListHandle, ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import { CollectionListHeader } from './collection-list-header';
const useCollectionOperationsRenderer = ({
info,
service,
}: {
info: DeleteCollectionInfo;
service: CollectionService;
}) => {
const collectionOperationsRenderer = useCallback(
(collection: Collection) => {
return (
<CollectionOperationCell
info={info}
collection={collection}
service={service}
/>
);
},
[info, service]
);
return collectionOperationsRenderer;
};
export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
handleCreateCollection,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
@@ -55,30 +29,24 @@ export const VirtualizedCollectionList = ({
[]
);
const collectionService = useService(CollectionService);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useDeleteCollectionInfo();
const collectionOperations = useCollectionOperationsRenderer({
info,
service: collectionService,
});
const filteredSelectedCollectionIds = useMemo(() => {
const ids = new Set(collections.map(collection => collection.id));
const ids = new Set(collectionMetas.map(collection => collection.id));
return selectedCollectionIds.filter(id => ids.has(id));
}, [collections, selectedCollectionIds]);
}, [collectionMetas, selectedCollectionIds]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const collectionOperationRenderer = useCallback(
(item: ListItem) => {
const collection = item as CollectionMeta;
return collectionOperations(collection);
},
[collectionOperations]
);
const collectionOperationRenderer = useCallback((item: ListItem) => {
const collection = item;
return (
<CollectionOperationCell collectionMeta={collection as CollectionMeta} />
);
}, []);
const collectionHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
@@ -92,9 +60,11 @@ export const VirtualizedCollectionList = ({
if (selectedCollectionIds.length === 0) {
return;
}
collectionService.deleteCollection(info, ...selectedCollectionIds);
for (const collectionId of selectedCollectionIds) {
collectionService.deleteCollection(collectionId);
}
hideFloatingToolbar();
}, [collectionService, hideFloatingToolbar, info, selectedCollectionIds]);
}, [collectionService, hideFloatingToolbar, selectedCollectionIds]);
return (
<>
@@ -14,7 +14,6 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/model';
@@ -29,8 +28,10 @@ import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
import { CollectionService } from '../../../modules/collection';
import { createTagFilter } from '../filter/utils';
import {
type Collection,
CollectionService,
} from '../../../modules/collection';
import { SaveAsCollectionButton } from '../view';
import * as styles from './page-list-header.css';
import { PageListNewPageButton } from './page-list-new-page-button';
@@ -133,11 +134,12 @@ export const CollectionPageListHeader = ({
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const { openConfirmModal } = useConfirmModal();
const name = useLiveData(collection.name$);
const createAndAddDocument = useCallback(
(createDocumentFn: () => DocRecord) => {
const newDoc = createDocumentFn();
collectionService.addPageToCollection(collection.id, newDoc.id);
collectionService.addDocToCollection(collection.id, newDoc.id);
},
[collection.id, collectionService]
);
@@ -183,7 +185,7 @@ export const CollectionPageListHeader = ({
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
<div className={styles.titleCollectionName}>{name}</div>
</div>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
@@ -221,12 +223,21 @@ export const TagPageListHeader = ({
}, [jumpToTags, workspaceId]);
const saveToCollection = useCallback(
(collection: Collection) => {
collectionService.addCollection({
...collection,
filterList: [createTagFilter(tag.id)],
(collectionName: string) => {
const id = collectionService.createCollection({
name: collectionName,
rules: {
filters: [
{
type: 'system',
key: 'tags',
method: 'include-all',
value: tag.id,
},
],
},
});
jumpToCollection(workspaceId, collection.id);
jumpToCollection(workspaceId, id);
},
[collectionService, tag.id, jumpToCollection, workspaceId]
);
@@ -1,18 +1,27 @@
import { IconButton, Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
CollectionRulesService,
type FilterParams,
} from '@affine/core/modules/collection-rules';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { PublicDocMode } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Filters } from '../../filter';
import { AddFilterMenu } from '../../filter/add-filter';
import { AffineShapeIcon, FavoriteTag } from '..';
import { FilterList } from '../filter';
import { VariableSelect } from '../filter/vars';
import { usePageHeaderColsDef } from '../header-col-def';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
@@ -20,7 +29,6 @@ import { SelectorLayout } from '../selector/selector-layout';
import type { ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import * as styles from './select-page.css';
import { useFilter } from './use-filter';
import { useSearch } from './use-search';
export const SelectPage = ({
@@ -58,12 +66,13 @@ export const SelectPage = ({
workspaceService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionRulesService,
} = useServices({
ShareDocsListService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
CollectionRulesService,
});
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
@@ -73,20 +82,6 @@ export const SelectPage = ({
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService.shareDocs]);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
if (mode === PublicDocMode.Edgeless) {
return 'edgeless';
} else if (mode === PublicDocMode.Page) {
return 'page';
} else {
return undefined;
}
},
[shareDocs]
);
const isFavorite = useCallback(
(meta: DocMeta) => favourites.some(fav => fav.id === meta.id),
[favourites]
@@ -106,22 +101,41 @@ export const SelectPage = ({
);
const pageHeaderColsDef = usePageHeaderColsDef();
const {
clickFilter,
createFilter,
filters,
showFilter,
updateFilters,
filteredList,
} = useFilter(
pageMetas.map(meta => ({
meta,
publicMode: getPublicMode(meta.id),
favorite: isFavorite(meta),
}))
);
const [filters, setFilters] = useState<FilterParams[]>([]);
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
const filteredPageMetas = useMemo(() => {
const idSet = new Set(filteredDocIds);
return pageMetas.filter(page => idSet.has(page.id));
}, [pageMetas, filteredDocIds]);
const { searchText, updateSearchText, searchedList } =
useSearch(filteredList);
useSearch(filteredPageMetas);
useEffect(() => {
const subscription = collectionRulesService
.watch([
...filters,
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
])
.subscribe(result => {
setFilteredDocIds(result.groups.flatMap(group => group.items));
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, filters]);
const operationsRenderer = useCallback(
(item: ListItem) => {
@@ -162,29 +176,21 @@ export const SelectPage = ({
{t['com.affine.selectPage.title']()}
</div>
)}
{!showFilter && filters.length === 0 ? (
{filters.length === 0 ? (
<Menu
items={
<VariableSelect
propertiesMeta={docCollection.meta.properties}
selected={filters}
onSelect={createFilter}
<AddFilterMenu
onAdd={params => setFilters([...filters, params])}
/>
}
>
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
<IconButton icon={<FilterIcon />} />
</Menu>
) : (
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
)}
) : null}
</div>
{showFilter ? (
{filters.length !== 0 ? (
<div style={{ padding: '12px 16px 16px' }}>
<FilterList
propertiesMeta={docCollection.meta.properties}
value={filters}
onChange={updateFilters}
/>
<Filters filters={filters} onChange={setFilters} />
</div>
) : null}
{searchedList.length ? (
@@ -1,45 +0,0 @@
import type { Filter } from '@affine/env/filter';
import type { MouseEvent } from 'react';
import { useCallback, useState } from 'react';
import {
filterPageByRules,
type PageDataForFilter,
} from '../use-collection-manager';
export const useFilter = (list: PageDataForFilter[]) => {
const [filters, changeFilters] = useState<Filter[]>([]);
const [showFilter, setShowFilter] = useState(false);
const clickFilter = useCallback(
(e: MouseEvent) => {
if (showFilter || filters.length !== 0) {
e.stopPropagation();
e.preventDefault();
setShowFilter(!showFilter);
}
},
[filters.length, showFilter]
);
const onCreateFilter = useCallback(
(filter: Filter) => {
changeFilters([...filters, filter]);
setShowFilter(true);
},
[filters]
);
return {
showFilter,
filters,
updateFilters: changeFilters,
clickFilter,
createFilter: onCreateFilter,
filteredList: list
.filter(pageData => {
if (pageData.meta.trash) {
return false;
}
return filterPageByRules(filters, [], pageData);
})
.map(pageData => pageData.meta),
};
};
@@ -1,14 +1,13 @@
import { toast, useConfirmModal } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import { type Collection } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import type { Tag } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, Filter } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { usePageItemGroupDefinitions } from '../group-definitions';
@@ -17,7 +16,6 @@ import { PageOperationCell } from '../operation-cell';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem } from '../types';
import { useFilteredPageMetas } from '../use-filtered-page-metas';
import { VirtualizedList } from '../virtualized-list';
import {
CollectionPageListHeader,
@@ -25,15 +23,14 @@ import {
TagPageListHeader,
} from './page-list-header';
const usePageOperationsRenderer = () => {
const usePageOperationsRenderer = (collection?: Collection) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const removeFromAllowList = useCallback(
(id: string) => {
collectionService.deletePagesFromCollections([id]);
collection?.removeDoc(id);
toast(t['com.affine.collection.removePage.success']());
},
[collectionService, t]
[collection, t]
);
const pageOperationsRenderer = useCallback(
(page: DocMeta, isInAllowList?: boolean) => {
@@ -53,14 +50,12 @@ const usePageOperationsRenderer = () => {
export const VirtualizedPageList = memo(function VirtualizedPageList({
tag,
collection,
filters,
listItem,
setHideHeaderCreateNewPage,
disableMultiDelete,
}: {
tag?: Tag;
collection?: Collection;
filters?: Filter[];
listItem?: DocMeta[];
setHideHeaderCreateNewPage?: (hide: boolean) => void;
disableMultiDelete?: boolean;
@@ -72,19 +67,28 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const pageOperations = usePageOperationsRenderer();
const pageOperations = usePageOperationsRenderer(collection);
const pageHeaderColsDef = usePageHeaderColsDef();
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters,
collection,
});
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection?.watch().subscribe(docIds => {
setFilteredPageIds(docIds);
});
return () => subscription?.unsubscribe();
}, [collection]);
const allowList = useLiveData(collection?.info$.map(info => info.allowList));
const pageMetasToRender = useMemo(() => {
if (listItem) {
return listItem;
}
return filteredPageMetas;
}, [filteredPageMetas, listItem]);
if (collection) {
return pageMetas.filter(
page => filteredPageIds.includes(page.id) && !page.trash
);
}
return pageMetas.filter(page => !page.trash);
}, [collection, filteredPageIds, listItem, pageMetas]);
const filteredSelectedPageIds = useMemo(() => {
const ids = new Set(pageMetasToRender.map(page => page.id));
@@ -98,10 +102,10 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
const pageOperationRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
const isInAllowList = collection?.allowList?.includes(page.id);
const isInAllowList = allowList?.includes(page.id);
return pageOperations(page, isInAllowList);
},
[collection, pageOperations]
[allowList, pageOperations]
);
const pageHeaderRenderer = useCallback(() => {
@@ -1,181 +0,0 @@
import { Menu, MenuItem, Tooltip } from '@affine/component';
import type { Filter, Literal, PropertiesMeta } from '@affine/env/filter';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { literalMatcher } from './literal-matcher';
import { tBoolean } from './logical/custom-type';
import type { TFunction, TType } from './logical/typesystem';
import { typesystem } from './logical/typesystem';
import { variableDefineMap } from './shared-types';
import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (filter: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const data = useMemo(() => {
const data = filterMatcher.find(v => v.data.name === value.funcName);
if (!data) {
return;
}
const instance = typesystem.instance(
{},
[variableDefineMap[value.left.name].type(propertiesMeta)],
tBoolean.create(),
data.type
);
return {
render: data.data.render,
type: instance,
};
}, [propertiesMeta, value.funcName, value.left.name]);
if (!data) {
return null;
}
const render =
data.render ??
(({ ast }) => {
const args = renderArgs(value, onChange, data.type);
return (
<div className={styles.filterContainerStyle}>
<Menu
items={
<VariableSelect
propertiesMeta={propertiesMeta}
selected={[]}
onSelect={onChange}
/>
}
>
<div
data-testid="variable-name"
className={clsx(styles.filterTypeStyle, styles.ellipsisTextStyle)}
>
<Tooltip content={ast.left.name}>
<div className={styles.filterTypeIconStyle}>
{variableDefineMap[ast.left.name].icon}
</div>
</Tooltip>
<FilterTag name={ast.left.name} />
</div>
</Menu>
<Menu
items={
<FunctionSelect
propertiesMeta={propertiesMeta}
value={value}
onChange={onChange}
/>
}
>
<div
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
data-testid="filter-name"
>
<FilterTag name={ast.funcName} />
</div>
</Menu>
{args}
</div>
);
});
return <>{render({ ast: value })}</>;
};
const FunctionSelect = ({
value,
onChange,
propertiesMeta,
}: {
value: Filter;
onChange: (value: Filter) => void;
propertiesMeta: PropertiesMeta;
}) => {
const list = useMemo(() => {
const type = vars.find(v => v.name === value.left.name)?.type;
if (!type) {
return [];
}
return filterMatcher.allMatchedData(type(propertiesMeta));
}, [propertiesMeta, value.left.name]);
return (
<div data-testid="filter-name-select">
{list.map(v => (
<MenuItem
onClick={() => {
onChange({
...value,
funcName: v.name,
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
});
}}
key={v.name}
>
<FilterTag name={v.name} />
</MenuItem>
))}
</div>
);
};
export const Arg = ({
type,
value,
onChange,
}: {
type: TType;
value: Literal;
onChange: (lit: Literal) => void;
}) => {
const data = useMemo(() => literalMatcher.match(type), [type]);
if (!data) {
return null;
}
return (
<div
data-testid="filter-arg"
className={clsx(styles.argStyle, styles.ellipsisTextStyle)}
>
{data.render({
type,
value: value?.value,
onChange: v => onChange({ type: 'literal', value: v }),
})}
</div>
);
};
export const renderArgs = (
filter: Filter,
onChange: (value: Filter) => void,
type: TFunction
): ReactNode => {
const rest = type.args.slice(1);
return rest.map((argType, i) => {
const value = filter.args[i];
return (
<Arg
key={`${argType.type}-${i}`}
type={argType}
value={value}
onChange={value => {
const args = type.args.map((_, index) =>
i === index ? value : filter.args[index]
);
onChange({
...filter,
args,
});
}}
></Arg>
);
});
};
@@ -1,17 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const datePickerTriggerInput = style({
fontSize: cssVar('fontXs'),
width: '50px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '22px',
textAlign: 'center',
':hover': {
background: cssVar('hoverColor'),
borderRadius: '4px',
},
});
@@ -1,52 +0,0 @@
import type { PopoverProps } from '@affine/component';
import { DatePicker, Popover } from '@affine/component';
import { useI18n } from '@affine/i18n';
import dayjs from 'dayjs';
import { useCallback, useState } from 'react';
import { datePickerTriggerInput } from './date-select.css';
const datePickerPopperContentOptions: PopoverProps['contentOptions'] = {
style: { padding: 20, marginTop: 10 },
};
export const DateSelect = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const onDateChange = useCallback(
(e: string) => {
setOpen(false);
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
},
[onChange]
);
return (
<Popover
open={open}
onOpenChange={setOpen}
contentOptions={datePickerPopperContentOptions}
content={
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
value={dayjs(value as number).format('YYYY-MM-DD')}
onChange={onDateChange}
/>
}
>
<input
value={dayjs(value as number).format('MMM DD')}
className={datePickerTriggerInput}
/>
</Popover>
);
};
@@ -1,25 +0,0 @@
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
import { filterMatcher } from './vars';
const evalRef = (ref: Ref, variableMap: VariableMap) => {
return variableMap[ref.name];
};
const evalLiteral = (lit?: Literal) => {
return lit?.value;
};
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
if (!impl) {
throw new Error('No function implementation found');
}
const leftValue = evalRef(filter.left, variableMap);
const args = filter.args.map(evalLiteral);
return impl(leftValue, ...args);
};
export const evalFilterList = (
filterList: Filter[],
variableMap: VariableMap
) => {
return filterList.every(filter => evalFilter(filter, variableMap));
};

Some files were not shown because too many files have changed in this diff Show More