mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Add design spec: gate Rich Text messages behind Premium
Non-premium users are blocked at send/edit when typed markdown would produce a RichTextMessageAttribute, and shown a .premiumPaywall toast (todo-gate pattern). Saved Messages and premium-disabled regions are exempt. Receiving/rendering rich messages is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cfe335d124
commit
9a86e79b5c
1 changed files with 196 additions and 0 deletions
|
|
@ -0,0 +1,196 @@
|
|||
# Gate Rich Text messages behind Premium — design
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved (design); ready for implementation plan.
|
||||
|
||||
## Problem
|
||||
|
||||
Rich Text messages (`RichTextMessageAttribute` carrying an `InstantPage` — headings, lists,
|
||||
tables, formulas) are auto-produced from typed markdown on send/edit (see
|
||||
[`docs/instantpage-richtext.md`](../../instantpage-richtext.md), "Markdown send: entity vs. rich
|
||||
detection"). We want sending/creating a Rich Text message to be a **Telegram Premium** feature:
|
||||
non-premium users are blocked at the point of sending or editing-into-rich and offered the Premium
|
||||
upsell, mirroring how the existing **todo/checklist** message type is gated.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope — gating message *creation* (the markdown → rich path):**
|
||||
|
||||
- Compose + send.
|
||||
- Editing a message whose new markdown would produce rich.
|
||||
- The long-press "Send options" sheet (its preview, and — transitively — its send).
|
||||
|
||||
**Out of scope (explicit non-goals):**
|
||||
|
||||
- **Rendering / receiving** rich messages. Non-premium users must still *see* formatted messages
|
||||
others send. Only the create/send side is gated.
|
||||
- No new `PremiumSource` enum case — the paywall reuses the generic `.settings` source.
|
||||
- No server promo-content / `premiumPromoConfiguration` changes.
|
||||
|
||||
## Behavior
|
||||
|
||||
When a non-premium user composes or edits markdown that the classifier
|
||||
(`richMarkdownAttributeIfNeeded`) would turn into a Rich Text message, the action is **blocked**
|
||||
and a `.premiumPaywall` toast is shown. Tapping the toast's info action opens the Premium intro
|
||||
screen. The user's typed text is **preserved** in the input/edit field (the send/edit simply does
|
||||
not proceed).
|
||||
|
||||
This matches the todo gate precedent at `ChatController.swift:5668` and
|
||||
`ChatControllerOpenTodoContextMenu.swift:71`.
|
||||
|
||||
### Gate conditions
|
||||
|
||||
The gate fires **iff all** of the following hold:
|
||||
|
||||
1. The text would produce a rich message (`richMarkdownAttributeIfNeeded(...) != nil`).
|
||||
2. The account is **not** premium.
|
||||
3. The chat is **not** the user's own Saved Messages (`peerId != context.account.peerId`).
|
||||
— "notes to self" carve-out, matching the premium-emoji gate
|
||||
(`ChatControllerNode.swift:4742`).
|
||||
4. Premium is **not** disabled in this region/build
|
||||
(`!PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }).isPremiumDisabled`).
|
||||
— so rich text isn't permanently blocked where Premium can't be purchased. (Note: the todo gate
|
||||
does **not** do this; we deliberately add it here.)
|
||||
|
||||
If premium is disabled, rich text behaves exactly as today (free for everyone).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Gate-decision helper (shared free function)
|
||||
|
||||
New file `submodules/TelegramUI/Sources/Chat/ChatRichTextPremiumGate.swift`:
|
||||
|
||||
```swift
|
||||
func isRichTextMessageGated(context: AccountContext, peerId: EnginePeer.Id?, isPremium: Bool) -> Bool {
|
||||
if isPremium {
|
||||
return false
|
||||
}
|
||||
if let peerId, peerId == context.account.peerId {
|
||||
return false // Saved Messages carve-out
|
||||
}
|
||||
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
|
||||
if premiumConfiguration.isPremiumDisabled {
|
||||
return false // premium-disabled regions
|
||||
}
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Used by all three sites (Sites 1 & 2 to decide whether to block; Site 3 to decide whether to build
|
||||
the preview). It does **not** itself call the classifier — each site already has (or computes) the
|
||||
`richMarkdownAttributeIfNeeded` result and combines it with this decision.
|
||||
|
||||
### 2. Paywall-presentation helper (method on `ChatControllerImpl`)
|
||||
|
||||
The toast is presented from the controller, which already owns `present`/`push`/`presentationData`.
|
||||
A new method on `ChatControllerImpl` (the same type the todo gate at `ChatController.swift:5668`
|
||||
lives on):
|
||||
|
||||
```swift
|
||||
func presentRichTextPremiumPaywall() {
|
||||
let controller = UndoOverlayController(
|
||||
presentationData: self.presentationData,
|
||||
content: .premiumPaywall(title: nil, text: self.presentationData.strings.Chat_RichText_PremiumRequired, customUndoText: nil, timeout: nil, linkAction: nil),
|
||||
action: { [weak self] action in
|
||||
guard let self else {
|
||||
return false
|
||||
}
|
||||
if case .info = action {
|
||||
let controller = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .settings, forceDark: false, dismissed: nil)
|
||||
self.push(controller)
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
self.present(controller, in: .current)
|
||||
}
|
||||
```
|
||||
|
||||
- New localized string `Chat_RichText_PremiumRequired`, e.g. *"Subscribe to Telegram Premium to
|
||||
send formatted messages with headings, lists and tables."* Added to the strings infra alongside
|
||||
the existing `Chat_Todo_PremiumRequired`.
|
||||
- `source: .settings` (generic) per the design decision — no dedicated enum case.
|
||||
|
||||
### 3. Wiring at the three entry points
|
||||
|
||||
**Site 1 — Send.** `ChatControllerNode.sendCurrentMessage` (~`ChatControllerNode.swift:4864`),
|
||||
the rich branch. After `richMarkdownAttributeIfNeeded` returns non-nil:
|
||||
|
||||
```swift
|
||||
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
|
||||
if isRichTextMessageGated(context: self.context, peerId: self.chatPresentationInterfaceState.chatLocation.peerId, isPremium: self.chatPresentationInterfaceState.isPremium) {
|
||||
self.controller?.presentRichTextPremiumPaywall()
|
||||
return
|
||||
}
|
||||
// ... existing rich-send code unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
`ChatControllerNode` reaches the controller via `self.controller` (a `ChatControllerImpl`). The
|
||||
early `return` aborts the whole send. **This site also covers the long-press send-options sheet's
|
||||
*send***: `ChatMessageDisplaySendMessageOptions`'s `.generic`/`.silently` modes call
|
||||
`controllerInteraction?.sendCurrentMessage` → `ChatController.swift:2228` → `chatDisplayNode.sendCurrentMessage`,
|
||||
and `.whenOnline` calls `chatDisplayNode.sendCurrentMessage` directly — all land in Site 1.
|
||||
|
||||
**Site 2 — Edit save.** `ChatControllerLoadDisplayNode` editMessage (~`:2224`). After computing
|
||||
`richTextAttribute`:
|
||||
|
||||
```swift
|
||||
if let richTextAttribute, isRichTextMessageGated(context: strongSelf.context, peerId: strongSelf.chatLocation.peerId, isPremium: strongSelf.presentationInterfaceState.isPremium) {
|
||||
strongSelf.presentRichTextPremiumPaywall()
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`ChatControllerLoadDisplayNode` is an extension of `ChatControllerImpl`, so `strongSelf` calls the
|
||||
helper directly. A **rich→plain** edit produces `richTextAttribute == nil`, so it is not gated and
|
||||
still saves normally.
|
||||
|
||||
**Site 3 — Send-options preview.** `ChatMessageDisplaySendMessageOptions` (~`:219`). Build the
|
||||
rich preview **only when not gated**:
|
||||
|
||||
```swift
|
||||
} else if mediaPreview == nil,
|
||||
let attributedText = textInputView.attributedText,
|
||||
let attribute = richMarkdownAttributeIfNeeded(context: selfController.context, attributedText: attributedText),
|
||||
!isRichTextMessageGated(context: selfController.context, peerId: selfController.presentationInterfaceState.chatLocation.peerId, isPremium: selfController.presentationInterfaceState.isPremium) {
|
||||
richTextPreview = ChatSendMessageRichTextPreview(context: selfController.context, instantPage: attribute.instantPage)
|
||||
}
|
||||
```
|
||||
|
||||
A gated user sees the **plain** preview in the options sheet (consistent with the block), and the
|
||||
actual send is stopped by Site 1's gate. No separate toast is presented here.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Editing an already-rich message as non-premium.** Only reachable if the user was formerly
|
||||
premium (or in Saved Messages, which is exempt). Site 2 blocks re-saving it as rich; a
|
||||
rich→plain edit still works.
|
||||
- **Premium disabled.** Gate 4 short-circuits everywhere → rich text is free, as today.
|
||||
- **`isSpecialChatContents` (business links / quick replies).** Already bypassed before the gate at
|
||||
Sites 1 & 3; unchanged.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatRichTextPremiumGate.swift` (new) | `isRichTextMessageGated(...)` free function. |
|
||||
| `submodules/TelegramUI/Sources/ChatController.swift` | `presentRichTextPremiumPaywall()` method on `ChatControllerImpl`. |
|
||||
| `submodules/TelegramUI/Sources/ChatControllerNode.swift` (~4864) | Site 1 gate. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` (~2224) | Site 2 gate. |
|
||||
| `submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift` (~219) | Site 3 preview suppression. |
|
||||
| Localizable strings | New `Chat_RichText_PremiumRequired`. |
|
||||
|
||||
## Verification
|
||||
|
||||
No unit tests in this project. Verify via the full Bazel build, then manual two-account smoke test:
|
||||
|
||||
1. Non-premium account, regular chat: type `# Heading\n- a\n- b`, send → paywall toast; tapping it
|
||||
opens Premium intro; text remains in input; nothing sent.
|
||||
2. Same, edit an existing plain message into a table → paywall on save; original message unchanged.
|
||||
3. Same, long-press send button on rich markdown → options sheet shows **plain** preview; sending
|
||||
from it → paywall.
|
||||
4. Non-premium account, **Saved Messages**: same rich markdown sends normally (carve-out).
|
||||
5. Premium account: rich markdown sends/edits normally (no toast).
|
||||
6. Non-premium account, plain markdown (e.g. `**bold**`, `---`): sends normally (not a rich
|
||||
trigger).
|
||||
Loading…
Add table
Add a link
Reference in a new issue