Compare commits

...

22 Commits

Author SHA1 Message Date
Cats Juice
a35332634a fix(core): correct doc icon padding in editor header (#13721)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Refined vertical spacing in the document icon picker header, reducing
excess top padding and setting a consistent bottom padding for a
cleaner, tighter layout.
* Improves visual alignment and readability without altering
functionality—interactions and behavior remain unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-10 09:12:01 +00:00
DarkSky
0063f039a7 feat(server): allow cleanup session for deleted docs (#13720)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Resolved occasional errors when removing document links from sessions,
ensuring cleanup completes reliably.
* Improved reliability during maintenance actions by preventing
unnecessary validation failures in system-initiated updates, while
preserving existing checks for user-initiated changes.

* **Chores**
* Internal adjustments to the session update flow to better support
maintenance operations without affecting user-facing behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-10 12:43:21 +08:00
Cats Juice
d80ca57e94 fix(core): change doc icon layout to avoid incorrect color caused by the transform (#13719)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Updated document title styling for improved readability (larger font,
increased line height, heavier weight).
* Refined spacing so titles align correctly when a document icon is
present (no extra top padding).
* Improved emoji rendering by using a consistent font and removing an
unnecessary visual artifact.
* Simplified title container behavior to ensure stable, predictable
alignment without placeholder-based shifts.

* **Chores**
* Minor UI cleanup and consistency adjustments for the icon/title area.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-10 02:08:05 +00:00
Lakr
c63e3e7fe6 fix(ios): adopt smaller font size for small device (#13715)
This pull request makes minor adjustments to the iOS frontend app,
focusing on UI fine-tuning and improving type safety for concurrency.
The most notable changes are a small font size adjustment in the paywall
badge, marking an enum as `Sendable` for safer concurrency, and removing
a StoreKit configuration reference from the Xcode scheme.

UI adjustments:
* Reduced the font size for the badge text in `PackageOptionView` from
12 to 10 for a more refined appearance.

Concurrency and type safety:
* Added the `Sendable` protocol conformance to the `SKUnitCategory` enum
to ensure it can be safely used across concurrency boundaries.

Project configuration:
* Removed the `StoreKitConfigurationFileReference` from the
`App.xcscheme`, which may help streamline scheme configuration or
prevent unnecessary StoreKit file usage during app launch.

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

## Summary by CodeRabbit

- Style
- Tweaked paywall option badge text size for a cleaner, more polished
look.

- Refactor
- Improved concurrency safety in underlying models to enhance stability.

- Chores
- Removed a development-only StoreKit configuration from the iOS debug
launch setup.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 19:13:02 +08:00
DarkSky
05d373081a fix(server): update email verified at oauth (#13714)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automatic email verification when signing in or reconnecting with a
linked OAuth provider: if the provider confirms the same email and your
account was unverified, your email will be marked as verified
automatically.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 09:46:05 +00:00
William Guinaudie
26fbde6b62 fix(core): quick search modal on mobile device (#13694)
When searching on a mobile device, the search modal is wider than the
screen, making it hard to use
<img width="345" height="454" alt="Screenshot 2025-10-04 at 17 43 54"
src="https://github.com/user-attachments/assets/10594459-86c5-470b-a22f-578363694383"
/>

Now with the fix applied, it is  usable

<img width="350" height="454" alt="Screenshot 2025-10-04 at 17 44 14"
src="https://github.com/user-attachments/assets/eb783f5b-e3b6-4b7d-8f31-0d876911d95f"
/>


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

## Summary by CodeRabbit

- **Style**
- Improved mobile responsiveness of the Quick Search modal. On screens
520px wide or smaller, the modal content now adapts its width instead of
enforcing a minimum, reducing overflow and improving readability on
small devices.
- No visual or behavioral changes on larger screens; existing layouts
and interactions remain unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 09:04:44 +00:00
Cats Juice
072b5b22df fix(core): display affine icon in lit correctly (#13708)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Added an alternative icon rendering option for document icons,
delivering crisper visuals and consistent emoji/icon display.
- Style
- Improved icon alignment and sizing within grouped icon buttons for
more consistent centering and appearance.

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

Co-authored-by: Wu Yue <akumatus@gmail.com>
2025-10-09 09:04:24 +00:00
3720
3c7461a5ce fix(editor): adjust callout emoji spacing based on first child block type (#13712)
- Remove fixed marginTop from emoji container style
- Dynamically calculate emoji marginTop based on first child block type
(h1-h6)
- Use model signal to reactively update spacing when children change
- Default to 10px for non-heading blocks

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

## Summary by CodeRabbit

- Style
- Improved emoji alignment in callout blocks. The emoji now adjusts its
top spacing based on the first line’s heading level, ensuring better
vertical alignment with headings (H1–H6) and more consistent visual
balance across different callout contents.
- Maintains existing margins and layout behavior otherwise, resulting in
a cleaner, more polished appearance without affecting functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 09:04:14 +00:00
DarkSky
1b859a37c5 feat: improve attachment headers (#13709)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Safer, consistent file downloads with automatic attachment headers and
filenames.
- Smarter MIME detection for uploads (avatars, workspace blobs, Copilot
files/transcripts).
  - Sensible default buffer limit when reading uploads.

- **Bug Fixes**
- Prevents risky content from rendering inline by forcing downloads and
adding no‑sniff protection.
- More accurate content types when original metadata is missing or
incorrect.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-09 08:04:18 +00:00
renovate[bot]
bf72833f05 chore: bump up nodemailer version to v7.0.7 [SECURITY] (#13704)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [nodemailer](https://nodemailer.com/)
([source](https://redirect.github.com/nodemailer/nodemailer)) | [`7.0.3`
-> `7.0.7`](https://renovatebot.com/diffs/npm/nodemailer/7.0.3/7.0.7) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/nodemailer/7.0.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nodemailer/7.0.3/7.0.7?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

### GitHub Vulnerability Alerts

####
[GHSA-mm7p-fcc7-pg87](https://redirect.github.com/nodemailer/nodemailer/security/advisories/GHSA-mm7p-fcc7-pg87)

The email parsing library incorrectly handles quoted local-parts
containing @&#8203;. This leads to misrouting of email recipients, where
the parser extracts and routes to an unintended domain instead of the
RFC-compliant target.

Payload: `"xclow3n@gmail.com x"@&#8203;internal.domain`
Using the following code to send mail
```
const nodemailer = require("nodemailer");

let transporter = nodemailer.createTransport({
  service: "gmail",
  auth: {
    user: "",
    pass: "",
  },
});

let mailOptions = {
  from: '"Test Sender" <your_email@gmail.com>', 
  to: "\"xclow3n@gmail.com x\"@&#8203;internal.domain",
  subject: "Hello from Nodemailer",
  text: "This is a test email sent using Gmail SMTP and Nodemailer!",
};

transporter.sendMail(mailOptions, (error, info) => {
  if (error) {
    return console.log("Error: ", error);
  }
  console.log("Message sent: %s", info.messageId);

});

(async () => {
  const parser = await import("@&#8203;sparser/email-address-parser");
  const { EmailAddress, ParsingOptions } = parser.default;
  const parsed = EmailAddress.parse(mailOptions.to /*, new ParsingOptions(true) */);

  if (!parsed) {
    console.error("Invalid email address:", mailOptions.to);
    return;
  }

  console.log("Parsed email:", {
    address: `${parsed.localPart}@&#8203;${parsed.domain}`,
    local: parsed.localPart,
    domain: parsed.domain,
  });
})();
```

Running the script and seeing how this mail is parsed according to RFC

```
Parsed email: {
  address: '"xclow3n@gmail.com x"@&#8203;internal.domain',
  local: '"xclow3n@gmail.com x"',
  domain: 'internal.domain'
}
```

But the email is sent to `xclow3n@gmail.com`

<img width="2128" height="439" alt="Image"
src="https://github.com/user-attachments/assets/20eb459c-9803-45a2-b30e-5d1177d60a8d"
/>

### Impact:

- Misdelivery / Data leakage: Email is sent to psres.net instead of
test.com.

- Filter evasion: Logs and anti-spam systems may be bypassed by hiding
recipients inside quoted local-parts.

-    Potential compliance issue: Violates RFC 5321/5322 parsing rules.

- Domain based access control bypass in downstream applications using
your library to send mails

### Recommendations

-    Fix parser to correctly treat quoted local-parts per RFC 5321/5322.

- Add strict validation rejecting local-parts containing embedded
@&#8203; unless fully compliant with quoting.

---

### Release Notes

<details>
<summary>nodemailer/nodemailer (nodemailer)</summary>

###
[`v7.0.7`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#707-2025-10-05)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.6...v7.0.7)

##### Bug Fixes

- **addressparser:** Fixed addressparser handling of quoted nested email
addresses
([1150d99](1150d99fba))
- **dns:** add memory leak prevention for DNS cache
([0240d67](0240d6795d))
- **linter:** Updated eslint and created prettier formatting task
([df13b74](df13b7487e))
- refresh expired DNS cache on error
([#&#8203;1759](https://redirect.github.com/nodemailer/nodemailer/issues/1759))
([ea0fc5a](ea0fc5a663))
- resolve linter errors in DNS cache tests
([3b8982c](3b8982c1f2))

###
[`v7.0.6`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#706-2025-08-27)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.5...v7.0.6)

##### Bug Fixes

- **encoder:** avoid silent data loss by properly flushing trailing
base64
([#&#8203;1747](https://redirect.github.com/nodemailer/nodemailer/issues/1747))
([01ae76f](01ae76f2cf))
- handle multiple XOAUTH2 token requests correctly
([#&#8203;1754](https://redirect.github.com/nodemailer/nodemailer/issues/1754))
([dbe0028](dbe0028635))
- ReDoS vulnerability in parseDataURI and \_processDataUrl
([#&#8203;1755](https://redirect.github.com/nodemailer/nodemailer/issues/1755))
([90b3e24](90b3e24d23))

###
[`v7.0.5`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#705-2025-07-07)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.4...v7.0.5)

##### Bug Fixes

- updated well known delivery service list
([fa2724b](fa2724b337))

###
[`v7.0.4`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#704-2025-06-29)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.3...v7.0.4)

##### Bug Fixes

- **pools:** Emit 'clear' once transporter is idle and all connections
are closed
([839e286](839e28634c))
- **smtp-connection:** jsdoc public annotation for socket
([#&#8203;1741](https://redirect.github.com/nodemailer/nodemailer/issues/1741))
([c45c84f](c45c84fe9b))
- **well-known-services:** Added AliyunQiye
([bb9e6da](bb9e6daffb))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" (UTC), 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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xMzEuOSIsInVwZGF0ZWRJblZlciI6IjQxLjEzMS45IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-09 04:36:15 +00:00
DarkSky
96b3de8ce7 chore: update docs 2025-10-04 19:29:45 +08:00
DarkSky
26a59db540 chore: update docs 2025-10-04 19:27:37 +08:00
Lakr
7d0b8aaa81 feat(ios): sync paywall with external purchased items (#13681)
This pull request introduces significant improvements to the integration
between the paywall feature and the web context within the iOS app. The
main focus is on enabling synchronization of subscription states between
the app and the embedded web view, refactoring how purchased items are
managed, and enhancing the paywall presentation logic. Additionally,
some debug-only code has been removed for cleaner production builds.

**Paywall and Web Context Integration**

* Added support for binding a `WKWebView` context to the paywall,
allowing the paywall to communicate with the web view for subscription
state updates and retrievals (`Paywall.presentWall` now accepts a
`bindWebContext` parameter, and `ViewModel` supports binding and using
the web context).
[[1]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32)
[[2]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0R54-R57)
[[3]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0L26-R38)
[[4]](diffhunk://#diff-1854d318d8fd8736d078f5960373ed440836263649a8193c8ee33e72a99424edL30-R36)

* On paywall dismissal, the app now triggers a JavaScript call to update
the subscription state in the web view, ensuring consistency between the
app and the web context.

**Purchased Items Refactor**

* Refactored `ViewModel` to distinguish between store-purchased items
and externally-purchased items (from the web context), and unified them
in a computed `purchasedItems` property. This improves clarity and
extensibility for handling entitlements from multiple sources.

* Added logic to fetch external entitlements by executing JavaScript in
the web view and decoding the subscription information, mapping external
plans to internal product identifiers.
[[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL99-R137)
[[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbR169-R209)

**Codebase Cleanup**

* Removed debug-only code for shake gesture and debug menu from
`AFFiNEViewController`, streamlining the production build.

**API and Model Enhancements**

* Made `SKUnitCategory` and its extensions public to allow broader usage
across modules, and introduced a configuration struct for the paywall.
[[1]](diffhunk://#diff-742ccf0c6bafd2db6cb9795382d556fbab90b8855ff38dc340aa39318541517dL10-R17)
[[2]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32)

**Other Minor Improvements**

* Improved constructor formatting for `PayWallPlugin` for readability.

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

- New Features
- Paywall now binds to the in-app web view so web-based subscriptions
are recognized alongside App Store purchases.
- Bug Fixes
- Entitlements combine App Store and web subscription state for more
accurate display.
- Dismissing the paywall immediately updates subscription status to
reduce stale states.
  - Improved reliability when presenting the paywall.
- Chores
  - Removed debug shake menu and debug paywall options from iOS builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-03 07:21:41 +00:00
Kieran Cui
856b69e1f6 fix(core): optimize settings dialog's right-side content scroll position (#13236)
In the settings dialog, when switching between different setting items,
the right-side content retains the previous scroll position. I think it
would be better for the right side to return to the top every time a
switch is made, so I submitted this PR.

**before**


https://github.com/user-attachments/assets/a2d10601-6173-41d3-8d68-6fbccc62aaa7


**after**


https://github.com/user-attachments/assets/f240348b-e131-4703-8232-1a07e924162d



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

## Summary by CodeRabbit

* **Bug Fixes**
* Ensured the settings dialog always scrolls to the top when the
settings state updates, improving user experience when navigating
settings.

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

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-10-01 14:52:39 +00:00
renovate[bot]
5fdae9161a chore: bump up SwifterSwift/SwifterSwift version to from: "6.2.0" (#12874)
> [!NOTE]
> Mend has cancelled [the proposed
renaming](https://redirect.github.com/renovatebot/renovate/discussions/37842)
of the Renovate GitHub app being renamed to `mend[bot]`.
> 
> This notice will be removed on 2025-10-07.

<hr>

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[SwifterSwift/SwifterSwift](https://redirect.github.com/SwifterSwift/SwifterSwift)
| minor | `from: "6.0.0"` -> `from: "6.2.0"` |

---

### Release Notes

<details>
<summary>SwifterSwift/SwifterSwift (SwifterSwift/SwifterSwift)</summary>

###
[`v6.2.0`](https://redirect.github.com/SwifterSwift/SwifterSwift/blob/HEAD/CHANGELOG.md#v620)

[Compare
Source](https://redirect.github.com/SwifterSwift/SwifterSwift/compare/6.1.1...6.2.0)

##### Added

- **NSView**
- Added `addArrangedSubviews(_ views: )` to add an array of views to the
end of the arrangedSubviews array.
[#&#8203;1181](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1181)
by [Roman Podymov](https://redirect.github.com/RomanPodymov)
- Added `removeArrangedSubviews` to remove all views in stack’s array of
arranged subviews.
[#&#8203;1181](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1181)
by [Roman Podymov](https://redirect.github.com/RomanPodymov)
- **Sequence**
- `sorted(by:)`, `sorted(by:with:)`, `sorted(by:and:)`,
`sorted(by:and:and:)`, `sum(for:)`, `first(where:equals:)` now have
alternatives that receive functions as parameters. This change maintains
compatibility with KeyPath while making the methods more flexible.
[#&#8203;1170](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1170)
by [MartonioJunior](https://redirect.github.com/MartonioJunior)

##### Changed

- **Sequence**
- `sorted(by:)`, `sorted(by:with:)`, `sorted(by:and:)`,
`sorted(by:and:and:)`, `sum(for:)`, `first(where:equals:)` now have
alternatives that receive functions as parameters. This change maintains
compatibility with KeyPath while making the methods more flexible.
[#&#8203;1170](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1170)
by [MartonioJunior](https://redirect.github.com/MartonioJunior)
- `contains(_:)` for `Element: Hashable` now can receive any type that
conforms to `Sequence`, not just an `Array`.
[#&#8203;1169](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1169)
by [MartonioJunior](https://redirect.github.com/MartonioJunior)

##### Fixed

- **PrivacyInfo.xcprivacy**
- XCode Generate Privacy Report: `Missing an expected key:
'NSPrivacyCollectedDataTypes'`.
[#&#8203;1182](https://redirect.github.com/SwifterSwift/SwifterSwift/issues/1182)
by [Phil](https://redirect.github.com/cdoky)

###
[`v6.1.1`](https://redirect.github.com/SwifterSwift/SwifterSwift/blob/HEAD/CHANGELOG.md#v611)

[Compare
Source](https://redirect.github.com/SwifterSwift/SwifterSwift/compare/6.1.0...6.1.1)

##### Added

- **Cocoapods**
- Added the privacy manifest to Cocoapods.
[#&#8203;1178](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1178)
by [guykogus](https://redirect.github.com/guykogus)

###
[`v6.1.0`](https://redirect.github.com/SwifterSwift/SwifterSwift/blob/HEAD/CHANGELOG.md#v610)

[Compare
Source](https://redirect.github.com/SwifterSwift/SwifterSwift/compare/6.0.0...6.1.0)

##### Deprecated

- **UIImageView**
- `blurred(withStyle:)` should have copied the image view and blurred
the new instance, but instead it performed the same functionality as
`blur(withStyle:)`, making the outcome unexpected as well as being
obsolete.
[#&#8203;1161](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1161)
by [guykogus](https://redirect.github.com/guykogus)

##### Added

- **Swift Package Manager**
- Added a privacy manifest to comply with Apple's requirements regarding
[Describing use of required reason
API](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api).
[#&#8203;1176](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1176)
by [guykogus](https://redirect.github.com/guykogus)
- **Measurement**
- Added `+=`, `-=`, `*=`, `/=` to add, subtract, multiply and divide
measurements.
[#&#8203;1162](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1162)
by [Roman Podymov](https://redirect.github.com/RomanPodymov)
- **Sequence**
- Added `product()` for calculating the product of all `Numeric`
elements.
[#&#8203;1168](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1168)
by [MartonioJunior](https://redirect.github.com/MartonioJunior)
- Added `product(for:)` for calculating the product of the `Numeric`
property for all elements in `Sequence`.
[#&#8203;1168](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1168)
by [MartonioJunior](https://redirect.github.com/MartonioJunior)
- **UIView**
- Added `removeBlur()` method for removing the applied blur effect from
the view.
[#&#8203;1159](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1159)
by [regi93](https://redirect.github.com/regi93)
- Added `makeCircle(diameter:)` method to make the view circular.
[#&#8203;1165](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1165)
by [happyduck-git](https://redirect.github.com/happyduck-git)

##### Fixed

- **UIImageView**
- Moved `blur(withStyle:)` from `UIImageView` to `UIView`, as it can be
performed on all views.
[#&#8203;1161](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1161)
by [guykogus](https://redirect.github.com/guykogus)
- **UIView**
- `GradientDirection` initializer and constants had access level
`internal` instead of `public`.
[#&#8203;1152](https://redirect.github.com/SwifterSwift/SwifterSwift/pull/1152)
by [guykogus](https://redirect.github.com/guykogus)

</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:eyJjcmVhdGVkSW5WZXIiOiI0MC42MC4xIiwidXBkYXRlZEluVmVyIjoiNDEuMTMxLjkiLCJ0YXJnZXRCcmFuY2giOiJjYW5hcnkiLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 17:41:45 +00:00
Wu Yue
03ef4625bc feat(core): handle AI subscription for pro models (#13682)
<img width="576" height="251" alt="截屏2025-09-30 14 55 20"
src="https://github.com/user-attachments/assets/947a4ab3-8b34-434d-94a6-afb5dad3d32c"
/>


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

- **New Features**
- Added “Subscribe to AI” action across chat experiences (panel,
content, composer, input, playground, peek view) that launches an in-app
checkout flow.
- Chat content now refreshes subscription status when opened; desktop
chat pages wire the subscription action for seamless checkout.

- **Style**
  - Polished hover state for the subscription icon in chat preferences.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 10:47:59 +00:00
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
107 changed files with 1882 additions and 629 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",

7
Cargo.lock generated
View File

@@ -161,6 +161,7 @@ dependencies = [
"affine_common",
"chrono",
"file-format",
"infer",
"mimalloc",
"napi",
"napi-build",
@@ -1504,9 +1505,9 @@ dependencies = [
[[package]]
name = "file-format"
version = "0.26.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ef3d5e8ae27277c8285ac43ed153158178ef0f79567f32024ca8140a0c7cd8"
checksum = "0eab8aa2fba5f39f494000a22f44bf3c755b7d7f8ffad3f36c6d507893074159"
[[package]]
name = "flate2"
@@ -1913,7 +1914,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.61.2",
]
[[package]]

View File

@@ -39,7 +39,7 @@ crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
dotenvy = "0.15"
file-format = { version = "0.26", features = ["reader"] }
file-format = { version = "0.28", features = ["reader"] }
homedir = "0.3"
infer = { version = "0.19.0" }
lasso = { version = "0.7", features = ["multi-threaded"] }

View File

@@ -6,12 +6,12 @@ We recommend users to always use the latest major version. Security updates will
| Version | Supported |
| --------------- | ------------------ |
| 0.17.x (stable) | :white_check_mark: |
| < 0.17.x | :x: |
| 0.24.x (stable) | :white_check_mark: |
| < 0.24.x | :x: |
## Reporting a Vulnerability
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info) or submit directly on [GitHub](https://github.com/toeverything/AFFiNE/security), **we encourage you to submit the relevant information directly via GitHub**. We expect your report to contain at least the following for us to evaluate and reproduce:
1. Using platform and version, for example:
@@ -22,8 +22,6 @@ We welcome you to provide us with bug reports via and email at [security@toevery
3. Your classification or analysis of the vulnerability (optional)
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.

View File

@@ -22,6 +22,7 @@
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emotion/css": "^11.13.5",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",

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 is dynamically set by JavaScript based on first child's height
marginBottom: '10px',
flexShrink: 0,
position: 'relative',
});
export const calloutEmojiStyles = css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':hover': {
cursor: 'pointer',
opacity: 0.7,
},
});
export const calloutChildrenStyles = css({
flex: 1,
minWidth: 0,
paddingLeft: '10px',
});
export const iconPickerContainerStyles = css({
position: 'absolute',
top: '100%',
left: 0,
zIndex: 1000,
background: 'white',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
width: '390px',
height: '400px',
});

View File

@@ -1,88 +1,160 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
createPopup,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import { type CalloutBlockModel } from '@blocksuite/affine-model';
import {
type CalloutBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
import {
DocModeProvider,
ThemeProvider,
type IconData,
IconPickerServiceIdentifier,
IconType,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import * as icons from '@blocksuite/icons/lit';
import type { BlockComponent } from '@blocksuite/std';
import { flip, offset } from '@floating-ui/dom';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { type Signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import type { TemplateResult } from 'lit';
import { html } from 'lit';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import {
calloutBlockContainerStyles,
calloutChildrenStyles,
calloutEmojiContainerStyles,
calloutEmojiStyles,
calloutHostStyles,
} from './callout-block-styles.js';
import { IconPickerWrapper } from './icon-picker-wrapper.js';
// Copy of renderUniLit and UniLit from affine-data-view
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
uni: UniComponent<Props, Expose> | undefined,
props?: Props,
options?: {
ref?: Signal<Expose | undefined>;
style?: Readonly<StyleInfo>;
class?: string;
}
): TemplateResult => {
return html` <uni-lit
.uni="${uni}"
.props="${props}"
.ref="${options?.ref}"
style=${options?.style ? styleMap(options?.style) : ''}
></uni-lit>`;
};
const getIcon = (icon?: IconData) => {
if (!icon) {
return null;
}
if (icon.type === IconType.Emoji) {
return icon.unicode;
}
if (icon.type === IconType.AffineIcon) {
return (
icons as Record<string, (props: { style: string }) => TemplateResult>
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
}
return null;
};
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
static override styles = css`
:host {
display: block;
margin: 8px 0;
private _popupCloseHandler: (() => void) | null = null;
override connectedCallback() {
super.connectedCallback();
this.classList.add(calloutHostStyles);
}
private _getEmojiMarginTop(): string {
if (this.model.children.length === 0) {
return '10px';
}
.affine-callout-block-container {
display: flex;
align-items: flex-start;
padding: 5px 10px;
border-radius: 8px;
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
const firstChild = this.model.children[0];
const flavour = firstChild.flavour;
const marginTopMap: Record<string, string> = {
'affine:paragraph:h1': '23px',
'affine:paragraph:h2': '20px',
'affine:paragraph:h3': '16px',
'affine:paragraph:h4': '15px',
'affine:paragraph:h5': '14px',
'affine:paragraph:h6': '13px',
};
// For heading blocks, use the type to determine margin
if (flavour === 'affine:paragraph') {
const paragraph = firstChild as ParagraphBlockModel;
const type = paragraph.props.type$.value;
const key = `${flavour}:${type}`;
return marginTopMap[key] || '10px';
}
.affine-callout-emoji-container {
user-select: none;
font-size: 1.2em;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
margin-bottom: 10px;
flex-shrink: 0;
// Default for all other block types
return '10px';
}
private _closeIconPicker() {
if (this._popupCloseHandler) {
this._popupCloseHandler();
this._popupCloseHandler = null;
}
.affine-callout-emoji:hover {
cursor: pointer;
opacity: 0.7;
}
private _toggleIconPicker(event: MouseEvent) {
// If popup is already open, close it
if (this._popupCloseHandler) {
this._closeIconPicker();
return;
}
.affine-callout-children {
flex: 1;
min-width: 0;
padding-left: 10px;
// Get IconPickerService from the framework
const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier);
if (!iconPickerService) {
console.warn('IconPickerService not found');
return;
}
`;
private _emojiMenuAbortController: AbortController | null = null;
private readonly _toggleEmojiMenu = () => {
if (this._emojiMenuAbortController) {
this._emojiMenuAbortController.abort();
}
this._emojiMenuAbortController = new AbortController();
// Get the uni-component from the service
const iconPickerComponent = iconPickerService.iconPickerComponent;
const theme = this.std.get(ThemeProvider).theme$.value;
createLitPortal({
template: html`<affine-emoji-menu
.theme=${theme}
.onEmojiSelect=${(data: { native: string }) => {
this.model.props.emoji = data.native;
}}
></affine-emoji-menu>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
// Create props for the icon picker
const props = {
onSelect: (iconData?: IconData) => {
this.model.props.icon$.value = iconData;
this._closeIconPicker(); // Close the picker after selection
},
container: this.host,
computePosition: {
referenceElement: this._emojiButton,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
onClose: () => {
this._closeIconPicker();
},
};
// Create IconPickerWrapper instance
const wrapper = new IconPickerWrapper();
wrapper.iconPickerComponent = iconPickerComponent;
wrapper.props = props;
wrapper.style.position = 'absolute';
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
wrapper.style.borderRadius = '8px';
// Create popup target from the clicked element
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
// Create popup
this._popupCloseHandler = createPopup(target, wrapper, {
onClose: () => {
this._popupCloseHandler = null;
},
abortController: this._emojiMenuAbortController,
closeOnClickAway: true,
});
};
}
private readonly _handleBlockClick = (event: MouseEvent) => {
// Check if the click target is emoji related element
@@ -94,6 +166,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
return;
}
// If there's no icon, open icon picker on click
const icon = this.model.props.icon$.value;
if (!icon) {
this._toggleIconPicker(event);
return;
}
// Only handle clicks when there are no children
if (this.model.children.length > 0) {
return;
@@ -125,9 +204,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
return this.std.get(DefaultInlineManagerExtension.identifier);
}
@query('.affine-callout-emoji')
private accessor _emojiButton!: HTMLElement;
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(
@@ -138,23 +214,39 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
}
override renderBlock() {
const emoji = this.model.props.emoji$.value;
const icon = this.model.props.icon$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value;
const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string>
)[backgroundColorName ?? ''];
const iconContent = getIcon(icon);
return html`
<div
class="affine-callout-block-container"
class="${calloutBlockContainerStyles}"
@click=${this._handleBlockClick}
style=${styleMap({
backgroundColor: backgroundColor ?? 'transparent',
})}
>
<div
@click=${this._toggleEmojiMenu}
contenteditable="false"
class="affine-callout-emoji-container"
style=${styleMap({
display: emoji.length === 0 ? 'none' : undefined,
})}
>
<span class="affine-callout-emoji">${emoji}</span>
</div>
<div class="affine-callout-children">
${iconContent
? html`
<div
@click=${this._toggleIconPicker}
contenteditable="false"
class="${calloutEmojiContainerStyles}"
style=${styleMap({
marginTop: this._getEmojiMarginTop(),
})}
>
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
>${iconContent}</span
>
</div>
`
: ''}
<div class="${calloutChildrenStyles}">
${this.renderChildren(this.model)}
</div>
</div>

View File

@@ -0,0 +1,204 @@
import {
createPopup,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { CalloutBlockModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type IconData,
IconPickerServiceIdentifier,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { IconPickerWrapper } from '../icon-picker-wrapper.js';
const colors = [
'default',
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
] as const;
const backgroundColorAction = {
id: 'background-color',
label: 'Background Color',
tooltip: 'Change background color',
icon: PaletteIcon(),
run() {
// This will be handled by the content function
},
content(ctx) {
const model = ctx.getCurrentModelByType(CalloutBlockModel);
if (!model) return null;
const updateBackground = (color: string) => {
ctx.store.updateBlock(model, { backgroundColorName: color });
};
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="background"
.tooltip=${'Background Color'}
>
${PaletteIcon()} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
<div class="highlight-heading">Background</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? null
: `var(--affine-text-highlight-${color})`;
const displayName = `${color} Background`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => updateBackground(color)}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': 'var(--affine-text-primary-color)',
'--background': value ?? 'transparent',
})}
></affine-text-duotone-icon>
<span class="label capitalize">${displayName}</span>
</editor-menu-action>
`;
})}
</div>
</editor-menu-button>
`;
},
} satisfies ToolbarAction;
const iconPickerAction = {
id: 'icon-picker',
label: 'Icon Picker',
tooltip: 'Change icon',
icon: SmileIcon(),
run() {
// This will be handled by the content function
},
content(ctx) {
const model = ctx.getCurrentModelByType(CalloutBlockModel);
if (!model) return null;
const handleIconPickerClick = (event: MouseEvent) => {
// Get IconPickerService from the framework
const iconPickerService = ctx.std.getOptional(
IconPickerServiceIdentifier
);
if (!iconPickerService) {
console.warn('IconPickerService not found');
return;
}
// Get the uni-component from the service
const iconPickerComponent = iconPickerService.iconPickerComponent;
// Create props for the icon picker
const props = {
onSelect: (iconData?: IconData) => {
// When iconData is undefined (delete icon), set icon to undefined
ctx.store.updateBlock(model, { icon: iconData });
closeHandler(); // Close the picker after selection
},
onClose: () => {
closeHandler();
},
};
// Create IconPickerWrapper instance
const wrapper = new IconPickerWrapper();
wrapper.iconPickerComponent = iconPickerComponent;
wrapper.props = props;
wrapper.style.position = 'absolute';
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
wrapper.style.borderRadius = '8px';
// Create popup target from the clicked element
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
// Create popup
const closeHandler = createPopup(target, wrapper, {
onClose: () => {
// Cleanup if needed
},
});
};
return html`
<editor-icon-button
aria-label="icon-picker"
.tooltip=${'Change Icon'}
@click=${handleIconPickerClick}
>
${SmileIcon()} ${EditorChevronDown}
</editor-icon-button>
`;
},
} satisfies ToolbarAction;
const builtinToolbarConfig = {
actions: [
{
id: 'style',
actions: [backgroundColorAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'icon',
actions: [iconPickerAction],
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(CalloutBlockModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
} satisfies ToolbarAction,
],
} as const satisfies ToolbarModuleConfig;
export const createBuiltinToolbarConfigExtension = (
flavour: string
): ExtensionType[] => {
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
];
};

View File

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

View File

@@ -1,34 +0,0 @@
import { WithDisposable } from '@blocksuite/global/lit';
import data from '@emoji-mart/data';
import { Picker } from 'emoji-mart';
import { html, LitElement, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
export class EmojiMenu extends WithDisposable(LitElement) {
override firstUpdated(props: PropertyValues) {
const result = super.firstUpdated(props);
const picker = new Picker({
data,
onEmojiSelect: this.onEmojiSelect,
autoFocus: true,
theme: this.theme,
});
this.emojiMenu.append(picker as unknown as Node);
return result;
}
@property({ attribute: false })
accessor onEmojiSelect: (data: any) => void = () => {};
@property({ attribute: false })
accessor theme: 'light' | 'dark' = 'light';
@query('.affine-emoji-menu')
accessor emojiMenu!: HTMLElement;
override render() {
return html`<div class="affine-emoji-menu"></div>`;
}
}

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

@@ -8,6 +8,7 @@ import { literal } from 'lit/static-html.js';
import { CalloutKeymapExtension } from './callout-keymap';
import { calloutSlashMenuConfig } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
import { effects } from './effects';
export class CalloutViewExtension extends ViewExtensionProvider {
@@ -25,6 +26,7 @@ export class CalloutViewExtension extends ViewExtensionProvider {
BlockViewExtension('affine:callout', literal`affine-callout`),
CalloutKeymapExtension,
SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig),
...createBuiltinToolbarConfigExtension('affine:callout'),
]);
}
}

View File

@@ -19,16 +19,16 @@ const DOC_BLOCK_CHILD_PADDING = 24;
export class DocTitle extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-title-container {
font-size: 40px;
line-height: 50px;
font-weight: 700;
}
.doc-icon-container,
.doc-title-container {
box-sizing: border-box;
font-family: var(--affine-font-family);
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-size: 40px;
line-height: 50px;
font-weight: 700;
outline: none;
resize: none;
border: 0;
@@ -47,6 +47,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
${DOC_BLOCK_CHILD_PADDING}px
);
}
.doc-icon-container + * .doc-title-container {
/* when doc icon exists, remove the top padding */
padding-top: 0;
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {

View File

@@ -1,3 +1,4 @@
import type { IconData } from '@blocksuite/affine-shared/services';
import {
BlockModel,
BlockSchemaExtension,
@@ -8,15 +9,17 @@ import {
import type { BlockMeta } from '../../utils/types';
export type CalloutProps = {
emoji: string;
icon?: IconData;
text: Text;
backgroundColorName?: string;
} & BlockMeta;
export const CalloutBlockSchema = defineBlockSchema({
flavour: 'affine:callout',
props: (internal): CalloutProps => ({
emoji: '😀',
icon: { type: 'emoji', unicode: '💡' } as IconData,
text: internal.Text(),
backgroundColorName: 'grey',
'meta:createdAt': undefined,
'meta:updatedAt': undefined,
'meta:createdBy': undefined,

View File

@@ -0,0 +1 @@
export * from './icon-picker-service/index.js';

View File

@@ -0,0 +1,29 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { createIdentifier } from '@blocksuite/global/di';
export enum IconType {
Emoji = 'emoji',
AffineIcon = 'affine-icon',
Blob = 'blob',
}
export type IconData =
| {
type: IconType.Emoji;
unicode: string;
}
| {
type: IconType.AffineIcon;
name: string;
color: string;
}
| {
type: IconType.Blob;
blob: Blob;
};
export interface IconPickerService {
iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>;
}
export const IconPickerServiceIdentifier =
createIdentifier<IconPickerService>('IconPickerService');

View File

@@ -13,6 +13,7 @@ export * from './feature-flag-service';
export * from './file-size-limit-service';
export * from './font-loader';
export * from './generate-url-service';
export * from './icon-picker-service';
export * from './link-preview-service';
export * from './native-clipboard-service';
export * from './notification-service';

View File

@@ -76,10 +76,16 @@ export const linkedDocPopoverStyles = css`
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.group icon-button svg {
.group icon-button svg,
.group icon-button .icon {
width: 20px;
height: 20px;
}
.group icon-button .icon {
display: flex;
align-items: center;
justify-content: center;
}
.linked-doc-popover .group {
display: flex;

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

@@ -10,6 +10,7 @@ crate-type = ["cdylib"]
affine_common = { workspace = true, features = ["doc-loader"] }
chrono = { workspace = true }
file-format = { workspace = true }
infer = { workspace = true }
napi = { workspace = true, features = ["async"] }
napi-derive = { workspace = true }
rand = { workspace = true }

View File

@@ -2,7 +2,11 @@ use napi_derive::napi;
#[napi]
pub fn get_mime(input: &[u8]) -> String {
file_format::FileFormat::from_bytes(input)
.media_type()
.to_string()
if let Some(kind) = infer::get(&input[..4096.min(input.len())]) {
kind.mime_type().to_string()
} else {
file_format::FileFormat::from_bytes(input)
.media_type()
.to_string()
}
}

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

@@ -30,6 +30,7 @@ import {
createTestingApp,
createWorkspace,
inviteUser,
smallestPng,
TestingApp,
TestUser,
} from './utils';
@@ -453,8 +454,6 @@ test('should create message correctly', async t => {
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,
@@ -475,8 +474,6 @@ test('should create message correctly', async t => {
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,

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

@@ -6,6 +6,8 @@ import ava from 'ava';
import {
createTestingApp,
getPublicUserById,
smallestGif,
smallestPng,
TestingApp,
updateAvatar,
} from '../utils';
@@ -27,7 +29,9 @@ test('should be able to upload user avatar', async t => {
const { app } = t.context;
await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
const res = await updateAvatar(app, avatar);
t.is(res.status, 200);
@@ -36,19 +40,23 @@ test('should be able to upload user avatar', async t => {
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
t.deepEqual(avatarRes.body, Buffer.from('test'));
t.deepEqual(avatarRes.body, avatar);
});
test('should be able to update user avatar, and invalidate old avatar url', async t => {
const { app } = t.context;
await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
let res = await updateAvatar(app, avatar);
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
const newAvatar = Buffer.from('new');
const newAvatar = await fetch(smallestGif)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
res = await updateAvatar(app, newAvatar);
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
@@ -58,14 +66,16 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
t.is(avatarRes.status, 404);
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
t.deepEqual(newAvatarRes.body, Buffer.from('new'));
t.deepEqual(newAvatarRes.body, newAvatar);
});
test('should be able to get public user by id', async t => {
const { app } = t.context;
const u1 = await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
await updateAvatar(app, avatar);
const u2 = await app.signup();

View File

@@ -3,6 +3,10 @@ import { type Blob } from '@prisma/client';
import { TestingApp } from './testing-app';
import { TEST_LOG_LEVEL } from './utils';
export const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
export async function listBlobs(
app: TestingApp,
workspaceId: string

View File

@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
};
export type * from './provider';
export { autoMetadata, toBuffer } from './utils';
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';

View File

@@ -1,6 +1,7 @@
import { Readable } from 'node:stream';
import { crc32 } from '@node-rs/crc32';
import type { Response } from 'express';
import { getStreamAsBuffer } from 'get-stream';
import { getMime } from '../../../native';
@@ -43,4 +44,53 @@ export function autoMetadata(
return metadata;
}
const DANGEROUS_INLINE_MIME_PREFIXES = [
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'application/xml',
'text/xml',
'text/javascript',
];
export function isDangerousInlineMime(mime: string | undefined) {
if (!mime) return false;
const lower = mime.toLowerCase();
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
}
export function applyAttachHeaders(
res: Response,
options: { filename?: string; buffer?: Buffer; contentType?: string }
) {
let { filename, buffer, contentType } = options;
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!contentType && buffer) contentType = sniffMime(buffer);
if (contentType && isDangerousInlineMime(contentType)) {
const safeName = (filename || 'download')
.replace(/[\r\n]/g, '')
.replace(/[^\w\s.-]/g, '_');
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
safeName
)}`
);
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', contentType || 'application/octet-stream');
}
}
export function sniffMime(
buffer: Buffer,
declared?: string
): string | undefined {
try {
const detected = getMime(buffer);
if (detected) return detected;
} catch {}
return declared;
}
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour

View File

@@ -1,6 +1,7 @@
import { Readable } from 'node:stream';
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
import { OneKB } from './unit';
export type CheckExceededResult =
| {
@@ -52,7 +53,7 @@ export async function readBuffer(
export async function readBufferWithLimit(
readable: Readable,
limit: number
limit: number = 500 * OneKB
): Promise<Buffer> {
return readBuffer(readable, size =>
size > limit

View File

@@ -1,7 +1,11 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { ActionForbidden, UserAvatarNotFound } from '../../base';
import {
ActionForbidden,
applyAttachHeaders,
UserAvatarNotFound,
} from '../../base';
import { Public } from '../auth/guard';
import { AvatarStorage } from '../storage';
@@ -30,6 +34,10 @@ export class UserAvatarController {
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('content-length', metadata.contentLength);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: `${id}`,
});
body.pipe(res);
}

View File

@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
import {
CannotDeleteOwnAccount,
type FileUpload,
readBufferWithLimit,
sniffMime,
Throttle,
UserNotFound,
} from '../../base';
@@ -98,20 +100,20 @@ export class UserResolver {
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
if (!avatar.mimetype.startsWith('image/')) {
throw new Error('Invalid file type');
}
if (!user) {
throw new UserNotFound();
}
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
}
const avatarUrl = await this.storage.put(
`${user.id}-avatar-${Date.now()}`,
avatar.createReadStream(),
{
contentType: avatar.mimetype,
}
avatarBuffer,
{ contentType }
);
if (user.avatarUrl) {

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import {
applyAttachHeaders,
BlobNotFound,
CallMetric,
CommentAttachmentNotFound,
@@ -83,6 +84,10 @@ export class WorkspacesController {
} else {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: name,
});
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);
@@ -215,6 +220,10 @@ export class WorkspacesController {
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: key,
});
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
body.pipe(res);

View File

@@ -396,7 +396,10 @@ export class CopilotSessionModel extends BaseModel {
}
@Transactional()
async update(options: UpdateChatSessionOptions): Promise<string> {
async update(
options: UpdateChatSessionOptions,
internalCall = false
): Promise<string> {
const { userId, sessionId, docId, promptName, pinned, title } = options;
const session = await this.getExists(
sessionId,
@@ -415,14 +418,16 @@ export class CopilotSessionModel extends BaseModel {
}
// not allow to update action session
if (session.prompt.action) {
throw new CopilotSessionInvalidInput(
`Cannot update action: ${session.id}`
);
} else if (docId && session.parentSessionId) {
throw new CopilotSessionInvalidInput(
`Cannot update docId for forked session: ${session.id}`
);
if (!internalCall) {
if (session.prompt.action) {
throw new CopilotSessionInvalidInput(
`Cannot update action: ${session.id}`
);
} else if (docId && session.parentSessionId) {
throw new CopilotSessionInvalidInput(
`Cannot update docId for forked session: ${session.id}`
);
}
}
if (promptName) {

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

@@ -31,6 +31,7 @@ import {
EventBus,
type FileUpload,
RequestMutex,
sniffMime,
Throttle,
TooManyRequest,
UserFriendlyError,
@@ -671,7 +672,11 @@ export class CopilotContextResolver {
const { filename, mimetype } = content;
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
const file = await session.addFile(blobId, filename, mimetype);
const file = await session.addFile(
blobId,
filename,
sniffMime(buffer, mimetype) || mimetype
);
await this.jobs.addFileEmbeddingQueue({
userId: user.id,

View File

@@ -32,6 +32,7 @@ import {
} from 'rxjs';
import {
applyAttachHeaders,
BlobNotFound,
CallMetric,
Config,
@@ -795,6 +796,10 @@ export class CopilotController implements BeforeApplicationShutdown {
} else {
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: key,
});
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);

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

@@ -30,6 +30,7 @@ import {
Paginated,
PaginationInput,
RequestMutex,
sniffMime,
Throttle,
TooManyRequest,
UserFriendlyError,
@@ -806,7 +807,10 @@ export class CopilotResolver {
filename,
uploaded.buffer
);
attachments.push({ attachment, mimeType: blob.mimetype });
attachments.push({
attachment,
mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype,
});
}
}

View File

@@ -636,11 +636,10 @@ export class ChatSessionService {
})
.then(s => s.map(s => [s.userId, s.id]));
for (const [userId, sessionId] of sessionIds) {
await this.models.copilotSession.update({
userId,
sessionId,
docId: null,
});
await this.models.copilotSession.update(
{ userId, sessionId, docId: null },
true
);
}
}

View File

@@ -12,6 +12,7 @@ import {
NoCopilotProviderAvailable,
OnEvent,
OnJob,
sniffMime,
} from '../../../base';
import { Models } from '../../../models';
import { PromptService } from '../prompt';
@@ -85,7 +86,10 @@ export class CopilotTranscriptionService {
`${blobId}-${idx}`,
buffer
);
infos.push({ url, mimeType: blob.mimetype });
infos.push({
url,
mimeType: sniffMime(buffer, blob.mimetype) || blob.mimetype,
});
}
const model = await this.getModel(userId);

View File

@@ -2,7 +2,12 @@ import { createHash } from 'node:crypto';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { FileUpload, JobQueue, PaginationInput } from '../../../base';
import {
FileUpload,
JobQueue,
PaginationInput,
sniffMime,
} from '../../../base';
import { ServerFeature, ServerService } from '../../../core';
import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
@@ -64,7 +69,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
const file = await this.models.copilotWorkspace.addFile(workspaceId, {
fileName,
blobId,
mimeType: content.mimetype,
mimeType: sniffMime(buffer, content.mimetype) || content.mimetype,
size: buffer.length,
});
return { blobId, file };

View File

@@ -221,6 +221,15 @@ export class OAuthController {
if (connectedAccount) {
// already connected
await this.updateConnectedAccount(connectedAccount, tokens);
if (
!connectedAccount.user.emailVerifiedAt &&
// external email may change, check if it matches exists email
externalAccount.email.toLowerCase() ===
connectedAccount.user.email.toLowerCase()
) {
await this.auth.setEmailVerified(connectedAccount.userId);
}
return connectedAccount.user;
}

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

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

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

@@ -50,9 +50,6 @@
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../App/Products.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@@ -74,29 +74,4 @@ class AFFiNEViewController: CAPBridgeViewController {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
#if DEBUG
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
showDebugMenu()
}
}
#endif
}
#if DEBUG
import AffinePaywall
extension AFFiNEViewController {
@objc private func showDebugMenu() {
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
Paywall.presentWall(toController: self, type: "Pro")
})
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
Paywall.presentWall(toController: self, type: "AI")
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
}
#endif

View File

@@ -6,7 +6,9 @@ import UIKit
@objc(PayWallPlugin)
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
init(associatedController: UIViewController? = nil) {
init(
associatedController: UIViewController?
) {
controller = associatedController
super.init()
}
@@ -27,7 +29,11 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
// TODO: GET TO KNOW THE PAYWALL TYPE
print("[*] showing paywall of type: \(type)")
DispatchQueue.main.async {
Paywall.presentWall(toController: controller, type: type)
Paywall.presentWall(
toController: controller,
bindWebContext: self.webView,
type: type
)
}
call.resolve(["success": true, "type": type])

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

@@ -44,7 +44,7 @@ struct PackageOptionView: View {
if !badge.isEmpty {
Text(badge)
.contentTransition(.numericText())
.font(.system(size: 12))
.font(.system(size: 10))
.bold()
.lineLimit(1)
.foregroundColor(AffineColors.layerPureWhite.color)

View File

@@ -7,14 +7,14 @@
import Foundation
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
var id: Int { rawValue }
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
public var id: Int { rawValue }
case pro
case ai
}
extension SKUnitCategory {
public extension SKUnitCategory {
var title: String {
switch self {
case .pro: "AFFINE.Pro"

View File

@@ -75,6 +75,21 @@ extension ViewModel {
func dismiss() {
print(#function)
if let context = associatedWebContext {
Task.detached {
do {
_ = try await context.callAsyncJavaScript(
"return await window.updateSubscriptionState();",
contentWorld: .page
)
print("updateSubscriptionState success")
} catch {
print("updateSubscriptionState error:", error.localizedDescription)
}
}
}
associatedController?.dismiss(animated: true)
}
}
@@ -96,12 +111,30 @@ nonisolated extension ViewModel {
// fetch purchased items if signed in
do {
let purchase = try await store.fetchEntitlements()
await MainActor.run { self.purchasedItems = purchase }
await MainActor.run { self.storePurchasedItems = purchase }
} catch {
print("fetchEntitlements error:", error)
if !initial { throw error }
}
// fetch external items by executing on webview's JS context
do {
guard let webView = await associatedWebContext else {
throw NSError(domain: "Paywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
])
}
let result = try await webView.callAsyncJavaScript(
"return await window.getSubscriptionState();",
contentWorld: .page
)
let purchased = decodeWebContextSubscriptionInformation(result)
print("fetched external purchased items:", purchased)
await MainActor.run { self.externalPurchasedItems = purchased }
} catch {
print("fetchExternalEntitlements error:", error.localizedDescription)
}
// select the package under purchased items if any
let availablePackages = await availablePackageOptions
let purchase = await purchasedItems
@@ -133,4 +166,45 @@ nonisolated extension ViewModel {
await MainActor.run { self.updating = false }
}
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
var ans: Set<String> = []
guard let dict = input as? [String: Any] else {
assertionFailure()
return ans
}
let pro = dict["pro"] as? [String: Any]
let ai = dict["ai"] as? [String: Any]
if let proPlan = pro?["recurring"] as? String {
switch proPlan.lowercased() {
case "lifetime":
// user actually purchased believer plan
// but we map it to yearly plan just for easier handling
// do not purchase any of this plan if already purchased
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
case "monthly":
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
case "yearly":
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
default:
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
assertionFailure()
}
}
if let aiPlan = ai?["recurring"] as? String {
switch aiPlan.lowercased() {
case "yearly":
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
default:
// ai plan can only be purchased as yearly plan
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
assertionFailure()
}
}
return ans
}
}

View File

@@ -7,6 +7,7 @@
import StoreKit
import SwiftUI
import WebKit
@MainActor
class ViewModel: ObservableObject {
@@ -23,10 +24,18 @@ class ViewModel: ObservableObject {
@Published var updating = false
@Published var products: [Product] = []
@Published var purchasedItems: Set<String> = []
@Published var storePurchasedItems: Set<String> = []
@Published var externalPurchasedItems: Set<String> = []
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
var purchasedItems: Set<String> {
Set<String>()
.union(storePurchasedItems)
.union(externalPurchasedItems)
}
private(set) weak var associatedController: UIViewController?
private(set) weak var associatedWebContext: WKWebView?
init() {
updateAppStoreStatus(initial: true)
@@ -42,6 +51,10 @@ class ViewModel: ObservableObject {
associatedController = controller
}
func bind(context: WKWebView) {
associatedWebContext = context
}
func select(category: SKUnitCategory) {
self.category = category
let units = SKUnit.units(for: category)

View File

@@ -7,14 +7,17 @@
import SwiftUI
import UIKit
import WebKit
public enum Paywall {
@MainActor
public static func presentWall(
toController controller: UIViewController,
bindWebContext context: WKWebView?,
type: String
) {
let viewModel = ViewModel()
if let context { viewModel.bind(context: context) }
switch type.lowercased() {
case "pro":
viewModel.select(category: .pro)

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.23.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.1"),
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.2.0"),
.package(url: "https://github.com/Recouse/EventSource.git", from: "0.1.5"),
.package(url: "https://github.com/Lakr233/ListViewKit.git", from: "1.1.6"),
.package(url: "https://github.com/Lakr233/MarkdownView.git", from: "3.4.2"),

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

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

@@ -137,6 +137,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor session: CopilotChatHistoryFragment | null | undefined;
@@ -462,6 +465,7 @@ export class ChatPanel extends SignalWatcher(
.peekViewService=${this.peekViewService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}

View File

@@ -149,6 +149,9 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor chips: ChatChip[] = [];
@@ -200,6 +203,7 @@ export class AIChatComposer extends SignalWatcher(
.notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}

View File

@@ -192,6 +192,9 @@ export class AIChatContent extends SignalWatcher(
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
@@ -381,6 +384,9 @@ export class AIChatContent extends SignalWatcher(
.catch(console.error);
}
// revalidate subscription to get the latest status
this.subscriptionService.subscription.revalidate();
this._disposables.add(
AIProvider.slots.actions.subscribe(({ event }) => {
const { status } = this.chatContextValue;
@@ -472,6 +478,7 @@ export class AIChatContent extends SignalWatcher(
.aiToolsConfigService=${this.aiToolsConfigService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',

View File

@@ -377,6 +377,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -534,6 +537,7 @@ export class AIChatInput extends SignalWatcher(
.notificationService=${this.notificationService}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
></chat-input-preference>
${status === 'transmitting' || status === 'loading'
? html`<button

View File

@@ -72,6 +72,9 @@ export class ChatInputPreference extends SignalWatcher(
.ai-model-prefix svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
.ai-model-postfix svg:hover {
color: ${unsafeCSSVarV2('icon/activated')};
}
.ai-model-version {
font-size: 12px;
color: ${unsafeCSSVarV2('text/tertiary')};
@@ -119,6 +122,9 @@ export class ChatInputPreference extends SignalWatcher(
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
model = computed(() => {
const modelId = this.aiModelService.modelId.value;
const activeModel = this.aiModelService.models.value.find(
@@ -161,7 +167,7 @@ export class ChatInputPreference extends SignalWatcher(
</div>
`,
postfix: html`
<div>
<div class="ai-model-postfix" @click=${this.onAISubscribe}>
${model.isPro && !isSubscribed ? LockIcon() : undefined}
</div>
`,

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

@@ -182,6 +182,9 @@ export class PlaygroundChat extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor onAISubscribe: (() => Promise<void>) | undefined;
@property({ attribute: false })
accessor addChat!: () => Promise<void>;
@@ -374,6 +377,7 @@ export class PlaygroundChat extends SignalWatcher(
.aiToolsConfigService=${this.aiToolsConfigService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.onAISubscribe=${this.onAISubscribe}
></ai-chat-composer>
</div>`;
}

View File

@@ -2,6 +2,8 @@ import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
import type { SubscriptionService } from '@affine/core/modules/cloud';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type {
@@ -622,6 +624,9 @@ export class AIChatBlockPeekView extends LitElement {
}}
.portalContainer=${this.parentElement}
.reasoningConfig=${this.reasoningConfig}
.subscriptionService=${this.subscriptionService}
.aiModelService=${this.aiModelService}
.onAISubscribe=${this.onAISubscribe}
></ai-chat-composer>
</div> `;
}
@@ -659,6 +664,15 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor aiModelService!: AIModelService;
@property({ attribute: false })
accessor subscriptionService!: SubscriptionService;
@property({ attribute: false })
accessor onAISubscribe!: () => Promise<void>;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -697,7 +711,10 @@ export const AIChatBlockPeekViewTemplate = (
affineFeatureFlagService: FeatureFlagService,
affineWorkspaceDialogService: WorkspaceDialogService,
aiDraftService: AIDraftService,
aiToolsConfigService: AIToolsConfigService
aiToolsConfigService: AIToolsConfigService,
subscriptionService: SubscriptionService,
aiModelService: AIModelService,
onAISubscribe: (() => Promise<void>) | undefined
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -710,5 +727,8 @@ export const AIChatBlockPeekViewTemplate = (
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
.aiDraftService=${aiDraftService}
.aiToolsConfigService=${aiToolsConfigService}
.subscriptionService=${subscriptionService}
.aiModelService=${aiModelService}
.onAISubscribe=${onAISubscribe}
></ai-chat-block-peek-view>`;
};

View File

@@ -175,6 +175,7 @@ const usePreviewExtensions = () => {
.ai(enableAI, framework)
.theme(framework)
.database(framework)
.iconPicker(framework)
.linkedDoc(framework)
.paragraph(enableAI)
.linkPreview(framework)

View File

@@ -11,7 +11,10 @@ export const docIconPickerTrigger = style({
lineHeight: 1,
},
'&[data-icon-type="emoji"]': {
fontFamily: 'emoji',
fontFamily: 'Inter',
},
'&::after': {
display: 'none',
},
},
});

View File

@@ -6,21 +6,12 @@ import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './doc-icon-picker.css';
const TitleContainer = ({
children,
isPlaceholder,
}: {
children: React.ReactNode;
isPlaceholder: boolean;
}) => {
const TitleContainer = ({ children }: { children: React.ReactNode }) => {
return (
<div
className="doc-icon-container"
style={{
paddingTop: 0,
paddingBottom: 0,
// title container has `padding-top`
transform: isPlaceholder ? 'translateY(80%)' : 'translateY(50%)',
paddingBottom: 8,
}}
>
{children}
@@ -54,7 +45,7 @@ export const DocIconPicker = ({
}
return (
<TitleContainer isPlaceholder={isPlaceholder}>
<TitleContainer>
<IconEditor
icon={icon?.icon}
onIconChange={data => {

View File

@@ -117,6 +117,7 @@ const usePatchSpecs = (mode: DocMode, shared?: boolean) => {
.electron(framework)
.linkPreview(framework)
.codeBlockPreview(framework)
.iconPicker(framework)
.comment(enableComment, framework).value;
if (BUILD_CONFIG.isMobileEdition) {

View File

@@ -16,6 +16,7 @@ import {
type AffineEditorViewOptions,
} from '@affine/core/blocksuite/view-extensions/editor-view/editor-view';
import { ElectronViewExtension } from '@affine/core/blocksuite/view-extensions/electron';
import { AffineIconPickerExtension } from '@affine/core/blocksuite/view-extensions/icon-picker';
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/view-extensions/link-preview-service';
import { MobileViewExtension } from '@affine/core/blocksuite/view-extensions/mobile';
import { PdfViewExtension } from '@affine/core/blocksuite/view-extensions/pdf';
@@ -58,6 +59,7 @@ type Configure = {
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
codeBlockPreview: (framework?: FrameworkProvider) => Configure;
iconPicker: (framework?: FrameworkProvider) => Configure;
comment: (
enableComment?: boolean,
framework?: FrameworkProvider
@@ -86,6 +88,7 @@ class ViewProvider {
AffineThemeViewExtension,
AffineEditorViewExtension,
AffineEditorConfigViewExtension,
AffineIconPickerExtension,
CodeBlockPreviewViewExtension,
EdgelessBlockHeaderConfigViewExtension,
TurboRendererViewExtension,
@@ -123,6 +126,7 @@ class ViewProvider {
electron: this._configureElectron,
linkPreview: this._configureLinkPreview,
codeBlockPreview: this._configureCodeBlockHtmlPreview,
iconPicker: this._configureIconPicker,
comment: this._configureComment,
value: this._manager,
};
@@ -146,6 +150,7 @@ class ViewProvider {
.electron()
.linkPreview()
.codeBlockPreview()
.iconPicker()
.comment();
return this.config;
@@ -333,6 +338,11 @@ class ViewProvider {
return this.config;
};
private readonly _configureIconPicker = (framework?: FrameworkProvider) => {
this._manager.configure(AffineIconPickerExtension, { framework });
return this.config;
};
private readonly _configureComment = (
enableComment?: boolean,
framework?: FrameworkProvider

View File

@@ -0,0 +1,23 @@
import { IconPickerServiceIdentifier } from '@blocksuite/affine/shared/services';
import { type ExtensionType } from '@blocksuite/affine/store';
import type { Container } from '@blocksuite/global/di';
import type { FrameworkProvider } from '@toeverything/infra';
import { IconPickerService } from '../../../modules/icon-picker/services/icon-picker';
/**
* Patch the icon picker service to make it available in BlockSuite
* @param framework
* @returns
*/
export function patchIconPickerService(
framework: FrameworkProvider
): ExtensionType {
return {
setup: (di: Container) => {
di.override(IconPickerServiceIdentifier, () => {
return framework.get(IconPickerService);
});
},
};
}

View File

@@ -0,0 +1,32 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchIconPickerService } from './icon-picker-service';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type AffineIconPickerViewOptions = z.infer<typeof optionsSchema>;
export class AffineIconPickerExtension extends ViewExtensionProvider<AffineIconPickerViewOptions> {
override name = 'affine-icon-picker-extension';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: AffineIconPickerViewOptions
) {
super.setup(context, options);
if (!options?.framework) {
return;
}
const { framework } = options;
context.register(patchIconPickerService(framework));
}
}

View File

@@ -44,6 +44,7 @@ export const useAISpecs = () => {
.mobile(framework)
.electron(framework)
.linkPreview(framework)
.iconPicker(framework)
.codeBlockPreview(framework).value;
return manager.get('page');

View File

@@ -0,0 +1,52 @@
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useFramework } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
/**
* Hook to handle AI subscription checkout
* @returns A function that initiates the AI subscription checkout process
*/
export const useAISubscribe = () => {
const framework = useFramework();
const handleAISubscribe = useCallback(async () => {
try {
const authService = framework.get(AuthService);
const subscriptionService = framework.get(SubscriptionService);
const urlService = framework.get(UrlService);
const account = authService.session.account$.value;
if (!account) {
return;
}
const idempotencyKey = nanoid();
const checkoutOptions = {
recurring: SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.AI,
variant: null,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
account,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
),
};
const session = await subscriptionService.createCheckoutSession({
idempotencyKey,
...checkoutOptions,
});
urlService.openExternal(session);
} catch (error) {
console.error(error);
}
}, [framework]);
return handleAISubscribe;
};

View File

@@ -190,6 +190,7 @@ const SettingModalInner = ({
}
});
}
modalContentWrapperRef.current?.scrollTo({ top: 0 });
}, [settingState]);
return (
<FrameworkScope scope={currentServer.scope}>

View File

@@ -11,6 +11,7 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
@@ -197,6 +198,7 @@ export const Component = () => {
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const mockStd = useMockStd();
const handleAISubscribe = useAISubscribe();
// init or update ai-chat-content
useEffect(() => {
@@ -233,6 +235,8 @@ export const Component = () => {
content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.subscriptionService = framework.get(SubscriptionService);
content.aiModelService = framework.get(AIModelService);
content.onAISubscribe = handleAISubscribe;
content.createSession = createSession;
content.onOpenDoc = onOpenDoc;
@@ -260,6 +264,7 @@ export const Component = () => {
onContextChange,
specs,
onOpenDoc,
handleAISubscribe,
]);
// init or update header ai-chat-toolbar

View File

@@ -4,6 +4,7 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
@@ -63,6 +64,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
} = useAIChatConfig();
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const handleAISubscribe = useAISubscribe();
useEffect(() => {
if (!editor || !editor.host) return;
@@ -109,6 +111,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
chatPanelRef.current.subscriptionService =
framework.get(SubscriptionService);
chatPanelRef.current.aiModelService = framework.get(AIModelService);
chatPanelRef.current.onAISubscribe = handleAISubscribe;
containerRef.current?.append(chatPanelRef.current);
} else {
@@ -141,6 +144,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
playgroundConfig,
confirmModal,
specs,
handleAISubscribe,
]);
const [autoResized, setAutoResized] = useState(false);

View File

@@ -29,7 +29,7 @@ import type { DocRecord, DocsService } from '../../doc';
import type { ExplorerIconService } from '../../explorer-icon/services/explorer-icon';
import type { I18nService } from '../../i18n';
import type { JournalService } from '../../journal';
import { getDocIconComponent } from './icon';
import { getDocIconComponent, getDocIconComponentLit } from './icon';
type IconType = 'rc' | 'lit';
interface DocDisplayIconOptions<T extends IconType> {
@@ -152,7 +152,9 @@ export class DocDisplayMetaService extends Service {
// if (emoji) return () => emoji;
const icon = get(this.explorerIconService.icon$('doc', docId))?.icon;
if (icon) {
return getDocIconComponent(icon);
return options?.type === 'lit'
? getDocIconComponentLit(icon)
: getDocIconComponent(icon);
}
}

View File

@@ -1,7 +1,25 @@
import { type IconData, IconRenderer } from '@affine/component';
import { type IconData, IconRenderer, IconType } from '@affine/component';
import * as litIcons from '@blocksuite/icons/lit';
import { html } from 'lit';
export const getDocIconComponent = (icon: IconData) => {
const Icon = () => <IconRenderer data={icon} />;
Icon.displayName = 'DocIcon';
return Icon;
};
export const getDocIconComponentLit = (icon: IconData) => {
return () => {
if (icon.type === IconType.Emoji) {
return html`<div class="icon">${icon.unicode}</div>`;
}
if (icon.type === IconType.AffineIcon) {
return html`<div
style="color: ${icon.color}; display: flex; align-items: center; justify-content: center;"
>
${litIcons[`${icon.name}Icon` as keyof typeof litIcons]()}
</div>`;
}
return null;
};
};

View File

@@ -0,0 +1,9 @@
import { type Framework } from '@toeverything/infra';
import { IconPickerService } from './services/icon-picker';
export { IconPickerService } from './services/icon-picker';
export function configureIconPickerModule(framework: Framework) {
framework.service(IconPickerService);
}

View File

@@ -0,0 +1,16 @@
import { IconPicker, uniReactRoot } from '@affine/component';
// Import the identifier for internal use
import { type IconPickerService as IIconPickerService } from '@blocksuite/affine-shared/services';
import { Service } from '@toeverything/infra';
// Re-export types from BlockSuite shared services
export type {
IconData,
IconPickerService as IIconPickerService,
} from '@blocksuite/affine-shared/services';
export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services';
export class IconPickerService extends Service implements IIconPickerService {
public readonly iconPickerComponent =
uniReactRoot.createUniComponent(IconPicker);
}

View File

@@ -33,6 +33,7 @@ import { configureFavoriteModule } from './favorite';
import { configureFeatureFlagModule } from './feature-flag';
import { configureGlobalContextModule } from './global-context';
import { configureI18nModule } from './i18n';
import { configureIconPickerModule } from './icon-picker';
import { configureImportClipperModule } from './import-clipper';
import { configureImportTemplateModule } from './import-template';
import { configureIntegrationModule } from './integration';
@@ -132,4 +133,5 @@ export function configureCommonModules(framework: Framework) {
configureCommentModule(framework);
configureDocSummaryModule(framework);
configurePaywallModule(framework);
configureIconPickerModule(framework);
}

View File

@@ -2,10 +2,13 @@ import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
import {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -33,6 +36,9 @@ export const AIChatBlockPeekView = ({
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
const aiDraftService = framework.get(AIDraftService);
const aiToolsConfigService = framework.get(AIToolsConfigService);
const subscriptionService = framework.get(SubscriptionService);
const aiModelService = framework.get(AIModelService);
const handleAISubscribe = useAISubscribe();
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
@@ -45,7 +51,10 @@ export const AIChatBlockPeekView = ({
affineFeatureFlagService,
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService
aiToolsConfigService,
subscriptionService,
aiModelService,
handleAISubscribe
);
return toReactNode(template);
}, [
@@ -59,5 +68,8 @@ export const AIChatBlockPeekView = ({
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService,
subscriptionService,
aiModelService,
handleAISubscribe,
]);
};

View File

@@ -62,4 +62,9 @@ export const modalContent = style({
animationFillMode: 'forwards',
},
},
'@media': {
'screen and (max-width: 520px)': {
minWidth: 'auto',
},
},
});

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