mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-07 10:03:45 +00:00
Compare commits
22 Commits
v0.25.0-be
...
v0.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35332634a | ||
|
|
0063f039a7 | ||
|
|
d80ca57e94 | ||
|
|
c63e3e7fe6 | ||
|
|
05d373081a | ||
|
|
26fbde6b62 | ||
|
|
072b5b22df | ||
|
|
3c7461a5ce | ||
|
|
1b859a37c5 | ||
|
|
bf72833f05 | ||
|
|
96b3de8ce7 | ||
|
|
26a59db540 | ||
|
|
7d0b8aaa81 | ||
|
|
856b69e1f6 | ||
|
|
5fdae9161a | ||
|
|
03ef4625bc | ||
|
|
4b3ebd899b | ||
|
|
b59c1f9e57 | ||
|
|
b44fdbce0c | ||
|
|
123d50a484 | ||
|
|
2d1caff45c | ||
|
|
8006812bc0 |
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"scenarios": {
|
||||
"type": "object",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
@@ -693,7 +693,7 @@
|
||||
"embedding": "gemini-embedding-001",
|
||||
"image": "gpt-image-1",
|
||||
"rerank": "gpt-4.1",
|
||||
"coding": "claude-sonnet-4@20250514",
|
||||
"coding": "claude-sonnet-4-5@20250929",
|
||||
"complex_text_generation": "gpt-4o-2024-08-06",
|
||||
"quick_decision_making": "gpt-5-mini",
|
||||
"quick_text_generation": "gemini-2.5-flash",
|
||||
|
||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -161,6 +161,7 @@ dependencies = [
|
||||
"affine_common",
|
||||
"chrono",
|
||||
"file-format",
|
||||
"infer",
|
||||
"mimalloc",
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1504,9 +1505,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "file-format"
|
||||
version = "0.26.0"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ef3d5e8ae27277c8285ac43ed153158178ef0f79567f32024ca8140a0c7cd8"
|
||||
checksum = "0eab8aa2fba5f39f494000a22f44bf3c755b7d7f8ffad3f36c6d507893074159"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
@@ -1913,7 +1914,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,7 +39,7 @@ crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.26", features = ["reader"] }
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
homedir = "0.3"
|
||||
infer = { version = "0.19.0" }
|
||||
lasso = { version = "0.7", features = ["multi-threaded"] }
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -6,12 +6,12 @@ We recommend users to always use the latest major version. Security updates will
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.17.x (stable) | :white_check_mark: |
|
||||
| < 0.17.x | :x: |
|
||||
| 0.24.x (stable) | :white_check_mark: |
|
||||
| < 0.24.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info) or submit directly on [GitHub](https://github.com/toeverything/AFFiNE/security), **we encourage you to submit the relevant information directly via GitHub**. We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
|
||||
1. Using platform and version, for example:
|
||||
|
||||
@@ -22,8 +22,6 @@ We welcome you to provide us with bug reports via and email at [security@toevery
|
||||
|
||||
3. Your classification or analysis of the vulnerability (optional)
|
||||
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
|
||||
|
||||
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
|
||||
|
||||
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
56
blocksuite/affine/blocks/callout/src/callout-block-styles.ts
Normal file
56
blocksuite/affine/blocks/callout/src/callout-block-styles.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const calloutHostStyles = css({
|
||||
display: 'block',
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const calloutBlockContainerStyles = css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '5px 10px',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const calloutEmojiContainerStyles = css({
|
||||
userSelect: 'none',
|
||||
fontSize: '1.2em',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// marginTop is dynamically set by JavaScript based on first child's height
|
||||
marginBottom: '10px',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const calloutEmojiStyles = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
export const calloutChildrenStyles = css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingLeft: '10px',
|
||||
});
|
||||
|
||||
export const iconPickerContainerStyles = css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
background: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
width: '390px',
|
||||
height: '400px',
|
||||
});
|
||||
@@ -1,88 +1,160 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import {
|
||||
createPopup,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import { type CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
ThemeProvider,
|
||||
type IconData,
|
||||
IconPickerServiceIdentifier,
|
||||
IconType,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import * as icons from '@blocksuite/icons/lit';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { css, html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
calloutBlockContainerStyles,
|
||||
calloutChildrenStyles,
|
||||
calloutEmojiContainerStyles,
|
||||
calloutEmojiStyles,
|
||||
calloutHostStyles,
|
||||
} from './callout-block-styles.js';
|
||||
import { IconPickerWrapper } from './icon-picker-wrapper.js';
|
||||
// Copy of renderUniLit and UniLit from affine-data-view
|
||||
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
uni: UniComponent<Props, Expose> | undefined,
|
||||
props?: Props,
|
||||
options?: {
|
||||
ref?: Signal<Expose | undefined>;
|
||||
style?: Readonly<StyleInfo>;
|
||||
class?: string;
|
||||
}
|
||||
): TemplateResult => {
|
||||
return html` <uni-lit
|
||||
.uni="${uni}"
|
||||
.props="${props}"
|
||||
.ref="${options?.ref}"
|
||||
style=${options?.style ? styleMap(options?.style) : ''}
|
||||
></uni-lit>`;
|
||||
};
|
||||
const getIcon = (icon?: IconData) => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
if (icon.type === IconType.Emoji) {
|
||||
return icon.unicode;
|
||||
}
|
||||
if (icon.type === IconType.AffineIcon) {
|
||||
return (
|
||||
icons as Record<string, (props: { style: string }) => TemplateResult>
|
||||
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
private _popupCloseHandler: (() => void) | null = null;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(calloutHostStyles);
|
||||
}
|
||||
|
||||
private _getEmojiMarginTop(): string {
|
||||
if (this.model.children.length === 0) {
|
||||
return '10px';
|
||||
}
|
||||
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
|
||||
const firstChild = this.model.children[0];
|
||||
const flavour = firstChild.flavour;
|
||||
|
||||
const marginTopMap: Record<string, string> = {
|
||||
'affine:paragraph:h1': '23px',
|
||||
'affine:paragraph:h2': '20px',
|
||||
'affine:paragraph:h3': '16px',
|
||||
'affine:paragraph:h4': '15px',
|
||||
'affine:paragraph:h5': '14px',
|
||||
'affine:paragraph:h6': '13px',
|
||||
};
|
||||
|
||||
// For heading blocks, use the type to determine margin
|
||||
if (flavour === 'affine:paragraph') {
|
||||
const paragraph = firstChild as ParagraphBlockModel;
|
||||
const type = paragraph.props.type$.value;
|
||||
const key = `${flavour}:${type}`;
|
||||
return marginTopMap[key] || '10px';
|
||||
}
|
||||
|
||||
.affine-callout-emoji-container {
|
||||
user-select: none;
|
||||
font-size: 1.2em;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
// Default for all other block types
|
||||
return '10px';
|
||||
}
|
||||
|
||||
private _closeIconPicker() {
|
||||
if (this._popupCloseHandler) {
|
||||
this._popupCloseHandler();
|
||||
this._popupCloseHandler = null;
|
||||
}
|
||||
.affine-callout-emoji:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
private _toggleIconPicker(event: MouseEvent) {
|
||||
// If popup is already open, close it
|
||||
if (this._popupCloseHandler) {
|
||||
this._closeIconPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
.affine-callout-children {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
// Get IconPickerService from the framework
|
||||
const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier);
|
||||
if (!iconPickerService) {
|
||||
console.warn('IconPickerService not found');
|
||||
return;
|
||||
}
|
||||
`;
|
||||
|
||||
private _emojiMenuAbortController: AbortController | null = null;
|
||||
private readonly _toggleEmojiMenu = () => {
|
||||
if (this._emojiMenuAbortController) {
|
||||
this._emojiMenuAbortController.abort();
|
||||
}
|
||||
this._emojiMenuAbortController = new AbortController();
|
||||
// Get the uni-component from the service
|
||||
const iconPickerComponent = iconPickerService.iconPickerComponent;
|
||||
|
||||
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||
|
||||
createLitPortal({
|
||||
template: html`<affine-emoji-menu
|
||||
.theme=${theme}
|
||||
.onEmojiSelect=${(data: { native: string }) => {
|
||||
this.model.props.emoji = data.native;
|
||||
}}
|
||||
></affine-emoji-menu>`,
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
// Create props for the icon picker
|
||||
const props = {
|
||||
onSelect: (iconData?: IconData) => {
|
||||
this.model.props.icon$.value = iconData;
|
||||
this._closeIconPicker(); // Close the picker after selection
|
||||
},
|
||||
container: this.host,
|
||||
computePosition: {
|
||||
referenceElement: this._emojiButton,
|
||||
placement: 'bottom-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
autoUpdate: { animationFrame: true },
|
||||
onClose: () => {
|
||||
this._closeIconPicker();
|
||||
},
|
||||
};
|
||||
|
||||
// Create IconPickerWrapper instance
|
||||
const wrapper = new IconPickerWrapper();
|
||||
wrapper.iconPickerComponent = iconPickerComponent;
|
||||
wrapper.props = props;
|
||||
wrapper.style.position = 'absolute';
|
||||
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
|
||||
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
|
||||
wrapper.style.borderRadius = '8px';
|
||||
|
||||
// Create popup target from the clicked element
|
||||
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
|
||||
|
||||
// Create popup
|
||||
this._popupCloseHandler = createPopup(target, wrapper, {
|
||||
onClose: () => {
|
||||
this._popupCloseHandler = null;
|
||||
},
|
||||
abortController: this._emojiMenuAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private readonly _handleBlockClick = (event: MouseEvent) => {
|
||||
// Check if the click target is emoji related element
|
||||
@@ -94,6 +166,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no icon, open icon picker on click
|
||||
const icon = this.model.props.icon$.value;
|
||||
if (!icon) {
|
||||
this._toggleIconPicker(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle clicks when there are no children
|
||||
if (this.model.children.length > 0) {
|
||||
return;
|
||||
@@ -125,9 +204,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
return this.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
@query('.affine-callout-emoji')
|
||||
private accessor _emojiButton!: HTMLElement;
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(
|
||||
@@ -138,23 +214,39 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const emoji = this.model.props.emoji$.value;
|
||||
const icon = this.model.props.icon$.value;
|
||||
const backgroundColorName = this.model.props.backgroundColorName$.value;
|
||||
const backgroundColor = (
|
||||
cssVarV2.block.callout.background as Record<string, string>
|
||||
)[backgroundColorName ?? ''];
|
||||
|
||||
const iconContent = getIcon(icon);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="affine-callout-block-container"
|
||||
class="${calloutBlockContainerStyles}"
|
||||
@click=${this._handleBlockClick}
|
||||
style=${styleMap({
|
||||
backgroundColor: backgroundColor ?? 'transparent',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
@click=${this._toggleEmojiMenu}
|
||||
contenteditable="false"
|
||||
class="affine-callout-emoji-container"
|
||||
style=${styleMap({
|
||||
display: emoji.length === 0 ? 'none' : undefined,
|
||||
})}
|
||||
>
|
||||
<span class="affine-callout-emoji">${emoji}</span>
|
||||
</div>
|
||||
<div class="affine-callout-children">
|
||||
${iconContent
|
||||
? html`
|
||||
<div
|
||||
@click=${this._toggleIconPicker}
|
||||
contenteditable="false"
|
||||
class="${calloutEmojiContainerStyles}"
|
||||
style=${styleMap({
|
||||
marginTop: this._getEmojiMarginTop(),
|
||||
})}
|
||||
>
|
||||
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
|
||||
>${iconContent}</span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<div class="${calloutChildrenStyles}">
|
||||
${this.renderChildren(this.model)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
204
blocksuite/affine/blocks/callout/src/configs/toolbar.ts
Normal file
204
blocksuite/affine/blocks/callout/src/configs/toolbar.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
createPopup,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type IconData,
|
||||
IconPickerServiceIdentifier,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { IconPickerWrapper } from '../icon-picker-wrapper.js';
|
||||
|
||||
const colors = [
|
||||
'default',
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'teal',
|
||||
'blue',
|
||||
'purple',
|
||||
'grey',
|
||||
] as const;
|
||||
|
||||
const backgroundColorAction = {
|
||||
id: 'background-color',
|
||||
label: 'Background Color',
|
||||
tooltip: 'Change background color',
|
||||
icon: PaletteIcon(),
|
||||
run() {
|
||||
// This will be handled by the content function
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(CalloutBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const updateBackground = (color: string) => {
|
||||
ctx.store.updateBlock(model, { backgroundColorName: color });
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="background"
|
||||
.tooltip=${'Background Color'}
|
||||
>
|
||||
${PaletteIcon()} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
<div class="highlight-heading">Background</div>
|
||||
${repeat(colors, color => {
|
||||
const isDefault = color === 'default';
|
||||
const value = isDefault
|
||||
? null
|
||||
: `var(--affine-text-highlight-${color})`;
|
||||
const displayName = `${color} Background`;
|
||||
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid="background-${color}"
|
||||
@click=${() => updateBackground(color)}
|
||||
>
|
||||
<affine-text-duotone-icon
|
||||
style=${styleMap({
|
||||
'--color': 'var(--affine-text-primary-color)',
|
||||
'--background': value ?? 'transparent',
|
||||
})}
|
||||
></affine-text-duotone-icon>
|
||||
<span class="label capitalize">${displayName}</span>
|
||||
</editor-menu-action>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarAction;
|
||||
|
||||
const iconPickerAction = {
|
||||
id: 'icon-picker',
|
||||
label: 'Icon Picker',
|
||||
tooltip: 'Change icon',
|
||||
icon: SmileIcon(),
|
||||
run() {
|
||||
// This will be handled by the content function
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(CalloutBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const handleIconPickerClick = (event: MouseEvent) => {
|
||||
// Get IconPickerService from the framework
|
||||
const iconPickerService = ctx.std.getOptional(
|
||||
IconPickerServiceIdentifier
|
||||
);
|
||||
if (!iconPickerService) {
|
||||
console.warn('IconPickerService not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the uni-component from the service
|
||||
const iconPickerComponent = iconPickerService.iconPickerComponent;
|
||||
|
||||
// Create props for the icon picker
|
||||
const props = {
|
||||
onSelect: (iconData?: IconData) => {
|
||||
// When iconData is undefined (delete icon), set icon to undefined
|
||||
ctx.store.updateBlock(model, { icon: iconData });
|
||||
closeHandler(); // Close the picker after selection
|
||||
},
|
||||
onClose: () => {
|
||||
closeHandler();
|
||||
},
|
||||
};
|
||||
|
||||
// Create IconPickerWrapper instance
|
||||
const wrapper = new IconPickerWrapper();
|
||||
wrapper.iconPickerComponent = iconPickerComponent;
|
||||
wrapper.props = props;
|
||||
wrapper.style.position = 'absolute';
|
||||
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
|
||||
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
|
||||
wrapper.style.borderRadius = '8px';
|
||||
|
||||
// Create popup target from the clicked element
|
||||
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
|
||||
|
||||
// Create popup
|
||||
const closeHandler = createPopup(target, wrapper, {
|
||||
onClose: () => {
|
||||
// Cleanup if needed
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="icon-picker"
|
||||
.tooltip=${'Change Icon'}
|
||||
@click=${handleIconPickerClick}
|
||||
>
|
||||
${SmileIcon()} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarAction;
|
||||
|
||||
const builtinToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'style',
|
||||
actions: [backgroundColorAction],
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'icon',
|
||||
actions: [iconPickerAction],
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(CalloutBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
} satisfies ToolbarAction,
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string
|
||||
): ExtensionType[] => {
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { CalloutBlockComponent } from './callout-block';
|
||||
import { EmojiMenu } from './emoji-menu';
|
||||
import { IconPickerWrapper } from './icon-picker-wrapper';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-callout', CalloutBlockComponent);
|
||||
customElements.define('affine-emoji-menu', EmojiMenu);
|
||||
customElements.define('icon-picker-wrapper', IconPickerWrapper);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-callout': CalloutBlockComponent;
|
||||
'affine-emoji-menu': EmojiMenu;
|
||||
'icon-picker-wrapper': IconPickerWrapper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import data from '@emoji-mart/data';
|
||||
import { Picker } from 'emoji-mart';
|
||||
import { html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
export class EmojiMenu extends WithDisposable(LitElement) {
|
||||
override firstUpdated(props: PropertyValues) {
|
||||
const result = super.firstUpdated(props);
|
||||
|
||||
const picker = new Picker({
|
||||
data,
|
||||
onEmojiSelect: this.onEmojiSelect,
|
||||
autoFocus: true,
|
||||
theme: this.theme,
|
||||
});
|
||||
this.emojiMenu.append(picker as unknown as Node);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onEmojiSelect: (data: any) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor theme: 'light' | 'dark' = 'light';
|
||||
|
||||
@query('.affine-emoji-menu')
|
||||
accessor emojiMenu!: HTMLElement;
|
||||
|
||||
override render() {
|
||||
return html`<div class="affine-emoji-menu"></div>`;
|
||||
}
|
||||
}
|
||||
52
blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts
Normal file
52
blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { IconData } from '@blocksuite/affine-shared/services';
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
// Copy of renderUniLit from callout-block.ts
|
||||
const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
uni: UniComponent<Props, Expose> | undefined,
|
||||
props?: Props,
|
||||
options?: {
|
||||
ref?: Signal<Expose | undefined>;
|
||||
style?: Readonly<StyleInfo>;
|
||||
class?: string;
|
||||
}
|
||||
): TemplateResult => {
|
||||
return html` <uni-lit
|
||||
.uni="${uni}"
|
||||
.props="${props}"
|
||||
.ref="${options?.ref}"
|
||||
style=${options?.style ? styleMap(options?.style) : ''}
|
||||
></uni-lit>`;
|
||||
};
|
||||
|
||||
export interface IconPickerWrapperProps {
|
||||
onSelect?: (iconData?: IconData) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export class IconPickerWrapper extends ShadowlessElement {
|
||||
iconPickerComponent?: UniComponent<IconPickerWrapperProps, any>;
|
||||
props?: IconPickerWrapperProps;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.iconPickerComponent) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return renderUniLit(this.iconPickerComponent, this.props);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'icon-picker-wrapper': IconPickerWrapper;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { CalloutKeymapExtension } from './callout-keymap';
|
||||
import { calloutSlashMenuConfig } from './configs/slash-menu';
|
||||
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
|
||||
import { effects } from './effects';
|
||||
|
||||
export class CalloutViewExtension extends ViewExtensionProvider {
|
||||
@@ -25,6 +26,7 @@ export class CalloutViewExtension extends ViewExtensionProvider {
|
||||
BlockViewExtension('affine:callout', literal`affine-callout`),
|
||||
CalloutKeymapExtension,
|
||||
SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig),
|
||||
...createBuiltinToolbarConfigExtension('affine:callout'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,16 @@ const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-title-container {
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-icon-container,
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border: 0;
|
||||
@@ -47,6 +47,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
}
|
||||
.doc-icon-container + * .doc-title-container {
|
||||
/* when doc icon exists, remove the top padding */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IconData } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
BlockModel,
|
||||
BlockSchemaExtension,
|
||||
@@ -8,15 +9,17 @@ import {
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type CalloutProps = {
|
||||
emoji: string;
|
||||
icon?: IconData;
|
||||
text: Text;
|
||||
backgroundColorName?: string;
|
||||
} & BlockMeta;
|
||||
|
||||
export const CalloutBlockSchema = defineBlockSchema({
|
||||
flavour: 'affine:callout',
|
||||
props: (internal): CalloutProps => ({
|
||||
emoji: '😀',
|
||||
icon: { type: 'emoji', unicode: '💡' } as IconData,
|
||||
text: internal.Text(),
|
||||
backgroundColorName: 'grey',
|
||||
'meta:createdAt': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './icon-picker-service/index.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
export enum IconType {
|
||||
Emoji = 'emoji',
|
||||
AffineIcon = 'affine-icon',
|
||||
Blob = 'blob',
|
||||
}
|
||||
|
||||
export type IconData =
|
||||
| {
|
||||
type: IconType.Emoji;
|
||||
unicode: string;
|
||||
}
|
||||
| {
|
||||
type: IconType.AffineIcon;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
| {
|
||||
type: IconType.Blob;
|
||||
blob: Blob;
|
||||
};
|
||||
|
||||
export interface IconPickerService {
|
||||
iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>;
|
||||
}
|
||||
|
||||
export const IconPickerServiceIdentifier =
|
||||
createIdentifier<IconPickerService>('IconPickerService');
|
||||
@@ -13,6 +13,7 @@ export * from './feature-flag-service';
|
||||
export * from './file-size-limit-service';
|
||||
export * from './font-loader';
|
||||
export * from './generate-url-service';
|
||||
export * from './icon-picker-service';
|
||||
export * from './link-preview-service';
|
||||
export * from './native-clipboard-service';
|
||||
export * from './notification-service';
|
||||
|
||||
@@ -76,10 +76,16 @@ export const linkedDocPopoverStyles = css`
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.group icon-button svg {
|
||||
.group icon-button svg,
|
||||
.group icon-button .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.group icon-button .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group {
|
||||
display: flex;
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.6.8",
|
||||
"oxlint": "^1.15.0",
|
||||
"oxlint": "~1.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semver": "^7.6.3",
|
||||
"serve": "^14.2.4",
|
||||
|
||||
@@ -10,6 +10,7 @@ crate-type = ["cdylib"]
|
||||
affine_common = { workspace = true, features = ["doc-loader"] }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
napi = { workspace = true, features = ["async"] }
|
||||
napi-derive = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,11 @@ use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
pub fn get_mime(input: &[u8]) -> String {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
if let Some(kind) = infer::get(&input[..4096.min(input.len())]) {
|
||||
kind.mime_type().to_string()
|
||||
} else {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
> should honor requested pro model during active
|
||||
|
||||
'claude-sonnet-4@20250514'
|
||||
'claude-sonnet-4-5@20250929'
|
||||
|
||||
> should fallback to default model when requesting non-optional model during active
|
||||
|
||||
|
||||
Binary file not shown.
@@ -30,6 +30,7 @@ import {
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
inviteUser,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
TestUser,
|
||||
} from './utils';
|
||||
@@ -453,8 +454,6 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
@@ -475,8 +474,6 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
|
||||
@@ -2074,11 +2074,11 @@ test('should resolve model correctly based on subscription status and prompt con
|
||||
messages: {
|
||||
create: [{ idx: 0, role: 'system', content: 'test' }],
|
||||
},
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'] },
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4@20250514',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -2138,7 +2138,7 @@ test('should resolve model correctly based on subscription status and prompt con
|
||||
'should pick default model when no requested model during active'
|
||||
);
|
||||
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514');
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4-5@20250929');
|
||||
t.snapshot(model7, 'should honor requested pro model during active');
|
||||
|
||||
const model8 = await s.resolveModel(true, 'not-in-optional');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import { omit } from 'lodash-es';
|
||||
import Sinon from 'sinon';
|
||||
@@ -14,6 +14,7 @@ import { Models } from '../../models';
|
||||
import { PaymentModule } from '../../plugins/payment';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||
import { UserSubscriptionResolver } from '../../plugins/payment/resolver';
|
||||
import {
|
||||
RcEvent,
|
||||
resolveProductMapping,
|
||||
@@ -39,6 +40,7 @@ type Ctx = {
|
||||
rc: RevenueCatService;
|
||||
webhook: RevenueCatWebhookHandler;
|
||||
controller: RevenueCatWebhookController;
|
||||
subResolver: UserSubscriptionResolver;
|
||||
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
@@ -85,6 +87,7 @@ test.beforeEach(async t => {
|
||||
const rc = app.get(RevenueCatService);
|
||||
const webhook = app.get(RevenueCatWebhookHandler);
|
||||
const controller = app.get(RevenueCatWebhookController);
|
||||
const subResolver = app.get(UserSubscriptionResolver);
|
||||
|
||||
t.context.module = app;
|
||||
t.context.db = db;
|
||||
@@ -95,6 +98,7 @@ test.beforeEach(async t => {
|
||||
t.context.rc = rc;
|
||||
t.context.webhook = webhook;
|
||||
t.context.controller = controller;
|
||||
t.context.subResolver = subResolver;
|
||||
|
||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||
t.context.mockSubSeq = sequences => {
|
||||
@@ -927,3 +931,90 @@ test('should not dispatch webhook event when authorization header is missing or
|
||||
const after = event.emitAsync.getCalls()?.length || 0;
|
||||
t.is(after - before, 0, 'should not emit event');
|
||||
});
|
||||
|
||||
test('should refresh user subscriptions (empty / revenuecat / stripe-only)', async t => {
|
||||
const { subResolver, db, mockSubSeq } = t.context;
|
||||
|
||||
const currentUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: '',
|
||||
name: '',
|
||||
disabled: false,
|
||||
hasPassword: true,
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
// prepare mocks:
|
||||
// first call returns Pro subscription
|
||||
// second call returns AI subscription.
|
||||
const stub = mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'AI',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-02T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-02T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.ai.Annual',
|
||||
store: 'play_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// case1: empty -> should sync (first sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 1, 'Scenario1: RC API called once');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'pro'),
|
||||
'case1: pro saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case2: existing revenuecat -> should sync again (second sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'Scenario2: RC API called second time');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'ai'),
|
||||
'case2: ai saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case3: only stripe subscription -> should NOT sync (call count remains 2)
|
||||
{
|
||||
await db.subscription.deleteMany({
|
||||
where: { targetId: user.id, provider: 'revenuecat' },
|
||||
});
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
provider: 'stripe',
|
||||
status: 'active',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
},
|
||||
});
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'case3: RC API not called again');
|
||||
t.is(subs.length, 1, 'case3: only stripe subscription returned');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import ava from 'ava';
|
||||
import {
|
||||
createTestingApp,
|
||||
getPublicUserById,
|
||||
smallestGif,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
updateAvatar,
|
||||
} from '../utils';
|
||||
@@ -27,7 +29,9 @@ test('should be able to upload user avatar', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const res = await updateAvatar(app, avatar);
|
||||
|
||||
t.is(res.status, 200);
|
||||
@@ -36,19 +40,23 @@ test('should be able to upload user avatar', async t => {
|
||||
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.deepEqual(avatarRes.body, Buffer.from('test'));
|
||||
t.deepEqual(avatarRes.body, avatar);
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
let res = await updateAvatar(app, avatar);
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = Buffer.from('new');
|
||||
const newAvatar = await fetch(smallestGif)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
res = await updateAvatar(app, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
@@ -58,14 +66,16 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
t.is(avatarRes.status, 404);
|
||||
|
||||
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
|
||||
t.deepEqual(newAvatarRes.body, Buffer.from('new'));
|
||||
t.deepEqual(newAvatarRes.body, newAvatar);
|
||||
});
|
||||
|
||||
test('should be able to get public user by id', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
await updateAvatar(app, avatar);
|
||||
const u2 = await app.signup();
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { type Blob } from '@prisma/client';
|
||||
import { TestingApp } from './testing-app';
|
||||
import { TEST_LOG_LEVEL } from './utils';
|
||||
|
||||
export const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
|
||||
export async function listBlobs(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
|
||||
};
|
||||
|
||||
export type * from './provider';
|
||||
export { autoMetadata, toBuffer } from './utils';
|
||||
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import type { Response } from 'express';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
@@ -43,4 +44,53 @@ export function autoMetadata(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const DANGEROUS_INLINE_MIME_PREFIXES = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/javascript',
|
||||
];
|
||||
|
||||
export function isDangerousInlineMime(mime: string | undefined) {
|
||||
if (!mime) return false;
|
||||
const lower = mime.toLowerCase();
|
||||
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
|
||||
}
|
||||
|
||||
export function applyAttachHeaders(
|
||||
res: Response,
|
||||
options: { filename?: string; buffer?: Buffer; contentType?: string }
|
||||
) {
|
||||
let { filename, buffer, contentType } = options;
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!contentType && buffer) contentType = sniffMime(buffer);
|
||||
if (contentType && isDangerousInlineMime(contentType)) {
|
||||
const safeName = (filename || 'download')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.replace(/[^\w\s.-]/g, '_');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
|
||||
safeName
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
res.setHeader('Content-Type', contentType || 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
|
||||
export function sniffMime(
|
||||
buffer: Buffer,
|
||||
declared?: string
|
||||
): string | undefined {
|
||||
try {
|
||||
const detected = getMime(buffer);
|
||||
if (detected) return detected;
|
||||
} catch {}
|
||||
return declared;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
|
||||
import { OneKB } from './unit';
|
||||
|
||||
export type CheckExceededResult =
|
||||
| {
|
||||
@@ -52,7 +53,7 @@ export async function readBuffer(
|
||||
|
||||
export async function readBufferWithLimit(
|
||||
readable: Readable,
|
||||
limit: number
|
||||
limit: number = 500 * OneKB
|
||||
): Promise<Buffer> {
|
||||
return readBuffer(readable, size =>
|
||||
size > limit
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ActionForbidden, UserAvatarNotFound } from '../../base';
|
||||
import {
|
||||
ActionForbidden,
|
||||
applyAttachHeaders,
|
||||
UserAvatarNotFound,
|
||||
} from '../../base';
|
||||
import { Public } from '../auth/guard';
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@@ -30,6 +34,10 @@ export class UserAvatarController {
|
||||
res.setHeader('last-modified', metadata.lastModified.toISOString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: `${id}`,
|
||||
});
|
||||
|
||||
body.pipe(res);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
type FileUpload,
|
||||
readBufferWithLimit,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
@@ -98,20 +100,20 @@ export class UserResolver {
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
if (!avatar.mimetype.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
|
||||
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
const avatarUrl = await this.storage.put(
|
||||
`${user.id}-avatar-${Date.now()}`,
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
avatarBuffer,
|
||||
{ contentType }
|
||||
);
|
||||
|
||||
if (user.avatarUrl) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
CommentAttachmentNotFound,
|
||||
@@ -83,6 +84,10 @@ export class WorkspacesController {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: name,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
@@ -215,6 +220,10 @@ export class WorkspacesController {
|
||||
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
|
||||
);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -396,7 +396,10 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async update(options: UpdateChatSessionOptions): Promise<string> {
|
||||
async update(
|
||||
options: UpdateChatSessionOptions,
|
||||
internalCall = false
|
||||
): Promise<string> {
|
||||
const { userId, sessionId, docId, promptName, pinned, title } = options;
|
||||
const session = await this.getExists(
|
||||
sessionId,
|
||||
@@ -415,14 +418,16 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
// not allow to update action session
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
if (!internalCall) {
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (promptName) {
|
||||
|
||||
@@ -55,7 +55,7 @@ defineModuleConfig('copilot', {
|
||||
embedding: 'gemini-embedding-001',
|
||||
image: 'gpt-image-1',
|
||||
rerank: 'gpt-4.1',
|
||||
coding: 'claude-sonnet-4@20250514',
|
||||
coding: 'claude-sonnet-4-5@20250929',
|
||||
complex_text_generation: 'gpt-4o-2024-08-06',
|
||||
quick_decision_making: 'gpt-5-mini',
|
||||
quick_text_generation: 'gemini-2.5-flash',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
EventBus,
|
||||
type FileUpload,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -671,7 +672,11 @@ export class CopilotContextResolver {
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(blobId, filename, mimetype);
|
||||
const file = await session.addFile(
|
||||
blobId,
|
||||
filename,
|
||||
sniffMime(buffer, mimetype) || mimetype
|
||||
);
|
||||
|
||||
await this.jobs.addFileEmbeddingQueue({
|
||||
userId: user.id,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from 'rxjs';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
Config,
|
||||
@@ -795,6 +796,10 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -1390,7 +1390,7 @@ If there are items in the content that can be used as to-do tasks, please refer
|
||||
{
|
||||
name: 'Make it real',
|
||||
action: 'Make it real',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1431,7 +1431,7 @@ When sent new wireframes, respond ONLY with the contents of the html file.`,
|
||||
{
|
||||
name: 'Make it real with text',
|
||||
action: 'Make it real with text',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1712,7 +1712,7 @@ const modelActions: Prompt[] = [
|
||||
{
|
||||
name: 'Apply Updates',
|
||||
action: 'Apply Updates',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -1868,7 +1868,7 @@ Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, an
|
||||
},
|
||||
{
|
||||
name: 'Code Artifact',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1932,7 +1932,7 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4@20250514',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
@@ -2092,7 +2092,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
'codeArtifact',
|
||||
'blobRead',
|
||||
],
|
||||
proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'],
|
||||
proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
@@ -40,27 +50,6 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
protected instance!: AnthropicSDKProvider;
|
||||
|
||||
@@ -24,6 +24,16 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4.5',
|
||||
id: 'claude-sonnet-4-5@20250929',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4@20250514',
|
||||
@@ -34,27 +44,6 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet@20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-v2@20241022',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
protected instance!: GoogleVertexAnthropicProvider;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Paginated,
|
||||
PaginationInput,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -806,7 +807,10 @@ export class CopilotResolver {
|
||||
filename,
|
||||
uploaded.buffer
|
||||
);
|
||||
attachments.push({ attachment, mimeType: blob.mimetype });
|
||||
attachments.push({
|
||||
attachment,
|
||||
mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -636,11 +636,10 @@ export class ChatSessionService {
|
||||
})
|
||||
.then(s => s.map(s => [s.userId, s.id]));
|
||||
for (const [userId, sessionId] of sessionIds) {
|
||||
await this.models.copilotSession.update({
|
||||
userId,
|
||||
sessionId,
|
||||
docId: null,
|
||||
});
|
||||
await this.models.copilotSession.update(
|
||||
{ userId, sessionId, docId: null },
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NoCopilotProviderAvailable,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { PromptService } from '../prompt';
|
||||
@@ -85,7 +86,10 @@ export class CopilotTranscriptionService {
|
||||
`${blobId}-${idx}`,
|
||||
buffer
|
||||
);
|
||||
infos.push({ url, mimeType: blob.mimetype });
|
||||
infos.push({
|
||||
url,
|
||||
mimeType: sniffMime(buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const model = await this.getModel(userId);
|
||||
|
||||
@@ -2,7 +2,12 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { FileUpload, JobQueue, PaginationInput } from '../../../base';
|
||||
import {
|
||||
FileUpload,
|
||||
JobQueue,
|
||||
PaginationInput,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { ServerFeature, ServerService } from '../../../core';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotStorage } from '../storage';
|
||||
@@ -64,7 +69,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
|
||||
const file = await this.models.copilotWorkspace.addFile(workspaceId, {
|
||||
fileName,
|
||||
blobId,
|
||||
mimeType: content.mimetype,
|
||||
mimeType: sniffMime(buffer, content.mimetype) || content.mimetype,
|
||||
size: buffer.length,
|
||||
});
|
||||
return { blobId, file };
|
||||
|
||||
@@ -221,6 +221,15 @@ export class OAuthController {
|
||||
if (connectedAccount) {
|
||||
// already connected
|
||||
await this.updateConnectedAccount(connectedAccount, tokens);
|
||||
|
||||
if (
|
||||
!connectedAccount.user.emailVerifiedAt &&
|
||||
// external email may change, check if it matches exists email
|
||||
externalAccount.email.toLowerCase() ===
|
||||
connectedAccount.user.email.toLowerCase()
|
||||
) {
|
||||
await this.auth.setEmailVerified(connectedAccount.userId);
|
||||
}
|
||||
return connectedAccount.user;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -31,6 +30,7 @@ import { AccessController } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { CheckoutParams, SubscriptionService } from './service';
|
||||
import {
|
||||
InvoiceStatus,
|
||||
@@ -463,7 +463,22 @@ export class SubscriptionResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
private normalizeSubscription(s: Subscription) {
|
||||
if (
|
||||
s.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
s.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
s.variant = null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@@ -487,16 +502,9 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription => {
|
||||
if (
|
||||
subscription.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
subscription.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
subscription.variant = null;
|
||||
}
|
||||
});
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
@@ -534,6 +542,71 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Mutation(() => [SubscriptionType], {
|
||||
description: 'Refresh current user subscriptions and return latest.',
|
||||
})
|
||||
async refreshUserSubscriptions(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<Subscription[]> {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
let current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const existsPlans = Object.values(SubscriptionPlan);
|
||||
const subscriptions = current.reduce(
|
||||
(r, s) => {
|
||||
if (existsPlans.includes(s.plan as SubscriptionPlan)) {
|
||||
r[s.plan as SubscriptionPlan] = s.provider;
|
||||
}
|
||||
return r;
|
||||
},
|
||||
{} as Record<SubscriptionPlan, Provider>
|
||||
);
|
||||
|
||||
// has revenuecat subscription or no subscription at all
|
||||
const shouldSync =
|
||||
current.length === 0 ||
|
||||
subscriptions.pro === Provider.revenuecat ||
|
||||
subscriptions.ai === Provider.revenuecat;
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.rcHandler.syncAppUser(user.id);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
|
||||
@@ -1299,6 +1299,9 @@ type Mutation {
|
||||
"""mark notification as read"""
|
||||
readNotification(id: String!): Boolean!
|
||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||
|
||||
"""Refresh current user subscriptions and return latest."""
|
||||
refreshUserSubscriptions: [SubscriptionType!]!
|
||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||
|
||||
"""Remove user avatar"""
|
||||
|
||||
@@ -2218,6 +2218,25 @@ export const setWorkspacePublicByIdMutation = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const refreshSubscriptionMutation = {
|
||||
id: 'refreshSubscriptionMutation' as const,
|
||||
op: 'refreshSubscription',
|
||||
query: `mutation refreshSubscription {
|
||||
refreshUserSubscriptions {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}`,
|
||||
deprecations: ["'id' is deprecated: removed"],
|
||||
};
|
||||
|
||||
export const subscriptionQuery = {
|
||||
id: 'subscriptionQuery' as const,
|
||||
op: 'subscription',
|
||||
|
||||
13
packages/common/graphql/src/graphql/subscription-refresh.gql
Normal file
13
packages/common/graphql/src/graphql/subscription-refresh.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation refreshSubscription {
|
||||
refreshUserSubscriptions {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}
|
||||
@@ -1451,6 +1451,8 @@ export interface Mutation {
|
||||
/** mark notification as read */
|
||||
readNotification: Scalars['Boolean']['output'];
|
||||
recoverDoc: Scalars['DateTime']['output'];
|
||||
/** Refresh current user subscriptions and return latest. */
|
||||
refreshUserSubscriptions: Array<SubscriptionType>;
|
||||
releaseDeletedBlobs: Scalars['Boolean']['output'];
|
||||
/** Remove user avatar */
|
||||
removeAvatar: RemoveAvatar;
|
||||
@@ -5996,6 +5998,26 @@ export type SetWorkspacePublicByIdMutation = {
|
||||
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
|
||||
};
|
||||
|
||||
export type RefreshSubscriptionMutationVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type RefreshSubscriptionMutation = {
|
||||
__typename?: 'Mutation';
|
||||
refreshUserSubscriptions: Array<{
|
||||
__typename?: 'SubscriptionType';
|
||||
id: string | null;
|
||||
status: SubscriptionStatus;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
start: string;
|
||||
end: string | null;
|
||||
nextBillAt: string | null;
|
||||
canceledAt: string | null;
|
||||
variant: SubscriptionVariant | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type SubscriptionQuery = {
|
||||
@@ -7081,6 +7103,11 @@ export type Mutations =
|
||||
variables: SetWorkspacePublicByIdMutationVariables;
|
||||
response: SetWorkspacePublicByIdMutation;
|
||||
}
|
||||
| {
|
||||
name: 'refreshSubscriptionMutation';
|
||||
variables: RefreshSubscriptionMutationVariables;
|
||||
response: RefreshSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateDocDefaultRoleMutation';
|
||||
variables: UpdateDocDefaultRoleMutationVariables;
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../App/Products.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -74,29 +74,4 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
showDebugMenu()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import AffinePaywall
|
||||
extension AFFiNEViewController {
|
||||
@objc private func showDebugMenu() {
|
||||
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "Pro")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "AI")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,9 @@ import UIKit
|
||||
|
||||
@objc(PayWallPlugin)
|
||||
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
init(associatedController: UIViewController? = nil) {
|
||||
init(
|
||||
associatedController: UIViewController?
|
||||
) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
}
|
||||
@@ -27,7 +29,11 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// TODO: GET TO KNOW THE PAYWALL TYPE
|
||||
print("[*] showing paywall of type: \(type)")
|
||||
DispatchQueue.main.async {
|
||||
Paywall.presentWall(toController: controller, type: type)
|
||||
Paywall.presentWall(
|
||||
toController: controller,
|
||||
bindWebContext: self.webView,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
|
||||
call.resolve(["success": true, "type": type])
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class RefreshSubscriptionMutation: GraphQLMutation {
|
||||
public static let operationName: String = "refreshSubscription"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"mutation refreshSubscription { refreshUserSubscriptions { __typename id status plan recurring start end nextBillAt canceledAt variant } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
|
||||
public struct Data: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("refreshUserSubscriptions", [RefreshUserSubscription].self),
|
||||
] }
|
||||
|
||||
/// Refresh current user subscriptions and return latest.
|
||||
public var refreshUserSubscriptions: [RefreshUserSubscription] { __data["refreshUserSubscriptions"] }
|
||||
|
||||
/// RefreshUserSubscription
|
||||
///
|
||||
/// Parent Type: `SubscriptionType`
|
||||
public struct RefreshUserSubscription: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.SubscriptionType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String?.self),
|
||||
.field("status", GraphQLEnum<AffineGraphQL.SubscriptionStatus>.self),
|
||||
.field("plan", GraphQLEnum<AffineGraphQL.SubscriptionPlan>.self),
|
||||
.field("recurring", GraphQLEnum<AffineGraphQL.SubscriptionRecurring>.self),
|
||||
.field("start", AffineGraphQL.DateTime.self),
|
||||
.field("end", AffineGraphQL.DateTime?.self),
|
||||
.field("nextBillAt", AffineGraphQL.DateTime?.self),
|
||||
.field("canceledAt", AffineGraphQL.DateTime?.self),
|
||||
.field("variant", GraphQLEnum<AffineGraphQL.SubscriptionVariant>?.self),
|
||||
] }
|
||||
|
||||
@available(*, deprecated, message: "removed")
|
||||
public var id: String? { __data["id"] }
|
||||
public var status: GraphQLEnum<AffineGraphQL.SubscriptionStatus> { __data["status"] }
|
||||
/// The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
|
||||
/// There won't actually be a subscription with plan 'Free'
|
||||
public var plan: GraphQLEnum<AffineGraphQL.SubscriptionPlan> { __data["plan"] }
|
||||
public var recurring: GraphQLEnum<AffineGraphQL.SubscriptionRecurring> { __data["recurring"] }
|
||||
public var start: AffineGraphQL.DateTime { __data["start"] }
|
||||
public var end: AffineGraphQL.DateTime? { __data["end"] }
|
||||
public var nextBillAt: AffineGraphQL.DateTime? { __data["nextBillAt"] }
|
||||
public var canceledAt: AffineGraphQL.DateTime? { __data["canceledAt"] }
|
||||
public var variant: GraphQLEnum<AffineGraphQL.SubscriptionVariant>? { __data["variant"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ struct PackageOptionView: View {
|
||||
if !badge.isEmpty {
|
||||
Text(badge)
|
||||
.contentTransition(.numericText())
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: 10))
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
var id: Int { rawValue }
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
public var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
extension SKUnitCategory {
|
||||
public extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
|
||||
@@ -75,6 +75,21 @@ extension ViewModel {
|
||||
|
||||
func dismiss() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -96,12 +111,30 @@ nonisolated extension ViewModel {
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.purchasedItems = purchase }
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
@@ -133,4 +166,45 @@ nonisolated extension ViewModel {
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@@ -23,10 +24,18 @@ class ViewModel: ObservableObject {
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
@@ -42,6 +51,10 @@ class ViewModel: ObservableObject {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.23.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.1"),
|
||||
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.2.0"),
|
||||
.package(url: "https://github.com/Recouse/EventSource.git", from: "0.1.5"),
|
||||
.package(url: "https://github.com/Lakr233/ListViewKit.git", from: "1.1.6"),
|
||||
.package(url: "https://github.com/Lakr233/MarkdownView.git", from: "3.4.2"),
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ServerScope,
|
||||
ServerService,
|
||||
ServersService,
|
||||
SubscriptionService,
|
||||
ValidatorProvider,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { refreshSubscriptionMutation } from '@affine/graphql';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { Container } from '@blocksuite/affine/global/di';
|
||||
@@ -328,6 +330,37 @@ const frameworkProvider = framework.provider();
|
||||
workspaceRef?.dispose();
|
||||
}
|
||||
};
|
||||
(window as any).getSubscriptionState = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentServerId = globalContextService.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
||||
const currentServer =
|
||||
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||
defaultServerService.server;
|
||||
const subscriptionService = currentServer.scope.get(SubscriptionService);
|
||||
await subscriptionService.subscription.waitForRevalidation();
|
||||
return {
|
||||
pro: subscriptionService.subscription.pro$.value,
|
||||
ai: subscriptionService.subscription.ai$.value,
|
||||
};
|
||||
};
|
||||
(window as any).updateSubscriptionState = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentServerId = globalContextService.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
||||
const currentServer =
|
||||
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||
defaultServerService.server;
|
||||
await currentServer
|
||||
.gql({
|
||||
query: refreshSubscriptionMutation,
|
||||
})
|
||||
.catch(console.error);
|
||||
const subscriptionService = currentServer.scope.get(SubscriptionService);
|
||||
subscriptionService.subscription.revalidate();
|
||||
};
|
||||
|
||||
// setup application lifecycle events, and emit application start event
|
||||
window.addEventListener('focus', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{ "path": "../../component" },
|
||||
{ "path": "../../core" },
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../../common/graphql" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
|
||||
@@ -148,7 +148,7 @@ export const AffineIconPicker = ({
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<Scrollable.Root className={pickerStyles.scrollRoot}>
|
||||
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
|
||||
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
|
||||
{/* Recent */}
|
||||
{recentIcons.length ? (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
|
||||
// Memoized individual emoji button to prevent unnecessary re-renders
|
||||
export const EmojiButton = memo(function EmojiButton({
|
||||
emoji,
|
||||
onSelect,
|
||||
}: {
|
||||
emoji: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(emoji);
|
||||
}, [emoji, onSelect]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={emoji}
|
||||
size={24}
|
||||
style={{ padding: 4 }}
|
||||
icon={<span>{emoji}</span>}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,145 +1,15 @@
|
||||
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
import Input from '../../../input';
|
||||
import { Loading } from '../../../loading';
|
||||
import { Menu } from '../../../menu';
|
||||
import { Scrollable } from '../../../scrollbar';
|
||||
import * as pickerStyles from '../picker.css';
|
||||
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
|
||||
import rawData from './data/en.json';
|
||||
// import { emojiGroupList } from './gen-data';
|
||||
import * as styles from './emoji-picker.css';
|
||||
import type { CompactEmoji } from './type';
|
||||
|
||||
type EmojiGroup = {
|
||||
name: string;
|
||||
emojis: Array<CompactEmoji>;
|
||||
};
|
||||
const emojiGroupList = rawData as EmojiGroup[];
|
||||
|
||||
const useRecentEmojis = () => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const recentEmojis = localStorage.getItem('recentEmojis');
|
||||
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
|
||||
}, []);
|
||||
|
||||
const add = useCallback((emoji: string) => {
|
||||
setRecentEmojis(prevRecentEmojis => {
|
||||
const newRecentEmojis = [
|
||||
emoji,
|
||||
...prevRecentEmojis.filter(e => e !== emoji),
|
||||
].slice(0, 10);
|
||||
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
|
||||
return newRecentEmojis;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recentEmojis,
|
||||
add,
|
||||
};
|
||||
};
|
||||
|
||||
// Memoized individual emoji button to prevent unnecessary re-renders
|
||||
const EmojiButton = memo(function EmojiButton({
|
||||
emoji,
|
||||
onSelect,
|
||||
}: {
|
||||
emoji: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(emoji);
|
||||
}, [emoji, onSelect]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={emoji}
|
||||
size={24}
|
||||
style={{ padding: 4 }}
|
||||
icon={<span>{emoji}</span>}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized emoji groups to prevent unnecessary re-renders
|
||||
const EmojiGroups = memo(function EmojiGroups({
|
||||
onSelect,
|
||||
keyword,
|
||||
skin,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
keyword?: string;
|
||||
skin?: number;
|
||||
}) {
|
||||
const [groups, setGroups] = useState<EmojiGroup[]>([]);
|
||||
|
||||
const loading = !keyword && !groups.length;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!keyword) {
|
||||
setGroups(emojiGroupList);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroups(
|
||||
emojiGroupList
|
||||
.map(group => ({
|
||||
...group,
|
||||
emojis: group.emojis.filter(emoji =>
|
||||
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
|
||||
),
|
||||
}))
|
||||
.filter(group => group.emojis.length > 0)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<Loading size={16} />
|
||||
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return groups.map(group => (
|
||||
<div key={group.name} className={pickerStyles.group}>
|
||||
<div className={pickerStyles.groupName} data-group-name={group.name}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className={pickerStyles.groupGrid}>
|
||||
{group.emojis.map(emoji => (
|
||||
<EmojiButton
|
||||
key={emoji.label}
|
||||
emoji={
|
||||
skin !== undefined && emoji.skins
|
||||
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
|
||||
: emoji.unicode
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
});
|
||||
import { EmojiGroups } from './groups';
|
||||
import { useRecentEmojis } from './recent';
|
||||
|
||||
const skinList = [
|
||||
{ unicode: '👋', value: undefined },
|
||||
@@ -155,54 +25,10 @@ export const EmojiPicker = ({
|
||||
}: {
|
||||
onSelect?: (emoji: string) => void;
|
||||
}) => {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const [skin, setSkin] = useState<number | undefined>(undefined);
|
||||
const { recentEmojis, add: addRecent } = useRecentEmojis();
|
||||
|
||||
const checkActiveGroup = useCallback(() => {
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
|
||||
// get actual scrollable element
|
||||
const viewport = scrollable.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement;
|
||||
if (!viewport) return;
|
||||
|
||||
const scrollTop = viewport.scrollTop;
|
||||
|
||||
// find the first group that is at the top of the scrollable element
|
||||
for (let i = emojiGroupList.length - 1; i >= 0; i--) {
|
||||
const group = emojiGroupList[i];
|
||||
const groupElement = viewport.querySelector(
|
||||
`[data-group-name="${group.name}"]`
|
||||
) as HTMLElement;
|
||||
if (!groupElement) continue;
|
||||
|
||||
// use offsetTop to get the position of the element relative to the scrollable element
|
||||
const elementTop = groupElement.offsetTop;
|
||||
|
||||
if (elementTop <= scrollTop + 50) {
|
||||
setActiveGroupId(group.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const jumpToGroup = useCallback((groupName: string) => {
|
||||
const groupElement = scrollableRef.current?.querySelector(
|
||||
`[data-group-name="${groupName}"]`
|
||||
) as HTMLElement;
|
||||
if (!groupElement) return;
|
||||
|
||||
setActiveGroupId(groupName);
|
||||
groupElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
const { add: addRecent, recentEmojis } = useRecentEmojis();
|
||||
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
@@ -212,10 +38,6 @@ export const EmojiPicker = ({
|
||||
[addRecent, onSelect]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkActiveGroup();
|
||||
}, [checkActiveGroup]);
|
||||
|
||||
return (
|
||||
<div className={pickerStyles.root}>
|
||||
<header className={pickerStyles.searchContainer}>
|
||||
@@ -271,62 +93,14 @@ export const EmojiPicker = ({
|
||||
/>
|
||||
</Menu>
|
||||
</header>
|
||||
<Scrollable.Root className={pickerStyles.scrollRoot} ref={scrollableRef}>
|
||||
<Scrollable.Viewport
|
||||
onScrollEnd={checkActiveGroup}
|
||||
className={pickerStyles.scrollViewport}
|
||||
>
|
||||
{/* Recent */}
|
||||
{recentEmojis.length ? (
|
||||
<div className={pickerStyles.group}>
|
||||
<div className={pickerStyles.groupName} data-group-name="Recent">
|
||||
Recent
|
||||
</div>
|
||||
<div className={pickerStyles.groupGrid}>
|
||||
{recentEmojis.map(emoji => (
|
||||
<EmojiButton
|
||||
key={emoji}
|
||||
emoji={emoji}
|
||||
onSelect={handleEmojiSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Groups */}
|
||||
<EmojiGroups
|
||||
onSelect={handleEmojiSelect}
|
||||
keyword={keyword}
|
||||
skin={skin}
|
||||
/>
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
<div className={styles.footer}>
|
||||
{['Recent', ...GROUPS].map(group => {
|
||||
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
|
||||
const active = activeGroupId === group;
|
||||
return (
|
||||
<IconButton
|
||||
size={18}
|
||||
style={{ padding: 3 }}
|
||||
key={group}
|
||||
icon={
|
||||
<Icon
|
||||
className={
|
||||
active ? styles.footerIconActive : styles.footerIcon
|
||||
}
|
||||
/>
|
||||
}
|
||||
className={clsx(
|
||||
active ? styles.footerButtonActive : styles.footerButton
|
||||
)}
|
||||
onClick={() => jumpToGroup(group)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Groups */}
|
||||
<EmojiGroups
|
||||
recent={recentEmojis}
|
||||
onSelect={handleEmojiSelect}
|
||||
keyword={keyword}
|
||||
skin={skin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { RecentIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
import { Loading } from '../../../loading';
|
||||
import {
|
||||
Masonry,
|
||||
type MasonryGroup,
|
||||
type MasonryItem,
|
||||
type MasonryRef,
|
||||
} from '../../../masonry';
|
||||
import * as pickerStyles from '../picker.css';
|
||||
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
|
||||
import rawData from './data/en.json';
|
||||
import { EmojiButton } from './emoji-button';
|
||||
import * as styles from './emoji-picker.css';
|
||||
import type { CompactEmoji, EmojiGroup } from './type';
|
||||
|
||||
const emojiGroupList = rawData as EmojiGroup[];
|
||||
|
||||
const initEmojiGroupMap = () => {
|
||||
const emojiGroupMap = new Map<string, Map<string, CompactEmoji>>();
|
||||
emojiGroupList.forEach(group => {
|
||||
emojiGroupMap.set(
|
||||
group.name,
|
||||
new Map(group.emojis.map(emoji => [emoji.label, emoji]))
|
||||
);
|
||||
});
|
||||
return emojiGroupMap;
|
||||
};
|
||||
const emojiGroupMap = initEmojiGroupMap();
|
||||
|
||||
const EmojiGroupContext = createContext<{
|
||||
onSelect: (emoji: string) => void;
|
||||
skin?: number;
|
||||
}>({
|
||||
onSelect: () => {},
|
||||
});
|
||||
|
||||
const RecentGroupItem = memo(function RecentGroupItem({
|
||||
itemId,
|
||||
}: {
|
||||
itemId: string;
|
||||
}) {
|
||||
const { onSelect } = useContext(EmojiGroupContext);
|
||||
|
||||
return <EmojiButton emoji={itemId} onSelect={onSelect} />;
|
||||
});
|
||||
const EmojiGroupItem = memo(function EmojiGroupItem({
|
||||
groupId,
|
||||
itemId,
|
||||
}: {
|
||||
groupId: string;
|
||||
itemId: string;
|
||||
}) {
|
||||
const emoji = emojiGroupMap.get(groupId)?.get(itemId);
|
||||
const { onSelect, skin } = useContext(EmojiGroupContext);
|
||||
|
||||
if (!emoji) return null;
|
||||
|
||||
return (
|
||||
<EmojiButton
|
||||
emoji={
|
||||
skin !== undefined && emoji.skins
|
||||
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
|
||||
: emoji.unicode
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const EmojiGroupHeader = memo(function EmojiGroupHeader({
|
||||
groupId,
|
||||
}: {
|
||||
groupId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={pickerStyles.groupName} data-group-name={groupId}>
|
||||
{groupId}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized emoji groups to prevent unnecessary re-renders
|
||||
export const EmojiGroups = memo(function EmojiGroups({
|
||||
recent,
|
||||
onSelect,
|
||||
keyword,
|
||||
skin,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
recent?: string[];
|
||||
keyword?: string;
|
||||
skin?: number;
|
||||
}) {
|
||||
const masonryRef = useRef<MasonryRef>(null);
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
|
||||
'Recent'
|
||||
);
|
||||
const [groups, setGroups] = useState<EmojiGroup[]>([]);
|
||||
|
||||
const loading = !keyword && !groups.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyword) {
|
||||
setGroups(emojiGroupList);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setGroups(
|
||||
emojiGroupList
|
||||
.map(group => ({
|
||||
...group,
|
||||
emojis: group.emojis.filter(emoji =>
|
||||
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
|
||||
),
|
||||
}))
|
||||
.filter(group => group.emojis.length > 0)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const emojiGroups = groups.map(group => {
|
||||
return {
|
||||
id: group.name,
|
||||
height: 30,
|
||||
Component: EmojiGroupHeader,
|
||||
items: group.emojis.map(emoji => {
|
||||
return {
|
||||
id: emoji.label,
|
||||
height: 32,
|
||||
ratio: 1,
|
||||
Component: EmojiGroupItem,
|
||||
} satisfies MasonryItem;
|
||||
}),
|
||||
} satisfies MasonryGroup;
|
||||
});
|
||||
if (recent?.length) {
|
||||
emojiGroups.unshift({
|
||||
id: 'Recent',
|
||||
height: 30,
|
||||
Component: EmojiGroupHeader,
|
||||
items: recent.map(emoji => {
|
||||
return {
|
||||
id: emoji,
|
||||
height: 32,
|
||||
ratio: 1,
|
||||
Component: RecentGroupItem,
|
||||
} satisfies MasonryItem;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return emojiGroups;
|
||||
}, [groups, recent]);
|
||||
const contextValue = useMemo(() => ({ onSelect, skin }), [onSelect, skin]);
|
||||
|
||||
const jumpToGroup = useCallback((groupName: string) => {
|
||||
setActiveGroupId(groupName);
|
||||
masonryRef.current?.scrollToGroup(groupName);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<Loading size={16} />
|
||||
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmojiGroupContext.Provider value={contextValue}>
|
||||
<div className={pickerStyles.emojiScrollRoot}>
|
||||
<Masonry
|
||||
ref={masonryRef}
|
||||
virtualScroll
|
||||
items={items}
|
||||
itemWidthMin={32}
|
||||
itemWidth={32}
|
||||
paddingX={12}
|
||||
paddingY={8}
|
||||
gapX={4}
|
||||
gapY={4}
|
||||
onStickyGroupChange={setActiveGroupId}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{['Recent', ...GROUPS].map(group => {
|
||||
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
|
||||
const active = activeGroupId === group;
|
||||
return (
|
||||
<IconButton
|
||||
size={18}
|
||||
style={{ padding: 3 }}
|
||||
key={group}
|
||||
icon={
|
||||
<Icon
|
||||
className={
|
||||
active ? styles.footerIconActive : styles.footerIcon
|
||||
}
|
||||
/>
|
||||
}
|
||||
className={clsx(
|
||||
active ? styles.footerButtonActive : styles.footerButton
|
||||
)}
|
||||
onClick={() => jumpToGroup(group)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</EmojiGroupContext.Provider>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const useRecentEmojis = () => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const recentEmojis = localStorage.getItem('recentEmojis');
|
||||
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
|
||||
}, []);
|
||||
|
||||
const add = useCallback((emoji: string) => {
|
||||
setRecentEmojis(prevRecentEmojis => {
|
||||
const newRecentEmojis = [
|
||||
emoji,
|
||||
...prevRecentEmojis.filter(e => e !== emoji),
|
||||
].slice(0, 10);
|
||||
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
|
||||
return newRecentEmojis;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recentEmojis,
|
||||
add,
|
||||
};
|
||||
};
|
||||
@@ -9,3 +9,8 @@ export type CompactEmoji = {
|
||||
unicode: string;
|
||||
skins?: Array<Omit<CompactEmoji, 'skins'>>;
|
||||
};
|
||||
|
||||
export type EmojiGroup = {
|
||||
name: string;
|
||||
emojis: Array<CompactEmoji>;
|
||||
};
|
||||
|
||||
@@ -27,8 +27,19 @@ export const searchInput = style({
|
||||
export const scrollRoot = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
padding: '0px 12px',
|
||||
});
|
||||
export const emojiScrollRoot = style([
|
||||
scrollRoot,
|
||||
{
|
||||
paddingTop: '8px',
|
||||
},
|
||||
]);
|
||||
export const iconScrollRoot = style([
|
||||
scrollRoot,
|
||||
{
|
||||
padding: '0px 12px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const scrollViewport = style({
|
||||
padding: '8px 0px',
|
||||
@@ -52,6 +63,7 @@ export const groupName = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 4px',
|
||||
backgroundColor: cssVarV2.layer.background.overlayPanel,
|
||||
});
|
||||
|
||||
export const groupGrid = style({
|
||||
|
||||
@@ -2,10 +2,12 @@ import clsx from 'clsx';
|
||||
import { debounce } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import {
|
||||
forwardRef,
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -61,29 +63,39 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
columns?: number;
|
||||
resizeDebounce?: number;
|
||||
preloadHeight?: number;
|
||||
|
||||
onStickyGroupChange?: (groupId?: string) => void;
|
||||
}
|
||||
|
||||
export const Masonry = ({
|
||||
items,
|
||||
gapX = 12,
|
||||
gapY = 12,
|
||||
itemWidth = 'stretch',
|
||||
itemWidthMin = 100,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
className,
|
||||
virtualScroll = false,
|
||||
locateMode = 'leftTop',
|
||||
groupsGap = 0,
|
||||
groupHeaderGapWithItems = 0,
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
preloadHeight = 50,
|
||||
resizeDebounce = 20,
|
||||
onGroupCollapse,
|
||||
...props
|
||||
}: MasonryProps) => {
|
||||
export type MasonryRef = {
|
||||
scrollToGroup: (groupId: string) => void;
|
||||
};
|
||||
|
||||
export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||
{
|
||||
items,
|
||||
gapX = 12,
|
||||
gapY = 12,
|
||||
itemWidth = 'stretch',
|
||||
itemWidthMin = 100,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
className,
|
||||
virtualScroll = false,
|
||||
locateMode = 'leftTop',
|
||||
groupsGap = 0,
|
||||
groupHeaderGapWithItems = 0,
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
preloadHeight = 50,
|
||||
resizeDebounce = 20,
|
||||
onGroupCollapse,
|
||||
onStickyGroupChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
@@ -212,7 +224,9 @@ export const Masonry = ({
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateActiveMap(layoutMap, scrollY);
|
||||
if (stickyGroupHeader) {
|
||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||
const stickyGroupId = calcSticky({ scrollY, layoutMap });
|
||||
setStickyGroupId(stickyGroupId);
|
||||
onStickyGroupChange?.(stickyGroupId);
|
||||
}
|
||||
}, 50);
|
||||
rootEl.addEventListener('scroll', handler);
|
||||
@@ -221,7 +235,29 @@ export const Masonry = ({
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
|
||||
}, [
|
||||
layoutMap,
|
||||
onStickyGroupChange,
|
||||
stickyGroupHeader,
|
||||
updateActiveMap,
|
||||
virtualScroll,
|
||||
]);
|
||||
|
||||
const scrollToGroup = useCallback(
|
||||
(groupId: string) => {
|
||||
const group = layoutMap.get(groupId);
|
||||
if (!group) return;
|
||||
rootRef.current?.scrollTo({
|
||||
top: group.y,
|
||||
behavior: 'instant',
|
||||
});
|
||||
},
|
||||
[layoutMap]
|
||||
);
|
||||
|
||||
useImperativeHandle<MasonryRef, MasonryRef>(ref, () => {
|
||||
return { scrollToGroup };
|
||||
});
|
||||
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
@@ -312,7 +348,7 @@ export const Masonry = ({
|
||||
<Scrollable.Scrollbar className={styles.scrollbar} />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
type MasonryItemProps = MasonryItem &
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {
|
||||
|
||||
@@ -112,12 +112,18 @@ export const calcLayout = (
|
||||
const ratioMode = 'ratio' in item;
|
||||
const height = ratioMode ? item.ratio * width : item.height;
|
||||
|
||||
const aroundGapXValue =
|
||||
columns > 1
|
||||
? (totalWidth - paddingX * 2 - width * columns) / (columns - 1)
|
||||
: 0;
|
||||
const gapXValue = Math.max(gapX, aroundGapXValue);
|
||||
|
||||
if (ratioMode) {
|
||||
const minRatio = Math.min(...ratioStack);
|
||||
const minRatioIndex = ratioStack.indexOf(minRatio);
|
||||
const minHeight = heightStack[minRatioIndex];
|
||||
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
|
||||
const x = minRatioIndex * (width + gapX) + paddingX;
|
||||
const x = minRatioIndex * (width + gapXValue) + paddingX;
|
||||
const y = finalHeight + minHeight + hasGap;
|
||||
|
||||
ratioStack[minRatioIndex] += item.ratio * 10000;
|
||||
@@ -133,7 +139,7 @@ export const calcLayout = (
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const x = minHeightIndex * (width + gapXValue) + paddingX;
|
||||
const y = finalHeight + minHeight + hasGap;
|
||||
|
||||
const ratio = height / width;
|
||||
@@ -193,7 +199,7 @@ export const calcSticky = (options: {
|
||||
|
||||
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
|
||||
const next = groupEntries[index + 1];
|
||||
return xywh.y < scrollY && (!next || next[1].y > scrollY);
|
||||
return xywh.y <= scrollY && (!next || next[1].y > scrollY);
|
||||
});
|
||||
|
||||
return stickyGroupEntry
|
||||
|
||||
@@ -137,6 +137,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor session: CopilotChatHistoryFragment | null | undefined;
|
||||
|
||||
@@ -462,6 +465,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.peekViewService=${this.peekViewService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.onContextChange=${this.onContextChange}
|
||||
.width=${this.sidebarWidth}
|
||||
|
||||
@@ -149,6 +149,9 @@ export class AIChatComposer extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor chips: ChatChip[] = [];
|
||||
|
||||
@@ -200,6 +203,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
.notificationService=${this.notificationService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.portalContainer=${this.portalContainer}
|
||||
.onChatSuccess=${this.onChatSuccess}
|
||||
.trackOptions=${this.trackOptions}
|
||||
|
||||
@@ -192,6 +192,9 @@ export class AIChatContent extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor subscriptionService!: SubscriptionService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
||||
|
||||
@@ -381,6 +384,9 @@ export class AIChatContent extends SignalWatcher(
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// revalidate subscription to get the latest status
|
||||
this.subscriptionService.subscription.revalidate();
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.actions.subscribe(({ event }) => {
|
||||
const { status } = this.chatContextValue;
|
||||
@@ -472,6 +478,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
.aiToolsConfigService=${this.aiToolsConfigService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.trackOptions=${{
|
||||
where: 'chat-panel',
|
||||
control: 'chat-send',
|
||||
|
||||
@@ -377,6 +377,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isRootSession: boolean = true;
|
||||
|
||||
@@ -534,6 +537,7 @@ export class AIChatInput extends SignalWatcher(
|
||||
.notificationService=${this.notificationService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></chat-input-preference>
|
||||
${status === 'transmitting' || status === 'loading'
|
||||
? html`<button
|
||||
|
||||
@@ -72,6 +72,9 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
.ai-model-prefix svg {
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
.ai-model-postfix svg:hover {
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
.ai-model-version {
|
||||
font-size: 12px;
|
||||
color: ${unsafeCSSVarV2('text/tertiary')};
|
||||
@@ -119,6 +122,9 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
model = computed(() => {
|
||||
const modelId = this.aiModelService.modelId.value;
|
||||
const activeModel = this.aiModelService.models.value.find(
|
||||
@@ -161,7 +167,7 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
</div>
|
||||
`,
|
||||
postfix: html`
|
||||
<div>
|
||||
<div class="ai-model-postfix" @click=${this.onAISubscribe}>
|
||||
${model.isPro && !isSubscribed ? LockIcon() : undefined}
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -160,6 +160,12 @@ export abstract class ArtifactTool<
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// open the preview panel immediately
|
||||
this.openOrUpdatePreviewPanel();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const err = this.getErrorTemplate();
|
||||
if (err) {
|
||||
|
||||
@@ -182,6 +182,9 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiToolsConfigService!: AIToolsConfigService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe: (() => Promise<void>) | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addChat!: () => Promise<void>;
|
||||
|
||||
@@ -374,6 +377,7 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
.aiToolsConfigService=${this.aiToolsConfigService}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></ai-chat-composer>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
} from '@affine/core/modules/ai-button';
|
||||
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||
import type { SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type {
|
||||
@@ -622,6 +624,9 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
}}
|
||||
.portalContainer=${this.parentElement}
|
||||
.reasoningConfig=${this.reasoningConfig}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></ai-chat-composer>
|
||||
</div> `;
|
||||
}
|
||||
@@ -659,6 +664,15 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor aiToolsConfigService!: AIToolsConfigService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor subscriptionService!: SubscriptionService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor _historyMessages: ChatMessage[] = [];
|
||||
|
||||
@@ -697,7 +711,10 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
affineFeatureFlagService: FeatureFlagService,
|
||||
affineWorkspaceDialogService: WorkspaceDialogService,
|
||||
aiDraftService: AIDraftService,
|
||||
aiToolsConfigService: AIToolsConfigService
|
||||
aiToolsConfigService: AIToolsConfigService,
|
||||
subscriptionService: SubscriptionService,
|
||||
aiModelService: AIModelService,
|
||||
onAISubscribe: (() => Promise<void>) | undefined
|
||||
) => {
|
||||
return html`<ai-chat-block-peek-view
|
||||
.blockModel=${blockModel}
|
||||
@@ -710,5 +727,8 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
|
||||
.aiDraftService=${aiDraftService}
|
||||
.aiToolsConfigService=${aiToolsConfigService}
|
||||
.subscriptionService=${subscriptionService}
|
||||
.aiModelService=${aiModelService}
|
||||
.onAISubscribe=${onAISubscribe}
|
||||
></ai-chat-block-peek-view>`;
|
||||
};
|
||||
|
||||
@@ -175,6 +175,7 @@ const usePreviewExtensions = () => {
|
||||
.ai(enableAI, framework)
|
||||
.theme(framework)
|
||||
.database(framework)
|
||||
.iconPicker(framework)
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI)
|
||||
.linkPreview(framework)
|
||||
|
||||
@@ -11,7 +11,10 @@ export const docIconPickerTrigger = style({
|
||||
lineHeight: 1,
|
||||
},
|
||||
'&[data-icon-type="emoji"]': {
|
||||
fontFamily: 'emoji',
|
||||
fontFamily: 'Inter',
|
||||
},
|
||||
'&::after': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,21 +6,12 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import * as styles from './doc-icon-picker.css';
|
||||
|
||||
const TitleContainer = ({
|
||||
children,
|
||||
isPlaceholder,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isPlaceholder: boolean;
|
||||
}) => {
|
||||
const TitleContainer = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className="doc-icon-container"
|
||||
style={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
// title container has `padding-top`
|
||||
transform: isPlaceholder ? 'translateY(80%)' : 'translateY(50%)',
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -54,7 +45,7 @@ export const DocIconPicker = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleContainer isPlaceholder={isPlaceholder}>
|
||||
<TitleContainer>
|
||||
<IconEditor
|
||||
icon={icon?.icon}
|
||||
onIconChange={data => {
|
||||
|
||||
@@ -117,6 +117,7 @@ const usePatchSpecs = (mode: DocMode, shared?: boolean) => {
|
||||
.electron(framework)
|
||||
.linkPreview(framework)
|
||||
.codeBlockPreview(framework)
|
||||
.iconPicker(framework)
|
||||
.comment(enableComment, framework).value;
|
||||
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AffineEditorViewOptions,
|
||||
} from '@affine/core/blocksuite/view-extensions/editor-view/editor-view';
|
||||
import { ElectronViewExtension } from '@affine/core/blocksuite/view-extensions/electron';
|
||||
import { AffineIconPickerExtension } from '@affine/core/blocksuite/view-extensions/icon-picker';
|
||||
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/view-extensions/link-preview-service';
|
||||
import { MobileViewExtension } from '@affine/core/blocksuite/view-extensions/mobile';
|
||||
import { PdfViewExtension } from '@affine/core/blocksuite/view-extensions/pdf';
|
||||
@@ -58,6 +59,7 @@ type Configure = {
|
||||
electron: (framework?: FrameworkProvider) => Configure;
|
||||
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||
codeBlockPreview: (framework?: FrameworkProvider) => Configure;
|
||||
iconPicker: (framework?: FrameworkProvider) => Configure;
|
||||
comment: (
|
||||
enableComment?: boolean,
|
||||
framework?: FrameworkProvider
|
||||
@@ -86,6 +88,7 @@ class ViewProvider {
|
||||
AffineThemeViewExtension,
|
||||
AffineEditorViewExtension,
|
||||
AffineEditorConfigViewExtension,
|
||||
AffineIconPickerExtension,
|
||||
CodeBlockPreviewViewExtension,
|
||||
EdgelessBlockHeaderConfigViewExtension,
|
||||
TurboRendererViewExtension,
|
||||
@@ -123,6 +126,7 @@ class ViewProvider {
|
||||
electron: this._configureElectron,
|
||||
linkPreview: this._configureLinkPreview,
|
||||
codeBlockPreview: this._configureCodeBlockHtmlPreview,
|
||||
iconPicker: this._configureIconPicker,
|
||||
comment: this._configureComment,
|
||||
value: this._manager,
|
||||
};
|
||||
@@ -146,6 +150,7 @@ class ViewProvider {
|
||||
.electron()
|
||||
.linkPreview()
|
||||
.codeBlockPreview()
|
||||
.iconPicker()
|
||||
.comment();
|
||||
|
||||
return this.config;
|
||||
@@ -333,6 +338,11 @@ class ViewProvider {
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureIconPicker = (framework?: FrameworkProvider) => {
|
||||
this._manager.configure(AffineIconPickerExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureComment = (
|
||||
enableComment?: boolean,
|
||||
framework?: FrameworkProvider
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IconPickerServiceIdentifier } from '@blocksuite/affine/shared/services';
|
||||
import { type ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { IconPickerService } from '../../../modules/icon-picker/services/icon-picker';
|
||||
|
||||
/**
|
||||
* Patch the icon picker service to make it available in BlockSuite
|
||||
* @param framework
|
||||
* @returns
|
||||
*/
|
||||
export function patchIconPickerService(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.override(IconPickerServiceIdentifier, () => {
|
||||
return framework.get(IconPickerService);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { patchIconPickerService } from './icon-picker-service';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
type AffineIconPickerViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class AffineIconPickerExtension extends ViewExtensionProvider<AffineIconPickerViewOptions> {
|
||||
override name = 'affine-icon-picker-extension';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: AffineIconPickerViewOptions
|
||||
) {
|
||||
super.setup(context, options);
|
||||
if (!options?.framework) {
|
||||
return;
|
||||
}
|
||||
const { framework } = options;
|
||||
context.register(patchIconPickerService(framework));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export const useAISpecs = () => {
|
||||
.mobile(framework)
|
||||
.electron(framework)
|
||||
.linkPreview(framework)
|
||||
.iconPicker(framework)
|
||||
.codeBlockPreview(framework).value;
|
||||
|
||||
return manager.get('page');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
||||
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { useFramework } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to handle AI subscription checkout
|
||||
* @returns A function that initiates the AI subscription checkout process
|
||||
*/
|
||||
export const useAISubscribe = () => {
|
||||
const framework = useFramework();
|
||||
|
||||
const handleAISubscribe = useCallback(async () => {
|
||||
try {
|
||||
const authService = framework.get(AuthService);
|
||||
const subscriptionService = framework.get(SubscriptionService);
|
||||
const urlService = framework.get(UrlService);
|
||||
|
||||
const account = authService.session.account$.value;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idempotencyKey = nanoid();
|
||||
const checkoutOptions = {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
plan: SubscriptionPlan.AI,
|
||||
variant: null,
|
||||
coupon: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
account,
|
||||
SubscriptionPlan.AI,
|
||||
SubscriptionRecurring.Yearly
|
||||
),
|
||||
};
|
||||
|
||||
const session = await subscriptionService.createCheckoutSession({
|
||||
idempotencyKey,
|
||||
...checkoutOptions,
|
||||
});
|
||||
|
||||
urlService.openExternal(session);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [framework]);
|
||||
|
||||
return handleAISubscribe;
|
||||
};
|
||||
@@ -190,6 +190,7 @@ const SettingModalInner = ({
|
||||
}
|
||||
});
|
||||
}
|
||||
modalContentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
}, [settingState]);
|
||||
return (
|
||||
<FrameworkScope scope={currentServer.scope}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
@@ -197,6 +198,7 @@ export const Component = () => {
|
||||
const confirmModal = useConfirmModal();
|
||||
const specs = useAISpecs();
|
||||
const mockStd = useMockStd();
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
// init or update ai-chat-content
|
||||
useEffect(() => {
|
||||
@@ -233,6 +235,8 @@ export const Component = () => {
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
|
||||
content.createSession = createSession;
|
||||
content.onOpenDoc = onOpenDoc;
|
||||
|
||||
@@ -260,6 +264,7 @@ export const Component = () => {
|
||||
onContextChange,
|
||||
specs,
|
||||
onOpenDoc,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
|
||||
// init or update header ai-chat-toolbar
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
|
||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
@@ -63,6 +64,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
} = useAIChatConfig();
|
||||
const confirmModal = useConfirmModal();
|
||||
const specs = useAISpecs();
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !editor.host) return;
|
||||
@@ -109,6 +111,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
chatPanelRef.current.subscriptionService =
|
||||
framework.get(SubscriptionService);
|
||||
chatPanelRef.current.aiModelService = framework.get(AIModelService);
|
||||
chatPanelRef.current.onAISubscribe = handleAISubscribe;
|
||||
|
||||
containerRef.current?.append(chatPanelRef.current);
|
||||
} else {
|
||||
@@ -141,6 +144,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
playgroundConfig,
|
||||
confirmModal,
|
||||
specs,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
|
||||
const [autoResized, setAutoResized] = useState(false);
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { DocRecord, DocsService } from '../../doc';
|
||||
import type { ExplorerIconService } from '../../explorer-icon/services/explorer-icon';
|
||||
import type { I18nService } from '../../i18n';
|
||||
import type { JournalService } from '../../journal';
|
||||
import { getDocIconComponent } from './icon';
|
||||
import { getDocIconComponent, getDocIconComponentLit } from './icon';
|
||||
|
||||
type IconType = 'rc' | 'lit';
|
||||
interface DocDisplayIconOptions<T extends IconType> {
|
||||
@@ -152,7 +152,9 @@ export class DocDisplayMetaService extends Service {
|
||||
// if (emoji) return () => emoji;
|
||||
const icon = get(this.explorerIconService.icon$('doc', docId))?.icon;
|
||||
if (icon) {
|
||||
return getDocIconComponent(icon);
|
||||
return options?.type === 'lit'
|
||||
? getDocIconComponentLit(icon)
|
||||
: getDocIconComponent(icon);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { type IconData, IconRenderer } from '@affine/component';
|
||||
import { type IconData, IconRenderer, IconType } from '@affine/component';
|
||||
import * as litIcons from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const getDocIconComponent = (icon: IconData) => {
|
||||
const Icon = () => <IconRenderer data={icon} />;
|
||||
Icon.displayName = 'DocIcon';
|
||||
return Icon;
|
||||
};
|
||||
|
||||
export const getDocIconComponentLit = (icon: IconData) => {
|
||||
return () => {
|
||||
if (icon.type === IconType.Emoji) {
|
||||
return html`<div class="icon">${icon.unicode}</div>`;
|
||||
}
|
||||
if (icon.type === IconType.AffineIcon) {
|
||||
return html`<div
|
||||
style="color: ${icon.color}; display: flex; align-items: center; justify-content: center;"
|
||||
>
|
||||
${litIcons[`${icon.name}Icon` as keyof typeof litIcons]()}
|
||||
</div>`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
9
packages/frontend/core/src/modules/icon-picker/index.ts
Normal file
9
packages/frontend/core/src/modules/icon-picker/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { IconPickerService } from './services/icon-picker';
|
||||
|
||||
export { IconPickerService } from './services/icon-picker';
|
||||
|
||||
export function configureIconPickerModule(framework: Framework) {
|
||||
framework.service(IconPickerService);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IconPicker, uniReactRoot } from '@affine/component';
|
||||
// Import the identifier for internal use
|
||||
import { type IconPickerService as IIconPickerService } from '@blocksuite/affine-shared/services';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
// Re-export types from BlockSuite shared services
|
||||
export type {
|
||||
IconData,
|
||||
IconPickerService as IIconPickerService,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services';
|
||||
|
||||
export class IconPickerService extends Service implements IIconPickerService {
|
||||
public readonly iconPickerComponent =
|
||||
uniReactRoot.createUniComponent(IconPicker);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { configureFavoriteModule } from './favorite';
|
||||
import { configureFeatureFlagModule } from './feature-flag';
|
||||
import { configureGlobalContextModule } from './global-context';
|
||||
import { configureI18nModule } from './i18n';
|
||||
import { configureIconPickerModule } from './icon-picker';
|
||||
import { configureImportClipperModule } from './import-clipper';
|
||||
import { configureImportTemplateModule } from './import-template';
|
||||
import { configureIntegrationModule } from './integration';
|
||||
@@ -132,4 +133,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureCommentModule(framework);
|
||||
configureDocSummaryModule(framework);
|
||||
configurePaywallModule(framework);
|
||||
configureIconPickerModule(framework);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import { toReactNode } from '@affine/component';
|
||||
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
||||
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
} from '@affine/core/modules/ai-button';
|
||||
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||
import { SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -33,6 +36,9 @@ export const AIChatBlockPeekView = ({
|
||||
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
|
||||
const aiDraftService = framework.get(AIDraftService);
|
||||
const aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
const subscriptionService = framework.get(SubscriptionService);
|
||||
const aiModelService = framework.get(AIModelService);
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
return useMemo(() => {
|
||||
const template = AIChatBlockPeekViewTemplate(
|
||||
@@ -45,7 +51,10 @@ export const AIChatBlockPeekView = ({
|
||||
affineFeatureFlagService,
|
||||
affineWorkspaceDialogService,
|
||||
aiDraftService,
|
||||
aiToolsConfigService
|
||||
aiToolsConfigService,
|
||||
subscriptionService,
|
||||
aiModelService,
|
||||
handleAISubscribe
|
||||
);
|
||||
return toReactNode(template);
|
||||
}, [
|
||||
@@ -59,5 +68,8 @@ export const AIChatBlockPeekView = ({
|
||||
affineWorkspaceDialogService,
|
||||
aiDraftService,
|
||||
aiToolsConfigService,
|
||||
subscriptionService,
|
||||
aiModelService,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -62,4 +62,9 @@ export const modalContent = style({
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
'screen and (max-width: 520px)': {
|
||||
minWidth: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user