mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 16:44:56 +00:00
Compare commits
43 Commits
l-sun/enab
...
v0.25.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df7353722 | ||
|
|
12daefdf54 | ||
|
|
9f94d5c216 | ||
|
|
8d6f7047c2 | ||
|
|
a92894990d | ||
|
|
6af1f6ab8d | ||
|
|
e7f76c1737 | ||
|
|
5b52349b96 | ||
|
|
bf87178c26 | ||
|
|
d272c4342d | ||
|
|
c540400496 | ||
|
|
54498df247 | ||
|
|
3f9d9fef63 | ||
|
|
7a90e1551c | ||
|
|
3c9d17c983 | ||
|
|
2f118206cc | ||
|
|
ca9811792d | ||
|
|
812c2d86d4 | ||
|
|
762b702e46 | ||
|
|
75a6c79b2c | ||
|
|
b25759c264 | ||
|
|
da3e3eb3fa | ||
|
|
e3f3c8c4a8 | ||
|
|
7fe95f50f4 | ||
|
|
195864fc88 | ||
|
|
93554304e2 | ||
|
|
2f38953cf9 | ||
|
|
ebf75e4d31 | ||
|
|
2d0721a78f | ||
|
|
e08fc5ef06 | ||
|
|
363f64ebfa | ||
|
|
21bb8142b0 | ||
|
|
750b008dc8 | ||
|
|
d231b47f1f | ||
|
|
4efbb630fc | ||
|
|
19bd29e90c | ||
|
|
2a2793eada | ||
|
|
b6a3241451 | ||
|
|
360c9545f4 | ||
|
|
1f228382c2 | ||
|
|
ee77c548ca | ||
|
|
a0b73cdcec | ||
|
|
89646869e4 |
@@ -148,6 +148,11 @@
|
||||
"description": "Whether allow new registrations.\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"allowSignupForOauth": {
|
||||
"type": "boolean",
|
||||
"description": "Whether allow new registrations via configured oauth.\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"requireEmailDomainVerification": {
|
||||
"type": "boolean",
|
||||
"description": "Whether require email domain record verification before accessing restricted resources.\n@default false",
|
||||
@@ -190,6 +195,11 @@
|
||||
"type": "object",
|
||||
"description": "Configuration for mailer module",
|
||||
"properties": {
|
||||
"SMTP.name": {
|
||||
"type": "string",
|
||||
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`",
|
||||
"default": "AFFiNE Server"
|
||||
},
|
||||
"SMTP.host": {
|
||||
"type": "string",
|
||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"\n@environment `MAILER_HOST`",
|
||||
@@ -225,6 +235,11 @@
|
||||
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"fallbackSMTP.name": {
|
||||
"type": "string",
|
||||
"description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"",
|
||||
"default": "AFFiNE Server"
|
||||
},
|
||||
"fallbackSMTP.host": {
|
||||
"type": "string",
|
||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
|
||||
@@ -669,12 +684,12 @@
|
||||
},
|
||||
"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\":\"claude-sonnet-4@20250514\",\"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@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\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
"audio_transcribing": "gemini-2.5-flash",
|
||||
"chat": "claude-sonnet-4@20250514",
|
||||
"chat": "gemini-2.5-flash",
|
||||
"embedding": "gemini-embedding-001",
|
||||
"image": "gpt-image-1",
|
||||
"rerank": "gpt-4.1",
|
||||
@@ -1093,18 +1108,33 @@
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Stripe API key to enable payment service.\n@default \"\"\n@environment `STRIPE_API_KEY`",
|
||||
"description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`",
|
||||
"default": ""
|
||||
},
|
||||
"webhookKey": {
|
||||
"type": "string",
|
||||
"description": "Stripe webhook key to enable payment service.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
|
||||
"description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
|
||||
"default": ""
|
||||
},
|
||||
"stripe": {
|
||||
"type": "object",
|
||||
"description": "Stripe sdk options\n@default {}\n@link https://docs.stripe.com/api",
|
||||
"default": {}
|
||||
"description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"webhookKey": ""
|
||||
}
|
||||
},
|
||||
"revenuecat": {
|
||||
"type": "object",
|
||||
"description": "RevenueCat integration configs\n@default {\"enabled\":false,\"apiKey\":\"\",\"projectId\":\"\",\"webhookAuth\":\"\",\"environment\":\"production\",\"productMap\":{}}\n@link https://www.revenuecat.com/docs/",
|
||||
"default": {
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
"projectId": "",
|
||||
"webhookAuth": "",
|
||||
"environment": "production",
|
||||
"productMap": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ Star us, and you will receive all release notifications from GitHub without any
|
||||
|
||||
**Multimodal AI partner ready to kick in any work**
|
||||
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, [AFFiNE AI](https://affine.pro/ai) pushes your creativity to the edge of your imagination,just like [Canvas AI](https://affine.pro/blog/best-canvas-ai) to generate mind map for brainstorming.
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, [AFFiNE AI](https://affine.pro/ai) pushes your creativity to the edge of your imagination, just like [Canvas AI](https://affine.pro/blog/best-canvas-ai) to generate mind map for brainstorming.
|
||||
|
||||
**Local-first & Real-time collaborative**
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import { type CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
@@ -22,14 +23,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
|
||||
}
|
||||
|
||||
.affine-callout-emoji-container {
|
||||
margin-right: 10px;
|
||||
margin-top: 14px;
|
||||
user-select: none;
|
||||
font-size: 1.2em;
|
||||
width: 24px;
|
||||
@@ -37,6 +37,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.affine-callout-emoji:hover {
|
||||
cursor: pointer;
|
||||
@@ -62,7 +65,7 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
createLitPortal({
|
||||
template: html`<affine-emoji-menu
|
||||
.theme=${theme}
|
||||
.onEmojiSelect=${(data: any) => {
|
||||
.onEmojiSelect=${(data: { native: string }) => {
|
||||
this.model.props.emoji = data.native;
|
||||
}}
|
||||
></affine-emoji-menu>`,
|
||||
@@ -81,6 +84,31 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleBlockClick = (event: MouseEvent) => {
|
||||
// Check if the click target is emoji related element
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.closest('.affine-callout-emoji-container') ||
|
||||
target.classList.contains('affine-callout-emoji')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle clicks when there are no children
|
||||
if (this.model.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent event bubbling
|
||||
event.stopPropagation();
|
||||
|
||||
// Create a new paragraph block
|
||||
const paragraphId = this.store.addBlock('affine:paragraph', {}, this.model);
|
||||
|
||||
// Focus the new paragraph
|
||||
focusTextModel(this.std, paragraphId);
|
||||
};
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager.getRenderer();
|
||||
}
|
||||
@@ -112,7 +140,10 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
override renderBlock() {
|
||||
const emoji = this.model.props.emoji$.value;
|
||||
return html`
|
||||
<div class="affine-callout-block-container">
|
||||
<div
|
||||
class="affine-callout-block-container"
|
||||
@click=${this._handleBlockClick}
|
||||
>
|
||||
<div
|
||||
@click=${this._toggleEmojiMenu}
|
||||
contenteditable="false"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BlockSelection,
|
||||
@@ -6,13 +9,46 @@ import {
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
|
||||
import { calloutToParagraphCommand } from './commands/callout-to-paragraph.js';
|
||||
import { splitCalloutCommand } from './commands/split-callout.js';
|
||||
|
||||
export const CalloutKeymapExtension = KeymapExtension(std => {
|
||||
return {
|
||||
Enter: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
const currentBlock = std.store.getBlock(text.from.blockId);
|
||||
if (!currentBlock) return false;
|
||||
|
||||
// Check if current block is a callout block
|
||||
let calloutBlock = currentBlock;
|
||||
if (!matchModels(currentBlock.model, [CalloutBlockModel])) {
|
||||
// If not, check if the parent is a callout block
|
||||
const parent = std.store.getParent(currentBlock.model);
|
||||
if (!parent || !matchModels(parent, [CalloutBlockModel])) {
|
||||
return false;
|
||||
}
|
||||
const parentBlock = std.store.getBlock(parent.id);
|
||||
if (!parentBlock) return false;
|
||||
calloutBlock = parentBlock;
|
||||
}
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(splitCalloutCommand, {
|
||||
blockId: calloutBlock.model.id,
|
||||
inlineIndex: text.from.index,
|
||||
currentBlockId: text.from.blockId,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
Backspace: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (text && text.isCollapsed() && text.from.index === 0) {
|
||||
const event = ctx.get('defaultState').event;
|
||||
event.preventDefault();
|
||||
|
||||
const block = std.store.getBlock(text.from.blockId);
|
||||
if (!block) return false;
|
||||
@@ -20,6 +56,22 @@ export const CalloutKeymapExtension = KeymapExtension(std => {
|
||||
if (!parent) return false;
|
||||
if (!matchModels(parent, [CalloutBlockModel])) return false;
|
||||
|
||||
// Check if current block is a paragraph inside callout
|
||||
if (matchModels(block.model, [ParagraphBlockModel])) {
|
||||
event.preventDefault();
|
||||
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(calloutToParagraphCommand, {
|
||||
id: block.model.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to selecting the callout block
|
||||
event.preventDefault();
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create(BlockSelection, {
|
||||
blockId: parent.id,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
export const calloutToParagraphCommand: Command<
|
||||
{
|
||||
id: string;
|
||||
stopCapturing?: boolean;
|
||||
},
|
||||
{
|
||||
success: boolean;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { id, stopCapturing = true } = ctx;
|
||||
const std = ctx.std;
|
||||
const doc = std.store;
|
||||
const model = doc.getBlock(id)?.model;
|
||||
|
||||
if (!model || !matchModels(model, [ParagraphBlockModel])) return false;
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent || !matchModels(parent, [CalloutBlockModel])) return false;
|
||||
|
||||
if (stopCapturing) std.store.captureSync();
|
||||
|
||||
// Get current block index in callout
|
||||
const currentIndex = parent.children.indexOf(model);
|
||||
const hasText = model.text && model.text.length > 0;
|
||||
|
||||
// Find previous paragraph block in callout
|
||||
let previousBlock = null;
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const sibling = parent.children[i];
|
||||
if (matchModels(sibling, [ParagraphBlockModel])) {
|
||||
previousBlock = sibling;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousBlock && hasText) {
|
||||
// Clone current text content before any operations to prevent data loss
|
||||
const currentText = model.text || new Text();
|
||||
|
||||
// Get previous block text and merge index
|
||||
const previousText = previousBlock.text || new Text();
|
||||
const mergeIndex = previousText.length;
|
||||
|
||||
// Apply each delta from cloned current text to previous block to preserve formatting
|
||||
previousText.join(currentText);
|
||||
|
||||
// Remove current block after text has been merged
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
// Focus at merge point in previous block
|
||||
focusTextModel(std, previousBlock.id, mergeIndex);
|
||||
} else if (previousBlock && !hasText) {
|
||||
// Move cursor to end of previous block
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
const previousText = previousBlock.text || new Text();
|
||||
focusTextModel(std, previousBlock.id, previousText.length);
|
||||
} else {
|
||||
// No previous block, select the entire callout
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create(BlockSelection, {
|
||||
blockId: parent.id,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return next({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command, EditorHost } from '@blocksuite/std';
|
||||
|
||||
export const splitCalloutCommand: Command<{
|
||||
blockId: string;
|
||||
inlineIndex: number;
|
||||
currentBlockId: string;
|
||||
}> = (ctx, next) => {
|
||||
const { blockId, inlineIndex, currentBlockId, std } = ctx;
|
||||
const host = std.host as EditorHost;
|
||||
const doc = host.store;
|
||||
|
||||
const calloutModel = doc.getBlock(blockId)?.model;
|
||||
if (!calloutModel || !matchModels(calloutModel, [CalloutBlockModel])) {
|
||||
console.error(`block ${blockId} is not a callout block`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = doc.getBlock(currentBlockId)?.model;
|
||||
if (!currentModel) {
|
||||
console.error(`current block ${currentBlockId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
if (matchModels(currentModel, [ParagraphBlockModel])) {
|
||||
// User is in a paragraph within the callout's children
|
||||
const afterText = currentModel.props.text.split(inlineIndex);
|
||||
|
||||
// Update the current paragraph's text to keep only the part before cursor
|
||||
doc.transact(() => {
|
||||
currentModel.props.text.delete(
|
||||
inlineIndex,
|
||||
currentModel.props.text.length - inlineIndex
|
||||
);
|
||||
});
|
||||
|
||||
// Create a new paragraph block after the current one
|
||||
const parent = doc.getParent(currentModel);
|
||||
if (parent) {
|
||||
const currentIndex = parent.children.indexOf(currentModel);
|
||||
const newParagraphId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: afterText,
|
||||
},
|
||||
parent,
|
||||
currentIndex + 1
|
||||
);
|
||||
|
||||
if (newParagraphId) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
focusTextModel(std, newParagraphId);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If current block is not a paragraph, create a new paragraph in callout
|
||||
const newParagraphId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text(),
|
||||
},
|
||||
calloutModel
|
||||
);
|
||||
|
||||
if (newParagraphId) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
focusTextModel(std, newParagraphId);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,24 +1,12 @@
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusBlockEnd } from '@blocksuite/affine-shared/commands';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
findAncestorModel,
|
||||
isInsideBlockByFlavour,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { FontIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import { calloutTooltip } from './tooltips';
|
||||
|
||||
export const calloutSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => {
|
||||
return (
|
||||
findAncestorModel(model, ancestor =>
|
||||
matchModels(ancestor, [CalloutBlockModel])
|
||||
) !== null
|
||||
);
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'Callout',
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emotion/css": "^11.13.5",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/affine-widget-frame-title": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { updateBlockAlign } from '@blocksuite/affine-block-note';
|
||||
import { ImageBlockModel, TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -51,7 +55,55 @@ const builtinToolbarConfig = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.comment',
|
||||
id: 'c.1.align-left',
|
||||
tooltip: 'Align left',
|
||||
icon: TextAlignLeftIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Left,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.2.align-center',
|
||||
tooltip: 'Align center',
|
||||
icon: TextAlignCenterIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Center,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.3.align-right',
|
||||
tooltip: 'Align right',
|
||||
icon: TextAlignRightIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Right,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,6 +143,15 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const alignItemsStyleMap = styleMap({
|
||||
alignItems:
|
||||
this.model.props.textAlign$.value === 'left'
|
||||
? 'flex-start'
|
||||
: this.model.props.textAlign$.value === 'right'
|
||||
? 'flex-end'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const resovledState = this.resourceController.resolveStateWith({
|
||||
loadingIcon: LoadingIcon({
|
||||
strokeColor: cssVarV2('button/pureWhiteText'),
|
||||
@@ -162,6 +171,7 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
|
||||
html`<affine-page-image
|
||||
.block=${this}
|
||||
.state=${resovledState}
|
||||
style="${alignItemsStyleMap}"
|
||||
></affine-page-image>`,
|
||||
() =>
|
||||
html`<affine-image-fallback-card
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -150,6 +150,10 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
|
||||
|
||||
const listIcon = getListIcon(model, !collapsed, _onClickIcon);
|
||||
|
||||
const textAlignStyle = styleMap({
|
||||
textAlign: this.model.props.textAlign$?.value,
|
||||
});
|
||||
|
||||
const children = html`<div
|
||||
class="affine-block-children-container"
|
||||
style=${styleMap({
|
||||
@@ -161,7 +165,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
|
||||
</div>`;
|
||||
|
||||
return html`
|
||||
<div class=${'affine-list-block-container'}>
|
||||
<div class=${'affine-list-block-container'} style="${textAlignStyle}">
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-list-rich-text-wrapper': true,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -8,3 +8,4 @@ export { indentBlock } from './indent-block.js';
|
||||
export { indentBlocks } from './indent-blocks.js';
|
||||
export { selectBlock } from './select-block.js';
|
||||
export { selectBlocksBetween } from './select-blocks-between.js';
|
||||
export { updateBlockAlign } from './update-block-align.js';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getImageSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type Command,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
|
||||
type UpdateBlockAlignConfig = {
|
||||
textAlign: TextAlign;
|
||||
selectedBlocks?: BlockComponent[];
|
||||
};
|
||||
|
||||
export const updateBlockAlign: Command<UpdateBlockAlignConfig> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
let { std, textAlign, selectedBlocks } = ctx;
|
||||
|
||||
if (selectedBlocks === null) {
|
||||
const [result, ctx] = std.command
|
||||
.chain()
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getTextSelectionCommand),
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
chain.pipe(getImageSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedBlocksCommand, { types: ['text', 'block', 'image'] })
|
||||
.run();
|
||||
if (result) {
|
||||
selectedBlocks = ctx.selectedBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedBlocks || selectedBlocks.length === 0) return false;
|
||||
|
||||
selectedBlocks.forEach(block => {
|
||||
std.store.updateBlock(block.model, { textAlign });
|
||||
});
|
||||
|
||||
const selectionManager = std.host.selection;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
if (!textSelection) {
|
||||
return false;
|
||||
}
|
||||
selectionManager.setGroup('note', [textSelection]);
|
||||
return next();
|
||||
};
|
||||
@@ -4,9 +4,15 @@ import {
|
||||
textFormatConfigs,
|
||||
} from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
type TextAlignConfig,
|
||||
textAlignConfigs,
|
||||
type TextConversionConfig,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
getSelectedModelsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type SlashMenuActionItem,
|
||||
@@ -17,7 +23,7 @@ import {
|
||||
import { HeadingsIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
|
||||
import { updateBlockType } from '../commands';
|
||||
import { updateBlockAlign, updateBlockType } from '../commands';
|
||||
import { tooltips } from './tooltips';
|
||||
|
||||
let basicIndex = 0;
|
||||
@@ -60,6 +66,10 @@ const noteSlashMenuConfig: SlashMenuConfig = {
|
||||
createConversionItem(config, `1_List@${index++}`)
|
||||
),
|
||||
|
||||
...textAlignConfigs.map((config, index) =>
|
||||
createAlignItem(config, `2_Align@${index++}`)
|
||||
),
|
||||
|
||||
...textFormatConfigs
|
||||
.filter(i => !['Code', 'Link'].includes(i.name))
|
||||
.map((config, index) =>
|
||||
@@ -89,6 +99,26 @@ function createConversionItem(
|
||||
};
|
||||
}
|
||||
|
||||
function createAlignItem(
|
||||
config: TextAlignConfig,
|
||||
group?: SlashMenuItem['group']
|
||||
): SlashMenuActionItem {
|
||||
const { textAlign, name, icon } = config;
|
||||
return {
|
||||
name,
|
||||
group,
|
||||
icon,
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(getSelectedModelsCommand, { types: ['text'] })
|
||||
.pipe(updateBlockAlign, { textAlign })
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTextFormatItem(
|
||||
config: TextFormatConfig,
|
||||
group?: SlashMenuItem['group']
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
NoteBlockSchema,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
focusBlockEnd,
|
||||
focusBlockStart,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
indentBlocks,
|
||||
selectBlock,
|
||||
selectBlocksBetween,
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from './commands';
|
||||
import { moveBlockConfigs } from './move-block';
|
||||
@@ -157,6 +161,36 @@ class NoteKeymap {
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _bindTextAlignHotKey = () => {
|
||||
return textAlignConfigs.reduce(
|
||||
(acc, item) => {
|
||||
const keymap = item.hotkey!.reduce(
|
||||
(acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
const [result] = this._std.command
|
||||
.chain()
|
||||
.pipe(updateBlockAlign, { textAlign: item.textAlign })
|
||||
.run();
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
},
|
||||
{} as Record<string, UIEventHandler>
|
||||
);
|
||||
|
||||
return {
|
||||
...acc,
|
||||
...keymap,
|
||||
};
|
||||
},
|
||||
{} as Record<string, UIEventHandler>
|
||||
);
|
||||
};
|
||||
|
||||
private _focusBlock: BlockComponent | null = null;
|
||||
|
||||
private readonly _getClosestNoteByBlockId = (blockId: string) => {
|
||||
@@ -568,6 +602,7 @@ class NoteKeymap {
|
||||
...this._bindMoveBlockHotKey(),
|
||||
...this._bindQuickActionHotKey(),
|
||||
...this._bindTextConversionHotKey(),
|
||||
...this._bindTextAlignHotKey(),
|
||||
Tab: ctx => {
|
||||
const [success] = this.std.command.exec(indentBlocks);
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
`;
|
||||
}
|
||||
|
||||
const textAlignStyle = styleMap({
|
||||
textAlign: this.model.props.textAlign$?.value,
|
||||
});
|
||||
|
||||
const children = html`<div
|
||||
class="affine-block-children-container"
|
||||
style=${styleMap({
|
||||
@@ -288,6 +292,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
'affine-paragraph-block-container': true,
|
||||
'highlight-comment': this.isCommentHighlighted,
|
||||
})}
|
||||
style="${textAlignStyle}"
|
||||
data-has-collapsed-siblings="${collapsedSiblings.length > 0}"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { updateBlockType } from '@blocksuite/affine-block-note';
|
||||
import {
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from '@blocksuite/affine-block-note';
|
||||
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
@@ -23,8 +26,12 @@ import {
|
||||
import {
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
type TextAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
copySelectedModelsCommand,
|
||||
deleteSelectedModelsCommand,
|
||||
@@ -46,6 +53,7 @@ import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
|
||||
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
CopyIcon,
|
||||
@@ -130,6 +138,64 @@ const conversionsActionGroup = {
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const alignActionGroup = {
|
||||
id: 'b.align',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
generate({ chain }) {
|
||||
const [ok, { selectedModels = [] }] = chain
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getTextSelectionCommand),
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
|
||||
.run();
|
||||
if (!ok) return null;
|
||||
|
||||
const alignment =
|
||||
textAlignConfigs.find(
|
||||
({ textAlign }) =>
|
||||
textAlign ===
|
||||
getMostCommonValue(
|
||||
selectedModels.map(
|
||||
({ props }) => props as { textAlign?: TextAlign }
|
||||
),
|
||||
'textAlign'
|
||||
)
|
||||
) ?? textAlignConfigs[0];
|
||||
const update = (textAlign: TextAlign) => {
|
||||
chain.pipe(updateBlockAlign, { textAlign }).run();
|
||||
};
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Align" .tooltip="${'Align'}">
|
||||
${alignment.icon} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
textAlignConfigs,
|
||||
item => item.name,
|
||||
({ textAlign, name, icon }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${name}
|
||||
@click=${() => update(textAlign)}
|
||||
>
|
||||
${icon}<span class="label">${name}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const inlineTextActionGroup = {
|
||||
id: 'b.inline-text',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
@@ -291,6 +357,7 @@ const turnIntoLinkedDoc = {
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
conversionsActionGroup,
|
||||
alignActionGroup,
|
||||
inlineTextActionGroup,
|
||||
highlightActionGroup,
|
||||
turnIntoDatabase,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emotion/css": "^11.13.5",
|
||||
|
||||
@@ -144,6 +144,16 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
|
||||
style=${styleMap({
|
||||
paddingLeft: `${virtualPadding}px`,
|
||||
paddingRight: `${virtualPadding}px`,
|
||||
marginLeft:
|
||||
!this.model.props.textAlign$.value ||
|
||||
this.model.props.textAlign$?.value === 'left'
|
||||
? undefined
|
||||
: 'auto',
|
||||
marginRight:
|
||||
!this.model.props.textAlign$.value ||
|
||||
this.model.props.textAlign$?.value === 'right'
|
||||
? undefined
|
||||
: 'auto',
|
||||
width: 'max-content',
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@blocksuite/sync": "workspace:*",
|
||||
|
||||
@@ -193,6 +193,7 @@ export const menuButtonItems = {
|
||||
(config: {
|
||||
name: string;
|
||||
label?: () => TemplateResult;
|
||||
info?: TemplateResult;
|
||||
prefix?: TemplateResult;
|
||||
postfix?: TemplateResult;
|
||||
isSelected?: boolean;
|
||||
@@ -211,7 +212,7 @@ export const menuButtonItems = {
|
||||
return html`
|
||||
${config.prefix}
|
||||
<div class="affine-menu-action-text">
|
||||
${config.label?.() ?? config.name}
|
||||
${config.label?.() ?? config.name} ${config.info}
|
||||
</div>
|
||||
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
|
||||
`;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emotion/css": "^11.13.5",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -19,6 +19,7 @@ const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-icon-container,
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
@@ -49,6 +50,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
.doc-icon-container,
|
||||
.doc-title-container {
|
||||
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types.js';
|
||||
import { ImageBlockTransformer } from './image-transformer.js';
|
||||
|
||||
@@ -20,6 +21,7 @@ export type ImageBlockProps = {
|
||||
rotate: number;
|
||||
size?: number;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
} & Omit<GfxCommonBlockProps, 'scale'> &
|
||||
BlockMeta;
|
||||
|
||||
@@ -34,6 +36,7 @@ const defaultImageProps: ImageBlockProps = {
|
||||
rotate: 0,
|
||||
size: -1,
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
// `toggle` type has been deprecated, do not use it
|
||||
@@ -13,6 +14,7 @@ export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle';
|
||||
export type ListProps = {
|
||||
type: ListType;
|
||||
text: Text;
|
||||
textAlign?: TextAlign;
|
||||
checked: boolean;
|
||||
collapsed: boolean;
|
||||
order: number | null;
|
||||
@@ -25,6 +27,7 @@ export const ListBlockSchema = defineBlockSchema({
|
||||
({
|
||||
type: 'bulleted',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type ParagraphType =
|
||||
@@ -19,6 +20,7 @@ export type ParagraphType =
|
||||
|
||||
export type ParagraphProps = {
|
||||
type: ParagraphType;
|
||||
textAlign?: TextAlign;
|
||||
text: Text;
|
||||
collapsed: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
@@ -29,6 +31,7 @@ export const ParagraphBlockSchema = defineBlockSchema({
|
||||
props: (internal): ParagraphProps => ({
|
||||
type: 'text',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
collapsed: false,
|
||||
comments: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type TableCell = {
|
||||
@@ -30,6 +31,7 @@ export interface TableBlockProps extends BlockMeta {
|
||||
// key = `${rowId}:${columnId}`
|
||||
cells: Record<string, TableCell>;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
export interface TableCellSerialized {
|
||||
@@ -53,6 +55,7 @@ export const TableBlockSchema = defineBlockSchema({
|
||||
columns: {},
|
||||
cells: {},
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
35
blocksuite/affine/rich-text/src/align.ts
Normal file
35
blocksuite/affine/rich-text/src/align.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export interface TextAlignConfig {
|
||||
textAlign: TextAlign;
|
||||
name: string;
|
||||
hotkey: string[] | null;
|
||||
icon: TemplateResult<1>;
|
||||
}
|
||||
|
||||
export const textAlignConfigs: TextAlignConfig[] = [
|
||||
{
|
||||
textAlign: TextAlign.Left,
|
||||
name: 'Align left',
|
||||
hotkey: [`Mod-Shift-L`],
|
||||
icon: TextAlignLeftIcon(),
|
||||
},
|
||||
{
|
||||
textAlign: TextAlign.Center,
|
||||
name: 'Align center',
|
||||
hotkey: [`Mod-Shift-E`],
|
||||
icon: TextAlignCenterIcon(),
|
||||
},
|
||||
{
|
||||
textAlign: TextAlign.Right,
|
||||
name: 'Align right',
|
||||
hotkey: [`Mod-Shift-R`],
|
||||
icon: TextAlignRightIcon(),
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,4 @@
|
||||
export { type TextAlignConfig, textAlignConfigs } from './align';
|
||||
export { type TextConversionConfig, textConversionConfigs } from './conversion';
|
||||
export {
|
||||
asyncGetRichText,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-selected-rect": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.3",
|
||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/integration-test": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@shoelace-style/shoelace": "2.20.1",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@faker-js/faker": "^9.3.0",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@magic-works/i18n-codegen": "^0.6.1",
|
||||
"@playwright/test": "=1.52.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Provider" AS ENUM ('stripe', 'revenuecat');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IapStore" AS ENUM ('app_store', 'play_store');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscriptions" ADD COLUMN "iap_store" "IapStore",
|
||||
ADD COLUMN "provider" "Provider" NOT NULL DEFAULT 'stripe',
|
||||
ADD COLUMN "rc_entitlement" VARCHAR,
|
||||
ADD COLUMN "rc_external_ref" VARCHAR,
|
||||
ADD COLUMN "rc_product_id" VARCHAR;
|
||||
@@ -127,7 +127,8 @@
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@faker-js/faker": "^9.6.0",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.1",
|
||||
|
||||
@@ -749,6 +749,16 @@ model Subscription {
|
||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||
// stripe schedule id
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
// subscription provider: stripe or revenuecat
|
||||
provider Provider @default(stripe)
|
||||
// iap store for revenuecat subscriptions
|
||||
iapStore IapStore? @map("iap_store")
|
||||
// revenuecat entitlement name like "Pro" / "AI"
|
||||
rcEntitlement String? @map("rc_entitlement") @db.VarChar
|
||||
// revenuecat product id like "app.affine.pro.Annual"
|
||||
rcProductId String? @map("rc_product_id") @db.VarChar
|
||||
// external reference, appstore originalTransactionId or play purchaseToken
|
||||
rcExternalRef String? @map("rc_external_ref") @db.VarChar
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
@@ -770,6 +780,16 @@ model Subscription {
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
enum Provider {
|
||||
stripe
|
||||
revenuecat
|
||||
}
|
||||
|
||||
enum IapStore {
|
||||
app_store
|
||||
play_store
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
stripeInvoiceId String @id @map("stripe_invoice_id")
|
||||
targetId String @map("target_id") @db.VarChar
|
||||
|
||||
@@ -444,3 +444,37 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
## should resolve model correctly based on subscription status and prompt config
|
||||
|
||||
> should honor requested pro model
|
||||
|
||||
'gemini-2.5-pro'
|
||||
|
||||
> should fallback to default model
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should fallback to default model when requesting pro model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should honor requested non-pro model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should pick default model when no requested model during trialing
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should pick default model when no requested model during active
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
> should honor requested pro model during active
|
||||
|
||||
'claude-sonnet-4@20250514'
|
||||
|
||||
> should fallback to default model when requesting non-optional model during active
|
||||
|
||||
'gemini-2.5-flash'
|
||||
|
||||
Binary file not shown.
@@ -60,6 +60,9 @@ import {
|
||||
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
|
||||
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
|
||||
import { CopilotWorkspaceService } from '../plugins/copilot/workspace';
|
||||
import { PaymentModule } from '../plugins/payment';
|
||||
import { SubscriptionService } from '../plugins/payment/service';
|
||||
import { SubscriptionStatus } from '../plugins/payment/types';
|
||||
import { MockCopilotProvider } from './mocks';
|
||||
import { createTestingModule, TestingModule } from './utils';
|
||||
import { WorkflowTestCases } from './utils/copilot';
|
||||
@@ -82,6 +85,7 @@ type Context = {
|
||||
storage: CopilotStorage;
|
||||
workflow: CopilotWorkflowService;
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
executors: {
|
||||
image: CopilotChatImageExecutor;
|
||||
text: CopilotChatTextExecutor;
|
||||
@@ -116,6 +120,7 @@ test.before(async t => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
PaymentModule,
|
||||
QuotaModule,
|
||||
StorageModule,
|
||||
CopilotModule,
|
||||
@@ -124,6 +129,13 @@ test.before(async t => {
|
||||
// use real JobQueue for testing
|
||||
builder.overrideProvider(JobQueue).useClass(JobQueue);
|
||||
builder.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
|
||||
builder.overrideProvider(SubscriptionService).useClass(
|
||||
class {
|
||||
select() {
|
||||
return { getSubscription: async () => undefined };
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +157,7 @@ test.before(async t => {
|
||||
const transcript = module.get(CopilotTranscriptionService);
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
const subscription = module.get(SubscriptionService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -163,6 +176,7 @@ test.before(async t => {
|
||||
t.context.transcript = transcript;
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
|
||||
t.context.executors = {
|
||||
image: module.get(CopilotChatImageExecutor),
|
||||
@@ -2047,3 +2061,90 @@ test('should handle copilot cron jobs correctly', async t => {
|
||||
toBeGenerateStub.restore();
|
||||
jobAddStub.restore();
|
||||
});
|
||||
|
||||
test('should resolve model correctly based on subscription status and prompt config', async t => {
|
||||
const { db, session, subscription } = t.context;
|
||||
|
||||
// 1) Seed a prompt that has optionalModels and proModels in config
|
||||
const promptName = 'resolve-model-test';
|
||||
await db.aiPrompt.create({
|
||||
data: {
|
||||
name: promptName,
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: {
|
||||
create: [{ idx: 0, role: 'system', content: 'test' }],
|
||||
},
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'] },
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4@20250514',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 2) Create a chat session with this prompt
|
||||
const sessionId = await session.create({
|
||||
promptName,
|
||||
docId: 'test',
|
||||
workspaceId: 'test',
|
||||
userId,
|
||||
pinned: false,
|
||||
});
|
||||
const s = (await session.get(sessionId))!;
|
||||
|
||||
const mockStatus = (status?: SubscriptionStatus) => {
|
||||
Sinon.restore();
|
||||
Sinon.stub(subscription, 'select').callsFake(() => ({
|
||||
// @ts-expect-error mock
|
||||
getSubscription: async () => (status ? { status } : null),
|
||||
}));
|
||||
};
|
||||
|
||||
// payment disabled -> allow requested if in optional; pro not blocked
|
||||
{
|
||||
const model1 = await s.resolveModel(false, 'gemini-2.5-pro');
|
||||
t.snapshot(model1, 'should honor requested pro model');
|
||||
|
||||
const model2 = await s.resolveModel(false, 'not-in-optional');
|
||||
t.snapshot(model2, 'should fallback to default model');
|
||||
}
|
||||
|
||||
// payment enabled + trialing: requesting pro should fallback to default
|
||||
{
|
||||
mockStatus(SubscriptionStatus.Trialing);
|
||||
const model3 = await s.resolveModel(true, 'gemini-2.5-pro');
|
||||
t.snapshot(
|
||||
model3,
|
||||
'should fallback to default model when requesting pro model during trialing'
|
||||
);
|
||||
|
||||
const model4 = await s.resolveModel(true, 'gemini-2.5-flash');
|
||||
t.snapshot(model4, 'should honor requested non-pro model during trialing');
|
||||
|
||||
const model5 = await s.resolveModel(true);
|
||||
t.snapshot(
|
||||
model5,
|
||||
'should pick default model when no requested model during trialing'
|
||||
);
|
||||
}
|
||||
|
||||
// payment enabled + active: without requested -> default model; requested pro should be honored
|
||||
{
|
||||
mockStatus(SubscriptionStatus.Active);
|
||||
const model6 = await s.resolveModel(true);
|
||||
t.snapshot(
|
||||
model6,
|
||||
'should pick default model when no requested model during active'
|
||||
);
|
||||
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514');
|
||||
t.snapshot(model7, 'should honor requested pro model during active');
|
||||
|
||||
const model8 = await s.resolveModel(true, 'not-in-optional');
|
||||
t.snapshot(
|
||||
model8,
|
||||
'should fallback to default model when requesting non-optional model during active'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Snapshot report for `src/__tests__/payment/revenuecat.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `revenuecat.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should resolve product mapping consistently (whitelist, override, unknown)
|
||||
|
||||
> should map product for whitelist/override/unknown
|
||||
|
||||
{
|
||||
override: {
|
||||
customMonthly: {
|
||||
plan: 'pro',
|
||||
recurring: 'monthly',
|
||||
},
|
||||
},
|
||||
unknown: null,
|
||||
whitelist: {
|
||||
aiAnnual: {
|
||||
plan: 'ai',
|
||||
recurring: 'yearly',
|
||||
},
|
||||
proAnnual: {
|
||||
plan: 'pro',
|
||||
recurring: 'yearly',
|
||||
},
|
||||
proMonthly: {
|
||||
plan: 'pro',
|
||||
recurring: 'monthly',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
## should standardize RC subscriber response and upsert subscription with observability fields
|
||||
|
||||
> should standardize payload and have events
|
||||
|
||||
{
|
||||
activatedCount: 1,
|
||||
canceledCount: 0,
|
||||
dbObservability: {
|
||||
iapStore: 'app_store',
|
||||
provider: 'revenuecat',
|
||||
rcEntitlement: 'Pro',
|
||||
rcExternalRef: 'orig-tx-1',
|
||||
rcProductId: 'app.affine.pro.Annual',
|
||||
},
|
||||
lastActivated: {
|
||||
plan: 'pro',
|
||||
recurring: 'yearly',
|
||||
},
|
||||
subscriberCount: 1,
|
||||
}
|
||||
|
||||
## should process expiration/refund by deleting subscription and emitting canceled
|
||||
|
||||
> should process expiration/refund and emit canceled
|
||||
|
||||
{
|
||||
activatedCount: 0,
|
||||
canceledCount: 1,
|
||||
finalDBCount: 0,
|
||||
lastCanceled: {
|
||||
plan: 'pro',
|
||||
recurring: 'yearly',
|
||||
},
|
||||
subscriberCount: 1,
|
||||
}
|
||||
|
||||
## should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions
|
||||
|
||||
> should enqueue per-user RC reconciliation jobs (deduplicated by userId)
|
||||
|
||||
{
|
||||
queued: [
|
||||
{
|
||||
name: 'nightly.revenuecat.syncUser',
|
||||
opts: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
delay: 60000,
|
||||
type: 'exponential',
|
||||
},
|
||||
jobId: 'nightly-rc-sync-u1',
|
||||
},
|
||||
payload: {
|
||||
userId: 'u1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nightly.revenuecat.syncUser',
|
||||
opts: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
delay: 60000,
|
||||
type: 'exponential',
|
||||
},
|
||||
jobId: 'nightly-rc-sync-u2',
|
||||
},
|
||||
payload: {
|
||||
userId: 'u2',
|
||||
},
|
||||
},
|
||||
],
|
||||
uniqueJobCount: 2,
|
||||
}
|
||||
|
||||
## should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
|
||||
|
||||
> should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
|
||||
|
||||
{
|
||||
results: [
|
||||
{
|
||||
activatedCount: 1,
|
||||
name: 'Pro monthly on iOS',
|
||||
rec: {
|
||||
iapStore: 'app_store',
|
||||
plan: 'pro',
|
||||
provider: 'revenuecat',
|
||||
rcEntitlement: 'Pro',
|
||||
rcExternalRef: 'orig-ios-1',
|
||||
rcProductId: 'app.affine.pro.Monthly',
|
||||
recurring: 'monthly',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
{
|
||||
activatedCount: 1,
|
||||
name: 'AI annual on Android',
|
||||
rec: {
|
||||
iapStore: 'play_store',
|
||||
plan: 'ai',
|
||||
provider: 'revenuecat',
|
||||
rcEntitlement: 'AI',
|
||||
rcExternalRef: 'token-android-1',
|
||||
rcProductId: 'app.affine.pro.ai.Annual',
|
||||
recurring: 'yearly',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
## should keep active and advance period dates when a trialing subscription renews
|
||||
|
||||
> should keep active after trial renewal
|
||||
|
||||
{
|
||||
activatedCount: 2,
|
||||
canceledCount: 0,
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
## should remove or cancel the record and revoke entitlement when a trialing subscription expires
|
||||
|
||||
> should remove record
|
||||
|
||||
{
|
||||
canceledCount: 1,
|
||||
finalDBCount: 0,
|
||||
}
|
||||
|
||||
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
|
||||
|
||||
> should keep active until period end when will_renew is false
|
||||
|
||||
{
|
||||
activatedCount: 1,
|
||||
canceledCount: 0,
|
||||
hasCanceledAt: true,
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
## should retain record as past_due (inactive but not expired) and NOT emit canceled event
|
||||
|
||||
> should retain past_due record and NOT emit canceled event
|
||||
|
||||
{
|
||||
canceledCount: 0,
|
||||
status: 'past_due',
|
||||
}
|
||||
|
||||
## should skip RC upsert when Stripe active already exists for same plan
|
||||
|
||||
> should skip RC upsert when Stripe active already exists
|
||||
|
||||
{
|
||||
activatedCount: 0,
|
||||
hasRCRecord: false,
|
||||
}
|
||||
|
||||
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
|
||||
|
||||
> should reconcile and fix missing or out-of-order states for revenuecat records
|
||||
|
||||
{
|
||||
activatedCount: 1,
|
||||
canceledCount: 0,
|
||||
subscriberCount: 1,
|
||||
}
|
||||
|
||||
## should treat refund as early expiration and revoke immediately
|
||||
|
||||
> should delete record and emit canceled on refund
|
||||
|
||||
{
|
||||
canceledCount: 1,
|
||||
finalDBCount: 0,
|
||||
}
|
||||
|
||||
## should ignore non-whitelisted productId and not write to DB
|
||||
|
||||
> should ignore non-whitelisted productId and not write to DB
|
||||
|
||||
{
|
||||
activatedCount: 0,
|
||||
canceledCount: 0,
|
||||
dbCount: 0,
|
||||
}
|
||||
|
||||
## should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)
|
||||
|
||||
> should map via entitlement+duration fallback and ignore unsupported durations
|
||||
|
||||
{
|
||||
aiViaFallback: {
|
||||
plan: 'ai',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'yearly',
|
||||
},
|
||||
eventsCounts: {
|
||||
afterFirst: {
|
||||
a: 1,
|
||||
c: 0,
|
||||
},
|
||||
afterSecond: {
|
||||
a: 2,
|
||||
c: 0,
|
||||
},
|
||||
afterThird: {
|
||||
a: 2,
|
||||
c: 0,
|
||||
},
|
||||
},
|
||||
proViaFallback: {
|
||||
plan: 'pro',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'monthly',
|
||||
},
|
||||
totalCount: 2,
|
||||
}
|
||||
Binary file not shown.
929
packages/backend/server/src/__tests__/payment/revenuecat.spec.ts
Normal file
929
packages/backend/server/src/__tests__/payment/revenuecat.spec.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import { omit } from 'lodash-es';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import {
|
||||
EventBus,
|
||||
ManagedByAppStoreOrPlay,
|
||||
SubscriptionAlreadyExists,
|
||||
} from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { FeatureService } from '../../core/features';
|
||||
import { Models } from '../../models';
|
||||
import { PaymentModule } from '../../plugins/payment';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||
import {
|
||||
RcEvent,
|
||||
resolveProductMapping,
|
||||
RevenueCatService,
|
||||
RevenueCatWebhookController,
|
||||
RevenueCatWebhookHandler,
|
||||
type Subscription,
|
||||
} from '../../plugins/payment/revenuecat';
|
||||
import { SubscriptionService } from '../../plugins/payment/service';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
type Ctx = {
|
||||
module: TestingApp;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||
|
||||
service: SubscriptionService;
|
||||
rc: RevenueCatService;
|
||||
webhook: RevenueCatWebhookHandler;
|
||||
controller: RevenueCatWebhookController;
|
||||
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
triggerWebhook: (
|
||||
userId: string,
|
||||
event: Omit<RcEvent, 'app_id' | 'environment'>
|
||||
) => Promise<void>;
|
||||
collectEvents: () => {
|
||||
activatedCount: number;
|
||||
canceledCount: number;
|
||||
events: Record<string, any[]>;
|
||||
};
|
||||
};
|
||||
|
||||
const test = ava as TestFn<Ctx>;
|
||||
let user: User;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [
|
||||
ConfigModule.override({
|
||||
payment: {
|
||||
revenuecat: {
|
||||
enabled: true,
|
||||
webhookAuth: '42',
|
||||
},
|
||||
},
|
||||
}),
|
||||
PaymentModule,
|
||||
],
|
||||
tapModule: m => {
|
||||
m.overrideProvider(FeatureService).useValue(
|
||||
Sinon.createStubInstance(FeatureService)
|
||||
);
|
||||
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||
},
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
const models = app.get(Models);
|
||||
const event = app.get(EventBus) as Sinon.SinonStubbedInstance<EventBus>;
|
||||
|
||||
const service = app.get(SubscriptionService);
|
||||
const rc = app.get(RevenueCatService);
|
||||
const webhook = app.get(RevenueCatWebhookHandler);
|
||||
const controller = app.get(RevenueCatWebhookController);
|
||||
|
||||
t.context.module = app;
|
||||
t.context.db = db;
|
||||
t.context.models = models;
|
||||
t.context.event = event;
|
||||
|
||||
t.context.service = service;
|
||||
t.context.rc = rc;
|
||||
t.context.webhook = webhook;
|
||||
t.context.controller = controller;
|
||||
|
||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||
t.context.mockSubSeq = sequences => {
|
||||
const stub = Sinon.stub(rc, 'getSubscriptions');
|
||||
sequences.forEach((seq, idx) => {
|
||||
if (idx === 0) stub.onFirstCall().resolves(seq);
|
||||
else if (idx === 1) stub.onSecondCall().resolves(seq);
|
||||
else stub.onCall(idx).resolves(seq);
|
||||
});
|
||||
return stub;
|
||||
};
|
||||
t.context.triggerWebhook = async (appUserId, event) => {
|
||||
await webhook.onWebhook({
|
||||
appUserId,
|
||||
event: {
|
||||
...event,
|
||||
app_id: 'app.affine.pro',
|
||||
environment: 'SANDBOX',
|
||||
} as RcEvent,
|
||||
});
|
||||
};
|
||||
|
||||
t.context.collectEvents = () => {
|
||||
const events = event.emit.getCalls().reduce(
|
||||
(acc, c) => {
|
||||
const [key, value] = c.args;
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(value);
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: any[] }
|
||||
);
|
||||
const activatedCount = events['user.subscription.activated']?.length || 0;
|
||||
const canceledCount = events['user.subscription.canceled']?.length || 0;
|
||||
|
||||
return { activatedCount, canceledCount, events };
|
||||
};
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
user = await t.context.models.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
Sinon.reset();
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should resolve product mapping consistently (whitelist, override, unknown)', t => {
|
||||
const override = {
|
||||
'custom.sku.monthly': { plan: 'pro', recurring: 'monthly' },
|
||||
} as Record<string, { plan: string; recurring: string }>;
|
||||
|
||||
const actual = {
|
||||
whitelist: {
|
||||
proMonthly: resolveProductMapping({
|
||||
productId: 'app.affine.pro.Monthly',
|
||||
}),
|
||||
proAnnual: resolveProductMapping({ productId: 'app.affine.pro.Annual' }),
|
||||
aiAnnual: resolveProductMapping({
|
||||
productId: 'app.affine.pro.ai.Annual',
|
||||
}),
|
||||
},
|
||||
override: {
|
||||
customMonthly: resolveProductMapping(
|
||||
{ productId: 'custom.sku.monthly' },
|
||||
override
|
||||
),
|
||||
},
|
||||
unknown: resolveProductMapping({ productId: 'unknown.sku' }),
|
||||
};
|
||||
|
||||
t.snapshot(actual, 'should map product for whitelist/override/unknown');
|
||||
});
|
||||
|
||||
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
|
||||
const { webhook, collectEvents, mockSub } = t.context;
|
||||
|
||||
const subscriber = mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await webhook.onWebhook({
|
||||
appUserId: user.id,
|
||||
event: {
|
||||
id: 'evt_1',
|
||||
environment: 'PRODUCTION',
|
||||
app_id: 'app.affine.pro',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
original_transaction_id: 'orig-tx-1',
|
||||
},
|
||||
});
|
||||
const { activatedCount, canceledCount, events } = collectEvents();
|
||||
|
||||
const record = await t.context.db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
select: {
|
||||
provider: true,
|
||||
iapStore: true,
|
||||
rcEntitlement: true,
|
||||
rcProductId: true,
|
||||
rcExternalRef: true,
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
subscriberCount: subscriber.getCalls()?.length || 0,
|
||||
activatedCount,
|
||||
canceledCount,
|
||||
lastActivated: omit(
|
||||
events['user.subscription.activated']?.slice(-1)?.[0],
|
||||
'userId'
|
||||
),
|
||||
dbObservability: record,
|
||||
},
|
||||
'should standardize payload and have events'
|
||||
);
|
||||
});
|
||||
|
||||
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'annual',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const subscriber = mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: false,
|
||||
latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2024-02-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_2',
|
||||
type: 'EXPIRATION',
|
||||
store: 'app_store',
|
||||
original_transaction_id: 'orig-tx-2',
|
||||
});
|
||||
|
||||
const finalDBCount = await db.subscription.count({
|
||||
where: { targetId: user.id, plan: 'pro' },
|
||||
});
|
||||
|
||||
const { activatedCount, canceledCount, events } = collectEvents();
|
||||
t.snapshot(
|
||||
{
|
||||
finalDBCount,
|
||||
subscriberCount: subscriber.getCalls()?.length || 0,
|
||||
activatedCount,
|
||||
canceledCount,
|
||||
lastCanceled: omit(
|
||||
events['user.subscription.canceled']?.slice(-1)?.[0],
|
||||
'userId'
|
||||
),
|
||||
},
|
||||
'should process expiration/refund and emit canceled'
|
||||
);
|
||||
});
|
||||
|
||||
test('should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions', async t => {
|
||||
const { module, db } = t.context;
|
||||
|
||||
const cron = module.get(SubscriptionCronJobs);
|
||||
|
||||
const common = { provider: 'revenuecat', start: new Date() } as const;
|
||||
await db.subscription.createMany({
|
||||
data: [
|
||||
{
|
||||
targetId: 'u1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
recurring: 'monthly',
|
||||
...common,
|
||||
},
|
||||
{
|
||||
targetId: 'u2',
|
||||
plan: 'ai',
|
||||
status: 'trialing',
|
||||
recurring: 'annual',
|
||||
...common,
|
||||
},
|
||||
{
|
||||
targetId: 'u1',
|
||||
plan: 'ai',
|
||||
status: 'past_due',
|
||||
recurring: 'monthly',
|
||||
...common,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await cron.reconcileRevenueCatSubscriptions();
|
||||
|
||||
const calls = module.queue.add.getCalls().map(c => ({
|
||||
name: c.args[0],
|
||||
payload: c.args[1],
|
||||
opts: c.args[2],
|
||||
}));
|
||||
t.snapshot(
|
||||
{
|
||||
queued: calls,
|
||||
uniqueJobCount: calls.filter(
|
||||
c => c.name === 'nightly.revenuecat.syncUser'
|
||||
).length,
|
||||
},
|
||||
'should enqueue per-user RC reconciliation jobs (deduplicated by userId)'
|
||||
);
|
||||
});
|
||||
|
||||
test('should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)', async t => {
|
||||
const { db, event, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'Pro monthly on iOS',
|
||||
stub: [
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-02-10T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Monthly',
|
||||
store: 'app_store' as const,
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
event: {
|
||||
id: 'evt_ios_1',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
original_transaction_id: 'orig-ios-1',
|
||||
},
|
||||
expectedPlan: 'pro' as const,
|
||||
},
|
||||
{
|
||||
name: 'AI annual on Android',
|
||||
stub: [
|
||||
{
|
||||
identifier: 'AI',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.ai.Annual',
|
||||
store: 'play_store' as const,
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
event: {
|
||||
id: 'evt_android_1',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'play_store',
|
||||
purchase_token: 'token-android-1',
|
||||
},
|
||||
expectedPlan: 'ai' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
mockSubSeq(scenarios.map(s => s.stub));
|
||||
|
||||
for (const s of scenarios) {
|
||||
// reset event history between scenarios for clean counts
|
||||
event.emit.resetHistory?.();
|
||||
await triggerWebhook(user.id, s.event);
|
||||
const rec = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
|
||||
select: {
|
||||
plan: true,
|
||||
recurring: true,
|
||||
status: true,
|
||||
provider: true,
|
||||
iapStore: true,
|
||||
rcEntitlement: true,
|
||||
rcProductId: true,
|
||||
rcExternalRef: true,
|
||||
},
|
||||
});
|
||||
const { activatedCount } = collectEvents();
|
||||
results.push({ name: s.name, rec, activatedCount });
|
||||
}
|
||||
|
||||
t.snapshot(
|
||||
{ results },
|
||||
'should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)'
|
||||
);
|
||||
});
|
||||
|
||||
test('should keep active and advance period dates when a trialing subscription renews', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-04-08T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_trial',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
period_type: 'trial',
|
||||
store: 'app_store',
|
||||
});
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_renew',
|
||||
type: 'RENEWAL',
|
||||
store: 'app_store',
|
||||
});
|
||||
|
||||
const rec = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
select: { status: true, start: true, end: true },
|
||||
});
|
||||
const { activatedCount, canceledCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{ status: rec?.status, activatedCount, canceledCount },
|
||||
'should keep active after trial renewal'
|
||||
);
|
||||
});
|
||||
|
||||
test('should remove or cancel the record and revoke entitlement when a trialing subscription expires', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: false,
|
||||
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2024-01-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_trial2',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
period_type: 'trial',
|
||||
store: 'app_store',
|
||||
});
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_expire_trial',
|
||||
type: 'EXPIRATION',
|
||||
store: 'app_store',
|
||||
});
|
||||
|
||||
const finalDBCount = await db.subscription.count({
|
||||
where: { targetId: user.id, plan: 'pro' },
|
||||
});
|
||||
const { canceledCount } = collectEvents();
|
||||
t.snapshot({ finalDBCount, canceledCount }, 'should remove record');
|
||||
});
|
||||
|
||||
test('should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-06-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_cancel_before_end',
|
||||
type: 'CANCELLATION',
|
||||
store: 'app_store',
|
||||
});
|
||||
const rec = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
select: { status: true, canceledAt: true },
|
||||
});
|
||||
const { activatedCount, canceledCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{
|
||||
status: rec?.status,
|
||||
hasCanceledAt: !!rec?.canceledAt,
|
||||
activatedCount,
|
||||
canceledCount,
|
||||
},
|
||||
'should keep active until period end when will_renew is false'
|
||||
);
|
||||
});
|
||||
|
||||
test('should retain record as past_due (inactive but not expired) and NOT emit canceled event', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: false,
|
||||
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2999-01-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_pastdue',
|
||||
type: 'BILLING_ISSUE',
|
||||
store: 'app_store',
|
||||
});
|
||||
|
||||
const rec = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
select: { status: true },
|
||||
});
|
||||
const { canceledCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{ status: rec?.status, canceledCount },
|
||||
'should retain past_due record and NOT emit canceled event'
|
||||
);
|
||||
});
|
||||
|
||||
test('should block checkout when an existing subscription of the same plan is active', async t => {
|
||||
const { module, db } = t.context;
|
||||
|
||||
const manager = module.get(UserSubscriptionManager);
|
||||
|
||||
{
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
manager.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: null,
|
||||
},
|
||||
{
|
||||
successCallbackLink: '/',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
},
|
||||
{ user: { id: user.id, email: user.email } }
|
||||
),
|
||||
{ instanceOf: ManagedByAppStoreOrPlay }
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
await db.subscription.update({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
data: { provider: 'stripe' },
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
manager.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: null,
|
||||
},
|
||||
{
|
||||
successCallbackLink: '/',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
},
|
||||
{ user: { id: user.id, email: user.email } }
|
||||
),
|
||||
{ instanceOf: SubscriptionAlreadyExists }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
provider: 'stripe',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-07-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Monthly',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_conflict',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
});
|
||||
|
||||
const rcRec = await db.subscription.findFirst({
|
||||
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
|
||||
});
|
||||
const { activatedCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{ hasRCRecord: !!rcRec, activatedCount },
|
||||
'should skip RC upsert when Stripe active already exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
|
||||
const { db, service } = t.context;
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'monthly',
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// local helper used multiple times within this test
|
||||
const expectManaged = async (fn: () => Promise<any>) =>
|
||||
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
|
||||
|
||||
await expectManaged(() =>
|
||||
service.cancelSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
|
||||
);
|
||||
|
||||
await expectManaged(() =>
|
||||
service.resumeSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
|
||||
);
|
||||
|
||||
await expectManaged(() =>
|
||||
service.updateSubscriptionRecurring(
|
||||
{ plan: SubscriptionPlan.Pro, userId: user.id },
|
||||
SubscriptionRecurring.Yearly
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
|
||||
const { webhook, collectEvents, mockSub } = t.context;
|
||||
|
||||
const subscriber = mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'play_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await webhook.syncAppUser(user.id);
|
||||
const { activatedCount, canceledCount } = collectEvents();
|
||||
const subscriberCount = subscriber.getCalls()?.length || 0;
|
||||
|
||||
t.snapshot(
|
||||
{ subscriberCount, activatedCount, canceledCount },
|
||||
'should reconcile and fix missing or out-of-order states for revenuecat records'
|
||||
);
|
||||
});
|
||||
|
||||
test('should treat refund as early expiration and revoke immediately', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
provider: 'revenuecat',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: false,
|
||||
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-01-15T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Monthly',
|
||||
store: 'app_store',
|
||||
willRenew: false,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_refund',
|
||||
type: 'CANCELLATION',
|
||||
store: 'app_store',
|
||||
});
|
||||
|
||||
const count = await db.subscription.count({
|
||||
where: { targetId: user.id, plan: 'pro' },
|
||||
});
|
||||
const { canceledCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{ finalDBCount: count, canceledCount },
|
||||
'should delete record and emit canceled on refund'
|
||||
);
|
||||
});
|
||||
|
||||
test('should ignore non-whitelisted productId and not write to DB', async t => {
|
||||
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||
mockSub([
|
||||
{
|
||||
identifier: 'Weird',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
productId: 'unknown.sku',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
]);
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_unknown',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
});
|
||||
const dbCount = await db.subscription.count({ where: { targetId: user.id } });
|
||||
const { activatedCount, canceledCount } = collectEvents();
|
||||
t.snapshot(
|
||||
{ dbCount, activatedCount, canceledCount },
|
||||
'should ignore non-whitelisted productId and not write to DB'
|
||||
);
|
||||
});
|
||||
|
||||
test('should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)', async t => {
|
||||
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||
|
||||
mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2025-09-01T00:00:00.000Z'),
|
||||
productId: 'unknown.sku',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: 'P1M',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'AI',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-10-01T00:00:00.000Z'),
|
||||
productId: 'unknown.sku',
|
||||
store: 'play_store',
|
||||
willRenew: true,
|
||||
duration: 'P1Y',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-02-01T00:00:00.000Z'),
|
||||
productId: 'unknown.sku',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: 'P3M', // not supported -> ignore
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// pro monthly via fallback
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_fb1',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
});
|
||||
const r1 = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
select: { plan: true, recurring: true, provider: true },
|
||||
});
|
||||
const s1 = collectEvents();
|
||||
|
||||
// ai yearly via fallback
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_fb2',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'play_store',
|
||||
});
|
||||
const r2 = await db.subscription.findUnique({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
|
||||
select: { plan: true, recurring: true, provider: true },
|
||||
});
|
||||
const s2 = collectEvents();
|
||||
|
||||
// unsupported duration ignored
|
||||
await triggerWebhook(user.id, {
|
||||
id: 'evt_fb3',
|
||||
type: 'INITIAL_PURCHASE',
|
||||
store: 'app_store',
|
||||
});
|
||||
const count = await db.subscription.count({ where: { targetId: user.id } });
|
||||
const s3 = collectEvents();
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
proViaFallback: r1,
|
||||
aiViaFallback: r2,
|
||||
totalCount: count,
|
||||
eventsCounts: {
|
||||
afterFirst: { a: s1.activatedCount, c: s1.canceledCount },
|
||||
afterSecond: { a: s2.activatedCount, c: s2.canceledCount },
|
||||
afterThird: { a: s3.activatedCount, c: s3.canceledCount },
|
||||
},
|
||||
},
|
||||
'should map via entitlement+duration fallback and ignore unsupported durations'
|
||||
);
|
||||
});
|
||||
|
||||
test('should not dispatch webhook event when authorization header is missing or mismatched', async t => {
|
||||
const { controller, event } = t.context;
|
||||
const before = event.emitAsync.getCalls()?.length || 0;
|
||||
const e = { id: '42', type: 'INITIAL_PURCHASE', app_user_id: user.id };
|
||||
await controller.handleWebhook({ body: { event: e } } as any, undefined);
|
||||
const after = event.emitAsync.getCalls()?.length || 0;
|
||||
t.is(after - before, 0, 'should not emit event');
|
||||
});
|
||||
@@ -192,8 +192,10 @@ test.before(async t => {
|
||||
payment: {
|
||||
enabled: true,
|
||||
showLifetimePrice: true,
|
||||
apiKey: '1',
|
||||
webhookKey: '1',
|
||||
stripe: {
|
||||
apiKey: '1',
|
||||
webhookKey: '1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
|
||||
@@ -637,6 +637,11 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'invalid_input',
|
||||
message: 'Workspace id is required to update team subscription.',
|
||||
},
|
||||
managed_by_app_store_or_play: {
|
||||
type: 'action_forbidden',
|
||||
message:
|
||||
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
|
||||
},
|
||||
|
||||
// Copilot errors
|
||||
copilot_session_not_found: {
|
||||
|
||||
@@ -651,6 +651,12 @@ export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyErr
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagedByAppStoreOrPlay extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'managed_by_app_store_or_play', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'copilot_session_not_found', message);
|
||||
@@ -1189,6 +1195,7 @@ export enum ErrorNames {
|
||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
||||
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
||||
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
||||
MANAGED_BY_APP_STORE_OR_PLAY,
|
||||
COPILOT_SESSION_NOT_FOUND,
|
||||
COPILOT_SESSION_INVALID_INPUT,
|
||||
COPILOT_SESSION_DELETED,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { NodeEnv } from '../../env';
|
||||
import { Config } from '../config';
|
||||
import { mapAnyError } from '../nestjs/exception';
|
||||
import { GQLLoggerPlugin } from './logger-plugin';
|
||||
@@ -30,7 +31,7 @@ export type GraphqlContext = {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
useGlobalPrefix: true,
|
||||
playground: true,
|
||||
graphiql: env.NODE_ENV === NodeEnv.Development,
|
||||
sortSchema: true,
|
||||
autoSchemaFile: join(
|
||||
env.projectRoot,
|
||||
|
||||
@@ -50,6 +50,13 @@ export class AccessTokenResolver {
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@Query(() => [RevealedAccessToken])
|
||||
async revealedAccessTokens(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<RevealedAccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id, true);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface AuthConfig {
|
||||
ttr: number;
|
||||
};
|
||||
allowSignup: boolean;
|
||||
allowSignupForOauth: boolean;
|
||||
requireEmailDomainVerification: boolean;
|
||||
requireEmailVerification: boolean;
|
||||
passwordRequirements: ConfigItem<{
|
||||
@@ -27,6 +28,10 @@ defineModuleConfig('auth', {
|
||||
desc: 'Whether allow new registrations.',
|
||||
default: true,
|
||||
},
|
||||
allowSignupForOauth: {
|
||||
desc: 'Whether allow new registrations via configured oauth.',
|
||||
default: true,
|
||||
},
|
||||
requireEmailDomainVerification: {
|
||||
desc: 'Whether require email domain record verification before accessing restricted resources.',
|
||||
default: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ declare global {
|
||||
interface AppConfigSchema {
|
||||
mailer: {
|
||||
SMTP: {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -16,6 +17,7 @@ declare global {
|
||||
|
||||
fallbackDomains: ConfigItem<string[]>;
|
||||
fallbackSMTP: {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -28,6 +30,11 @@ declare global {
|
||||
}
|
||||
|
||||
defineModuleConfig('mailer', {
|
||||
'SMTP.name': {
|
||||
desc: 'Name of the email server (e.g. your domain name)',
|
||||
default: 'AFFiNE Server',
|
||||
env: 'MAILER_SERVERNAME',
|
||||
},
|
||||
'SMTP.host': {
|
||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||
default: '',
|
||||
@@ -64,6 +71,10 @@ defineModuleConfig('mailer', {
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
'fallbackSMTP.name': {
|
||||
desc: 'Name of the fallback email server (e.g. your domain name)',
|
||||
default: 'AFFiNE Server',
|
||||
},
|
||||
'fallbackSMTP.host': {
|
||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||
default: '',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user