Compare commits

...

16 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
53 changed files with 496 additions and 120 deletions

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

@@ -20,7 +20,7 @@ export const calloutEmojiContainerStyles = css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '10px',
// marginTop is dynamically set by JavaScript based on first child's height
marginBottom: '10px',
flexShrink: 0,
position: 'relative',

View File

@@ -4,7 +4,10 @@ import {
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 {
@@ -69,6 +72,35 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
this.classList.add(calloutHostStyles);
}
private _getEmojiMarginTop(): string {
if (this.model.children.length === 0) {
return '10px';
}
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';
}
// Default for all other block types
return '10px';
}
private _closeIconPicker() {
if (this._popupCloseHandler) {
this._popupCloseHandler();
@@ -204,6 +236,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
@click=${this._toggleIconPicker}
contenteditable="false"
class="${calloutEmojiContainerStyles}"
style=${styleMap({
marginTop: this._getEmojiMarginTop(),
})}
>
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
>${iconContent}</span

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -28414,9 +28414,9 @@ __metadata:
linkType: hard
"nodemailer@npm:^7.0.0":
version: 7.0.3
resolution: "nodemailer@npm:7.0.3"
checksum: 10/d51e9b30753c982c35cf77839f3b7c1f138eb3fb5607c34f724ddc32360e56c3d631ff5b4eba5491f1f8805b428b945850441b4bd893bd2283c55be615f020c5
version: 7.0.9
resolution: "nodemailer@npm:7.0.9"
checksum: 10/88883c58afe356d2b4c24b1e976c04857e8a7a7145e1752dab69072900b0cc2e3daa0964a08c653e692fb64382453e1cfcee3d863828844c8d6f6239727b9023
languageName: node
linkType: hard