Compare commits

..

15 Commits

Author SHA1 Message Date
EYHN
4b3ebd899b feat(ios): update js subscription api (#13678)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Added on-demand subscription refresh and state retrieval in the iOS
app, enabling up-to-date subscription status and billing information.
- Exposed lightweight runtime APIs to check and update subscription
state for improved account visibility.

- Chores
- Integrated shared GraphQL package and project references to support
subscription operations.
- Updated workspace configuration to include the common GraphQL module
for the iOS app.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 03:12:51 +00:00
DarkSky
b59c1f9e57 feat(server): update claude models (#13677)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Copilot now defaults to the updated Claude Sonnet 4.5 model across
experiences for improved responses.

* **Chores**
* Consolidated available Anthropic models, removing older Sonnet 3.x
variants and standardizing Sonnet 4/4.5 options.
* Updated configuration defaults and schema mappings to reference the
new Sonnet 4.5 model.

* **Tests**
* Updated unit and end-to-end tests to reference the new model to ensure
consistent behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 02:49:55 +00:00
Cats Juice
b44fdbce0c feat(component): virtual scroll emoji groups in emoji picker (#13671)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Revamped Emoji Picker: grouped browsing with sticky group headers,
footer navigation, and a new EmojiButton for quicker selection.
  - Recent emojis with persisted history and single-tap add.
- Programmatic group navigation and callbacks for sticky-group changes.

- Style
  - Updated scroll area paddings for emoji and icon pickers.
  - Enhanced group header background for better contrast.

- Refactor
- Simplified emoji picker internals for leaner, more responsive
rendering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 01:59:39 +00:00
Cats Juice
123d50a484 feat(core): open artifacts tools automatically (#13668)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The AI Artifact Tool now auto-opens its preview panel as soon as it
loads, giving immediate visibility without extra clicks.
* The preview initializes proactively and remains in sync as data
updates, streamlining the workflow and reducing setup friction.
* Improves first-use experience by ensuring the preview is ready and
visible on connection, enhancing responsiveness and clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 01:40:59 +00:00
DarkSky
2d1caff45c feat(server): refresh subscription (#13670)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an on-demand mutation to refresh the current user's
subscriptions, syncing with RevenueCat when applicable and handling
Stripe-only cases.
* Subscription variant normalization for clearer plan information and
consistent results.

* **Tests**
* Added tests for refresh behavior: empty state, RevenueCat-backed
multi-step sync, and Stripe-only scenarios.

* **Client**
* New client operation to invoke the refresh mutation and retrieve
updated subscription fields.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-29 12:35:18 +00:00
3720
8006812bc0 refactor(editor): new icon picker (#13658)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* In-tree icon picker for Callout blocks (emoji, app icons, images) with
popup UI and editor-wide extension/service.
* Callout toolbar adds background color presets, an icon-picker action,
and a destructive Delete action.

* **Refactor**
* Replaced legacy emoji workflow with icon-based rendering, updated
state, styling, and lifecycle for callouts.

* **Tests**
  * Updated callout E2E to reflect new default icon and picker behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
2025-09-29 11:06:14 +00:00
Lakr
8df7353722 chore(ios): iap paywall update (#13669)
This pull request introduces several improvements and refactors to the
iOS frontend, with a focus on the paywall system, configuration, and
developer experience. The most significant changes include dynamic
pricing updates for subscription packages, the introduction of a
centralized pricing configuration, and enhanced developer documentation
and settings for Claude Code. There are also minor fixes and
improvements to restore purchase flows, App Store syncing, and protocol
usage guidance.

**Paywall System Improvements**

* Subscription package pricing and display is now dynamically updated
based on App Store data, ensuring users see accurate, localized pricing
and descriptions. This includes new logic for calculating monthly prices
and updating package button text. (`ViewModel.swift`,
`ViewModel+Action.swift`, `SKUnit+Pro.swift`, `SKUnit+AI.swift`)
[[1]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0R83-R160)
[[2]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0L102-R199)
[[3]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL58-R73)
[[4]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL74-R94)
[[5]](diffhunk://#diff-ea535c02550f727587e74521da8fd90dec23cbe3c685f9c4aa4923ce0bbdb363L19-R35)
[[6]](diffhunk://#diff-a5fef660f959bbb52ce3f19bba8bfbd0bb00d66c9f18a20a998101b5df6c8f60L18-R22)
* Introduced a new `PricingConfiguration.swift` file to centralize
product identifiers, default selections, and display strings for
subscription products, improving maintainability and consistency.
(`PricingConfiguration.swift`, `SKUnit+Pro.swift`, `SKUnit+AI.swift`)
[[1]](diffhunk://#diff-de4566ecd5bd29f36737ae5e5904345bd1a5c8f0a73140c3ebba41856bae3e86R1-R54)
[[2]](diffhunk://#diff-ea535c02550f727587e74521da8fd90dec23cbe3c685f9c4aa4923ce0bbdb363L19-R35)
[[3]](diffhunk://#diff-a5fef660f959bbb52ce3f19bba8bfbd0bb00d66c9f18a20a998101b5df6c8f60L18-R22)

**Developer Experience and Documentation**

* Added `AGENTS.md` to provide comprehensive guidance for Claude Code
and developers, including project overview, build commands,
architecture, native bridge APIs, Swift code style, and dependencies.
(`AGENTS.md`)
* Added a local settings file (`settings.local.json`) to configure
permissions for Claude Code, allowing specific Bash commands for iOS
builds. (`settings.local.json`)
* Updated Swift architecture guidelines to discourage protocol-oriented
design unless necessary, favoring dependency injection and composition.
(`AGENTS.md`)

**User Experience Improvements**

* The purchase footer now includes an underline for "Restore Purchase"
and a clear message about subscription auto-renewal and cancellation
flexibility. (`PurchaseFooterView.swift`)
* Improved restore purchase and App Store sync logic to better handle
user sign-in prompts and error handling. (`ViewModel+Action.swift`,
`Store.swift`)
[[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL45-R49)
[[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL58-R73)
[[3]](diffhunk://#diff-9f18fbbf15591c56380ce46358089c663ce4440f596db8577de76dc6cd306b54R26-R28)

**Minor Fixes and Refactoring**

* Made `docId` in `DeleteSessionInput` optional to match GraphQL schema
expectations. (`DeleteSessionInput.graphql.swift`)
[[1]](diffhunk://#diff-347e5828e46f435d7d7090a3e3eb7445af8c616f663e8711cd832f385f870a9bL14-R14)
[[2]](diffhunk://#diff-347e5828e46f435d7d7090a3e3eb7445af8c616f663e8711cd832f385f870a9bL25-R25)
* Minor formatting and dependency list updates in `Package.swift`.
(`Package.swift`)
* Fixed concurrency usage in event streaming for chat manager.
(`ChatManager+Stream.swift`)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* New Features
* Paywall options now dynamically reflect product data with clearer
labels and monthly price calculations.
* Added an auto‑renewal note (“cancel anytime”) and underlined “Restore
Purchase” for better clarity.

* Refactor
* Improved purchase/restore flow reliability and UI updates for a
smoother experience.

* Documentation
* Added a comprehensive development guide and updated architecture/style
guidance for iOS.

* Chores
* Introduced local build permissions configuration for iOS development.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-29 09:18:47 +00:00
Cats Juice
12daefdf54 fix(core): prevent emoji being clipped and adjust icon-picker default color (#13664)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Style
- Updated icon picker to use the primary icon color, improving visual
consistency (including SVG icons).
- Improved emoji rendering in the document icon picker by applying an
emoji-specific font for elements marked as emoji, matching existing size
and line-height.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-29 02:25:31 +00:00
Wu Yue
9f94d5c216 feat(core): support ai chat delete action (#13655)
<img width="411" height="205" alt="截屏2025-09-26 10 58 39"
src="https://github.com/user-attachments/assets/c3bce144-7847-4794-b766-5a3777cbc00d"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Delete icon added to AI session history with tooltip and confirmation
prompt; deleting current session opens a new session.
- Session deletion wired end-to-end (toolbar → provider → backend) and
shows notifications.

- Improvements
- Cleanup now supports deleting sessions with or without a document ID
(document-specific or workspace-wide).
- UI tweaks for cleaner session item layout and safer click handling
(delete won’t trigger item click).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-27 11:58:58 +00:00
Lakr
8d6f7047c2 fix(ios): build project (#13656)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Access Tokens screen now shows revealed access tokens, including the
token value where available.

- Chores
  - Updated iOS Paywall package to use Swift tools version 5.9.
  - Removed an unused internal iOS package to streamline the app.
- Aligned access token data model to the latest backend schema for
improved consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 10:10:30 +00:00
github-actions[bot]
a92894990d chore(i18n): sync translations (#13651)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-09-26 09:13:17 +00:00
L-Sun
6af1f6ab8d fix(core): infinitied loop (#13653)
Fix #13649 

#### PR Dependency Tree


* **PR #13653** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Streamlined internal async handling to depend only on specified
inputs, reducing unnecessary updates and improving responsiveness.
  * Preserved existing error handling for async operations.

* **Chores**
* Adjusted lint configuration/comments to align with the updated
dependency strategy, reducing false-positive warnings.

No user-facing UI changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 08:59:33 +00:00
Rokas
e7f76c1737 chore: update mermaid (#13510)
https://github.com/toeverything/AFFiNE/issues/13509

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Upgraded Mermaid dependency to v11.1.0 in the frontend core package.

* **Impact**
* Improved diagram rendering and compatibility with newer Mermaid
syntax.
* Potential performance and security improvements from upstream updates.
  * No UI changes expected; existing diagrams should continue to work.
  * Please verify critical diagram views for any rendering differences.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-09-26 07:40:42 +00:00
Xun Sun
5b52349b96 feat: implement textAlign property (#11790)
for paragraph blocks, image blocks, list blocks, and table blocks

Should fix #8617 and #11254.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added text alignment options (left, center, right) for paragraph,
list, image, note, and table blocks.
- Introduced alignment controls in toolbars and slash menus for easier
formatting.
- Enabled keyboard shortcuts for quick text alignment changes (supports
Mac and Windows).
- **Localization**
- Added English, Simplified Chinese, and Traditional Chinese
translations for new alignment commands and shortcuts.
- **Style**
  - Blocks now visually reflect selected text alignment in their layout.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
2025-09-26 07:23:28 +00:00
renovate[bot]
bf87178c26 chore: bump up @googleapis/androidpublisher version to v31 (#13633)
Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs
from Renovate will soon appear from 'Mend'. Learn more
[here](https://redirect.github.com/renovatebot/renovate/discussions/37842).

This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[@googleapis/androidpublisher](https://redirect.github.com/googleapis/google-api-nodejs-client)
| [`^28.0.0` ->
`^31.0.0`](https://renovatebot.com/diffs/npm/@googleapis%2fandroidpublisher/28.0.1/31.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@googleapis%2fandroidpublisher/31.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@googleapis%2fandroidpublisher/28.0.1/31.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>googleapis/google-api-nodejs-client
(@&#8203;googleapis/androidpublisher)</summary>

###
[`v31.0.0`](https://redirect.github.com/googleapis/google-api-nodejs-client/blob/HEAD/CHANGELOG.md#13100-2024-01-05)

[Compare
Source](https://redirect.github.com/googleapis/google-api-nodejs-client/compare/v30.0.0...v31.0.0)

##### ⚠ BREAKING CHANGES

- **serviceconsumermanagement:** This release has breaking changes.
- **playintegrity:** This release has breaking changes.

##### Features

- **chromepolicy:** update the API
([8429e3c](8429e3c9d6))
- **chromeuxreport:** update the API
([6d52abb](6d52abb902))
- **customsearch:** update the API
([1169e4c](1169e4c607))
- **dialogflow:** update the API
([4b1e073](4b1e0734d9))
- **displayvideo:** update the API
([45b61b5](45b61b5d20))
- **oslogin:** update the API
([cfc90e7](cfc90e7c9c))
- **playintegrity:** update the API
([767af5f](767af5f12e))
- regenerate index files
([4246fd1](4246fd1c64))
- **serviceconsumermanagement:** update the API
([a68206a](a68206a211))

##### Bug Fixes

- **accesscontextmanager:** update the API
([845c716](845c7168e9))
- **admin:** update the API
([4664d6b](4664d6bb4c))
- **backupdr:** update the API
([19b0192](19b019219b))
- **calendar:** update the API
([0ca9bbc](0ca9bbc4e4))
- **cloudbuild:** update the API
([31158a2](31158a226c))
- **cloudidentity:** update the API
([22610b3](22610b3d15))
- **cloudprofiler:** update the API
([2c5cbc4](2c5cbc4299))
- **cloudtrace:** update the API
([2a811d5](2a811d5fe8))
- **iap:** update the API
([ec596c1](ec596c1b87))
- **playdeveloperreporting:** update the API
([7181840](7181840daf))
- **servicenetworking:** update the API
([50c7dbd](50c7dbd323))
- **spanner:** update the API
([0e40d67](0e40d67436))

###
[`v30.0.0`](https://redirect.github.com/googleapis/google-api-nodejs-client/blob/HEAD/CHANGELOG.md#13000-2024-01-03)

##### ⚠ BREAKING CHANGES

- **networksecurity:** This release has breaking changes.
- **metastore:** This release has breaking changes.
- **gmail:** This release has breaking changes.
- **gkehub:** This release has breaking changes.
- **drivelabels:** This release has breaking changes.
- **dialogflow:** This release has breaking changes.
- **datacatalog:** This release has breaking changes.
- **content:** This release has breaking changes.
- **connectors:** This release has breaking changes.
- **cloudbuild:** This release has breaking changes.
- **chat:** This release has breaking changes.
- **batch:** This release has breaking changes.
- **artifactregistry:** This release has breaking changes.
- **aiplatform:** This release has breaking changes.
- **advisorynotifications:** This release has breaking changes.

##### Features

- **accesscontextmanager:** update the API
([26d496e](26d496e416))
- **adexchangebuyer2:** update the API
([31c0066](31c006606f))
- **admin:** update the API
([79ce913](79ce9133d7))
- **advisorynotifications:** update the API
([0f44091](0f440919dd))
- **aiplatform:** update the API
([66739ce](66739ce624))
- **alloydb:** update the API
([590f835](590f835773))
- **analyticsdata:** update the API
([25d0b67](25d0b6763e))
- **analyticshub:** update the API
([8279edf](8279edf154))
- **androidpublisher:** update the API
([c6d69a0](c6d69a049d))
- **artifactregistry:** update the API
([6fda22c](6fda22c487))
- **assuredworkloads:** update the API
([41debeb](41debeba59))
- **backupdr:** update the API
([1018945](1018945770))
- **batch:** update the API
([9ef21e0](9ef21e0459))
- **bigquery:** update the API
([f1deeab](f1deeabbb0))
- **blockchainnodeengine:** update the API
([07ac2e7](07ac2e721d))
- **chat:** update the API
([88428f0](88428f0d91))
- **checks:** update the API
([2d78a72](2d78a72c71))
- **cloudbilling:** update the API
([857a51e](857a51e47b))
- **cloudbuild:** update the API
([ddf4c10](ddf4c10cf4))
- **cloudchannel:** update the API
([aecac6b](aecac6be45))
- **clouddeploy:** update the API
([62d7fd6](62d7fd6070))
- **cloudfunctions:** update the API
([c5aae9a](c5aae9a7cf))
- **cloudprofiler:** update the API
([2933bff](2933bff415))
- **cloudsupport:** update the API
([feb88b5](feb88b5521))
- **composer:** update the API
([53b83d6](53b83d65b1))
- **compute:** update the API
([ffbf00b](ffbf00b1c1))
- **connectors:** update the API
([f433bd6](f433bd6284))
- **container:** update the API
([cac432f](cac432f882))
- **content:** update the API
([c0dd4c0](c0dd4c0bc2))
- **datacatalog:** update the API
([a939d7e](a939d7eaf2))
- **dataflow:** update the API
([9721cda](9721cda955))
- **dataform:** update the API
([d2bfeab](d2bfeabcbe))
- **datafusion:** update the API
([413c94e](413c94e5db))
- **dataplex:** update the API
([8da4b12](8da4b128b1))
- **dataproc:** update the API
([5a60626](5a606262b3))
- **dialogflow:** update the API
([8829da4](8829da4a7e))
- **discoveryengine:** update the API
([567c02d](567c02d288))
- **dlp:** update the API
([7cbdc6a](7cbdc6aaf4))
- **dns:** update the API
([f783244](f7832440a5))
- **documentai:** update the API
([01cc7b5](01cc7b5994))
- **drivelabels:** update the API
([50a1b75](50a1b75751))
- **drive:** update the API
([c07f193](c07f193c33))
- **file:** update the API
([324d0f6](324d0f69b3))
- **firebaseappcheck:** update the API
([c8fb050](c8fb050246))
- **firebaserules:** update the API
([2a44570](2a445705f0))
- **gkehub:** update the API
([044e086](044e0861ed))
- **gkeonprem:** update the API
([6c9398e](6c9398e54e))
- **gmail:** update the API
([c7698bd](c7698bda1d))
- **healthcare:** update the API
([d34ee61](d34ee618f9))
- **metastore:** update the API
([6887f67](6887f67506))
- **migrationcenter:** update the API
([e890439](e890439ac6))
- **monitoring:** update the API
([738848d](738848dcb6))
- **networkmanagement:** update the API
([d8a3556](d8a35563fc))
- **networksecurity:** update the API
([166232f](166232fe14))
- **networkservices:** update the API
([076de17](076de17ce5))
- **notebooks:** update the API
([a08d104](a08d104800))
- **orgpolicy:** update the API
([5c8f8c7](5c8f8c727c))
- **oslogin:** update the API
([f1475c5](f1475c544f))
- **paymentsresellersubscription:** update the API
([d79cf5a](d79cf5a6cf))
- **playdeveloperreporting:** update the API
([6ef5718](6ef5718e6e))
- **policysimulator:** update the API
([58e6545](58e654547c))
- **prod\_tt\_sasportal:** update the API
([99b92fe](99b92fe5d9))
- **pubsub:** update the API
([f17fac3](f17fac34c0))
- **recaptchaenterprise:** update the API
([7952baa](7952baabbe))
- **recommender:** update the API
([76b9501](76b9501327))
- **redis:** update the API
([fd4636b](fd4636b1c9))
- regenerate index files
([33f2d78](33f2d78b2c))
- **retail:** update the API
([0aa095b](0aa095b51a))
- **run:** update the API
([48a19bf](48a19bf416))
- **sasportal:** update the API
([2459cce](2459cce1e4))
- **script:** update the API
([0520e5e](0520e5efd5))
- **securitycenter:** update the API
([74c634a](74c634a34a))
- **serviceconsumermanagement:** update the API
([0552119](05521190fe))
- **servicemanagement:** update the API
([429940b](429940b1b4))
- **servicenetworking:** update the API
([42a1422](42a142249e))
- **serviceusage:** update the API
([c2ad070](c2ad070ce4))
- **storage:** update the API
([c0609c9](c0609c901b))
- **translate:** update the API
([77a0522](77a05229d2))
- **vault:** update the API
([db163fd](db163fd3b3))
- **vision:** update the API
([77a0a91](77a0a9136e))
- **vpcaccess:** update the API
([8db5275](8db52757e6))
- **workloadmanager:** update the API
([4c49597](4c4959752e))
- **workstations:** update the API
([174cd20](174cd20129))

##### Bug Fixes

- **accessapproval:** update the API
([227915d](227915d92f))
- **analyticsadmin:** update the API
([b858170](b858170642))
- **androidmanagement:** update the API
([35f8862](35f886254c))
- **apphub:** update the API
([e5a7c92](e5a7c92a2a))
- **binaryauthorization:** update the API
([7f20317](7f20317264))
- **calendar:** update the API
([e6ba462](e6ba462408))
- **chromepolicy:** update the API
([a5a5351](a5a5351998))
- **classroom:** update the API
([9d2ed12](9d2ed12202))
- **cloudasset:** update the API
([20a91d5](20a91d5cb6))
- **cloudidentity:** update the API
([5155e11](5155e11cd2))
- **cloudkms:** update the API
([90bab2c](90bab2c738))
- **cloudscheduler:** update the API
([2c7b902](2c7b90229a))
- **cloudtasks:** update the API
([a8d66db](a8d66db055))
- **contactcenterinsights:** update the API
([828c5d3](828c5d3e08))
- **datamigration:** update the API
([56a65a8](56a65a8590))
- **deploymentmanager:** update the API
([b48abef](b48abef098))
- **displayvideo:** update the API
([299cf97](299cf97f91))
- **firebaseappdistribution:** update the API
([b102fcc](b102fccab5))
- **gkebackup:** update the API
([30ca612](30ca612728))
- **iam:** update the API
([4e12124](4e121245a3))
- **iap:** update the API
([65c644e](65c644e9de))
- **language:** update the API
([77252e1](77252e1b9c))
- **logging:** update the API
([1b4dc67](1b4dc6732c))
- **mybusinessbusinessinformation:** update the API
([5e4c0fe](5e4c0fe093))
- **places:** update the API
([6bbdf72](6bbdf72e3e))
- **policytroubleshooter:** update the API
([ad18f3b](ad18f3b0f6))
- **privateca:** update the API
([b230959](b23095912e))
- **runtimeconfig:** update the API
([0dfe961](0dfe9610eb))
- **secretmanager:** update the API
([a202268](a202268db9))
- **servicedirectory:** update the API
([ddc06a2](ddc06a219b))
- **sourcerepo:** update the API
([1965102](19651026ae))
- **spanner:** update the API
([ce99980](ce99980e71))
- **sqladmin:** update the API
([de59e8d](de59e8dd22))
- **storagetransfer:** update the API
([d6081de](d6081dea7d))
- **videointelligence:** update the API
([9d377f5](9d377f5e3e))
- **vmmigration:** update the API
([68a1d5f](68a1d5fede))
- **walletobjects:** update the API
([920ddc7](920ddc780c))
- **workflowexecutions:** update the API
([6553987](6553987f65))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-09-26 07:18:12 +00:00
96 changed files with 2508 additions and 1153 deletions

View File

@@ -684,7 +684,7 @@
},
"scenarios": {
"type": "object",
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
"default": {
"override_enabled": false,
"scenarios": {
@@ -693,7 +693,7 @@
"embedding": "gemini-embedding-001",
"image": "gpt-image-1",
"rerank": "gpt-4.1",
"coding": "claude-sonnet-4@20250514",
"coding": "claude-sonnet-4-5@20250929",
"complex_text_generation": "gpt-4o-2024-08-06",
"quick_decision_making": "gpt-5-mini",
"quick_text_generation": "gemini-2.5-flash",

View File

@@ -10,7 +10,6 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@affine/component": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
@@ -23,6 +22,7 @@
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emotion/css": "^11.13.5",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",

View File

@@ -0,0 +1,56 @@
import { css } from '@emotion/css';
export const calloutHostStyles = css({
display: 'block',
margin: '8px 0',
});
export const calloutBlockContainerStyles = css({
display: 'flex',
alignItems: 'flex-start',
padding: '5px 10px',
borderRadius: '8px',
});
export const calloutEmojiContainerStyles = css({
userSelect: 'none',
fontSize: '1.2em',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '10px',
marginBottom: '10px',
flexShrink: 0,
position: 'relative',
});
export const calloutEmojiStyles = css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':hover': {
cursor: 'pointer',
opacity: 0.7,
},
});
export const calloutChildrenStyles = css({
flex: 1,
minWidth: 0,
paddingLeft: '10px',
});
export const iconPickerContainerStyles = css({
position: 'absolute',
top: '100%',
left: 0,
zIndex: 1000,
background: 'white',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
width: '390px',
height: '400px',
});

View File

@@ -1,6 +1,10 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import {
createPopup,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import { type CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model';
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 {
@@ -8,15 +12,24 @@ import {
type IconData,
IconPickerServiceIdentifier,
IconType,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import * as icons from '@blocksuite/icons/lit';
import type { BlockComponent } from '@blocksuite/std';
import { type Signal, signal } from '@preact/signals-core';
import { type Signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import type { TemplateResult } from 'lit';
import { css, html } from 'lit';
import { html } from 'lit';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import {
calloutBlockContainerStyles,
calloutChildrenStyles,
calloutEmojiContainerStyles,
calloutEmojiStyles,
calloutHostStyles,
} from './callout-block-styles.js';
import { IconPickerWrapper } from './icon-picker-wrapper.js';
// Copy of renderUniLit and UniLit from affine-data-view
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
uni: UniComponent<Props, Expose> | undefined,
@@ -35,9 +48,8 @@ export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
></uni-lit>`;
};
const getIcon = (icon?: IconData) => {
console.log(icon);
if (!icon) {
return '💡';
return null;
}
if (icon.type === IconType.Emoji) {
return icon.unicode;
@@ -47,85 +59,35 @@ const getIcon = (icon?: IconData) => {
icons as Record<string, (props: { style: string }) => TemplateResult>
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
}
return '💡';
return null;
};
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
static override styles = css`
:host {
display: block;
margin: 8px 0;
}
private _popupCloseHandler: (() => void) | null = null;
.affine-callout-block-container {
display: flex;
align-items: flex-start;
padding: 5px 10px;
border-radius: 8px;
}
.affine-callout-emoji-container {
user-select: none;
font-size: 1.2em;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
margin-bottom: 10px;
flex-shrink: 0;
position: relative;
}
.affine-callout-emoji {
display: flex;
align-items: center;
justify-content: center;
}
.affine-callout-emoji:hover {
cursor: pointer;
opacity: 0.7;
}
.affine-callout-children {
flex: 1;
min-width: 0;
padding-left: 10px;
}
.icon-picker-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 300px;
height: 400px;
}
`;
private readonly showIconPicker$ = signal(false);
private _closeEmojiMenu() {
this.showIconPicker$.value = false;
override connectedCallback() {
super.connectedCallback();
this.classList.add(calloutHostStyles);
}
private _toggleIconPicker() {
this.showIconPicker$.value = !this.showIconPicker$.value;
private _closeIconPicker() {
if (this._popupCloseHandler) {
this._popupCloseHandler();
this._popupCloseHandler = null;
}
}
private _renderIconPicker() {
if (!this.showIconPicker$.value) {
return html``;
private _toggleIconPicker(event: MouseEvent) {
// If popup is already open, close it
if (this._popupCloseHandler) {
this._closeIconPicker();
return;
}
// Get IconPickerService from the framework
const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier);
if (!iconPickerService) {
console.warn('IconPickerService not found');
return html``;
return;
}
// Get the uni-component from the service
@@ -135,23 +97,31 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
const props = {
onSelect: (iconData?: IconData) => {
this.model.props.icon$.value = iconData;
this._closeEmojiMenu(); // Close the picker after selection
this._closeIconPicker(); // Close the picker after selection
},
onClose: () => {
this._closeEmojiMenu();
this._closeIconPicker();
},
};
return html`
<div
@click=${(e: MouseEvent) => {
e.stopPropagation();
}}
class="icon-picker-container"
>
${renderUniLit(iconPickerComponent, props)}
</div>
`;
// Create IconPickerWrapper instance
const wrapper = new IconPickerWrapper();
wrapper.iconPickerComponent = iconPickerComponent;
wrapper.props = props;
wrapper.style.position = 'absolute';
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
wrapper.style.borderRadius = '8px';
// Create popup target from the clicked element
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
// Create popup
this._popupCloseHandler = createPopup(target, wrapper, {
onClose: () => {
this._popupCloseHandler = null;
},
});
}
private readonly _handleBlockClick = (event: MouseEvent) => {
@@ -164,6 +134,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
return;
}
// If there's no icon, open icon picker on click
const icon = this.model.props.icon$.value;
if (!icon) {
this._toggleIconPicker(event);
return;
}
// Only handle clicks when there are no children
if (this.model.children.length > 0) {
return;
@@ -206,33 +183,35 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
override renderBlock() {
const icon = this.model.props.icon$.value;
const background = this.model.props.background$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value;
const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string>
)[backgroundColorName ?? ''];
const themeProvider = this.std.get(ThemeProvider);
const theme = themeProvider.theme$.value;
const backgroundColor = themeProvider.generateColorProperty(
background || DefaultTheme.NoteBackgroundColorMap.White,
DefaultTheme.NoteBackgroundColorMap.White,
theme
);
const iconContent = getIcon(icon);
return html`
<div
class="affine-callout-block-container"
class="${calloutBlockContainerStyles}"
@click=${this._handleBlockClick}
style=${styleMap({
backgroundColor: backgroundColor,
backgroundColor: backgroundColor ?? 'transparent',
})}
>
<div
@click=${this._toggleIconPicker}
contenteditable="false"
class="affine-callout-emoji-container"
>
<span class="affine-callout-emoji">${getIcon(icon)}</span>
${this._renderIconPicker()}
</div>
<div class="affine-callout-children">
${iconContent
? html`
<div
@click=${this._toggleIconPicker}
contenteditable="false"
class="${calloutEmojiContainerStyles}"
>
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
>${iconContent}</span
>
</div>
`
: ''}
<div class="${calloutChildrenStyles}">
${this.renderChildren(this.model)}
</div>
</div>

View File

@@ -1,18 +1,28 @@
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model';
import {
createPopup,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { CalloutBlockModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type IconData,
IconPickerServiceIdentifier,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { PaletteIcon } from '@blocksuite/icons/lit';
import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { IconPickerWrapper } from '../icon-picker-wrapper.js';
const colors = [
'default',
'red',
@@ -38,27 +48,7 @@ const backgroundColorAction = {
if (!model) return null;
const updateBackground = (color: string) => {
// Map text highlight colors to note background colors
const colorMap: Record<
string,
keyof typeof DefaultTheme.NoteBackgroundColorMap | null
> = {
default: null,
red: 'Red',
orange: 'Orange',
yellow: 'Yellow',
green: 'Green',
teal: 'Green', // Map teal to green as it's not available in NoteBackgroundColorMap
blue: 'Blue',
purple: 'Purple',
grey: 'White', // Map grey to white as it's the closest available
};
const mappedColor = colorMap[color];
const backgroundValue = mappedColor
? DefaultTheme.NoteBackgroundColorMap[mappedColor]
: null;
ctx.store.updateBlock(model, { background: backgroundValue });
ctx.store.updateBlock(model, { backgroundColorName: color });
};
return html`
@@ -103,12 +93,102 @@ const backgroundColorAction = {
},
} satisfies ToolbarAction;
const iconPickerAction = {
id: 'icon-picker',
label: 'Icon Picker',
tooltip: 'Change icon',
icon: SmileIcon(),
run() {
// This will be handled by the content function
},
content(ctx) {
const model = ctx.getCurrentModelByType(CalloutBlockModel);
if (!model) return null;
const handleIconPickerClick = (event: MouseEvent) => {
// Get IconPickerService from the framework
const iconPickerService = ctx.std.getOptional(
IconPickerServiceIdentifier
);
if (!iconPickerService) {
console.warn('IconPickerService not found');
return;
}
// Get the uni-component from the service
const iconPickerComponent = iconPickerService.iconPickerComponent;
// Create props for the icon picker
const props = {
onSelect: (iconData?: IconData) => {
// When iconData is undefined (delete icon), set icon to undefined
ctx.store.updateBlock(model, { icon: iconData });
closeHandler(); // Close the picker after selection
},
onClose: () => {
closeHandler();
},
};
// Create IconPickerWrapper instance
const wrapper = new IconPickerWrapper();
wrapper.iconPickerComponent = iconPickerComponent;
wrapper.props = props;
wrapper.style.position = 'absolute';
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
wrapper.style.borderRadius = '8px';
// Create popup target from the clicked element
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
// Create popup
const closeHandler = createPopup(target, wrapper, {
onClose: () => {
// Cleanup if needed
},
});
};
return html`
<editor-icon-button
aria-label="icon-picker"
.tooltip=${'Change Icon'}
@click=${handleIconPickerClick}
>
${SmileIcon()} ${EditorChevronDown}
</editor-icon-button>
`;
},
} satisfies ToolbarAction;
const builtinToolbarConfig = {
actions: [
{
id: 'style',
actions: [backgroundColorAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'icon',
actions: [iconPickerAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(CalloutBlockModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
} satisfies ToolbarAction,
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,11 +1,14 @@
import { CalloutBlockComponent } from './callout-block';
import { IconPickerWrapper } from './icon-picker-wrapper';
export function effects() {
customElements.define('affine-callout', CalloutBlockComponent);
customElements.define('icon-picker-wrapper', IconPickerWrapper);
}
declare global {
interface HTMLElementTagNameMap {
'affine-callout': CalloutBlockComponent;
'icon-picker-wrapper': IconPickerWrapper;
}
}

View File

@@ -0,0 +1,52 @@
import type { IconData } from '@blocksuite/affine-shared/services';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/std';
import { type Signal } from '@preact/signals-core';
import { html, type TemplateResult } from 'lit';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
// Copy of renderUniLit from callout-block.ts
const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
uni: UniComponent<Props, Expose> | undefined,
props?: Props,
options?: {
ref?: Signal<Expose | undefined>;
style?: Readonly<StyleInfo>;
class?: string;
}
): TemplateResult => {
return html` <uni-lit
.uni="${uni}"
.props="${props}"
.ref="${options?.ref}"
style=${options?.style ? styleMap(options?.style) : ''}
></uni-lit>`;
};
export interface IconPickerWrapperProps {
onSelect?: (iconData?: IconData) => void;
onClose?: () => void;
}
export class IconPickerWrapper extends ShadowlessElement {
iconPickerComponent?: UniComponent<IconPickerWrapperProps, any>;
props?: IconPickerWrapperProps;
constructor() {
super();
}
override render() {
if (!this.iconPickerComponent) {
return html``;
}
return renderUniLit(this.iconPickerComponent, this.props);
}
}
declare global {
interface HTMLElementTagNameMap {
'icon-picker-wrapper': IconPickerWrapper;
}
}

View File

@@ -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,
},
{

View File

@@ -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

View File

@@ -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,

View File

@@ -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';

View File

@@ -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();
};

View File

@@ -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']

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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',
})}
>

View File

@@ -6,22 +6,20 @@ import {
type Text,
} from '@blocksuite/store';
import type { Color } from '../../themes/index.js';
import { DefaultTheme } from '../../themes/index.js';
import type { BlockMeta } from '../../utils/types';
export type CalloutProps = {
icon?: IconData;
text: Text;
background: Color;
backgroundColorName?: string;
} & BlockMeta;
export const CalloutBlockSchema = defineBlockSchema({
flavour: 'affine:callout',
props: (internal): CalloutProps => ({
icon: undefined,
icon: { type: 'emoji', unicode: '💡' } as IconData,
text: internal.Text(),
background: DefaultTheme.NoteBackgroundColorMap.White,
backgroundColorName: 'grey',
'meta:createdAt': undefined,
'meta:updatedAt': undefined,
'meta:createdBy': undefined,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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(),
},
];

View File

@@ -1,3 +1,4 @@
export { type TextAlignConfig, textAlignConfigs } from './align';
export { type TextConversionConfig, textConversionConfigs } from './conversion';
export {
asyncGetRichText,

View File

@@ -1,6 +1,5 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { createIdentifier } from '@blocksuite/global/di';
import type { TemplateResult } from 'lit';
export enum IconType {
Emoji = 'emoji',
AffineIcon = 'affine-icon',
@@ -22,15 +21,8 @@ export type IconData =
blob: Blob;
};
export interface IconPickerOptions {
onSelect?: (icon: IconData) => void;
onClose?: () => void;
currentIcon?: IconData;
}
export interface IconPickerService {
iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>;
renderIconPicker(options: IconPickerOptions): TemplateResult;
}
export const IconPickerServiceIdentifier =

View File

@@ -82,7 +82,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.0.0",
"msw": "^2.6.8",
"oxlint": "^1.15.0",
"oxlint": "~1.18.0",
"prettier": "^3.4.2",
"semver": "^7.6.3",
"serve": "^14.2.4",

View File

@@ -473,7 +473,7 @@ Generated by [AVA](https://avajs.dev).
> should honor requested pro model during active
'claude-sonnet-4@20250514'
'claude-sonnet-4-5@20250929'
> should fallback to default model when requesting non-optional model during active

View File

@@ -2074,11 +2074,11 @@ test('should resolve model correctly based on subscription status and prompt con
messages: {
create: [{ idx: 0, role: 'system', content: 'test' }],
},
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'] },
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
optionalModels: [
'gemini-2.5-flash',
'gemini-2.5-pro',
'claude-sonnet-4@20250514',
'claude-sonnet-4-5@20250929',
],
},
});
@@ -2138,7 +2138,7 @@ test('should resolve model correctly based on subscription status and prompt con
'should pick default model when no requested model during active'
);
const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514');
const model7 = await s.resolveModel(true, 'claude-sonnet-4-5@20250929');
t.snapshot(model7, 'should honor requested pro model during active');
const model8 = await s.resolveModel(true, 'not-in-optional');

View File

@@ -1,4 +1,4 @@
import { PrismaClient, User } from '@prisma/client';
import { PrismaClient, type User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { omit } from 'lodash-es';
import Sinon from 'sinon';
@@ -14,6 +14,7 @@ import { Models } from '../../models';
import { PaymentModule } from '../../plugins/payment';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { UserSubscriptionManager } from '../../plugins/payment/manager';
import { UserSubscriptionResolver } from '../../plugins/payment/resolver';
import {
RcEvent,
resolveProductMapping,
@@ -39,6 +40,7 @@ type Ctx = {
rc: RevenueCatService;
webhook: RevenueCatWebhookHandler;
controller: RevenueCatWebhookController;
subResolver: UserSubscriptionResolver;
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
@@ -85,6 +87,7 @@ test.beforeEach(async t => {
const rc = app.get(RevenueCatService);
const webhook = app.get(RevenueCatWebhookHandler);
const controller = app.get(RevenueCatWebhookController);
const subResolver = app.get(UserSubscriptionResolver);
t.context.module = app;
t.context.db = db;
@@ -95,6 +98,7 @@ test.beforeEach(async t => {
t.context.rc = rc;
t.context.webhook = webhook;
t.context.controller = controller;
t.context.subResolver = subResolver;
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
t.context.mockSubSeq = sequences => {
@@ -927,3 +931,90 @@ test('should not dispatch webhook event when authorization header is missing or
const after = event.emitAsync.getCalls()?.length || 0;
t.is(after - before, 0, 'should not emit event');
});
test('should refresh user subscriptions (empty / revenuecat / stripe-only)', async t => {
const { subResolver, db, mockSubSeq } = t.context;
const currentUser = {
id: user.id,
email: user.email,
avatarUrl: '',
name: '',
disabled: false,
hasPassword: true,
emailVerified: true,
};
// prepare mocks:
// first call returns Pro subscription
// second call returns AI subscription.
const stub = mockSubSeq([
[
{
identifier: 'Pro',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-09-01T00:00:00.000Z'),
expirationDate: new Date('2026-09-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: true,
duration: null,
},
],
[
{
identifier: 'AI',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-09-02T00:00:00.000Z'),
expirationDate: new Date('2026-09-02T00:00:00.000Z'),
productId: 'app.affine.pro.ai.Annual',
store: 'play_store',
willRenew: true,
duration: null,
},
],
]);
// case1: empty -> should sync (first sequence)
{
const subs = await subResolver.refreshUserSubscriptions(currentUser);
t.is(stub.callCount, 1, 'Scenario1: RC API called once');
t.truthy(
subs.find(s => s.plan === 'pro'),
'case1: pro saved'
);
}
// case2: existing revenuecat -> should sync again (second sequence)
{
const subs = await subResolver.refreshUserSubscriptions(currentUser);
t.is(stub.callCount, 2, 'Scenario2: RC API called second time');
t.truthy(
subs.find(s => s.plan === 'ai'),
'case2: ai saved'
);
}
// case3: only stripe subscription -> should NOT sync (call count remains 2)
{
await db.subscription.deleteMany({
where: { targetId: user.id, provider: 'revenuecat' },
});
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
provider: 'stripe',
status: 'active',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_123',
},
});
const subs = await subResolver.refreshUserSubscriptions(currentUser);
t.is(stub.callCount, 2, 'case3: RC API not called again');
t.is(subs.length, 1, 'case3: only stripe subscription returned');
}
});

View File

@@ -55,7 +55,7 @@ defineModuleConfig('copilot', {
embedding: 'gemini-embedding-001',
image: 'gpt-image-1',
rerank: 'gpt-4.1',
coding: 'claude-sonnet-4@20250514',
coding: 'claude-sonnet-4-5@20250929',
complex_text_generation: 'gpt-4o-2024-08-06',
quick_decision_making: 'gpt-5-mini',
quick_text_generation: 'gemini-2.5-flash',

View File

@@ -1390,7 +1390,7 @@ If there are items in the content that can be used as to-do tasks, please refer
{
name: 'Make it real',
action: 'Make it real',
model: 'claude-sonnet-4@20250514',
model: 'claude-sonnet-4-5@20250929',
messages: [
{
role: 'system',
@@ -1431,7 +1431,7 @@ When sent new wireframes, respond ONLY with the contents of the html file.`,
{
name: 'Make it real with text',
action: 'Make it real with text',
model: 'claude-sonnet-4@20250514',
model: 'claude-sonnet-4-5@20250929',
messages: [
{
role: 'system',
@@ -1712,7 +1712,7 @@ const modelActions: Prompt[] = [
{
name: 'Apply Updates',
action: 'Apply Updates',
model: 'claude-sonnet-4@20250514',
model: 'claude-sonnet-4-5@20250929',
messages: [
{
role: 'user',
@@ -1868,7 +1868,7 @@ Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, an
},
{
name: 'Code Artifact',
model: 'claude-sonnet-4@20250514',
model: 'claude-sonnet-4-5@20250929',
messages: [
{
role: 'system',
@@ -1932,7 +1932,7 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
optionalModels: [
'gemini-2.5-flash',
'gemini-2.5-pro',
'claude-sonnet-4@20250514',
'claude-sonnet-4-5@20250929',
],
messages: [
{
@@ -2092,7 +2092,7 @@ Below is the user's query. Please respond in the user's preferred language witho
'codeArtifact',
'blobRead',
],
proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'],
proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'],
},
};

View File

@@ -30,6 +30,16 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4-5-20250929',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4-20250514',
@@ -40,27 +50,6 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
},
],
},
{
name: 'Claude 3.7 Sonnet',
id: 'claude-3-7-sonnet-20250219',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
name: 'Claude 3.5 Sonnet',
id: 'claude-3-5-sonnet-20241022',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
defaultForOutputType: true,
},
],
},
];
protected instance!: AnthropicSDKProvider;

View File

@@ -24,6 +24,16 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
},
],
},
{
name: 'Claude Sonnet 4.5',
id: 'claude-sonnet-4-5@20250929',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4@20250514',
@@ -34,27 +44,6 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
},
],
},
{
name: 'Claude 3.7 Sonnet',
id: 'claude-3-7-sonnet@20250219',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
name: 'Claude 3.5 Sonnet',
id: 'claude-3-5-sonnet-v2@20241022',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
defaultForOutputType: true,
},
],
},
];
protected instance!: GoogleVertexAnthropicProvider;

View File

@@ -125,8 +125,8 @@ class DeleteSessionInput {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, { nullable: true })
docId!: string | undefined;
@Field(() => [String])
sessionIds!: string[];
@@ -737,11 +737,24 @@ export class CopilotResolver {
@Args({ name: 'options', type: () => DeleteSessionInput })
options: DeleteSessionInput
): Promise<string[]> {
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
if (!options.sessionIds.length) {
const { workspaceId, docId, sessionIds } = options;
if (docId) {
await this.ac
.user(user.id)
.doc({ workspaceId, docId })
.allowLocal()
.assert('Doc.Update');
} else {
await this.ac
.user(user.id)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
}
if (!sessionIds.length) {
throw new NotFoundException('Session not found');
}
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest('Server is busy');

View File

@@ -12,8 +12,7 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Provider, type User } from '@prisma/client';
import { GraphQLJSONObject } from 'graphql-scalars';
import { groupBy } from 'lodash-es';
import Stripe from 'stripe';
@@ -31,6 +30,7 @@ import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces';
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
import { RevenueCatWebhookHandler } from './revenuecat';
import { CheckoutParams, SubscriptionService } from './service';
import {
InvoiceStatus,
@@ -463,7 +463,22 @@ export class SubscriptionResolver {
@Resolver(() => UserType)
export class UserSubscriptionResolver {
constructor(private readonly db: PrismaClient) {}
constructor(
private readonly db: PrismaClient,
private readonly rcHandler: RevenueCatWebhookHandler
) {}
private normalizeSubscription(s: Subscription) {
if (
s.variant &&
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
s.variant as SubscriptionVariant
)
) {
s.variant = null;
}
return s;
}
@ResolveField(() => [SubscriptionType])
async subscriptions(
@@ -487,16 +502,9 @@ export class UserSubscriptionResolver {
},
});
subscriptions.forEach(subscription => {
if (
subscription.variant &&
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
subscription.variant as SubscriptionVariant
)
) {
subscription.variant = null;
}
});
subscriptions.forEach(subscription =>
this.normalizeSubscription(subscription)
);
return subscriptions;
}
@@ -534,6 +542,71 @@ export class UserSubscriptionResolver {
},
});
}
@Throttle('strict')
@Mutation(() => [SubscriptionType], {
description: 'Refresh current user subscriptions and return latest.',
})
async refreshUserSubscriptions(
@CurrentUser() user: CurrentUser
): Promise<Subscription[]> {
if (!user) {
throw new AuthenticationRequired();
}
let current = await this.db.subscription.findMany({
where: {
targetId: user.id,
status: {
in: [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
],
},
},
});
const existsPlans = Object.values(SubscriptionPlan);
const subscriptions = current.reduce(
(r, s) => {
if (existsPlans.includes(s.plan as SubscriptionPlan)) {
r[s.plan as SubscriptionPlan] = s.provider;
}
return r;
},
{} as Record<SubscriptionPlan, Provider>
);
// has revenuecat subscription or no subscription at all
const shouldSync =
current.length === 0 ||
subscriptions.pro === Provider.revenuecat ||
subscriptions.ai === Provider.revenuecat;
if (shouldSync) {
try {
await this.rcHandler.syncAppUser(user.id);
current = await this.db.subscription.findMany({
where: {
targetId: user.id,
status: {
in: [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
],
},
},
});
// ignore errors
} catch {}
}
current.forEach(subscription => this.normalizeSubscription(subscription));
return current;
}
}
@Resolver(() => WorkspaceType)

View File

@@ -542,7 +542,7 @@ type DeleteAccount {
}
input DeleteSessionInput {
docId: String!
docId: String
sessionIds: [String!]!
workspaceId: String!
}
@@ -1299,6 +1299,9 @@ type Mutation {
"""mark notification as read"""
readNotification(id: String!): Boolean!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
"""Refresh current user subscriptions and return latest."""
refreshUserSubscriptions: [SubscriptionType!]!
releaseDeletedBlobs(workspaceId: String!): Boolean!
"""Remove user avatar"""

View File

@@ -2218,6 +2218,25 @@ export const setWorkspacePublicByIdMutation = {
}`,
};
export const refreshSubscriptionMutation = {
id: 'refreshSubscriptionMutation' as const,
op: 'refreshSubscription',
query: `mutation refreshSubscription {
refreshUserSubscriptions {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
variant
}
}`,
deprecations: ["'id' is deprecated: removed"],
};
export const subscriptionQuery = {
id: 'subscriptionQuery' as const,
op: 'subscription',

View File

@@ -0,0 +1,13 @@
mutation refreshSubscription {
refreshUserSubscriptions {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
variant
}
}

View File

@@ -654,7 +654,7 @@ export interface DeleteAccount {
}
export interface DeleteSessionInput {
docId: Scalars['String']['input'];
docId?: InputMaybe<Scalars['String']['input']>;
sessionIds: Array<Scalars['String']['input']>;
workspaceId: Scalars['String']['input'];
}
@@ -1451,6 +1451,8 @@ export interface Mutation {
/** mark notification as read */
readNotification: Scalars['Boolean']['output'];
recoverDoc: Scalars['DateTime']['output'];
/** Refresh current user subscriptions and return latest. */
refreshUserSubscriptions: Array<SubscriptionType>;
releaseDeletedBlobs: Scalars['Boolean']['output'];
/** Remove user avatar */
removeAvatar: RemoveAvatar;
@@ -5996,6 +5998,26 @@ export type SetWorkspacePublicByIdMutation = {
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
};
export type RefreshSubscriptionMutationVariables = Exact<{
[key: string]: never;
}>;
export type RefreshSubscriptionMutation = {
__typename?: 'Mutation';
refreshUserSubscriptions: Array<{
__typename?: 'SubscriptionType';
id: string | null;
status: SubscriptionStatus;
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
start: string;
end: string | null;
nextBillAt: string | null;
canceledAt: string | null;
variant: SubscriptionVariant | null;
}>;
};
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
export type SubscriptionQuery = {
@@ -7081,6 +7103,11 @@ export type Mutations =
variables: SetWorkspacePublicByIdMutationVariables;
response: SetWorkspacePublicByIdMutation;
}
| {
name: 'refreshSubscriptionMutation';
variables: RefreshSubscriptionMutationVariables;
response: RefreshSubscriptionMutation;
}
| {
name: 'updateDocDefaultRoleMutation';
variables: UpdateDocDefaultRoleMutationVariables;

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": ["Bash(xcodebuild:*)", "Bash(xcbeautify)"],
"deny": [],
"ask": []
}
}

View File

@@ -1,3 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the AFFiNE iOS application built with Capacitor, React, and TypeScript. It's a hybrid mobile app that wraps a React web application in a native iOS shell.
## Development Commands
### Build and Development
- `yarn dev` - Start development server with live reload
- `yarn build` - Build the web application
- `yarn sync` - Sync web assets with Capacitor iOS project
- `yarn sync:dev` - Sync with development server (CAP_SERVER_URL=http://localhost:8080)
- `yarn xcode` - Open Xcode project
- `yarn codegen` - Generate GraphQL and Rust bindings
- `xcodebuild -workspace App.xcworkspace -scheme App -destination 'platform=iOS Simulator,name=iPhone 15' build | xcbeautify` - Build iOS project with xcbeautify
### iOS Build Process
1. `BUILD_TYPE=canary PUBLIC_PATH="/" yarn affine @affine/ios build` - Build web assets
2. `yarn affine @affine/ios cap sync` - Sync with iOS project
3. `yarn affine @affine/ios cap open ios` - Open in Xcode
### Live Reload Setup
1. Run `yarn dev` and select `ios` for Distribution option
2. Run `yarn affine @affine/ios sync:dev`
3. Run `yarn affine @affine/ios cap open ios`
## Architecture
### Core Technologies
- **Capacitor 7.x** - Native iOS bridge
- **React 19** - UI framework
- **TypeScript** - Language
- **Blocksuite** - Document editor
- **DI Framework** - Dependency injection via `@toeverything/infra`
### Key Directories
- `src/` - React application source
- `App/` - Native iOS Swift code
- `dist/` - Built web assets
- `capacitor-cordova-ios-plugins/` - Capacitor plugins
### Native Bridge Integration
The app exposes JavaScript APIs to native iOS code through `window` object:
- `getCurrentServerBaseUrl()` - Get current server URL
- `getCurrentI18nLocale()` - Get current locale
- `getAiButtonFeatureFlag()` - Check AI button feature flag
- `getCurrentWorkspaceId()` - Get current workspace ID
- `getCurrentDocId()` - Get current document ID
- `getCurrentDocContentInMarkdown()` - Export current doc as markdown
- `createNewDocByMarkdownInCurrentWorkspace()` - Import markdown as new doc
### Swift Code Style
Follow the guidelines in `AGENTS.md`:
- 2-space indentation
- PascalCase for types, camelCase for properties/methods
- Modern Swift features: `@Observable`, `async/await`, `actor`
- Protocol-oriented design, dependency injection
- Early returns, guard statements for optional unwrapping
### Build Configuration
- TypeScript config extends `../../../../tsconfig.web.json`
- Webpack bundling via `@affine-tools/cli`
- Capacitor config in `capacitor.config.ts`
- GraphQL codegen via Apollo
- Rust bindings generated via Uniffi
### Dependencies
- Workspace packages: `@affine/core`, `@affine/component`, `@affine/env`
- Capacitor plugins: App, Browser, Haptics, Keyboard
- React ecosystem: React Router, Next Themes
- Storage: IDB, Yjs for collaborative editing
### Testing and Quality
- TypeScript strict mode enabled
- ESLint/Prettier configuration from workspace root
- No specific test commands in this package (tests likely in workspace root)
# Swift Code Style Guidelines
## Core Style
@@ -37,7 +129,7 @@
## Architecture
- Protocol-oriented design
- Avoid using protocol-oriented design unless necessary
- Dependency injection over singletons
- Composition over inheritance
- Factory/Repository patterns

View File

@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Intelligents",
defaultLocalization: "en",
platforms: [
.iOS(.v15),
.macCatalyst(.v15),
],
products: [
.library(name: "Intelligents", targets: ["Intelligents"]),
],
dependencies: [
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
.package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.3.1"),
.package(url: "https://github.com/Lakr233/MSDisplayLink", from: "2.0.8"),
],
targets: [
.target(name: "Intelligents", dependencies: [
"SpringInterpolation",
"MSDisplayLink",
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
]),
]
)

View File

@@ -0,0 +1,62 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class RefreshSubscriptionMutation: GraphQLMutation {
public static let operationName: String = "refreshSubscription"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation refreshSubscription { refreshUserSubscriptions { __typename id status plan recurring start end nextBillAt canceledAt variant } }"#
))
public init() {}
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("refreshUserSubscriptions", [RefreshUserSubscription].self),
] }
/// Refresh current user subscriptions and return latest.
public var refreshUserSubscriptions: [RefreshUserSubscription] { __data["refreshUserSubscriptions"] }
/// RefreshUserSubscription
///
/// Parent Type: `SubscriptionType`
public struct RefreshUserSubscription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.SubscriptionType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String?.self),
.field("status", GraphQLEnum<AffineGraphQL.SubscriptionStatus>.self),
.field("plan", GraphQLEnum<AffineGraphQL.SubscriptionPlan>.self),
.field("recurring", GraphQLEnum<AffineGraphQL.SubscriptionRecurring>.self),
.field("start", AffineGraphQL.DateTime.self),
.field("end", AffineGraphQL.DateTime?.self),
.field("nextBillAt", AffineGraphQL.DateTime?.self),
.field("canceledAt", AffineGraphQL.DateTime?.self),
.field("variant", GraphQLEnum<AffineGraphQL.SubscriptionVariant>?.self),
] }
@available(*, deprecated, message: "removed")
public var id: String? { __data["id"] }
public var status: GraphQLEnum<AffineGraphQL.SubscriptionStatus> { __data["status"] }
/// The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
/// There won't actually be a subscription with plan 'Free'
public var plan: GraphQLEnum<AffineGraphQL.SubscriptionPlan> { __data["plan"] }
public var recurring: GraphQLEnum<AffineGraphQL.SubscriptionRecurring> { __data["recurring"] }
public var start: AffineGraphQL.DateTime { __data["start"] }
public var end: AffineGraphQL.DateTime? { __data["end"] }
public var nextBillAt: AffineGraphQL.DateTime? { __data["nextBillAt"] }
public var canceledAt: AffineGraphQL.DateTime? { __data["canceledAt"] }
public var variant: GraphQLEnum<AffineGraphQL.SubscriptionVariant>? { __data["variant"] }
}
}
}

View File

@@ -7,7 +7,7 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
public static let operationName: String = "listUserAccessTokens"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query listUserAccessTokens { accessTokens { __typename id name createdAt expiresAt } }"#
#"query listUserAccessTokens { revealedAccessTokens { __typename id name createdAt expiresAt token } }"#
))
public init() {}
@@ -18,31 +18,33 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("accessTokens", [AccessToken].self),
.field("revealedAccessTokens", [RevealedAccessToken].self),
] }
public var accessTokens: [AccessToken] { __data["accessTokens"] }
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
/// AccessToken
/// RevealedAccessToken
///
/// Parent Type: `AccessToken`
public struct AccessToken: AffineGraphQL.SelectionSet {
/// Parent Type: `RevealedAccessToken`
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AccessToken }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
.field("token", String.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
public var token: String { __data["token"] }
}
}
}

View File

@@ -11,7 +11,7 @@ public struct DeleteSessionInput: InputObject {
}
public init(
docId: String,
docId: GraphQLNullable<String> = nil,
sessionIds: [String],
workspaceId: String
) {
@@ -22,7 +22,7 @@ public struct DeleteSessionInput: InputObject {
])
}
public var docId: String {
public var docId: GraphQLNullable<String> {
get { __data["docId"] }
set { __data["docId"] = newValue }
}

View File

@@ -1,12 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let AccessToken = ApolloAPI.Object(
typename: "AccessToken",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -20,7 +20,6 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
public static func objectType(forTypename typename: String) -> ApolloAPI.Object? {
switch typename {
case "AccessToken": return AffineGraphQL.Objects.AccessToken
case "AggregateBucketHitsObjectType": return AffineGraphQL.Objects.AggregateBucketHitsObjectType
case "AggregateBucketObjectType": return AffineGraphQL.Objects.AggregateBucketObjectType
case "AggregateResultObjectType": return AffineGraphQL.Objects.AggregateResultObjectType

View File

@@ -1,4 +1,4 @@
// swift-tools-version: 6.2
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -21,7 +21,7 @@ let package = Package(
targets: [
.target(
name: "AffinePaywall",
dependencies: ["AffineResources"],
dependencies: ["AffineResources"]
),
]
)

View File

@@ -63,6 +63,7 @@ struct PurchaseFooterView: View {
Text("Already Purchased")
} else {
Text("Restore Purchase")
.underline()
}
}
.font(.system(size: 12))
@@ -70,6 +71,12 @@ struct PurchaseFooterView: View {
.foregroundStyle(AffineColors.textSecondary.color)
.opacity(viewModel.products.isEmpty ? 0 : 1)
.disabled(isPurchased)
Text("The Monthly and Annual plans renew automatically, but youre free to cancel at any time if its not right for you.")
.font(.system(size: 12))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.animation(.spring, value: viewModel.updating)
}

View File

@@ -0,0 +1,54 @@
//
// PricingConfiguration.swift
// AffinePaywall
//
// Created by Claude Code on 9/29/25.
//
import Foundation
enum PricingConfiguration {
static let proMonthly = ProductConfiguration(
productIdentifier: "app.affine.pro.Monthly",
revenueCatIdentifier: "app.affine.pro.Monthly",
description: "Monthly",
isDefaultSelected: false
)
static let proAnnual = ProductConfiguration(
productIdentifier: "app.affine.pro.Annual",
revenueCatIdentifier: "app.affine.pro.Annual",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true
)
static let aiAnnual = ProductConfiguration(
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual",
description: "",
isDefaultSelected: true
)
}
struct ProductConfiguration {
let productIdentifier: String
let revenueCatIdentifier: String
let description: String
let badge: String?
let isDefaultSelected: Bool
init(
productIdentifier: String,
revenueCatIdentifier: String,
description: String,
badge: String? = nil,
isDefaultSelected: Bool = false
) {
self.productIdentifier = productIdentifier
self.revenueCatIdentifier = revenueCatIdentifier
self.description = description
self.badge = badge
self.isDefaultSelected = isDefaultSelected
}
}

View File

@@ -15,11 +15,11 @@ extension SKUnit {
secondaryText: "A true multimodal AI copilot.",
package: [
SKUnitPackageOption(
price: "$8.9 per month",
price: "...", // Will be populated from App Store
description: "",
isDefaultSelected: true,
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually",
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual"
),

View File

@@ -16,23 +16,23 @@ extension SKUnit {
secondaryText: "For family and small teams.",
package: [
SKUnitPackageOption(
price: "$7.99",
description: "Monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $7.99/month",
price: "...", // Will be populated from App Store
description: PricingConfiguration.proMonthly.description,
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.Monthly",
revenueCatIdentifier: "app.affine.pro.Monthly"
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
),
SKUnitPackageOption(
price: "$6.75",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true,
primaryTitle: "Upgrade for $6.75/month",
price: "...", // Will be populated from App Store
description: PricingConfiguration.proAnnual.description,
badge: PricingConfiguration.proAnnual.badge,
isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.Annual",
revenueCatIdentifier: "app.affine.pro.Annual"
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
),
]
),

View File

@@ -23,6 +23,9 @@ final nonisolated class Store: ObservableObject, Sendable {
.flatMap(\.package)
.map(\.productIdentifier)
print("fetching products for identifiers: \(identifiers)")
#if DEBUG
try await Task.sleep(for: .seconds(1)) // simulate network delay
#endif
let products = try await Product.products(
for: identifiers.map { .init($0) }
)

View File

@@ -42,7 +42,11 @@ extension ViewModel {
await MainActor.run {
self.updating = false
if shouldDismiss { self.dismiss() }
}
if shouldDismiss {
await MainActor.run {
self.dismiss()
}
}
}
}
@@ -55,7 +59,18 @@ extension ViewModel {
guard !updating else { return }
print(#function, unit, option)
updateAppStoreStatus(initial: false)
Task.detached {
// before we continue, sync any changes from App Store
// this will ask user to sign in if needed
do {
try await store.fetchAppStoreContents()
} catch {
// ignore user's cancellation on restore, not a huge deal
print("updateAppStoreItems error:", error)
}
await MainActor.run { self.updateAppStoreStatus(initial: false) }
}
}
func dismiss() {
@@ -71,18 +86,12 @@ nonisolated extension ViewModel {
await MainActor.run { self.updating = true }
do {
// before we continue, sync any changes from App Store
// this will ask user to sign in if needed
do {
try await store.fetchAppStoreContents()
} catch {
// ignore user's cancellation on restore, not a huge deal
print("updateAppStoreItems error:", error)
}
// now we fetch records from app store
let products = try await store.fetchProducts()
await MainActor.run { self.products = products }
await MainActor.run {
self.products = products
self.updatePackageOptions(with: products)
}
// fetch purchased items if signed in
do {

View File

@@ -24,6 +24,7 @@ class ViewModel: ObservableObject {
@Published var updating = false
@Published var products: [Product] = []
@Published var purchasedItems: Set<String> = []
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
private(set) weak var associatedController: UIViewController?
@@ -79,6 +80,84 @@ class ViewModel: ObservableObject {
_ = selectePackageOption // ensure selectePackageOption is valid
}
func updatePackageOptions(with products: [Product]) {
var updatedOptions = packageOptions
for (index, option) in updatedOptions.enumerated() {
if let product = products.first(where: { $0.id == option.productIdentifier }) {
let price = product.displayPrice
let description = product.description
let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText(
for: product,
option: option
)
updatedOptions[index] = SKUnitPackageOption(
id: option.id,
price: price,
description: option.description.isEmpty ? description : option.description,
badge: option.badge,
isDefaultSelected: option.isDefaultSelected,
primaryTitle: purchasePrimaryTitle,
secondaryTitle: purchaseSecondaryTitle,
productIdentifier: option.productIdentifier,
revenueCatIdentifier: option.revenueCatIdentifier
)
}
}
packageOptions = updatedOptions
}
private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) {
let monthlyPrice = calculateMonthlyPrice(for: product, option: option)
if option.productIdentifier.contains(".ai.") {
return ("\(monthlyPrice) per month", "billed annually")
} else {
return ("Upgrade for \(monthlyPrice) per month", "")
}
}
private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String {
guard let subscription = product.subscription else {
preconditionFailure("Product must have subscription information")
}
switch subscription.subscriptionPeriod.unit {
case .year:
let yearlyPrice = product.price
let monthlyPrice = yearlyPrice / 12.0
// Round up to ensure total price is slightly lower than yearly price
var roundedMonthlyPrice = monthlyPrice
var rounded = Decimal()
NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = product.priceFormatStyle.currencyCode
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) {
return formattedMonthlyPrice
}
case .month:
return product.displayPrice
case .week, .day:
preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)")
@unknown default:
preconditionFailure("Unknown subscription period")
}
return product.displayPrice
}
}
@MainActor
@@ -99,20 +178,24 @@ extension ViewModel {
}
var selectePackageOption: SKUnitPackageOption {
let item = selectedUnit.package
.first { $0.id == selectedPackageIdentifier }
let unitPackageIds = selectedUnit.package.map(\.id)
let item = packageOptions
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
if let item { return item }
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
let defaultItem = packageOptions
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
if let defaultItem {
selectedPackageIdentifier = defaultItem.id
return defaultItem
}
let lastItem = selectedUnit.package.last!
let lastItem = packageOptions
.first { unitPackageIds.contains($0.id) }!
selectedPackageIdentifier = lastItem.id
return lastItem
}
var availablePackageOptions: [SKUnitPackageOption] {
selectedUnit.package
let unitPackageIds = selectedUnit.package.map(\.id)
return packageOptions.filter { unitPackageIds.contains($0.id) }
}
}

View File

@@ -275,10 +275,10 @@ private extension ChatManager {
let closable = ClosableTask(detachedTask: .detached(operation: {
let eventSource = EventSource()
let dataTask = await eventSource.dataTask(for: request)
let dataTask = eventSource.dataTask(for: request)
var document = ""
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
for await event in await dataTask.events() {
for await event in dataTask.events() {
switch event {
case .open:
print("[*] connection opened")

View File

@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
CapacitorKeyboard: 09fd91dcde4f8a37313e7f11bde553ad1ed52036
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082

View File

@@ -17,6 +17,7 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@blocksuite/affine": "workspace:*",

View File

@@ -14,6 +14,7 @@ import {
ServerScope,
ServerService,
ServersService,
SubscriptionService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { DocsService } from '@affine/core/modules/doc';
@@ -38,6 +39,7 @@ import {
} from '@affine/core/modules/workspace';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { getWorkerUrl } from '@affine/env/worker';
import { refreshSubscriptionMutation } from '@affine/graphql';
import { I18n } from '@affine/i18n';
import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { Container } from '@blocksuite/affine/global/di';
@@ -328,6 +330,37 @@ const frameworkProvider = framework.provider();
workspaceRef?.dispose();
}
};
(window as any).getSubscriptionState = async () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const currentServerId = globalContextService.globalContext.serverId.get();
const serversService = frameworkProvider.get(ServersService);
const defaultServerService = frameworkProvider.get(DefaultServerService);
const currentServer =
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
const subscriptionService = currentServer.scope.get(SubscriptionService);
await subscriptionService.subscription.waitForRevalidation();
return {
pro: subscriptionService.subscription.pro$.value,
ai: subscriptionService.subscription.ai$.value,
};
};
(window as any).updateSubscriptionState = async () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const currentServerId = globalContextService.globalContext.serverId.get();
const serversService = frameworkProvider.get(ServersService);
const defaultServerService = frameworkProvider.get(DefaultServerService);
const currentServer =
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
await currentServer
.gql({
query: refreshSubscriptionMutation,
})
.catch(console.error);
const subscriptionService = currentServer.scope.get(SubscriptionService);
subscriptionService.subscription.revalidate();
};
// setup application lifecycle events, and emit application start event
window.addEventListener('focus', () => {

View File

@@ -10,6 +10,7 @@
{ "path": "../../component" },
{ "path": "../../core" },
{ "path": "../../../common/env" },
{ "path": "../../../common/graphql" },
{ "path": "../../i18n" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../../blocksuite/affine/all" },

View File

@@ -16,6 +16,7 @@ export const contentRoot = style({
export const iconPicker = style({
padding: 0,
lineHeight: 1,
color: cssVarV2.icon.primary,
});
globalStyle(`${iconPicker} span:has(svg)`, {
lineHeight: 0,

View File

@@ -148,7 +148,7 @@ export const AffineIconPicker = ({
</header>
{/* Content */}
<Scrollable.Root className={pickerStyles.scrollRoot}>
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
{/* Recent */}
{recentIcons.length ? (

View File

@@ -0,0 +1,26 @@
import { memo, useCallback } from 'react';
import { IconButton } from '../../../button';
// Memoized individual emoji button to prevent unnecessary re-renders
export const EmojiButton = memo(function EmojiButton({
emoji,
onSelect,
}: {
emoji: string;
onSelect: (emoji: string) => void;
}) {
const handleClick = useCallback(() => {
onSelect(emoji);
}, [emoji, onSelect]);
return (
<IconButton
key={emoji}
size={24}
style={{ padding: 4 }}
icon={<span>{emoji}</span>}
onClick={handleClick}
/>
);
});

View File

@@ -1,145 +1,15 @@
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
import { SearchIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import {
memo,
startTransition,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useState } from 'react';
import { IconButton } from '../../../button';
import Input from '../../../input';
import { Loading } from '../../../loading';
import { Menu } from '../../../menu';
import { Scrollable } from '../../../scrollbar';
import * as pickerStyles from '../picker.css';
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
import rawData from './data/en.json';
// import { emojiGroupList } from './gen-data';
import * as styles from './emoji-picker.css';
import type { CompactEmoji } from './type';
type EmojiGroup = {
name: string;
emojis: Array<CompactEmoji>;
};
const emojiGroupList = rawData as EmojiGroup[];
const useRecentEmojis = () => {
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
useEffect(() => {
const recentEmojis = localStorage.getItem('recentEmojis');
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
}, []);
const add = useCallback((emoji: string) => {
setRecentEmojis(prevRecentEmojis => {
const newRecentEmojis = [
emoji,
...prevRecentEmojis.filter(e => e !== emoji),
].slice(0, 10);
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
return newRecentEmojis;
});
}, []);
return {
recentEmojis,
add,
};
};
// Memoized individual emoji button to prevent unnecessary re-renders
const EmojiButton = memo(function EmojiButton({
emoji,
onSelect,
}: {
emoji: string;
onSelect: (emoji: string) => void;
}) {
const handleClick = useCallback(() => {
onSelect(emoji);
}, [emoji, onSelect]);
return (
<IconButton
key={emoji}
size={24}
style={{ padding: 4 }}
icon={<span>{emoji}</span>}
onClick={handleClick}
/>
);
});
// Memoized emoji groups to prevent unnecessary re-renders
const EmojiGroups = memo(function EmojiGroups({
onSelect,
keyword,
skin,
}: {
onSelect: (emoji: string) => void;
keyword?: string;
skin?: number;
}) {
const [groups, setGroups] = useState<EmojiGroup[]>([]);
const loading = !keyword && !groups.length;
useEffect(() => {
startTransition(() => {
if (!keyword) {
setGroups(emojiGroupList);
return;
}
setGroups(
emojiGroupList
.map(group => ({
...group,
emojis: group.emojis.filter(emoji =>
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
),
}))
.filter(group => group.emojis.length > 0)
);
});
}, [keyword]);
if (loading) {
return (
<div className={styles.loadingWrapper}>
<Loading size={16} />
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
</div>
);
}
return groups.map(group => (
<div key={group.name} className={pickerStyles.group}>
<div className={pickerStyles.groupName} data-group-name={group.name}>
{group.name}
</div>
<div className={pickerStyles.groupGrid}>
{group.emojis.map(emoji => (
<EmojiButton
key={emoji.label}
emoji={
skin !== undefined && emoji.skins
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
: emoji.unicode
}
onSelect={onSelect}
/>
))}
</div>
</div>
));
});
import { EmojiGroups } from './groups';
import { useRecentEmojis } from './recent';
const skinList = [
{ unicode: '👋', value: undefined },
@@ -155,54 +25,10 @@ export const EmojiPicker = ({
}: {
onSelect?: (emoji: string) => void;
}) => {
const scrollableRef = useRef<HTMLDivElement>(null);
const [keyword, setKeyword] = useState<string>('');
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
undefined
);
const [skin, setSkin] = useState<number | undefined>(undefined);
const { recentEmojis, add: addRecent } = useRecentEmojis();
const checkActiveGroup = useCallback(() => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
// get actual scrollable element
const viewport = scrollable.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement;
if (!viewport) return;
const scrollTop = viewport.scrollTop;
// find the first group that is at the top of the scrollable element
for (let i = emojiGroupList.length - 1; i >= 0; i--) {
const group = emojiGroupList[i];
const groupElement = viewport.querySelector(
`[data-group-name="${group.name}"]`
) as HTMLElement;
if (!groupElement) continue;
// use offsetTop to get the position of the element relative to the scrollable element
const elementTop = groupElement.offsetTop;
if (elementTop <= scrollTop + 50) {
setActiveGroupId(group.name);
return;
}
}
}, []);
const jumpToGroup = useCallback((groupName: string) => {
const groupElement = scrollableRef.current?.querySelector(
`[data-group-name="${groupName}"]`
) as HTMLElement;
if (!groupElement) return;
setActiveGroupId(groupName);
groupElement.scrollIntoView({ behavior: 'smooth' });
}, []);
const { add: addRecent, recentEmojis } = useRecentEmojis();
const handleEmojiSelect = useCallback(
(emoji: string) => {
@@ -212,10 +38,6 @@ export const EmojiPicker = ({
[addRecent, onSelect]
);
useEffect(() => {
checkActiveGroup();
}, [checkActiveGroup]);
return (
<div className={pickerStyles.root}>
<header className={pickerStyles.searchContainer}>
@@ -271,62 +93,14 @@ export const EmojiPicker = ({
/>
</Menu>
</header>
<Scrollable.Root className={pickerStyles.scrollRoot} ref={scrollableRef}>
<Scrollable.Viewport
onScrollEnd={checkActiveGroup}
className={pickerStyles.scrollViewport}
>
{/* Recent */}
{recentEmojis.length ? (
<div className={pickerStyles.group}>
<div className={pickerStyles.groupName} data-group-name="Recent">
Recent
</div>
<div className={pickerStyles.groupGrid}>
{recentEmojis.map(emoji => (
<EmojiButton
key={emoji}
emoji={emoji}
onSelect={handleEmojiSelect}
/>
))}
</div>
</div>
) : null}
{/* Groups */}
<EmojiGroups
onSelect={handleEmojiSelect}
keyword={keyword}
skin={skin}
/>
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
<div className={styles.footer}>
{['Recent', ...GROUPS].map(group => {
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
const active = activeGroupId === group;
return (
<IconButton
size={18}
style={{ padding: 3 }}
key={group}
icon={
<Icon
className={
active ? styles.footerIconActive : styles.footerIcon
}
/>
}
className={clsx(
active ? styles.footerButtonActive : styles.footerButton
)}
onClick={() => jumpToGroup(group)}
/>
);
})}
</div>
{/* Groups */}
<EmojiGroups
recent={recentEmojis}
onSelect={handleEmojiSelect}
keyword={keyword}
skin={skin}
/>
</div>
);
};

View File

@@ -0,0 +1,227 @@
import { RecentIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
createContext,
memo,
startTransition,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { IconButton } from '../../../button';
import { Loading } from '../../../loading';
import {
Masonry,
type MasonryGroup,
type MasonryItem,
type MasonryRef,
} from '../../../masonry';
import * as pickerStyles from '../picker.css';
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
import rawData from './data/en.json';
import { EmojiButton } from './emoji-button';
import * as styles from './emoji-picker.css';
import type { CompactEmoji, EmojiGroup } from './type';
const emojiGroupList = rawData as EmojiGroup[];
const initEmojiGroupMap = () => {
const emojiGroupMap = new Map<string, Map<string, CompactEmoji>>();
emojiGroupList.forEach(group => {
emojiGroupMap.set(
group.name,
new Map(group.emojis.map(emoji => [emoji.label, emoji]))
);
});
return emojiGroupMap;
};
const emojiGroupMap = initEmojiGroupMap();
const EmojiGroupContext = createContext<{
onSelect: (emoji: string) => void;
skin?: number;
}>({
onSelect: () => {},
});
const RecentGroupItem = memo(function RecentGroupItem({
itemId,
}: {
itemId: string;
}) {
const { onSelect } = useContext(EmojiGroupContext);
return <EmojiButton emoji={itemId} onSelect={onSelect} />;
});
const EmojiGroupItem = memo(function EmojiGroupItem({
groupId,
itemId,
}: {
groupId: string;
itemId: string;
}) {
const emoji = emojiGroupMap.get(groupId)?.get(itemId);
const { onSelect, skin } = useContext(EmojiGroupContext);
if (!emoji) return null;
return (
<EmojiButton
emoji={
skin !== undefined && emoji.skins
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
: emoji.unicode
}
onSelect={onSelect}
/>
);
});
const EmojiGroupHeader = memo(function EmojiGroupHeader({
groupId,
}: {
groupId: string;
}) {
return (
<div className={pickerStyles.groupName} data-group-name={groupId}>
{groupId}
</div>
);
});
// Memoized emoji groups to prevent unnecessary re-renders
export const EmojiGroups = memo(function EmojiGroups({
recent,
onSelect,
keyword,
skin,
}: {
onSelect: (emoji: string) => void;
recent?: string[];
keyword?: string;
skin?: number;
}) {
const masonryRef = useRef<MasonryRef>(null);
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
'Recent'
);
const [groups, setGroups] = useState<EmojiGroup[]>([]);
const loading = !keyword && !groups.length;
useEffect(() => {
if (!keyword) {
setGroups(emojiGroupList);
return;
}
startTransition(() => {
setGroups(
emojiGroupList
.map(group => ({
...group,
emojis: group.emojis.filter(emoji =>
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
),
}))
.filter(group => group.emojis.length > 0)
);
});
}, [keyword]);
const items = useMemo(() => {
const emojiGroups = groups.map(group => {
return {
id: group.name,
height: 30,
Component: EmojiGroupHeader,
items: group.emojis.map(emoji => {
return {
id: emoji.label,
height: 32,
ratio: 1,
Component: EmojiGroupItem,
} satisfies MasonryItem;
}),
} satisfies MasonryGroup;
});
if (recent?.length) {
emojiGroups.unshift({
id: 'Recent',
height: 30,
Component: EmojiGroupHeader,
items: recent.map(emoji => {
return {
id: emoji,
height: 32,
ratio: 1,
Component: RecentGroupItem,
} satisfies MasonryItem;
}),
});
}
return emojiGroups;
}, [groups, recent]);
const contextValue = useMemo(() => ({ onSelect, skin }), [onSelect, skin]);
const jumpToGroup = useCallback((groupName: string) => {
setActiveGroupId(groupName);
masonryRef.current?.scrollToGroup(groupName);
}, []);
if (loading) {
return (
<div className={styles.loadingWrapper}>
<Loading size={16} />
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
</div>
);
}
return (
<EmojiGroupContext.Provider value={contextValue}>
<div className={pickerStyles.emojiScrollRoot}>
<Masonry
ref={masonryRef}
virtualScroll
items={items}
itemWidthMin={32}
itemWidth={32}
paddingX={12}
paddingY={8}
gapX={4}
gapY={4}
onStickyGroupChange={setActiveGroupId}
/>
</div>
<div className={styles.footer}>
{['Recent', ...GROUPS].map(group => {
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
const active = activeGroupId === group;
return (
<IconButton
size={18}
style={{ padding: 3 }}
key={group}
icon={
<Icon
className={
active ? styles.footerIconActive : styles.footerIcon
}
/>
}
className={clsx(
active ? styles.footerButtonActive : styles.footerButton
)}
onClick={() => jumpToGroup(group)}
/>
);
})}
</div>
</EmojiGroupContext.Provider>
);
});

View File

@@ -0,0 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
export const useRecentEmojis = () => {
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
useEffect(() => {
const recentEmojis = localStorage.getItem('recentEmojis');
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
}, []);
const add = useCallback((emoji: string) => {
setRecentEmojis(prevRecentEmojis => {
const newRecentEmojis = [
emoji,
...prevRecentEmojis.filter(e => e !== emoji),
].slice(0, 10);
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
return newRecentEmojis;
});
}, []);
return {
recentEmojis,
add,
};
};

View File

@@ -9,3 +9,8 @@ export type CompactEmoji = {
unicode: string;
skins?: Array<Omit<CompactEmoji, 'skins'>>;
};
export type EmojiGroup = {
name: string;
emojis: Array<CompactEmoji>;
};

View File

@@ -27,8 +27,19 @@ export const searchInput = style({
export const scrollRoot = style({
height: 0,
flexGrow: 1,
padding: '0px 12px',
});
export const emojiScrollRoot = style([
scrollRoot,
{
paddingTop: '8px',
},
]);
export const iconScrollRoot = style([
scrollRoot,
{
padding: '0px 12px',
},
]);
export const scrollViewport = style({
padding: '8px 0px',
@@ -52,6 +63,7 @@ export const groupName = style({
display: 'flex',
alignItems: 'center',
padding: '0px 4px',
backgroundColor: cssVarV2.layer.background.overlayPanel,
});
export const groupGrid = style({

View File

@@ -2,10 +2,12 @@ import clsx from 'clsx';
import { debounce } from 'lodash-es';
import throttle from 'lodash-es/throttle';
import {
forwardRef,
Fragment,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
@@ -61,29 +63,39 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
columns?: number;
resizeDebounce?: number;
preloadHeight?: number;
onStickyGroupChange?: (groupId?: string) => void;
}
export const Masonry = ({
items,
gapX = 12,
gapY = 12,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
className,
virtualScroll = false,
locateMode = 'leftTop',
groupsGap = 0,
groupHeaderGapWithItems = 0,
stickyGroupHeader = true,
collapsedGroups,
columns,
preloadHeight = 50,
resizeDebounce = 20,
onGroupCollapse,
...props
}: MasonryProps) => {
export type MasonryRef = {
scrollToGroup: (groupId: string) => void;
};
export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
{
items,
gapX = 12,
gapY = 12,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
className,
virtualScroll = false,
locateMode = 'leftTop',
groupsGap = 0,
groupHeaderGapWithItems = 0,
stickyGroupHeader = true,
collapsedGroups,
columns,
preloadHeight = 50,
resizeDebounce = 20,
onGroupCollapse,
onStickyGroupChange,
...props
},
ref
) {
const rootRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const [layoutMap, setLayoutMap] = useState<
@@ -212,7 +224,9 @@ export const Masonry = ({
const scrollY = (e.target as HTMLElement).scrollTop;
updateActiveMap(layoutMap, scrollY);
if (stickyGroupHeader) {
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
const stickyGroupId = calcSticky({ scrollY, layoutMap });
setStickyGroupId(stickyGroupId);
onStickyGroupChange?.(stickyGroupId);
}
}, 50);
rootEl.addEventListener('scroll', handler);
@@ -221,7 +235,29 @@ export const Masonry = ({
};
}
return;
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
}, [
layoutMap,
onStickyGroupChange,
stickyGroupHeader,
updateActiveMap,
virtualScroll,
]);
const scrollToGroup = useCallback(
(groupId: string) => {
const group = layoutMap.get(groupId);
if (!group) return;
rootRef.current?.scrollTo({
top: group.y,
behavior: 'instant',
});
},
[layoutMap]
);
useImperativeHandle<MasonryRef, MasonryRef>(ref, () => {
return { scrollToGroup };
});
return (
<Scrollable.Root>
@@ -312,7 +348,7 @@ export const Masonry = ({
<Scrollable.Scrollbar className={styles.scrollbar} />
</Scrollable.Root>
);
};
});
type MasonryItemProps = MasonryItem &
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {

View File

@@ -112,12 +112,18 @@ export const calcLayout = (
const ratioMode = 'ratio' in item;
const height = ratioMode ? item.ratio * width : item.height;
const aroundGapXValue =
columns > 1
? (totalWidth - paddingX * 2 - width * columns) / (columns - 1)
: 0;
const gapXValue = Math.max(gapX, aroundGapXValue);
if (ratioMode) {
const minRatio = Math.min(...ratioStack);
const minRatioIndex = ratioStack.indexOf(minRatio);
const minHeight = heightStack[minRatioIndex];
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
const x = minRatioIndex * (width + gapX) + paddingX;
const x = minRatioIndex * (width + gapXValue) + paddingX;
const y = finalHeight + minHeight + hasGap;
ratioStack[minRatioIndex] += item.ratio * 10000;
@@ -133,7 +139,7 @@ export const calcLayout = (
const minHeight = Math.min(...heightStack);
const minHeightIndex = heightStack.indexOf(minHeight);
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
const x = minHeightIndex * (width + gapX) + paddingX;
const x = minHeightIndex * (width + gapXValue) + paddingX;
const y = finalHeight + minHeight + hasGap;
const ratio = height / width;
@@ -193,7 +199,7 @@ export const calcSticky = (options: {
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
const next = groupEntries[index + 1];
return xywh.y < scrollY && (!next || next[1].y > scrollY);
return xywh.y <= scrollY && (!next || next[1].y > scrollY);
});
return stickyGroupEntry

View File

@@ -74,7 +74,7 @@
"lit": "^3.2.1",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"mermaid": "^10.9.1",
"mermaid": "^11.1.0",
"mp4-muxer": "^5.2.1",
"nanoid": "^5.0.9",
"next-themes": "^0.4.4",

View File

@@ -441,7 +441,7 @@ declare global {
) => Promise<AIHistory[] | undefined>;
cleanup: (
workspaceId: string,
docId: string,
docId: string | undefined,
sessionIds: string[]
) => Promise<void>;
ids: (

View File

@@ -130,6 +130,9 @@ export class AIChatPanelTitle extends SignalWatcher(
@property({ attribute: false })
accessor openDoc!: (docId: string, sessionId: string) => void;
@property({ attribute: false })
accessor deleteSession!: (session: BlockSuitePresets.AIRecentSession) => void;
private readonly openPlayground = () => {
const playgroundContent = html`
<playground-content
@@ -182,6 +185,7 @@ export class AIChatPanelTitle extends SignalWatcher(
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.onOpenDoc=${this.openDoc}
.onSessionDelete=${this.deleteSession}
.docDisplayConfig=${this.docDisplayConfig}
.notificationService=${this.notificationService}
></ai-chat-toolbar>

View File

@@ -237,6 +237,31 @@ export class ChatPanel extends SignalWatcher(
return this.session;
};
private readonly deleteSession = async (
session: BlockSuitePresets.AIRecentSession
) => {
if (!AIProvider.histories) {
return;
}
const confirm = await this.notificationService.confirm({
title: 'Delete this history?',
message:
'Do you want to delete this AI conversation history? Once deleted, it cannot be recovered.',
confirmText: 'Delete',
cancelText: 'Cancel',
});
if (confirm) {
await AIProvider.histories.cleanup(
session.workspaceId,
session.docId || undefined,
[session.sessionId]
);
if (session.sessionId === this.session?.sessionId) {
this.newSession();
}
}
};
private readonly updateSession = async (options: UpdateChatSessionInput) => {
await AIProvider.session?.updateSession(options);
const session = await AIProvider.session?.getSession(
@@ -413,6 +438,7 @@ export class ChatPanel extends SignalWatcher(
.togglePin=${this.togglePin}
.openSession=${this.openSession}
.openDoc=${this.openDoc}
.deleteSession=${this.deleteSession}
></ai-chat-panel-title>
${keyed(
this.hasPinned ? this.session?.sessionId : this.doc.id,

View File

@@ -42,6 +42,11 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
@property({ attribute: false })
accessor onSessionDelete!: (
session: BlockSuitePresets.AIRecentSession
) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -198,7 +203,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
.workspaceId=${this.workspaceId}
.docDisplayConfig=${this.docDisplayConfig}
.onSessionClick=${this.onSessionClick}
.onSessionDelete=${this.onSessionDelete}
.onDocClick=${this.onDocClick}
.notificationService=${this.notificationService}
></ai-session-history>
`,
portalStyles: {

View File

@@ -3,6 +3,7 @@ import { WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { DeleteIcon } from '@blocksuite/icons/lit';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
@@ -62,7 +63,6 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
position: relative;
display: flex;
height: 24px;
padding: 2px 4px;
justify-content: space-between;
align-items: center;
border-radius: 4px;
@@ -85,6 +85,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
font-size: 12px;
font-weight: 400;
line-height: 20px;
padding: 2px 4px;
color: ${unsafeCSSVarV2('text/primary')};
overflow: hidden;
text-overflow: ellipsis;
@@ -94,7 +95,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
.ai-session-doc {
display: flex;
width: 120px;
padding: 0px 4px;
padding: 2px;
align-items: center;
gap: 4px;
flex-shrink: 0;
@@ -117,6 +118,36 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
white-space: nowrap;
}
}
.ai-session-item-delete {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
background: ${unsafeCSSVarV2('layer/background/primary')};
border-radius: 2px;
padding: 2px;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
svg {
width: 16px;
height: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.ai-session-item:hover .ai-session-item-delete {
opacity: 1;
visibility: visible;
}
}
${scrollbarStyle('.ai-session-history')}
@@ -134,6 +165,11 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor onSessionClick!: (sessionId: string) => void;
@property({ attribute: false })
accessor onSessionDelete!: (
session: BlockSuitePresets.AIRecentSession
) => void;
@property({ attribute: false })
accessor onDocClick!: (docId: string, sessionId: string) => void;
@@ -272,6 +308,16 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
${session.docId
? this.renderSessionDoc(session.docId, session.sessionId)
: nothing}
<div
class="ai-session-item-delete"
@click=${(e: MouseEvent) => {
e.stopPropagation();
this.onSessionDelete(session);
}}
>
${DeleteIcon()}
<affine-tooltip>Delete</affine-tooltip>
</div>
</div>
`;
})}

View File

@@ -160,6 +160,12 @@ export abstract class ArtifactTool<
`;
}
override connectedCallback() {
super.connectedCallback();
// open the preview panel immediately
this.openOrUpdatePreviewPanel();
}
override render() {
const err = this.getErrorTemplate();
if (err) {

View File

@@ -261,7 +261,7 @@ export class CopilotClient {
async cleanupSessions(input: {
workspaceId: string;
docId: string;
docId: string | undefined;
sessionIds: string[];
}) {
try {

View File

@@ -794,7 +794,7 @@ Could you make a new website based on these notes and send back just the html fi
},
cleanup: async (
workspaceId: string,
docId: string,
docId: string | undefined,
sessionIds: string[]
) => {
await client.cleanupSessions({ workspaceId, docId, sessionIds });

View File

@@ -10,6 +10,9 @@ export const docIconPickerTrigger = style({
fontSize: 60,
lineHeight: 1,
},
'&[data-icon-type="emoji"]': {
fontFamily: 'emoji',
},
},
});

View File

@@ -22,8 +22,9 @@ export function useAsyncCallback<T extends any[]>(
const handleAsyncError = React.useContext(AsyncCallbackContext);
return React.useCallback(
(...args: any) => {
// oxlint-disable-next-line exhaustive-deps
callback(...args).catch(e => handleAsyncError(e));
},
[callback, handleAsyncError, ...deps] // eslint-disable-line react-hooks/exhaustive-deps
[...deps] // eslint-disable-line react-hooks/exhaustive-deps
);
}

View File

@@ -38,6 +38,9 @@ type KeyboardShortcutsI18NKeys =
| 'bodyText'
| 'increaseIndent'
| 'reduceIndent'
| 'alignLeft'
| 'alignCenter'
| 'alignRight'
| 'groupDatabase'
| 'moveUp'
| 'moveDown'
@@ -185,6 +188,9 @@ export const useMacPageKeyboardShortcuts = (): ShortcutMap => {
[tH('6')]: ['⌘', '⌥', '6'],
[t('increaseIndent')]: ['Tab'],
[t('reduceIndent')]: ['⇧', 'Tab'],
[t('alignLeft')]: ['⌘', '⇧', 'L'],
[t('alignCenter')]: ['⌘', '⇧', 'E'],
[t('alignRight')]: ['⌘', '⇧', 'R'],
[t('groupDatabase')]: ['⌘', 'G'],
[t('switch')]: ['⌥', 'S'],
// not implement yet
@@ -242,6 +248,9 @@ export const useWinPageKeyboardShortcuts = (): ShortcutMap => {
[tH('6')]: ['Ctrl', 'Shift', '6'],
[t('increaseIndent')]: ['Tab'],
[t('reduceIndent')]: ['Shift+Tab'],
[t('alignLeft')]: ['Ctrl', 'Shift', 'L'],
[t('alignCenter')]: ['Ctrl', 'Shift', 'E'],
[t('alignRight')]: ['Ctrl', 'Shift', 'R'],
[t('groupDatabase')]: ['Ctrl + G'],
['Switch']: ['Alt + S'],
// not implement yet

View File

@@ -1,69 +1,16 @@
import {
type IconData as ComponentIconData,
IconPicker,
IconType,
uniReactRoot,
} from '@affine/component';
import { IconPicker, uniReactRoot } from '@affine/component';
// Import the identifier for internal use
import {
type IconData,
type IconPickerOptions,
type IconPickerService as IIconPickerService,
} from '@blocksuite/affine-shared/services';
import { type IconPickerService as IIconPickerService } from '@blocksuite/affine-shared/services';
import { Service } from '@toeverything/infra';
import { html, type TemplateResult } from 'lit';
// Re-export types from BlockSuite shared services
export type {
IconData,
IconPickerOptions,
IconPickerService as IIconPickerService,
} from '@blocksuite/affine-shared/services';
export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services';
// Convert between BlockSuite IconData and Component IconData
function convertToBlockSuiteIconData(
componentIconData: ComponentIconData
): IconData {
if (componentIconData.type === IconType.Emoji) {
return {
type: 'emoji',
value: componentIconData.unicode,
};
} else if (componentIconData.type === IconType.AffineIcon) {
return {
type: 'icon',
value: componentIconData.name,
};
}
// For other types, default to icon type
return {
type: 'icon',
value: 'default',
};
}
export class IconPickerService extends Service implements IIconPickerService {
public readonly iconPickerComponent =
uniReactRoot.createUniComponent(IconPicker);
renderIconPicker(options: IconPickerOptions): TemplateResult {
const element = document.createElement('div');
// Adapt the options to match IconPicker component's expected interface
const adaptedOptions = {
onSelect: options.onSelect
? (data?: ComponentIconData) => {
if (data && options.onSelect) {
const blockSuiteIconData = convertToBlockSuiteIconData(data);
options.onSelect(blockSuiteIconData);
}
}
: undefined,
onClose: options.onClose,
};
this.iconPickerComponent(element, adaptedOptions, () => {});
return html`${element}`;
}
}

View File

@@ -2509,6 +2509,18 @@ export function useAFFiNEI18N(): {
* `Just now`
*/
["com.affine.just-now"](): string;
/**
* `Align center`
*/
["com.affine.keyboardShortcuts.alignCenter"](): string;
/**
* `Align left`
*/
["com.affine.keyboardShortcuts.alignLeft"](): string;
/**
* `Align right`
*/
["com.affine.keyboardShortcuts.alignRight"](): string;
/**
* `Append to daily note`
*/

View File

@@ -626,6 +626,9 @@
"com.affine.journal.placeholder.title": "No Journal",
"com.affine.journal.placeholder.create": "Create Daily Journal",
"com.affine.just-now": "Just now",
"com.affine.keyboardShortcuts.alignCenter": "Align center",
"com.affine.keyboardShortcuts.alignLeft": "Align left",
"com.affine.keyboardShortcuts.alignRight": "Align right",
"com.affine.keyboardShortcuts.appendDailyNote": "Append to daily note",
"com.affine.keyboardShortcuts.bodyText": "Body text",
"com.affine.keyboardShortcuts.bold": "Bold",

View File

@@ -8,7 +8,7 @@ test.describe('AIChatWith/Attachments', () => {
test.beforeEach(async ({ loggedInPage: page, utils }) => {
await utils.testUtils.setupTestEnvironment(
page,
'claude-sonnet-4@20250514'
'claude-sonnet-4-5@20250929'
);
await utils.chatPanel.openChatPanel(page);
});

View File

@@ -8,7 +8,7 @@ test.describe('AIChatWith/Collections', () => {
test.beforeEach(async ({ loggedInPage: page, utils }) => {
await utils.testUtils.setupTestEnvironment(
page,
'claude-sonnet-4@20250514'
'claude-sonnet-4-5@20250929'
);
await utils.chatPanel.openChatPanel(page);
await utils.editor.clearAllCollections(page);

View File

@@ -9,7 +9,7 @@ test.describe('AISettings/Embedding', () => {
test.beforeEach(async ({ loggedInPage: page, utils }) => {
await utils.testUtils.setupTestEnvironment(
page,
'claude-sonnet-4@20250514'
'claude-sonnet-4-5@20250929'
);
await utils.chatPanel.openChatPanel(page);
});

View File

@@ -24,9 +24,9 @@ test('add callout block using slash menu and change emoji', async ({
}) => {
await type(page, '/callout\naaaa\nbbbb');
const callout = page.locator('affine-callout');
const emoji = page.locator('affine-callout .affine-callout-emoji');
const emoji = page.locator('affine-callout').getByTestId('callout-emoji');
await expect(callout).toBeVisible();
await expect(emoji).toContainText('😀');
await expect(emoji).toContainText('💡');
const paragraph = page.locator('affine-callout affine-paragraph');
await expect(paragraph).toHaveCount(2);
@@ -35,18 +35,6 @@ test('add callout block using slash menu and change emoji', async ({
await expect(vLine).toHaveCount(2);
expect(await vLine.nth(0).innerText()).toBe('aaaa');
expect(await vLine.nth(1).innerText()).toBe('bbbb');
await emoji.click();
const emojiMenu = page.locator('affine-emoji-menu');
await expect(emojiMenu).toBeVisible();
await page
.locator('div')
.filter({ hasText: /^😀😃😄😁😆😅🤣😂🙂$/ })
.getByLabel('😆')
.click();
await page.getByTestId('page-editor-blank').click();
await expect(emojiMenu).not.toBeVisible();
await expect(emoji).toContainText('😆');
});
test('press backspace after callout block', async ({ page }) => {

View File

@@ -386,7 +386,7 @@ test.describe('paste to code block', () => {
await pressEnter(page);
await addCodeBlock(page);
const plainTextCode = [
' model: anthropic("claude-3-7-sonnet-20250219"),',
' model: anthropic("claude-sonnet-4-5-20250929"),',
' prompt: How many people will live in the world in 2040?',
' providerOptions: {',
' anthropic: {',

View File

@@ -92,13 +92,6 @@ test('should format quick bar show when clicking drag handle', async ({
const { formatBar } = getFormatBar(page);
await expect(formatBar).toBeVisible();
const box = await formatBar.boundingBox();
if (!box) {
throw new Error("formatBar doesn't exist");
}
assertAlmostEqual(box.x, 251, 5);
assertAlmostEqual(box.y - dragHandleRect.y, -55.5, 5);
});
test('should format quick bar show when select text by keyboard', async ({
@@ -548,17 +541,6 @@ test('should format quick bar work in single block selection', async ({
const { formatBar } = getFormatBar(page);
await expect(formatBar).toBeVisible();
const formatRect = await formatBar.boundingBox();
const selectionRect = await blockSelections.boundingBox();
if (!formatRect) {
throw new Error('formatRect is not found');
}
if (!selectionRect) {
throw new Error('selectionRect is not found');
}
assertAlmostEqual(formatRect.x - selectionRect.x, 147.5, 10);
assertAlmostEqual(formatRect.y - selectionRect.y, -48, 10);
const boldBtn = formatBar.getByTestId('bold');
await boldBtn.click();
const italicBtn = formatBar.getByTestId('italic');
@@ -603,17 +585,6 @@ test('should format quick bar work in multiple block selection', async ({
const formatBarController = getFormatBar(page);
await expect(formatBarController.formatBar).toBeVisible();
const box = await formatBarController.formatBar.boundingBox();
if (!box) {
throw new Error("formatBar doesn't exist");
}
const rect = await blockSelections.first().boundingBox();
if (!rect) {
throw new Error('rect is not found');
}
assertAlmostEqual(box.x - rect.x, 147.5, 10);
assertAlmostEqual(box.y - rect.y, -48, 10);
await formatBarController.boldBtn.click();
await formatBarController.italicBtn.click();
await formatBarController.underlineBtn.click();

View File

@@ -606,7 +606,7 @@ test.describe('slash search', () => {
await expect(slashMenu).toBeVisible();
await type(page, 'c');
await expect(slashItems).toHaveCount(8);
await expect(slashItems).toHaveCount(9);
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Copy']);
await expect(slashItems.nth(1).locator('.text')).toHaveText(['Italic']);
await expect(slashItems.nth(2).locator('.text')).toHaveText(['New Doc']);

View File

@@ -11,7 +11,7 @@
"dependencies": {
"@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*",
"@googleapis/androidpublisher": "^28.0.0",
"@googleapis/androidpublisher": "^31.0.0",
"typescript": "^5.7.2"
},
"devDependencies": {

View File

@@ -1287,6 +1287,7 @@ export const PackageList = [
'packages/frontend/component',
'packages/frontend/core',
'packages/common/env',
'packages/common/graphql',
'packages/frontend/i18n',
'packages/common/nbstore',
'blocksuite/affine/all',

1123
yarn.lock

File diff suppressed because it is too large Load Diff