Skip to content

Conversation

@LucasXu0
Copy link
Contributor

@LucasXu0 LucasXu0 commented Dec 1, 2025

Description

Screenshot 2025-12-01 at 20 44 21 Screenshot 2025-12-01 at 20 44 30

Checklist

General

  • I've included relevant documentation or comments for the changes introduced.
  • I've tested the changes in multiple environments (e.g., different browsers, operating systems).

Testing

  • I've added or updated tests to validate the changes introduced for AppFlowy Web.

Feature-Specific

  • For feature additions, I've added a preview (video, screenshot, or demo) in the "Feature Preview" section.
  • I've verified that this feature integrates seamlessly with existing functionality.

Summary by Sourcery

Add new AI Meeting and PDF block types to the editor and integrate them into the web editor UI.

New Features:

  • Introduce an AI Meeting block type that displays meeting metadata and indicates content is viewable in the desktop app.
  • Introduce a PDF block type that supports uploading or embedding PDFs, including retrying failed uploads and opening PDFs in a new tab.
  • Add a PDF-specific block popover for managing uploads and embedded links from the editor.

Tests:

  • Add Storybook stories for the PDF block covering various filename and URL states.
  • Add Storybook stories for the AI Meeting block covering different title configurations.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 1, 2025

Reviewer's Guide

Adds new editor block types for AI Meeting and PDF documents, including rendering components, popover-based PDF upload/embed workflows, file-upload retry support, and Storybook stories for visual testing.

Sequence diagram for PDF block upload and embed workflow

sequenceDiagram
  actor User
  participant Editor as EditorUI
  participant ElementRouter as ElementComponentRouter
  participant PDF as PDFBlock
  participant Popover as BlockPopover
  participant PopoverContent as PDFBlockPopoverContent
  participant Ctx as EditorContext
  participant FH as FileHandler
  participant CE as CustomEditor
  participant Remote as RemoteStorage

  User->>Editor: Insert PDF block
  Editor->>ElementRouter: Render block
  ElementRouter->>PDF: render(node)

  rect rgb(235,235,255)
    User->>PDF: Click empty PDF block
    PDF->>Popover: openPopover(blockId, PDFBlock, anchor)
    Popover->>PopoverContent: render(blockId, onClose)
  end

  alt Upload_tab
    User->>PopoverContent: Drop PDF files
    PopoverContent->>Ctx: uploadFileRemote(file)
    Ctx->>Remote: uploadFile(file)
    Remote-->>Ctx: url
    Ctx-->>PopoverContent: url
    PopoverContent->>FH: getData(file, url)
    FH-->>PopoverContent: PDFBlockData
    PopoverContent->>CE: setBlockData(editor, blockId, PDFBlockData)
    loop Additional_files
      PopoverContent->>CE: addBelowBlock(editor, blockId, PDFBlock, PDFBlockData)
    end
    PopoverContent->>Popover: onClose()
  else Embed_link_tab
    User->>PopoverContent: Paste PDF url
    PopoverContent->>CE: setBlockData(editor, blockId, PDFBlockData)
    PopoverContent->>Popover: onClose()
  end

  Editor->>ElementRouter: Rerender block
  ElementRouter->>PDF: render(node_with_url)
  User->>PDF: Click PDF block
  PDF->>User: Open url in new tab
Loading

Sequence diagram for PDF upload failure and retry

sequenceDiagram
  actor User
  participant PDF as PDFBlock
  participant FH as FileHandler
  participant Ctx as EditorContext
  participant CE as CustomEditor
  participant Remote as RemoteStorage

  rect rgb(245,235,235)
    note over PDF: Initial upload failed in popover
    PDF->>FH: getStoredFile(retry_local_url)
    FH-->>PDF: fileData(file, local_url)
    PDF->>PDF: setNeedRetry(true)
  end

  User->>PDF: Click Retry button
  PDF->>FH: getStoredFile(retry_local_url)
  FH-->>PDF: file
  PDF->>Ctx: uploadFileRemote(file)
  Ctx->>Remote: uploadFile(file)
  Remote-->>Ctx: url
  Ctx-->>PDF: url

  alt Upload_success
    PDF->>FH: cleanup(retry_local_url)
    FH-->>PDF: ok
    PDF->>CE: setBlockData(editor, blockId, PDFBlockData)
    PDF->>PDF: setNeedRetry(false)
  else Upload_failed
    PDF->>PDF: keepNeedRetry(true)
  end
Loading

Class diagram for new AI Meeting and PDF editor block types

classDiagram
  class BlockType {
    <<enumeration>>
    SimpleTableCellBlock
    ColumnsBlock
    ColumnBlock
    AIMeetingBlock
    PDFBlock
  }

  class BlockData {
  }

  class AIMeetingBlockData {
    +string title
  }

  class PDFBlockData {
    +string name
    +number uploaded_at
    +string url
    +FieldURLType url_type
    +string retry_local_url
  }

  BlockData <|-- AIMeetingBlockData
  BlockData <|-- PDFBlockData

  class BlockNode {
    +string blockId
    +BlockType type
    +BlockData data
    +BlockNode children
  }

  class AIMeetingNode {
    +string blockId
    +AIMeetingBlockData data
  }

  class PDFNode {
    +string blockId
    +PDFBlockData data
  }

  BlockNode <|-- AIMeetingNode
  BlockNode <|-- PDFNode

  class AIMeetingBlock {
    +render(node, children, attributes)
  }

  class PDFBlock {
    +render(node, children, attributes)
    -handleClick()
    -handleRetry(event)
    -uploadFileRemote(file)
  }

  class PDFBlockPopoverContent {
    +render(blockId, onClose)
    +handleTabChange(event, newValue)
    +handleInsertEmbedLink(url)
    +handleChangeUploadFiles(files)
    +uploadFileRemote(file)
    +insertPDFBlock(file)
    +getData(file, remoteUrl)
  }

  class EditorElementProps {
    +BlockNode node
  }

  class EditorContext {
    +uploadFile(file)
    +string workspaceId
    +string viewId
  }

  class FileHandler {
    +handleFileUpload(file)
    +getStoredFile(id)
    +cleanup(id)
  }

  class CustomEditor {
    +setBlockData(editor, blockId, data)
    +addBelowBlock(editor, blockId, type, data)
  }

  class YjsEditor {
    +boolean isElementReadOnly(element)
  }

  class BlockPopoverContext {
    +openPopover(blockId, type, anchor)
    +close()
  }

  class ElementComponentRouter {
    +Element(props)
  }

  BlockType ..> AIMeetingNode
  BlockType ..> PDFNode

  AIMeetingNode --> AIMeetingBlock : rendered_by
  PDFNode --> PDFBlock : rendered_by

  EditorElementProps <.. AIMeetingBlock
  EditorElementProps <.. PDFBlock

  PDFBlock --> EditorContext : uses_uploadFile
  PDFBlock --> FileHandler : creates
  PDFBlock --> CustomEditor : setBlockData
  PDFBlock --> YjsEditor : readOnly_state
  PDFBlock --> BlockPopoverContext : openPopover

  PDFBlockPopoverContent --> EditorContext : uses_uploadFile
  PDFBlockPopoverContent --> FileHandler : uses
  PDFBlockPopoverContent --> CustomEditor : setBlockData_addBelowBlock

  ElementComponentRouter --> AIMeetingBlock : case_AIMeetingBlock
  ElementComponentRouter --> PDFBlock : case_PDFBlock
Loading

File-Level Changes

Change Details Files
Extend core block model to support AI Meeting and PDF block types and data.
  • Add AIMeetingBlock and PDFBlock entries to BlockType enum.
  • Define AIMeetingBlockData and PDFBlockData shapes for storing meeting title and PDF metadata (name, URLs, upload status).
  • Introduce AIMeetingNode and PDFNode types in editor typings so Slate/Yjs can handle these blocks.
src/application/types.ts
src/components/editor/editor.type.ts
Wire new AI Meeting and PDF blocks into the editor element rendering and block popover system.
  • Map BlockType.AIMeetingBlock and BlockType.PDFBlock to their React components in the central Element renderer.
  • Add BlockType.PDFBlock case to BlockPopover to show a dedicated PDF configuration popover.
src/components/editor/components/element/Element.tsx
src/components/editor/components/block-popover/index.tsx
Implement interactive PDF block component with upload, retry, and toolbar behavior.
  • Render a PDF block UI that shows the file name or an upload prompt, opens the PDF in a new tab when clicked, and toggles a file toolbar on hover when a URL is present.
  • Integrate with EditorContext.uploadFile and FileHandler to support remote uploads, local fallback storage for failed uploads, and retrying failed uploads via a reload icon.
  • Respect editor read-only state, disable contentEditable accordingly, and use BlockPopover to open configuration when the block has no URL.
  • Update block data via CustomEditor.setBlockData to persist URL, name, uploaded_at, url_type, and retry_local_url fields.
src/components/editor/components/blocks/pdf/PDFBlock.tsx
Add PDF block popover for uploading or embedding PDFs via link, including multi-file handling.
  • Provide tabbed popover UI with an Upload tab (FileDropzone restricted to PDFs) and an Embed tab (URL input via EmbedLink).
  • On upload, call EditorContext.uploadFile and fall back to local FileHandler storage if remote upload fails, setting retry_local_url accordingly.
  • Update existing block data or insert additional PDF blocks for multiple uploaded files using CustomEditor helpers, deriving file names from URLs when embedding.
  • Expose helper getFileName to pull a file name from URL path segments.
src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx
Implement simple AI Meeting block UI component with desktop-only notice.
  • Render meeting title (or default label) in a styled container, using non-editable content to avoid inline editing.
  • Display a secondary message instructing users to view content on the desktop application.
src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx
src/components/editor/components/blocks/ai-meeting/index.ts
Add Storybook stories to exercise the new PDF and AI Meeting blocks under different data states.
  • Create PDFBlock stories covering various filename states (normal, missing, long, short, whitespace-only, no URL), with mock EditorContext and BlockPopoverContext wiring.
  • Create AIMeetingBlock stories for different title scenarios (default, missing, long, short, whitespace-only).
  • Export PDFBlock module index for easier imports.
src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx
src/components/editor/components/blocks/pdf/index.ts
src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.stories.tsx
src/assets/icons/pdf.svg

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In PDFBlockPopoverContent.getFileName, constructing new URL(url) will throw on invalid or relative URLs; consider wrapping this in a try/catch or falling back to a simpler string-based parse to avoid breaking the popover on bad input.
  • In PDFBlock.handleRetry, errors during retry are swallowed and the user only sees a spinner stop; consider surfacing a notification or explicit error state when retry fails so users understand what happened and whether another attempt is possible.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `PDFBlockPopoverContent.getFileName`, constructing `new URL(url)` will throw on invalid or relative URLs; consider wrapping this in a try/catch or falling back to a simpler string-based parse to avoid breaking the popover on bad input.
- In `PDFBlock.handleRetry`, errors during retry are swallowed and the user only sees a spinner stop; consider surfacing a notification or explicit error state when retry fails so users understand what happened and whether another attempt is possible.

## Individual Comments

### Comment 1
<location> `src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx:16-20` </location>
<code_context>
+
+import EmbedLink from 'src/components/_shared/image-upload/EmbedLink';
+
+export function getFileName(url: string) {
+  const urlObj = new URL(url);
+  const name = urlObj.pathname.split('/').pop();
+
+  return name;
+}
+
</code_context>

<issue_to_address>
**issue:** Harden `getFileName` against invalid URLs and empty path segments.

`new URL(url)` will throw for non-absolute inputs (e.g. missing protocol), which will bubble up from `handleInsertEmbedLink`. It also returns an empty last segment for URLs ending in `/`. Wrap parsing in a `try/catch` and handle falsy `name` by falling back to the original `url` (or a generic label), e.g.:

```ts
export function getFileName(rawUrl: string) {
  try {
    const urlObj = new URL(rawUrl);
    const name = urlObj.pathname.split("/").filter(Boolean).pop();
    return name || rawUrl;
  } catch {
    return rawUrl;
  }
}
```
</issue_to_address>

### Comment 2
<location> `src/components/editor/components/blocks/pdf/PDFBlock.tsx:27` </location>
<code_context>
+      const fileHandler = useMemo(() => new FileHandler(), []);
+      const [localUrl, setLocalUrl] = useState<string | undefined>(undefined);
+      const [loading, setLoading] = useState(false);
+      const { url: dataUrl, name, retry_local_url } = useMemo(() => data || {}, [data]);
+      const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element);
+      const emptyRef = useRef<HTMLDivElement>(null);
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the PDFBlock component to remove unnecessary hooks, clarify click behavior, and simplify class and handler state to make the code easier to follow.

You can trim a fair bit of complexity here without changing behavior by tightening a few patterns inside this component.

### 1. Drop unnecessary `useMemo` around `data` and `url`

You don’t really need the `useMemo` or the extra indirection via `dataUrl`:

```ts
// before
const { url: dataUrl, name, retry_local_url } = useMemo(() => data || {}, [data]);
...
const url = dataUrl;

// after
const { url, name, retry_local_url } = data ?? {};
```

This removes an extra memo and an extra variable, making the data flow easier to follow.

### 2. Simplify `className` computation and avoid duplicate `cursor-pointer`

You currently push `cursor-pointer` in two different branches. That can be simplified into an explicit “interactive” flag:

```ts
// before
const className = useMemo(() => {
  const classList = ['w-full'];

  if (url) {
    classList.push('cursor-pointer');
  } else {
    classList.push('text-text-secondary');
  }

  if (attributes.className) {
    classList.push(attributes.className);
  }

  if (!readOnly) {
    classList.push('cursor-pointer');
  }

  return classList.join(' ');
}, [attributes.className, readOnly, url]);

// after
const className = useMemo(() => {
  const isInteractive = !!url || !readOnly;
  const classList = ['w-full'];

  if (attributes.className) {
    classList.push(attributes.className);
  }

  if (isInteractive) {
    classList.push('cursor-pointer');
  }

  if (!url) {
    classList.push('text-text-secondary');
  }

  return classList.join(' ');
}, [attributes.className, readOnly, url]);
```

This expresses the intent directly: “pointer when interactive, faded when no URL”.

### 3. Use `useRef` instead of `useMemo` for `FileHandler`

Since `FileHandler` is used as an instance, a ref is simpler and avoids the mental model of “memoized value that might someday gain deps”:

```ts
// before
const fileHandler = useMemo(() => new FileHandler(), []);

// after
const fileHandlerRef = useRef(new FileHandler());
const fileHandler = fileHandlerRef.current;
```

This keeps the same lifecycle behavior but makes it clear it’s a stable instance.

### 4. Make the click behavior explicit with small helpers

`handleClick` currently mixes “open popover” and “open PDF” logic inline. Pulling small helpers out makes it easier to read without changing behavior:

```ts
const shouldOpenPopover = !url && !needRetry && !readOnly;

const openConfigPopover = () => {
  if (emptyRef.current && !readOnly) {
    openPopover(blockId, BlockType.PDFBlock, emptyRef.current);
  }
};

const openPdfInNewTab = () => {
  const link = url || localUrl;
  if (link) {
    window.open(link, '_blank');
  }
};

const handleClick = useCallback(async () => {
  try {
    if (shouldOpenPopover) {
      openConfigPopover();
    } else {
      openPdfInNewTab();
    }
  } catch (e: any) {
    notify.error(e.message);
  }
}, [shouldOpenPopover, url, localUrl, readOnly, openPopover, blockId]);
```

This doesn’t change functionality but makes the two modes of the click much more obvious.

These small refactors keep all features intact but reduce the cognitive load of the component and address some of the complexity concerns without a larger extraction into custom hooks.
</issue_to_address>

### Comment 3
<location> `src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx:56` </location>
<code_context>
+    [blockId, editor, onClose]
+  );
+
+  const uploadFileRemote = useCallback(
+    async (file: File) => {
+      try {
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the upload/insert logic into a single helper and simplifying the tab rendering so the data flow and UI conditions are more explicit and easier to follow.

You can reduce the complexity in two focused areas without changing behavior: the upload/insert flow and the tab/panel rendering.

---

### 1. Consolidate upload/insert flow into a single helper

Right now `uploadFileRemote`, `getData`, `insertPDFBlock`, and `handleChangeUploadFiles` are chained and share responsibilities. You can encapsulate “file → data → insert/update blocks” in a single helper, and keep `uploadFileRemote` and data shaping clearly separated.

Example refactor:

```ts
const uploadFileRemote = useCallback(
  async (file: File) => {
    if (!uploadFile) return undefined;
    try {
      return await uploadFile(file);
    } catch {
      return undefined;
    }
  },
  [uploadFile],
);

const buildPdfBlockData = useCallback(
  async (file: File) => {
    const remoteUrl = await uploadFileRemote(file);
    const data: PDFBlockData = {
      url: remoteUrl,
      name: file.name,
      uploaded_at: Date.now(),
      url_type: FieldURLType.Upload,
    };

    if (!remoteUrl) {
      const fileHandler = new FileHandler();
      const res = await fileHandler.handleFileUpload(file);
      data.retry_local_url = res.id;
    }

    return data;
  },
  [uploadFileRemote],
);

// single high‑level flow for first + remaining files
const insertOrUpdatePdfBlocks = useCallback(
  async (files: File[]) => {
    if (!files.length) return;

    const [first, ...rest] = files;

    // update current block with first file
    const firstData = await buildPdfBlockData(first);
    CustomEditor.setBlockData(editor, blockId, firstData);

    // insert remaining files below
    for (const file of rest.reverse()) {
      const data = await buildPdfBlockData(file);
      CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data);
    }
  },
  [blockId, editor, buildPdfBlockData],
);

const handleChangeUploadFiles = useCallback(
  async (files: File[]) => {
    if (!files.length) return;

    setUploading(true);
    try {
      await insertOrUpdatePdfBlocks(files);
      onClose();
    } finally {
      setUploading(false);
    }
  },
  [insertOrUpdatePdfBlocks, onClose],
);
```

This removes `getData` and `insertPDFBlock` as separate concepts and makes the control flow “files → set first block → insert rest” explicit.

---

### 2. Simplify tab rendering and default link extraction

You don’t need `tabOptions`, `selectedIndex`, or mapping twice for just two tabs. You can:

- Use `tabValue` as the discriminator.
- Render panels conditionally.
- Extract `defaultLink` once with proper null‑safety.

Example:

```ts
const defaultLink = useMemo(() => {
  const node = entry?.[0];
  return (node?.data as PDFBlockData | undefined)?.url ?? '';
}, [entry]);
```

Then:

```tsx
<ViewTabs
  value={tabValue}
  onChange={handleTabChange}
  className="min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2"
>
  <ViewTab iconPosition="start" color="inherit" label={t('button.upload')} value="upload" />
  <ViewTab iconPosition="start" color="inherit" label={t('document.plugins.file.networkTab')} value="embed" />
</ViewTabs>

<div className="appflowy-scroller max-h-[400px] overflow-y-auto p-2">
  {tabValue === 'upload' && (
    <FileDropzone
      accept={{ 'application/pdf': ['.pdf'] }}
      multiple
      placeholder={
        <span>
          Click to upload or drag and drop PDF files
          <span className="text-text-action">
            {' '}
            {t('document.plugins.photoGallery.browserLayout')}
          </span>
        </span>
      }
      onChange={handleChangeUploadFiles}
      loading={uploading}
    />
  )}

  {tabValue === 'embed' && (
    <EmbedLink
      onDone={handleInsertEmbedLink}
      defaultLink={defaultLink}
      placeholder="Embed a PDF link"
    />
  )}
</div>
```

This removes the `tabOptions` array, the `selectedIndex` indirection, and the need to cast `entry?.[0].data` inline, making the component easier to read and maintain while preserving the same behavior.
</issue_to_address>

### Comment 4
<location> `src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx:18-20` </location>
<code_context>
  const name = urlObj.pathname.split('/').pop();

  return name;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Inline variable that is immediately returned ([`inline-immediately-returned-variable`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/inline-immediately-returned-variable))

```suggestion
  return urlObj.pathname.split('/').pop();

```

<br/><details><summary>Explanation</summary>Something that we often see in people's code is assigning to a result variable
and then immediately returning it.

Returning the result directly shortens the code and removes an unnecessary
variable, reducing the mental load of reading the function.

Where intermediate variables can be useful is if they then get used as a
parameter or a condition, and the name can act like a comment on what the
variable represents. In the case where you're returning it from a function, the
function name is there to tell you what the result is, so the variable name
is unnecessary.
</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@appflowy appflowy merged commit fdb9b37 into main Dec 2, 2025
11 of 12 checks passed
@appflowy appflowy deleted the feat/ai_meeting_pdf_block branch December 2, 2025 07:26
josue693 pushed a commit to josue693/AppFlowy-Web that referenced this pull request Dec 21, 2025
* feat: support simple ai meeting and pdf block

* chore: update placeholder text

* chore: code review

* chore: lint code

* chore: update placeholder text

* fix: lint error
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants