From a55d2c072ce0bd964cdb9be408ecfde87b136161 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 10:35:16 +0200 Subject: [PATCH 1/8] docs: th login plan --- ...26-08-18-telegram-bot-deeplink-frontend.md | 796 ++++++++++++++++++ ...egram-bot-deeplink-notifications-design.md | 227 +++++ 2 files changed, 1023 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md create mode 100644 docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md diff --git a/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md b/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md new file mode 100644 index 00000000000..85ae30ed1c9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md @@ -0,0 +1,796 @@ +# Telegram Bot Deep-Link Frontend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Telegram Login Widget flow in `apps/cowswap-frontend/src/modules/notifications` with the bot deep-link flow, so connecting/disconnecting Telegram notifications never calls `oauth.telegram.org` and works inside an iframe (CoW Widget embed, Safe Apps). + +**Architecture:** A new `bffTelegramApi` client talks to the three bff routes from the sibling `bff` plan. A new `useTelegramConnect` hook owns all state (subscribed?, connecting?, deep link, expiry) and replaces `useTgAuthorization` + `useTgSubscription`. `useConnectTelegram` becomes a thin adapter so `NotificationSettings.tsx`'s existing consumption (`useConnectTelegram()` → ``) doesn't need to change. The Login Widget's inline button is replaced by a modal (link + QR code), following the existing `AffiliatePartnerQrModal` pattern. + +**Tech Stack:** React hooks, `react-qrcode-logo` (already a dependency), Jest + `@testing-library/react` for hook tests. + +**Spec:** `/Users/shoom/IdeaProjects/cowswap/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md` (section "4. `cowswap-frontend`") + +**Depends on:** the `bff` plan (`/Users/shoom/IdeaProjects/bff/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-backend.md`) for the three routes this calls. Tasks 1–2 here (API client, hook) can be built and unit-tested against a mocked `bffTelegramApi` before bff ships; Task 4's manual verification needs the real bff + cms deployed. + +## Global Constraints + +- Follow the `bffAffiliateApi.ts` pattern (`apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts`) for the new bff client class — same `fetchWithTimeout`/`JSON_HEADERS`/`parseJsonResponse`/`unwrapOk` helpers from `@cowprotocol/common-utils`, same `BFF_BASE_URL` from `@cowprotocol/common-const`. +- Follow the `AffiliatePartnerQrModal` pattern (`apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerQrModal.tsx`) for the new QR modal — same `react-qrcode-logo` usage, same `CowModal`/`ModalHeader` from `common/pure/Modal` / `@cowprotocol/ui`. +- The frontend never receives or stores a Telegram `hash`/`auth_date`/widget payload again — `TelegramData` shrinks to just what's needed for display. + +--- + +### Task 1: `bffTelegramApi` client + shrink `TelegramData` + +**Files:** +- Create: `apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts` +- Modify: `apps/cowswap-frontend/src/modules/notifications/types.ts` + +**Interfaces:** +- Produces: `bffTelegramApi.getConnectToken(account: string): Promise<{ token: string; deepLink: string }>`, `bffTelegramApi.getConnectStatus(account: string): Promise<{ connected: boolean; username?: string }>`, `bffTelegramApi.disconnect(account: string): Promise` — all consumed by Task 2's `useTelegramConnect`. + +- [ ] **Step 1: Shrink `TelegramData`** + +Replace the contents of `apps/cowswap-frontend/src/modules/notifications/types.ts`: + +```ts +// Telegram data shape used for display only — the frontend no longer holds +// widget auth payloads (hash/auth_date/id); the bot deep-link flow never +// exposes those to the browser. +export interface TelegramData { + username?: string +} +``` + +- [ ] **Step 2: Implement `bffTelegramApi.ts`** + +```ts +import { BFF_BASE_URL } from '@cowprotocol/common-const' +import { + fetchWithTimeout, + JSON_HEADERS, + parseJsonResponse, + stripTrailingSlash, + unwrapOk, +} from '@cowprotocol/common-utils' +import type { FetchJsonResponse } from '@cowprotocol/common-utils' + +export interface TelegramConnectTokenResponse { + token: string + deepLink: string +} + +export interface TelegramConnectStatusResponse { + connected: boolean + username?: string +} + +const TELEGRAM_API_TIMEOUT_MS = 10_000 + +class BffTelegramApi { + private readonly baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = stripTrailingSlash(baseUrl) + } + + private buildUrl(path: string): string { + return `${this.baseUrl}/${path.replace(/^\//, '')}` + } + + private async fetchJson(path: string, init?: RequestInit): Promise> { + const response = await fetchWithTimeout(this.buildUrl(path), { + method: 'GET', + headers: JSON_HEADERS, + ...init, + timeout: TELEGRAM_API_TIMEOUT_MS, + timeoutMessage: 'Unable to reach notifications service', + }) + return parseJsonResponse(response) + } + + async getConnectToken(account: string): Promise { + const result = await this.fetchJson( + `accounts/${account}/telegram/connect-token`, + { method: 'POST' } + ) + return unwrapOk(result, 'Telegram connect-token response missing') + } + + async getConnectStatus(account: string): Promise { + const result = await this.fetchJson(`accounts/${account}/telegram/connect-status`) + return unwrapOk(result, 'Telegram connect-status response missing') + } + + async disconnect(account: string): Promise { + await this.fetchJson<{ success: boolean }>(`accounts/${account}/telegram/subscription`, { method: 'DELETE' }) + } +} + +export const bffTelegramApi = new BffTelegramApi(BFF_BASE_URL) +``` + +- [ ] **Step 3: Type-check** + +Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` (or whatever the repo's existing type-check command is — check `package.json`'s `scripts` if this path is wrong). +Expected: no new type errors from these two files. (No runtime test here — this file is a thin wire-up, same as `bffAffiliateApi.ts` has no dedicated spec file; Task 2's hook test exercises it through a mock.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts apps/cowswap-frontend/src/modules/notifications/types.ts +git commit -m "feat(notifications): add bffTelegramApi client, shrink TelegramData" +``` + +--- + +### Task 2: `useTelegramConnect` hook + +**Files:** +- Create: `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts` +- Test: `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` (new) + +**Interfaces:** +- Consumes: `bffTelegramApi` (Task 1). +- Produces: `useTelegramConnect(account: string | undefined): TelegramConnectController` where + ```ts + export type ConnectState = 'idle' | 'connecting' | 'expired' + export interface TelegramConnectController { + isLoading: boolean + isSubscribed: boolean + username?: string + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void + disconnect(): Promise + } + ``` + Consumed by Task 3's `useConnectTelegram`/`ConnectTelegram`/`TelegramConnectionStatus`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts`: + +```ts +import { act, renderHook, waitFor } from '@testing-library/react' + +import { bffTelegramApi } from '../api/bffTelegramApi' +import { useTelegramConnect } from './useTelegramConnect' + +jest.mock('../api/bffTelegramApi') + +const mockedApi = bffTelegramApi as jest.Mocked + +describe('useTelegramConnect', () => { + beforeEach(() => { + jest.resetAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('loads the initial connect-status on mount', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + + const { result } = renderHook(() => useTelegramConnect('0xabc')) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.isSubscribed).toBe(true) + expect(result.current.username).toBe('ada') + }) + + it('does not fetch status when there is no account', async () => { + const { result } = renderHook(() => useTelegramConnect(undefined)) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(mockedApi.getConnectStatus).not.toHaveBeenCalled() + }) + + it('connect() fetches a token, exposes the deep link, and polls until connected', async () => { + mockedApi.getConnectStatus.mockResolvedValueOnce({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + mockedApi.getConnectStatus + .mockResolvedValueOnce({ connected: false }) // first poll tick: still pending + .mockResolvedValueOnce({ connected: true, username: 'ada' }) // second poll tick: connected + + const { result } = renderHook(() => useTelegramConnect('0xabc')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=tok') + + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + expect(result.current.isSubscribed).toBe(false) + expect(result.current.connectState).toBe('connecting') + + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + expect(result.current.isSubscribed).toBe(true) + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + }) + + it('connect() moves to "expired" after the connect timeout elapses without success', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + + const { result } = renderHook(() => useTelegramConnect('0xabc')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + + await act(async () => { + jest.advanceTimersByTime(10 * 60 * 1000) + await Promise.resolve() + }) + + expect(result.current.connectState).toBe('expired') + expect(result.current.deepLink).toBeNull() + }) + + it('cancelConnect() stops polling and resets to idle', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + + const { result } = renderHook(() => useTelegramConnect('0xabc')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + act(() => { + result.current.cancelConnect() + }) + + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + + const callsBeforeAdvance = mockedApi.getConnectStatus.mock.calls.length + await act(async () => { + jest.advanceTimersByTime(10_000) + await Promise.resolve() + }) + expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsBeforeAdvance) + }) + + it('disconnect() calls the API and flips isSubscribed off', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + mockedApi.disconnect.mockResolvedValue(undefined) + + const { result } = renderHook(() => useTelegramConnect('0xabc')) + await waitFor(() => expect(result.current.isSubscribed).toBe(true)) + + await act(async () => { + await result.current.disconnect() + }) + + expect(mockedApi.disconnect).toHaveBeenCalledWith('0xabc') + expect(result.current.isSubscribed).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm exec jest apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` +Expected: FAIL — cannot find module `./useTelegramConnect`. + +- [ ] **Step 3: Implement `useTelegramConnect.ts`** + +```ts +import { useCallback, useEffect, useRef, useState } from 'react' + +import { bffTelegramApi } from '../api/bffTelegramApi' + +const POLL_INTERVAL_MS = 3_000 +const CONNECT_TIMEOUT_MS = 10 * 60 * 1000 // matches the bff connect-token TTL + +export type ConnectState = 'idle' | 'connecting' | 'expired' + +export interface TelegramConnectController { + isLoading: boolean + isSubscribed: boolean + username?: string + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void + disconnect(): Promise +} + +export function useTelegramConnect(account: string | undefined): TelegramConnectController { + const [isLoading, setIsLoading] = useState(true) + const [isSubscribed, setIsSubscribed] = useState(false) + const [username, setUsername] = useState(undefined) + const [connectState, setConnectState] = useState('idle') + const [deepLink, setDeepLink] = useState(null) + + const pollTimerRef = useRef | null>(null) + const expiryTimerRef = useRef | null>(null) + + const stopConnecting = useCallback(() => { + if (pollTimerRef.current) clearInterval(pollTimerRef.current) + if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current) + pollTimerRef.current = null + expiryTimerRef.current = null + }, []) + + const refreshStatus = useCallback(async (): Promise => { + if (!account) return false + const status = await bffTelegramApi.getConnectStatus(account) + setIsSubscribed(status.connected) + setUsername(status.username) + return status.connected + }, [account]) + + useEffect(() => { + if (!account) { + setIsLoading(false) + return + } + setIsLoading(true) + refreshStatus().finally(() => setIsLoading(false)) + }, [account, refreshStatus]) + + // Stop any in-flight polling/expiry timers on unmount + useEffect(() => stopConnecting, [stopConnecting]) + + const connect = useCallback(async () => { + if (!account) return + + const { deepLink: link } = await bffTelegramApi.getConnectToken(account) + setDeepLink(link) + setConnectState('connecting') + + pollTimerRef.current = setInterval(() => { + refreshStatus() + .then((connected) => { + if (connected) { + stopConnecting() + setConnectState('idle') + setDeepLink(null) + } + }) + .catch(() => undefined) + }, POLL_INTERVAL_MS) + + expiryTimerRef.current = setTimeout(() => { + stopConnecting() + setConnectState('expired') + setDeepLink(null) + }, CONNECT_TIMEOUT_MS) + }, [account, refreshStatus, stopConnecting]) + + const cancelConnect = useCallback(() => { + stopConnecting() + setConnectState('idle') + setDeepLink(null) + }, [stopConnecting]) + + const disconnect = useCallback(async () => { + if (!account) return + await bffTelegramApi.disconnect(account) + setIsSubscribed(false) + setUsername(undefined) + }, [account]) + + return { isLoading, isSubscribed, username, connectState, deepLink, connect, cancelConnect, disconnect } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm exec jest apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts +git commit -m "feat(notifications): add useTelegramConnect hook for the bot deep-link flow" +``` + +--- + +### Task 3: Wire the hook into the UI (adapter hook, modal, container, pure component) + +**Files:** +- Modify: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx` +- Modify: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx` +- Create: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx` +- Modify: `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx` +- Modify: `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx` + +**Interfaces:** +- Consumes: `useTelegramConnect`/`TelegramConnectController`/`ConnectState` (Task 2). +- Produces: `ConnectTelegramController` (same exported name `useConnectTelegram()` that `NotificationSettings.tsx` already imports, now just re-typed to `TelegramConnectController` — that container file needs no changes). + +- [ ] **Step 1: Rewrite `useConnectTelegram.tsx`** + +Replace its contents entirely: + +```tsx +import { useWalletInfo } from '@cowprotocol/wallet' + +import { TelegramConnectController, useTelegramConnect } from '../../hooks/useTelegramConnect' + +export type ConnectTelegramController = TelegramConnectController + +export function useConnectTelegram(): ConnectTelegramController { + const { account } = useWalletInfo() + + return useTelegramConnect(account) +} +``` + +- [ ] **Step 2: Create the QR/deep-link modal** + +Create `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx`: + +```tsx +import { ReactNode } from 'react' + +import { ButtonOutlined, ButtonPrimary, ModalHeader, UI } from '@cowprotocol/ui' + +import QRCode from 'react-qrcode-logo' +import styled from 'styled-components/macro' + +import { CowModal } from 'common/pure/Modal' + +import { ConnectState } from '../../hooks/useTelegramConnect' + +const QR_SIZE_PX = 200 + +interface ConnectTelegramModalProps { + isOpen: boolean + connectState: ConnectState + deepLink: string | null + onRetry(): void + onDismiss(): void +} + +export function ConnectTelegramModal({ + isOpen, + connectState, + deepLink, + onRetry, + onDismiss, +}: ConnectTelegramModalProps): ReactNode { + return ( + + + Connect Telegram + + {connectState === 'expired' ? ( + <> + This link expired before you tapped Start in Telegram. + Get a new link + + ) : deepLink ? ( + <> + Scan this code, or open the link on this device, then tap “Start” in the chat. + + + + + Open in Telegram + + Waiting for you to tap Start… + + ) : ( + Preparing your connect link… + )} + + + + ) +} + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; +` + +const ModalBody = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 10px 20px 24px; +` + +const QrFrame = styled.div` + border-radius: 16px; + border: 1px solid var(${UI.COLOR_PAPER_DARKER}); + padding: 16px; + background: #fff; +` + +const Message = styled.p` + margin: 0; + text-align: center; + font-size: 13px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); +` + +const Status = styled.p` + margin: 0; + font-size: 12px; + color: var(${UI.COLOR_TEXT_OPACITY_60}); +` +``` + +If `ButtonPrimary` isn't exported from `@cowprotocol/ui` under that exact name, grep the lib for the actual name (`grep -rn "export.*Button" libs/ui/src`) and swap it in — everything else in this file follows `AffiliatePartnerQrModal.tsx`'s already-verified imports. + +- [ ] **Step 3: Update `TelegramConnectionStatus`** + +Replace `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx`: + +```tsx +import { ReactNode, useCallback, useState } from 'react' + +import { Loader, UI, Toggle } from '@cowprotocol/ui' + +import { ConnectTelegramModal } from '../../containers/ConnectTelegram/ConnectTelegramModal' +import { ConnectState } from '../../hooks/useTelegramConnect' + +interface TelegramConnectionStatusProps { + isLoading: boolean + isSubscribed: boolean + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void + disconnect(): Promise +} + +export function TelegramConnectionStatus({ + isLoading, + isSubscribed, + connectState, + deepLink, + connect, + cancelConnect, + disconnect, +}: TelegramConnectionStatusProps): ReactNode { + const [isDisconnecting, setIsDisconnecting] = useState(false) + + const handleToggle = useCallback(async () => { + if (isSubscribed) { + setIsDisconnecting(true) + try { + await disconnect() + } finally { + setIsDisconnecting(false) + } + } else { + await connect() + } + }, [isSubscribed, connect, disconnect]) + + if (isLoading || isDisconnecting) { + return + } + + return ( +
+ + +
+ ) +} +``` + +This drops the `needsAuthorization`/`authorize`/`subscribeWithData`/`toggleSubscription` props entirely — connecting and disconnecting are now just `connect()`/`disconnect()`, and "not yet linked" is simply `!isSubscribed`. + +- [ ] **Step 4: Update the Cosmos story** + +Replace `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx`: + +```tsx +import { TelegramConnectionStatus } from './index' + +const noop = (): void => {} +const asyncNoop = async (): Promise => {} + +const Fixtures = { + loading: () => ( + + ), + subscribed: () => ( + + ), + notConnected: () => ( + + ), + waitingForStart: () => ( + + ), + linkExpired: () => ( + + ), +} + +export default Fixtures +``` + +- [ ] **Step 5: Update `ConnectTelegram.tsx`** + +Replace `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx`: + +```tsx +import type { ReactElement } from 'react' + +import { ConnectTelegramController } from './ConnectTelegram/useConnectTelegram' + +import { TelegramConnectionStatus } from '../pure/TelegramConnectionStatus' + +interface ConnectTelegramProps { + controller: ConnectTelegramController +} + +export function ConnectTelegram({ controller }: ConnectTelegramProps): ReactElement { + const { isLoading, isSubscribed, connectState, deepLink, connect, cancelConnect, disconnect } = controller + + return ( + + ) +} +``` + +The `Wrapper` styled-div and `wrapperRef` are gone — there's no widget script to mount into anymore. + +- [ ] **Step 6: Type-check and run Cosmos** + +Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` +Expected: no errors. `NotificationSettings.tsx` needs no changes — it only reads `telegramController.username`, which `TelegramConnectController` still exposes. + +Run: `pnpm --filter cowswap-frontend run cosmos` (or the repo's documented Cosmos command) and open `TelegramConnectionStatus` to click through all five fixtures, confirming the modal opens for `waitingForStart` and `linkExpired`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/ +git commit -m "feat(notifications): replace Telegram widget UI with the deep-link/QR modal" +``` + +--- + +### Task 4: Remove the widget-flow code and stale docs + +**Files:** +- Delete: `apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts` +- Delete: `apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts` +- Delete: `apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx` +- Delete: `apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts` +- Modify: `apps/cowswap-frontend/src/modules/notifications/README.md` + +**Interfaces:** None — this task only removes now-dead code confirmed unused by Tasks 1–3. + +- [ ] **Step 1: Confirm nothing else references the files being deleted** + +Run: +```bash +grep -rln "useTgAuthorization\|useTgSubscription\|getTelegramAuth\|tgSubscriptionAtom" apps/cowswap-frontend/src +``` +Expected: only the files listed above (Task 3 already stopped importing them — `useConnectTelegram.tsx` no longer references `useTgAuthorization`/`useTgSubscription`). If anything else still matches, stop and re-check Task 3 before deleting. + +- [ ] **Step 2: Delete the files** + +```bash +git rm apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts +git rm apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts +git rm apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx +git rm apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts +``` + +If `services/` or `atoms/` are now empty, remove the empty directories too (git doesn't track empty dirs, so no extra command is needed once the last file in each is removed). + +- [ ] **Step 3: Update the module README** + +Replace `apps/cowswap-frontend/src/modules/notifications/README.md`: + +```markdown +# Notifications + +## Local development + +Telegram notifications are connected via a bot deep-link (`/start `), not the Telegram Login Widget — the frontend only talks to the bff, never to `oauth.telegram.org` or the CMS directly. + +To debug/develop Telegram subscriptions locally: +1. Point `REACT_APP_BFF_BASE_URL` at your local `bff` `apps/api` instance in `.env.local`. +2. Follow the `bff` repo's own local-dev instructions for `apps/api` and `apps/telegram` (these need `TELEGRAM_SECRET`, `CMS_BASE_URL`, `CMS_API_KEY`, and a Redis instance shared between the two apps). +3. Launch CoW Swap as usual (`pnpm run start`) — no bot id or Telegram-specific env var is needed on the frontend anymore. +``` + +- [ ] **Step 4: Remove the now-dead `REACT_APP_TG_BOT_ID` comment from `.env`** + +Run `git diff apps/cowswap-frontend/.env` first — this file already has uncommitted local changes from earlier in this session; don't blindly overwrite it. Open the file, find the commented `# REACT_APP_TG_BOT_ID=3713371337` line (around line 146) and delete just that line, leaving the rest of the existing diff intact. + +- [ ] **Step 5: Full check** + +Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` and `pnpm exec jest apps/cowswap-frontend/src/modules/notifications` +Expected: no errors, all notifications-module tests pass (including `getTrustedNotificationLink.test.ts`, unrelated but in the same directory tree). + +- [ ] **Step 6: Commit** + +```bash +git add -A apps/cowswap-frontend/src/modules/notifications apps/cowswap-frontend/.env +git commit -m "chore(notifications): remove the Telegram Login Widget flow" +``` diff --git a/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md b/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md new file mode 100644 index 00000000000..2f0ac8ce949 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md @@ -0,0 +1,227 @@ +--- +title: Telegram notifications via bot deep-link (replace Login Widget) +date: 2026-08-18 +status: approved +owner: alexandr@cow.fi +repos: + - cowswap (apps/cowswap-frontend) + - bff (apps/api, apps/telegram) + - cms (src/api/telegram-subscription) +--- + +# Telegram notifications via bot deep-link (replace Login Widget) + +## Why + +`useConnectTelegram` (`apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx`) +currently authorizes the Telegram notification bot via the Telegram Login +Widget: it loads `telegram-widget.js`, calls the documented +`window.Telegram.Login.auth()` popup API, and — critically — also calls a +reverse-engineered, undocumented endpoint (`getTelegramAuth.ts` → +`https://oauth.telegram.org/auth/get`) both as a silent "already logged in" +check on mount/every 3s, and as a fallback inside the popup flow. + +That endpoint is designed to run first-party inside Telegram's own iframe. When +cowswap-frontend itself runs inside an iframe — as the CoW Widget guest +(`libs/widget-lib`, `apps/widget-configurator`) or as a Safe Apps guest +(`useIsSafeApp`) — the request becomes cross-site-in-a-nested-iframe, and +browsers' third-party-cookie/SameSite partitioning blocks it, surfacing as a +CORS error. This is not fixable by changing headers on our side; it's an +unsupported dependency in that context. + +This design replaces the widget flow with a Telegram Bot deep-link +(`/start `) flow, which never touches `oauth.telegram.org` from the +browser — it is either a plain link tap (top-level navigation, immune to +iframe/CORS/cookie restrictions) or a server-to-server call. + +## Constraints decided up front + +| Decision | Choice | Rationale | +|---|---|---| +| Scope | Full replacement of the Login Widget, everywhere (not iframe-only fallback) | One auth code path to maintain; deep-link UX works identically top-level and embedded. | +| CMS write path | New CMS-internal endpoints (`link-via-bot` / `unlink-via-bot`), not a hash-signing hack in bff | CMS repo is available to edit; a dedicated internal endpoint is clearer/more maintainable than replicating Strapi's HMAC verification in bff. | +| Disconnect | New bff `DELETE` endpoint mirrors the connect flow | Keeps the existing in-app toggle-off UX working, rather than pushing users to a bot `/stop` command. | +| Token store | Redis-backed `CacheRepository` (already used elsewhere in bff), shared between `apps/api` and `apps/telegram` | Reuses existing infra; **requires Redis to be enabled in every environment this runs in** — see Risks. | +| CMS auth for new routes | Existing `CMS_API_KEY` bearer token bff already holds, routes excluded from the "Public" Strapi role | No new secret to provision/rotate. Requires one manual Strapi-admin permission grant (see Rollout). | +| Bot username source | `bot.getMe()` fetched once by `apps/api` and cached in-process | Avoids a new env var that must stay in sync with the bot token; falls back to env var if `getMe()` fails at boot. | + +## Architecture + +``` + cowswap-frontend + │ + 1. POST /accounts/:account/telegram/connect-token + ▼ + bff / apps/api (Fastify) + generates random token, stores in Redis + (CacheRepository) as telegram-connect: -> account, TTL 10m + │ + returns { token, deepLink: t.me/?start= } + ▼ + cowswap-frontend + shows "Open in Telegram" link + QR, starts polling + GET /accounts/:account/telegram/connect-status + │ + user taps deepLink (top-level nav) + ▼ + Telegram app + │ + sends "/start " + ▼ + bff / apps/telegram (already polling) + resolves token -> account via same Redis CacheRepository + (deletes token: single-use), then calls + │ + ▼ + cms: POST /telegram-subscription/link-via-bot + { account, chatId, firstName, username } (CMS_API_KEY auth) + │ + writes telegram_subscriptions row directly + (no hash / verifyTgAuthentication needed) + │ + bot replies in Telegram chat: success / expired-token + │ + cowswap-frontend's poll of connect-status flips to { connected: true } +``` + +Disconnect mirrors this: `DELETE /accounts/:account/telegram/subscription` on +`apps/api` calls `cms: POST /telegram-subscription/unlink-via-bot { account }`. + +## Component changes + +### 1. `cms` (`/Users/shoom/IdeaProjects/cms`) + +No schema migration: `telegram_subscriptions` already has `hash`, `authDate`, +`photoUrl` as optional; only `account` is required. + +- `src/api/telegram-subscription/controllers/telegram-subscription.ts`: add + `linkViaBot(context)` and `unlinkViaBot(context)`. Both skip + `verifyTgAuthentication` entirely — trust comes from the route not being + public, not from a per-request hash. `linkViaBot` calls the existing + `service.addSubscription(account, { id: chatId, first_name, username, auth_date: now, hash: null })` + (or a slightly widened service signature that makes `hash`/`photo_url` + optional — trivial, since the DB columns already are). `unlinkViaBot` calls + the existing `service.removeSubscriptions(account)` unchanged. +- `src/api/telegram-subscription/routes/telegram-subscription.ts`: add + `POST /telegram-subscription/link-via-bot` and + `POST /telegram-subscription/unlink-via-bot`. +- These two routes must **not** be granted to the Strapi "Public" role + (unlike `add-tg-subscription`/`remove-tg-subscription`/`check-tg-subscription`, + which are public because the browser calls them directly today). Strapi's + built-in API-token auth then gates them. + +### 2. `bff` — `apps/api` + +New file `apps/api/src/app/routes/accounts/_account/telegram/index.ts` +(sibling of the existing `accounts/_account/notifications.ts`), following the +same `isCmsEnabled` guard and inversify-container lookup pattern: + +- `POST /accounts/:account/telegram/connect-token` — generates a token via + `crypto.randomBytes(16).toString('hex')`, `cacheRepository.set('telegram-connect:' + token, account, 600)`, + returns `{ token, deepLink }` where `deepLink` is built from the bot + username (`bot.getMe()`, cached in-process at startup) and the token. +- `GET /accounts/:account/telegram/connect-status` — calls the existing + `PushSubscriptionsRepository.getAllTelegramSubscriptionsForAccounts([account])`, + returns `{ connected: boolean, username?: string }`. +- `DELETE /accounts/:account/telegram/subscription` — calls the CMS + `unlink-via-bot` endpoint, returns `{ success: boolean }`. + +### 3. `bff` — `apps/telegram` + +`apps/telegram/src/main.ts` currently only sends messages. Add, before/alongside +`mainLoop()`: + +- `telegramBot.on('message', handler)` where `handler` matches `/^\/start\s+(\S+)$/` + against `msg.text`. +- On match: read `telegram-connect:` from the same + `CacheRepository`/Redis instance `apps/api` writes to (via `getCacheRepository()` + from `@cowprotocol/services`), delete the key immediately (single-use), then + call `POST cms/telegram-subscription/link-via-bot` with + `{ account, chatId: msg.chat.id, firstName: msg.from.first_name, username: msg.from.username }`. +- Reply in-chat: success message on link, or "This link has expired — please + reconnect from CoW Swap" if the token was missing/already used. + +### 4. `cowswap-frontend` — `apps/cowswap-frontend/src/modules/notifications/` + +Removed: +- `services/getTelegramAuth.ts` (the `oauth.telegram.org/auth/get` XHR). +- Widget-script loading, `Telegram.Login.auth`/`AUTH_OPTIONS`, and the silent + re-check/poll in `hooks/useTgAuthorization.ts`. +- The `TelegramData`-with-`hash` payload building in `hooks/useTgSubscription.tsx` + and its direct `getCmsClient().POST('/add-tg-subscription' | '/remove-tg-subscription' | '/check-tg-subscription', ...)` + calls — the browser no longer talks to the CMS directly for this flow at all. + +Added: +- `common/services/bff` (or a module-local file, following the + `bffAffiliateApi.ts` pattern) — `bffTelegramApi.ts` with + `getConnectToken(account)`, `getConnectStatus(account)`, `disconnect(account)`. +- A replacement for `useConnectTelegram` that: calls `getConnectToken` on + demand (not eagerly on mount, since there's no more "already logged in" + widget concept to silently probe), exposes the `deepLink` for a + `ConnectTelegram` UI to render as a link + QR code, polls + `getConnectStatus` every 3s while a connect attempt is pending, and stops + polling once `connected: true` or the token's 10-minute TTL has elapsed + (surface a "link expired, try again" state). +- `ConnectTelegram` container drops `wrapperRef` (no more DOM node for the + widget script) and `TelegramConnectionStatus` gets a new "waiting for you to + tap the Telegram link" visual state instead of the widget's inline button. + +`types.ts`'s `TelegramData` shrinks to `{ username?: string }` — the frontend +never sees `hash`/`auth_date`/`id` again. + +## Error handling + +- **Token expiry**: bot replies with an explicit expired-token message; + frontend's own poll times out after the same TTL and shows a "try again" + state rather than polling forever. +- **CMS/bff unreachable during `/start` handling**: bot replies with a generic + "something went wrong, please try again" and does *not* delete the Redis + key before the CMS call succeeds, so a transient failure is retryable by the + user re-sending `/start ` (same token, still valid until TTL). +- **Redis unavailable**: `getCacheRepository()` silently falls back to + `CacheRepositoryMemory()` per-process today. Since `apps/api` and + `apps/telegram` are separate processes, this would make every token + unresolvable. This is treated as a hard deploy requirement, not handled in + code — see Risks. + +## Testing + +- CMS: unit test for `linkViaBot`/`unlinkViaBot` controller actions (mock + `strapi.service`), and a route-level check that the new paths are absent + from the Public role fixture/seed if one exists. +- bff `apps/api`: route tests for the three new endpoints against a fake + `PushSubscriptionsRepository`/`CacheRepository`, following existing tests + under `apps/api/src/app/routes/**/*.spec.ts` conventions. +- bff `apps/telegram`: unit test the `/start` handler against a mocked bot + and `CacheRepository` (token found / not found / CMS call fails). +- cowswap-frontend: hook test for the new connect hook (token fetch → poll → + connected), and a Cosmos story update for `TelegramConnectionStatus`'s new + "waiting" state. +- Manual E2E: cannot fully verify the iframe-CORS fix without an actual + embedded (CoW Widget or Safe App) deployment; note this explicitly when + reporting the fix rather than claiming it's verified end-to-end from + automated tests alone. + +## Rollout + +1. Ship CMS changes first (additive, no behavior change for existing + endpoints) and deploy. +2. Manually grant the bff API token permission for `link-via-bot` / + `unlink-via-bot` in the Strapi admin (Settings → API Tokens). +3. Confirm Redis is enabled (`REDIS_URL`/equivalent) in every environment + `apps/api` and `apps/telegram` run in — required for the token handoff to + work at all. +4. Ship bff changes (`apps/api` routes + `apps/telegram` handler), deploy. +5. Ship cowswap-frontend changes last, once the above are live in the target + environment(s). + +## Risks + +- **Shared Redis is load-bearing and silent-failing.** If it's ever disabled + or the two bff apps end up pointed at different Redis instances, connect + tokens simply never resolve, with no error surfaced beyond "link expired" + to the user. Worth a startup log/assertion in both apps if Redis is + disabled, even though this design doesn't add new fallback logic for it. +- **Manual Strapi permission grant** is an out-of-git operational step and + easy to forget when replicating to a new environment (e.g. a fresh staging + CMS instance). From a024ab8751cd355cf530ea30ce9440daee10ad64 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 11:54:13 +0200 Subject: [PATCH 2/8] feat(telegram): login but via link --- apps/cowswap-frontend/.env | 4 +- .../AccountElement/AccountElement.pure.tsx | 4 +- .../src/modules/notifications/README.md | 12 +- .../notifications/api/bffTelegramApi.ts | 77 ++ .../atoms/telegramSubscriptionAtom.ts | 14 + .../notifications/atoms/tgSubscriptionAtom.ts | 6 - .../containers/ConnectTelegram.tsx | 26 +- .../ConnectTelegram/ConnectTelegramModal.tsx | 115 +++ .../ConnectTelegram/useConnectTelegram.tsx | 73 +- .../NotificationSidebar.container.tsx | 13 +- .../hooks/useHasNotificationSubscription.ts | 21 +- .../hooks/useTelegramConnect.test.ts | 368 ++++++++ .../notifications/hooks/useTelegramConnect.ts | 184 ++++ .../notifications/hooks/useTgAuthorization.ts | 93 -- .../notifications/hooks/useTgSubscription.tsx | 211 ----- .../TelegramConnectionStatus/index.cosmos.tsx | 56 +- .../pure/TelegramConnectionStatus/index.tsx | 64 +- .../notifications/services/getTelegramAuth.ts | 31 - .../src/modules/notifications/types.ts | 9 - apps/cowswap-frontend/src/react-app-env.d.ts | 21 - ...26-08-18-telegram-bot-deeplink-frontend.md | 796 ------------------ ...egram-bot-deeplink-notifications-design.md | 227 ----- 22 files changed, 867 insertions(+), 1558 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts create mode 100644 apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts delete mode 100644 apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts create mode 100644 apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx create mode 100644 apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts create mode 100644 apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts delete mode 100644 apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts delete mode 100644 apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx delete mode 100644 apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts delete mode 100644 apps/cowswap-frontend/src/modules/notifications/types.ts delete mode 100644 docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md delete mode 100644 docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md diff --git a/apps/cowswap-frontend/.env b/apps/cowswap-frontend/.env index d0a3c3626ff..7bee11d8bfc 100644 --- a/apps/cowswap-frontend/.env +++ b/apps/cowswap-frontend/.env @@ -14,6 +14,7 @@ REACT_APP_NETWORK_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com # REACT_APP_NETWORK_URL_42161=https://... # REACT_APP_NETWORK_URL_43114=https://api.avax.network/ext/bc/C/rpc +REACT_APP_BFF_BASE_URL=http://localhost:8080 # INFURA KEY # REACT_APP_INFURA_KEY=your-infura-key @@ -142,9 +143,6 @@ REACT_APP_NETWORK_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com # CMS base URL # REACT_APP_CMS_BASE_URL=http://localhost:1337/api -# Notifications Telegram bot ID -# REACT_APP_TG_BOT_ID=3713371337 - ####################################### # Crowdin translations ####################################### diff --git a/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx b/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx index 707d0bf2797..9f82ee61841 100644 --- a/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx +++ b/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx @@ -1,6 +1,5 @@ import { ReactNode, useCallback, useRef, useState } from 'react' -import { useFeatureFlags } from '@cowprotocol/common-hooks' import { useWalletInfo } from '@cowprotocol/wallet' import { useToggleAccountModal } from 'modules/account' @@ -30,7 +29,8 @@ export function AccountElement({ className }: AccountElementProps): ReactNode { const shouldShowAffiliateTraderHeaderButton = useShouldShowAffiliateTraderHeaderButton() const unreadNotificationsCount = useUnreadSidebarNotificationsCount() const { isDismissed, dismiss } = useNotificationAlertDismissal() - const { areTelegramNotificationsEnabled } = useFeatureFlags() + // FIXME + const areTelegramNotificationsEnabled = true const { hasSubscription, isLoading } = useHasNotificationSubscription() const [isSidebarOpen, setSidebarOpen] = useState(false) diff --git a/apps/cowswap-frontend/src/modules/notifications/README.md b/apps/cowswap-frontend/src/modules/notifications/README.md index d23d16ca648..bbbe250d0f7 100644 --- a/apps/cowswap-frontend/src/modules/notifications/README.md +++ b/apps/cowswap-frontend/src/modules/notifications/README.md @@ -2,11 +2,9 @@ ## Local development -To debug/develop Telegram subscriptions locally you need to do: -1. Create your own telegram bot (https://core.telegram.org/bots/tutorial) -2. Put the bot id to `REACT_APP_TG_BOT_ID` in `.env.local`. You can get it from BotFather. Id is the fist digital part of API Token -3. Uncomment `REACT_APP_CMS_BASE_URL` in `.env.local` or add `REACT_APP_CMS_BASE_URL=http://localhost:1337/api` to `.env` -4. Run the CMS locally and add you bot API token to CMS `.env`: `TELEGRAM_SECRET=XXX` -5. In CMS settings, go to `Settings -> Users & Permissions plugin -> Roles -> Public -> Telegram subscription` and enable `addSubscription`, `removeSubscription`, and `checkSubscription` -6. Launch CoW Swap app as `pnpm run start --host=127.0.0.1`, because Telegram only allows using this domain locally +Telegram notifications are connected via a bot deep-link (`/start `), not the Telegram Login Widget — the frontend only talks to the bff, never to `oauth.telegram.org` or the CMS directly. +To debug/develop Telegram subscriptions locally: +1. Point `REACT_APP_BFF_BASE_URL` at your local `bff` `apps/api` instance in `.env.local`. +2. Follow the `bff` repo's own local-dev instructions for `apps/api` and `apps/telegram` (these need `TELEGRAM_SECRET`, `CMS_BASE_URL`, `CMS_API_KEY`, and a Redis instance shared between the two apps). +3. Launch CoW Swap as usual (`pnpm run start`) — no bot id or Telegram-specific env var is needed on the frontend anymore. diff --git a/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts new file mode 100644 index 00000000000..81b82c88d23 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts @@ -0,0 +1,77 @@ +import { BFF_BASE_URL } from '@cowprotocol/common-const' +import { + ApiError, + fetchWithTimeout, + JSON_HEADERS, + parseJsonResponse, + stripTrailingSlash, + unwrapOk, +} from '@cowprotocol/common-utils' +import type { ApiErrorPayload, FetchJsonResponse } from '@cowprotocol/common-utils' + +export interface TelegramConnectStatusResponse { + connected: boolean + username?: string +} + +export interface TelegramConnectTokenResponse { + token: string + deepLink: string +} + +const TELEGRAM_API_TIMEOUT_MS = 10_000 + +class BffTelegramApi { + private readonly baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = stripTrailingSlash(baseUrl) + } + + private buildUrl(path: string): string { + return `${this.baseUrl}/${path.replace(/^\//, '')}` + } + + private async fetchJson(path: string, init?: RequestInit): Promise> { + const response = await fetchWithTimeout(this.buildUrl(path), { + method: 'GET', + headers: JSON_HEADERS, + ...init, + timeout: TELEGRAM_API_TIMEOUT_MS, + timeoutMessage: 'Unable to reach notifications service', + }) + return parseJsonResponse(response) + } + + async getConnectToken(account: string): Promise { + const result = await this.fetchJson(`accounts/${account}/telegram/connect-token`, { + method: 'POST', + // Fastify (the real bff) 400s a POST that carries `Content-Type: application/json` + // with no body at all (FST_ERR_CTP_EMPTY_JSON_BODY) - send an explicit empty object. + body: '{}', + }) + return unwrapOk(result, 'Telegram connect-token response missing') + } + + async getConnectStatus(account: string): Promise { + const result = await this.fetchJson(`accounts/${account}/telegram/connect-status`) + return unwrapOk(result, 'Telegram connect-status response missing') + } + + async disconnect(account: string): Promise { + const { response, text, data } = await this.fetchJson<{ success: boolean }>( + `accounts/${account}/telegram/subscription`, + { + method: 'DELETE', + // See the comment in getConnectToken - same bodyless-POST/DELETE 400 applies here. + body: '{}', + }, + ) + + if (response.ok) return + + throw new ApiError(response.status, text, data as ApiErrorPayload) + } +} + +export const bffTelegramApi = new BffTelegramApi(BFF_BASE_URL) diff --git a/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts b/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts new file mode 100644 index 00000000000..e1f61b3bf2d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai' + +export interface TelegramSubscriptionState { + isSubscribed: boolean + username?: string +} + +/** + * Shared across every useTelegramConnect() instance so header/sidebar/settings + * agree on subscription state instead of each holding its own stale copy, and + * so a hook instance that remounts (e.g. the sidebar, which unmounts when closed) + * doesn't flash "not subscribed" before its own fetch resolves. + */ +export const telegramSubscriptionAtom = atom>({}) diff --git a/apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts b/apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts deleted file mode 100644 index dbb887b1dd4..00000000000 --- a/apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'jotai' - -/** - * Shared atom for Telegram subscription state across all hook instances, keyed by normalized wallet account. - */ -export const tgSubscriptionAtom = atom>({}) diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx index 9bb0830ef6d..86eacdff338 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx @@ -1,31 +1,25 @@ import type { ReactElement } from 'react' -import styled from 'styled-components/macro' - import { ConnectTelegramController } from './ConnectTelegram/useConnectTelegram' import { TelegramConnectionStatus } from '../pure/TelegramConnectionStatus' -const Wrapper = styled.div`` - interface ConnectTelegramProps { controller: ConnectTelegramController } export function ConnectTelegram({ controller }: ConnectTelegramProps): ReactElement { - const { wrapperRef, isLoading, isSubscribed, needsAuthorization, authorize, toggleSubscription, subscribeWithData } = - controller + const { isLoading, isSubscribed, connectState, deepLink, connect, cancelConnect, disconnect } = controller return ( - - - + ) } diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx new file mode 100644 index 00000000000..ab4e903feec --- /dev/null +++ b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx @@ -0,0 +1,115 @@ +import { ReactNode } from 'react' + +import { ButtonPrimary, ModalHeader, UI } from '@cowprotocol/ui' + +import { Trans } from '@lingui/react/macro' +import QRCode from 'react-qrcode-logo' +import styled from 'styled-components/macro' + +import { CowModal } from 'common/pure/Modal' + +import { ConnectState } from '../../hooks/useTelegramConnect' + +const QR_SIZE_PX = 200 + +interface ConnectTelegramModalProps { + isOpen: boolean + connectState: ConnectState + deepLink: string | null + onRetry(): void + onDismiss(): void +} + +export function ConnectTelegramModal({ + isOpen, + connectState, + deepLink, + onRetry, + onDismiss, +}: ConnectTelegramModalProps): ReactNode { + return ( + + + + Connect Telegram + + + {connectState === 'expired' ? ( + <> + + This link expired before you tapped Start in Telegram. + + + Get a new link + + + ) : connectState === 'error' ? ( + <> + + Something went wrong. Please try again. + + + Try again + + + ) : deepLink ? ( + <> + + + Scan this code, or open the link on this device, then tap “Start” in the chat. + + + + + + + Open in Telegram + + + Waiting for you to tap Start… + + + ) : ( + + Preparing your connect link… + + )} + + + + ) +} + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; +` + +const ModalBody = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 10px 20px 24px; +` + +const QrFrame = styled.div` + border-radius: 16px; + border: 1px solid var(${UI.COLOR_PAPER_DARKER}); + padding: 16px; + background: #fff; +` + +const Message = styled.p` + margin: 0; + text-align: center; + font-size: 13px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); +` + +const Status = styled.p` + margin: 0; + font-size: 12px; + color: var(${UI.COLOR_TEXT_OPACITY_60}); +` diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx index d080d50cd8b..13d464a229f 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx @@ -1,78 +1,11 @@ -import { RefObject, useEffect, useMemo, useRef, useState } from 'react' - import { useWalletInfo } from '@cowprotocol/wallet' -import { useTgAuthorization } from '../../hooks/useTgAuthorization' -import { useTgSubscription } from '../../hooks/useTgSubscription' -import { TelegramData } from '../../types' - -const TELEGRAM_AUTH_WIDGET_URL = 'https://telegram.org/js/telegram-widget.js?22' - -export interface ConnectTelegramController { - wrapperRef: RefObject - isLoading: boolean - isSubscribed: TelegramSubscriptionControls['isTgSubscribed'] - needsAuthorization: boolean - authorize: () => Promise - toggleSubscription: TelegramSubscriptionControls['toggleSubscription'] - subscribeWithData: TelegramSubscriptionControls['subscribeWithData'] - username?: string -} +import { TelegramConnectController, useTelegramConnect } from '../../hooks/useTelegramConnect' -type TelegramSubscriptionControls = ReturnType +export type ConnectTelegramController = TelegramConnectController export function useConnectTelegram(): ConnectTelegramController { const { account } = useWalletInfo() - const [isTelegramScriptLoading, setIsTelegramScriptLoading] = useState(true) - const telegramWrapperRef = useRef(null) - - const authorization = useTgAuthorization() - const { isTgSubscribed, isCmsCallInProgress, toggleSubscription, subscribeWithData } = useTgSubscription( - account, - authorization, - ) - - const { authorize, authenticate, tgData, isLoginInProgress, isAuthChecked } = authorization - - useEffect(() => { - if (!telegramWrapperRef.current) return - - if (telegramWrapperRef.current.getElementsByTagName('script').length > 0) { - setIsTelegramScriptLoading(false) - return - } - - const scriptElement = document.createElement('script') - scriptElement.src = TELEGRAM_AUTH_WIDGET_URL - scriptElement.async = true - scriptElement.onload = () => { - setIsTelegramScriptLoading(false) - } - - telegramWrapperRef.current.appendChild(scriptElement) - }, []) - - /** - * Authenticate once on start - */ - useEffect(() => { - authenticate() - }, [authenticate]) - - const isLoading = isTelegramScriptLoading || !isAuthChecked || isCmsCallInProgress || isLoginInProgress - const needsAuthorization = isAuthChecked && !tgData - return useMemo( - () => ({ - wrapperRef: telegramWrapperRef, - isLoading, - isSubscribed: isTgSubscribed, - needsAuthorization, - authorize, - toggleSubscription, - subscribeWithData, - username: tgData?.username, - }), - [authorize, isLoading, isTgSubscribed, needsAuthorization, subscribeWithData, toggleSubscription, tgData?.username], - ) + return useTelegramConnect(account) } diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx index 44b09bed668..08f3cd53469 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx @@ -1,7 +1,8 @@ +import { useAtomValue } from 'jotai' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import iconNotificationSettingsSrc from '@cowprotocol/assets/images/icon-notification-settings.svg' -import { useMediaQuery, useOnClickOutside, useFeatureFlags } from '@cowprotocol/common-hooks' +import { useMediaQuery, useOnClickOutside } from '@cowprotocol/common-hooks' import { Media } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' @@ -10,6 +11,7 @@ import { createPortal } from 'react-dom' import SVG from 'react-inlinesvg' import { CowSwapAnalyticsCategory, toCowSwapGtmEvent } from 'common/analytics/types' +import { openModalState } from 'common/state/openModalState' import { Sidebar, @@ -56,8 +58,10 @@ export function NotificationSidebar({ const sidebarRef = useRef(null) const headerRef = useRef(null) const isMobile = useMediaQuery(Media.upToSmall(false)) + const isAnyModalOpen = useAtomValue(openModalState) - const { areTelegramNotificationsEnabled } = useFeatureFlags() + // FIXME + const areTelegramNotificationsEnabled = true const { hasSubscription } = useHasNotificationSubscription() const { isDismissed: isSettingsPopoverDismissed, dismiss: dismissSettingsPopover } = useNotificationSettingsPopoverDismissal() @@ -75,7 +79,10 @@ export function NotificationSidebar({ setIsSettingsOpen(false) }, [onClose]) - useOnClickOutside([sidebarRef], onDismiss) + // Don't dismiss the sidebar on outside clicks while a modal (e.g. the Telegram + // connect modal) is open - it portals outside `sidebarRef`'s DOM subtree, so any + // click inside it would otherwise be treated as "outside the sidebar" and close it. + useOnClickOutside([sidebarRef], isAnyModalOpen ? undefined : onDismiss) const toggleSettingsOpen = useCallback(() => { setIsSettingsOpen((prev) => !prev) diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useHasNotificationSubscription.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useHasNotificationSubscription.ts index 3088934344e..19ca9d8f8fc 100644 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useHasNotificationSubscription.ts +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useHasNotificationSubscription.ts @@ -2,8 +2,7 @@ import { useMemo } from 'react' import { useWalletInfo } from '@cowprotocol/wallet' -import { useTgAuthorization } from './useTgAuthorization' -import { useTgSubscription } from './useTgSubscription' +import { useTelegramConnect } from './useTelegramConnect' export interface UseHasNotificationSubscriptionReturn { hasSubscription: boolean @@ -19,25 +18,17 @@ export interface UseHasNotificationSubscriptionReturn { */ export function useHasNotificationSubscription(): UseHasNotificationSubscriptionReturn { const { account } = useWalletInfo() - const authorization = useTgAuthorization() - const { isTgSubscribed } = useTgSubscription(account, authorization) - - const result = useMemo(() => { - // Don't show loading state if no account (user not connected) - const isLoading = !!account && !authorization.isAuthChecked + const { isLoading, isSubscribed } = useTelegramConnect(account) + return useMemo(() => { const channels = { - telegram: isTgSubscribed, + telegram: isSubscribed, } - const hasSubscription = channels.telegram - return { - hasSubscription, + hasSubscription: channels.telegram, isLoading, channels, } - }, [account, isTgSubscribed, authorization.isAuthChecked]) - - return result + }, [isLoading, isSubscribed]) } diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts new file mode 100644 index 00000000000..58bb1504b9b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts @@ -0,0 +1,368 @@ +import { createStore, Provider } from 'jotai' +import { createElement, ReactNode } from 'react' + +import { act, renderHook, waitFor } from '@testing-library/react' + +import { useTelegramConnect } from './useTelegramConnect' + +import { bffTelegramApi } from '../api/bffTelegramApi' + +jest.mock('../api/bffTelegramApi') + +const mockedApi = bffTelegramApi as jest.Mocked + +function createTestWrapper(): { + store: ReturnType + wrapper: (props: { children: ReactNode }) => ReactNode +} { + const store = createStore() + const wrapper = ({ children }: { children: ReactNode }): ReactNode => createElement(Provider, { store }, children) + return { store, wrapper } +} + +describe('useTelegramConnect', () => { + beforeEach(() => { + jest.resetAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('loads the initial connect-status on mount', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + const { wrapper } = createTestWrapper() + + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.isSubscribed).toBe(true) + expect(result.current.username).toBe('ada') + }) + + it('does not fetch status when there is no account', async () => { + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect(undefined), { wrapper }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(mockedApi.getConnectStatus).not.toHaveBeenCalled() + }) + + it('connect() fetches a token, exposes the deep link, and polls until connected', async () => { + mockedApi.getConnectStatus.mockResolvedValueOnce({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + mockedApi.getConnectStatus + .mockResolvedValueOnce({ connected: false }) // first poll tick: still pending + .mockResolvedValueOnce({ connected: true, username: 'ada' }) // second poll tick: connected + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=tok') + + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + expect(result.current.isSubscribed).toBe(false) + expect(result.current.connectState).toBe('connecting') + + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + expect(result.current.isSubscribed).toBe(true) + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + }) + + it('connect() called again while already connecting does not leave the previous timers running', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + // Simulate a double-click: connect() is invoked again before the first + // cycle's poll interval or expiry timeout ever fires. + await act(async () => { + await result.current.connect() + }) + await act(async () => { + await result.current.connect() + }) + + expect(mockedApi.getConnectToken).toHaveBeenCalledTimes(2) + + const callsBeforeAdvance = mockedApi.getConnectStatus.mock.calls.length + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + + // Only one poll interval should be alive - a leaked first-cycle interval + // would double this count. + expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsBeforeAdvance + 1) + + // cancelConnect() only clears the *current* timer refs. If the first + // connect() cycle's interval had been orphaned (not cleared by the + // second connect() call), it would keep polling here even after cancel. + act(() => { + result.current.cancelConnect() + }) + const callsAfterCancel = mockedApi.getConnectStatus.mock.calls.length + await act(async () => { + jest.advanceTimersByTime(3 * 3_000) + await Promise.resolve() + }) + expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsAfterCancel) + }) + + it('connect() moves to "expired" after the connect timeout elapses without success', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + + await act(async () => { + jest.advanceTimersByTime(10 * 60 * 1000) + await Promise.resolve() + }) + + expect(result.current.connectState).toBe('expired') + expect(result.current.deepLink).toBeNull() + }) + + it('connect() moves to "error" when getConnectToken rejects, without an unhandled rejection', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockRejectedValue(new Error('bff unreachable')) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + // connect() must not reject - the hook is responsible for catching the + // getConnectToken failure and surfacing it via connectState instead. + await result.current.connect() + }) + + expect(result.current.connectState).toBe('error') + expect(result.current.deepLink).toBeNull() + + consoleErrorSpy.mockRestore() + }) + + it('cancelConnect() stops polling and resets to idle', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + act(() => { + result.current.cancelConnect() + }) + + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + + const callsBeforeAdvance = mockedApi.getConnectStatus.mock.calls.length + await act(async () => { + jest.advanceTimersByTime(10_000) + await Promise.resolve() + }) + expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsBeforeAdvance) + }) + + it('disconnect() calls the API and flips isSubscribed off', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + mockedApi.disconnect.mockResolvedValue(undefined) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isSubscribed).toBe(true)) + + await act(async () => { + await result.current.disconnect() + }) + + expect(mockedApi.disconnect).toHaveBeenCalledWith('0xabc') + expect(result.current.isSubscribed).toBe(false) + }) + + it('disconnect() rejecting leaves the subscribed state untouched', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + mockedApi.disconnect.mockRejectedValue(new Error('failed to disconnect')) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isSubscribed).toBe(true)) + + await act(async () => { + await expect(result.current.disconnect()).rejects.toThrow('failed to disconnect') + }) + + expect(result.current.isSubscribed).toBe(true) + }) + + it('shares subscription state across two hook instances via the atom store', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + const { wrapper } = createTestWrapper() + + const first = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(first.result.current.isSubscribed).toBe(true)) + + // A second instance for the same account (e.g. the sidebar remounting) + // should see the cached subscribed state immediately, not flash "false" + // while its own fetch is in flight. + const second = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + expect(second.result.current.isSubscribed).toBe(true) + expect(second.result.current.username).toBe('ada') + + // Flush the second instance's own background refresh (it resolves to the + // same cached value) so it doesn't update state outside of act() after + // this test completes. + await act(async () => { + await Promise.resolve() + }) + }) + + it('resets connectState/deepLink when the account changes mid-connect', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + const { wrapper } = createTestWrapper() + + const { result, rerender } = renderHook(({ account }) => useTelegramConnect(account), { + wrapper, + initialProps: { account: '0xabc' as string | undefined }, + }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.connect() + }) + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).not.toBeNull() + + await act(async () => { + rerender({ account: '0xdef' }) + await Promise.resolve() + }) + + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + }) + + it("a stale poll tick from a previous account's connect() attempt does not clobber a new connect() attempt for the current account", async () => { + // Reproduces: connect(A) starts polling -> A's poll tick fires and calls + // getConnectStatus('A'), but we don't let that call resolve yet -> account + // switches to B (resetting A's connect flow) -> connect(B) starts a brand + // new poll/expiry cycle -> THEN A's still in-flight poll call resolves + // { connected: true }. Since the poll interval that issued that call was + // bound to A's connect() closure (accountAtCall === 'A'), its `.then()` + // must ignore the result once accountRef.current has diverged (now 'B'), + // instead of tearing down B's brand new connect state. + // + // The stale call has to still be "in flight" when the account switches - + // achieved here by deferring its resolution with a manually-controlled + // promise instead of letting jest fast-forward past it. + let resolveStaleAPoll: (value: { connected: boolean }) => void = () => undefined + const staleAPollPromise = new Promise<{ connected: boolean }>((resolve) => { + resolveStaleAPoll = resolve + }) + + mockedApi.getConnectToken.mockImplementation(async (acct: string) => ({ + token: `tok-${acct}`, + deepLink: `https://t.me/bot?start=${acct}`, + })) + + mockedApi.getConnectStatus + .mockImplementationOnce(async () => ({ connected: false })) // mount status check for account A + .mockImplementationOnce(() => staleAPollPromise) // A's poll tick - deliberately left pending + .mockImplementationOnce(async () => ({ connected: false })) // mount status check for account B + .mockImplementationOnce(async () => ({ connected: true })) // B's own poll tick - the real success + + const { wrapper } = createTestWrapper() + const { result, rerender } = renderHook(({ account }) => useTelegramConnect(account), { + wrapper, + initialProps: { account: '0xaaa' as string | undefined }, + }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + // Start connecting account A. + await act(async () => { + await result.current.connect() + }) + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=0xaaa') + + // A's poll interval fires and issues its getConnectStatus('0xaaa') call, + // which we're holding pending via staleAPollPromise. + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + + // Switch the wallet to account B *before* A's stale poll call resolves. + // This resets A's connect flow to idle (existing behaviour, covered by + // the previous test) and kicks off B's own mount status check. + await act(async () => { + rerender({ account: '0xbbb' }) + await Promise.resolve() + }) + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + + // Start connecting account B - a brand new token, poll interval, and + // expiry timer. + await act(async () => { + await result.current.connect() + }) + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=0xbbb') + + // Now let A's stale poll call resolve with connected: true. Without the + // accountRef.current === accountAtCall guard, this would incorrectly stop + // B's brand new polling/expiry timers and reset B's connectState/deepLink. + await act(async () => { + resolveStaleAPoll({ connected: true }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=0xbbb') + + // B's own poll tick still correctly resolves the connect flow once B + // actually connects. + await act(async () => { + jest.advanceTimersByTime(3_000) + await Promise.resolve() + }) + + expect(result.current.connectState).toBe('idle') + expect(result.current.deepLink).toBeNull() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts new file mode 100644 index 00000000000..2ed737b9b22 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts @@ -0,0 +1,184 @@ +import { useAtom } from 'jotai' +import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { bffTelegramApi } from '../api/bffTelegramApi' +import { TelegramSubscriptionState, telegramSubscriptionAtom } from '../atoms/telegramSubscriptionAtom' + +const POLL_INTERVAL_MS = 3_000 +const CONNECT_TIMEOUT_MS = 10 * 60 * 1000 // matches the bff connect-token TTL + +const DEFAULT_SUBSCRIPTION_STATE: TelegramSubscriptionState = { isSubscribed: false, username: undefined } + +export type ConnectState = 'idle' | 'connecting' | 'expired' | 'error' + +export interface TelegramConnectController { + isLoading: boolean + isSubscribed: boolean + username?: string + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void + disconnect(): Promise +} + +interface ConnectFlow { + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void +} + +export function useTelegramConnect(account: string | undefined): TelegramConnectController { + const [isLoading, setIsLoading] = useState(true) + const [subscriptionByAccount, setSubscriptionByAccount] = useAtom(telegramSubscriptionAtom) + + // Always-fresh ref to the current `account`, used to discard results from + // async calls that are still in flight when `account` changes or the hook unmounts. + const accountRef = useRef(account) + accountRef.current = account + + const accountKey = getAccountKey(account) + const { isSubscribed, username } = (accountKey && subscriptionByAccount[accountKey]) || DEFAULT_SUBSCRIPTION_STATE + + const refreshStatus = useCallback(async (): Promise => { + if (!account) return false + const accountAtCall = account + + const status = await bffTelegramApi.getConnectStatus(accountAtCall) + + // Discard the result if the account changed while this request was in flight. + if (accountRef.current !== accountAtCall) return status.connected + + setSubscriptionByAccount((prev) => ({ + ...prev, + [accountAtCall.toLowerCase()]: { isSubscribed: status.connected, username: status.username }, + })) + + return status.connected + }, [account, setSubscriptionByAccount]) + + useEffect(() => { + const accountAtEffect = account + + if (!accountAtEffect) { + setIsLoading(false) + return + } + + setIsLoading(true) + refreshStatus() + .catch(() => undefined) // a failed background status check shouldn't surface a hard error + .finally(() => { + if (accountRef.current === accountAtEffect) setIsLoading(false) + }) + }, [account, refreshStatus]) + + const { connectState, deepLink, connect, cancelConnect } = useConnectFlow(account, accountRef, refreshStatus) + + const disconnect = useCallback(async () => { + if (!account) return + const accountAtCall = account + + await bffTelegramApi.disconnect(accountAtCall) + + // Discard the result if the account changed while this request was in flight. + if (accountRef.current !== accountAtCall) return + + setSubscriptionByAccount((prev) => ({ + ...prev, + [accountAtCall.toLowerCase()]: { isSubscribed: false, username: undefined }, + })) + }, [account, setSubscriptionByAccount]) + + return { isLoading, isSubscribed, username, connectState, deepLink, connect, cancelConnect, disconnect } +} + +function getAccountKey(account: string | undefined): string | undefined { + return account?.toLowerCase() +} + +/** + * Owns the connect-token/QR/polling/expiry lifecycle for a single in-progress + * connect attempt. Split out of useTelegramConnect() purely to keep that hook's + * body short - this is not meant to be shared across multiple accounts at once. + */ +function useConnectFlow( + account: string | undefined, + accountRef: MutableRefObject, + refreshStatus: () => Promise, +): ConnectFlow { + const [connectState, setConnectState] = useState('idle') + const [deepLink, setDeepLink] = useState(null) + + const pollTimerRef = useRef | null>(null) + const expiryTimerRef = useRef | null>(null) + + const stopConnecting = useCallback(() => { + if (pollTimerRef.current) clearInterval(pollTimerRef.current) + if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current) + pollTimerRef.current = null + expiryTimerRef.current = null + }, []) + + // Reset any connect attempt bound to the previous account so stale timers/UI + // state don't leak across account switches, and stop timers on unmount. + useEffect(() => { + stopConnecting() + setConnectState('idle') + setDeepLink(null) + }, [account, stopConnecting]) + useEffect(() => stopConnecting, [stopConnecting]) + + const connect = useCallback(async () => { + if (!account) return + const accountAtCall = account + + try { + // Clear any previous polling/expiry timers so a re-entrant connect() call + // (e.g. a double-click before the trigger is disabled) can't orphan them. + stopConnecting() + + const { deepLink: link } = await bffTelegramApi.getConnectToken(accountAtCall) + + if (accountRef.current !== accountAtCall) return + + setDeepLink(link) + setConnectState('connecting') + } catch (error) { + if (accountRef.current !== accountAtCall) return + console.error('[useTelegramConnect] Failed to start the Telegram connect flow', error) + setConnectState('error') + return + } + + pollTimerRef.current = setInterval(() => { + refreshStatus() + .then((connected) => { + // Discard results from a poll tick bound to a stale account (e.g. the + // user switched accounts and started a new connect() attempt) so it + // can't clobber the current connect flow's state. + if (connected && accountRef.current === accountAtCall) { + stopConnecting() + setConnectState('idle') + setDeepLink(null) + } + }) + .catch(() => undefined) + }, POLL_INTERVAL_MS) + + expiryTimerRef.current = setTimeout(() => { + stopConnecting() + setConnectState('expired') + setDeepLink(null) + }, CONNECT_TIMEOUT_MS) + }, [account, accountRef, refreshStatus, stopConnecting]) + + const cancelConnect = useCallback(() => { + stopConnecting() + setConnectState('idle') + setDeepLink(null) + }, [stopConnecting]) + + return { connectState, deepLink, connect, cancelConnect } +} diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts deleted file mode 100644 index 099a33b12ce..00000000000 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -import ms from 'ms.macro' - -import { getTelegramAuth } from '../services/getTelegramAuth' -import { TelegramData } from '../types' - -const TG_SESSION_CHECK_INTERVAL = ms`3s` - -const ENV_TG_BOT_ID = process.env.REACT_APP_TG_BOT_ID -const TG_BOT_ID = ENV_TG_BOT_ID ? parseInt(ENV_TG_BOT_ID) : 7076584722 // cowNotificationsBot - -const AUTH_OPTIONS = { - bot_id: TG_BOT_ID, - lang: 'en', - request_access: 'write', -} - -export interface TgAuthorization { - tgData: TelegramData | null - authorize(): Promise - authenticate(): Promise - clearAuth(): void - isAuthChecked: boolean - isLoginInProgress: boolean -} - -export function useTgAuthorization(): TgAuthorization { - const [tgData, setTgData] = useState(null) - const [isAuthChecked, setIsAuthChecked] = useState(false) - const [isLoginInProgress, setIsLoginInProgress] = useState(false) - - const authenticate = useCallback((): Promise => { - return new Promise((resolve) => { - getTelegramAuth(TG_BOT_ID, (response) => { - const tgData = (response && response.user) || null - - setTgData(tgData) - setIsAuthChecked(true) - resolve?.(tgData) - }) - }) - }, []) - - const authorize = useCallback(async (): Promise => { - if (!window.Telegram) return null - - setIsLoginInProgress(true) - - return new Promise((resolve) => { - window.Telegram?.Login.auth(AUTH_OPTIONS, (data) => { - if (data) { - setTgData(data) - setIsLoginInProgress(false) - resolve(data) - } else { - authenticate().then((tgData) => { - setTgData(tgData) - setIsLoginInProgress(false) - resolve(tgData) - }) - } - }) - }) - }, [authenticate]) - - const clearAuth = useCallback((): void => { - setTgData(null) - setIsAuthChecked(true) // Keep checked state to avoid re-checking - }, []) - - /** - * Initial authentication check on mount - */ - useEffect(() => { - authenticate() - }, [authenticate]) - - /** - * Periodically check if the user is already authenticated - */ - useEffect(() => { - if (!tgData) return - - const intervalId = setInterval(authenticate, TG_SESSION_CHECK_INTERVAL) - - return () => { - clearInterval(intervalId) - } - }, [tgData, authenticate]) - - return { authorize, authenticate, clearAuth, tgData, isAuthChecked, isLoginInProgress } -} diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx b/apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx deleted file mode 100644 index 5dcae7dbd1c..00000000000 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useAtom } from 'jotai' -import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { getCmsClient } from '@cowprotocol/core' -import { areAddressesEqual, getAddressKey } from '@cowprotocol/cow-sdk' -import { useAddSnackbar } from '@cowprotocol/snackbars' - -import { TgAuthorization } from './useTgAuthorization' - -import { tgSubscriptionAtom } from '../atoms/tgSubscriptionAtom' -import { SubscriptionSuccessContent } from '../pure/SubscriptionSuccessContent.pure' -import { TelegramData } from '../types' - -const EMPTY_SUBSCRIPTION_RESPONSE = Promise.resolve({ data: false as const }) - -type SubscriptionApiCaller = ( - method: string, - targetAccount: string, - data: TelegramData, -) => Promise<{ data: boolean }> | undefined - -interface SubscriptionCheckEffectsParams { - account: string | undefined - callSubscriptionApi: SubscriptionApiCaller - setTgSubscribed: (targetAccount: string, value: boolean) => void - skipNextCheckRef: MutableRefObject - tgData: TelegramData | null -} - -interface SubscriptionCheckParams { - callSubscriptionApi: SubscriptionApiCaller - account: string - errorMessage: string - tgData: TelegramData - onSuccess: (isSubscribed: boolean) => void -} - -interface TgSubscriptionContext { - isTgSubscribed: boolean - isCmsCallInProgress: boolean - toggleSubscription(): Promise - subscribeWithData(data: TelegramData): Promise -} - -export function useTgSubscription(account: string | undefined, authorization: TgAuthorization): TgSubscriptionContext { - const { tgData, authorize, clearAuth } = authorization - const [isCmsCallInProgress, setIsCmsCallInProgress] = useState(false) - const [subscriptionByAccount, setSubscriptionByAccount] = useAtom(tgSubscriptionAtom) - const addSnackbar = useAddSnackbar() - const skipNextCheckRef = useRef(false) - const accountKey = account ? getAddressKey(account) : undefined - const isTgSubscribed = accountKey ? Boolean(subscriptionByAccount[accountKey]) : false - - const setTgSubscribed = useCallback( - (targetAccount: string, value: boolean) => { - const targetAccountKey = getAddressKey(targetAccount) - - setSubscriptionByAccount((state) => { - if (!value) { - const { [targetAccountKey]: _, ...nextState } = state - return nextState - } - - return { - ...state, - [targetAccountKey]: true, - } - }) - }, - [setSubscriptionByAccount], - ) - - const callSubscriptionApi = useSubscriptionApiCaller(account, setIsCmsCallInProgress) - - useSubscriptionCheckEffects({ - account, - callSubscriptionApi, - setTgSubscribed, - skipNextCheckRef, - tgData: tgData, - }) - - const addTgSubscription = useCallback( - async (data: TelegramData): Promise => { - if (!account) { - return false - } - - const { data: result } = await (callSubscriptionApi('/add-tg-subscription', account, data) ?? - EMPTY_SUBSCRIPTION_RESPONSE) - setTgSubscribed(account, result) - if (result) { - addSnackbar({ - id: `telegram-enabled-${Date.now()}`, - icon: 'success', - content: , - }) - } - - return result - }, - [account, callSubscriptionApi, addSnackbar, setTgSubscribed], - ) - - const removeSubscription = useCallback( - async (data: TelegramData): Promise => { - if (!account) { - return false - } - - const { data: result } = await (callSubscriptionApi('/remove-tg-subscription', account, data) ?? - EMPTY_SUBSCRIPTION_RESPONSE) - if (!result) { - return false - } - - setTgSubscribed(account, false) - clearAuth() - return true - }, - [account, callSubscriptionApi, clearAuth, setTgSubscribed], - ) - - const subscribeWithData = useCallback( - async (data: TelegramData) => { - skipNextCheckRef.current = true - try { - if (isTgSubscribed) { - await removeSubscription(data) - } else { - await addTgSubscription(data) - } - } finally { - skipNextCheckRef.current = false - } - }, - [isTgSubscribed, addTgSubscription, removeSubscription], - ) - - const toggleSubscription = useCallback(async () => { - const data = tgData || (await authorize()) - if (!data) return - await subscribeWithData(data) - }, [tgData, authorize, subscribeWithData]) - - return useMemo( - () => ({ isTgSubscribed, isCmsCallInProgress, toggleSubscription, subscribeWithData }), - [isTgSubscribed, isCmsCallInProgress, toggleSubscription, subscribeWithData], - ) -} - -function checkSubscriptionStatus({ - account, - callSubscriptionApi, - errorMessage, - tgData, - onSuccess, -}: SubscriptionCheckParams): void { - const subscriptionPromise = callSubscriptionApi('/check-tg-subscription', account, tgData) - if (!subscriptionPromise) return - - subscriptionPromise - .then(({ data: result }: { data: boolean }) => { - onSuccess(result) - }) - .catch((error: unknown) => { - console.error(errorMessage, error) - }) -} - -function useSubscriptionApiCaller( - account: string | undefined, - setIsCmsCallInProgress: (state: boolean) => void, -): SubscriptionApiCaller { - return useCallback( - (method: string, targetAccount: string, data: TelegramData) => { - if (!account || !areAddressesEqual(account, targetAccount)) return - setIsCmsCallInProgress(true) - return getCmsClient() - .POST(method, { body: { account: targetAccount, data } }) - .finally(() => setIsCmsCallInProgress(false)) - }, - [account, setIsCmsCallInProgress], - ) -} - -function useSubscriptionCheckEffects({ - account, - callSubscriptionApi, - setTgSubscribed, - skipNextCheckRef, - tgData, -}: SubscriptionCheckEffectsParams): void { - const tgDataRef = useRef(tgData) - - useEffect(() => { - tgDataRef.current = tgData - }, [tgData]) - - useEffect(() => { - if (!account || !tgDataRef.current || skipNextCheckRef.current) return - - checkSubscriptionStatus({ - account, - callSubscriptionApi, - errorMessage: 'Failed to check Telegram subscription', - tgData: tgDataRef.current, - onSuccess: (isSubscribed) => setTgSubscribed(account, isSubscribed), - }) - }, [account, tgData?.username, callSubscriptionApi, setTgSubscribed, skipNextCheckRef]) -} diff --git a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx index 7eca3955efb..a22876de053 100644 --- a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx @@ -1,48 +1,62 @@ import { TelegramConnectionStatus } from './index' -const subscribeAccount = (): void => {} -const subscribeWithData = async (_data: TelegramData): Promise => {} -const authorize = (): Promise => Promise.resolve(null) +const noop = (): void => {} +const asyncNoop = async (): Promise => {} const Fixtures = { loading: () => ( ), subscribed: () => ( ), - needLogin: () => ( + notConnected: () => ( ), - needsAuthorization: () => ( + waitingForStart: () => ( + ), + linkExpired: () => ( + ), } diff --git a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx index 1e40946d970..7d8de0af037 100644 --- a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx @@ -2,61 +2,71 @@ import { ReactNode, useCallback, useState } from 'react' import { Loader, UI, Toggle } from '@cowprotocol/ui' +import { ConnectTelegramModal } from '../../containers/ConnectTelegram/ConnectTelegramModal' +import { ConnectState } from '../../hooks/useTelegramConnect' + interface TelegramConnectionStatusProps { isLoading: boolean isSubscribed: boolean - needsAuthorization: boolean - toggleSubscription(): void - subscribeWithData(data: TelegramData): Promise - authorize(): Promise + connectState: ConnectState + deepLink: string | null + connect(): Promise + cancelConnect(): void + disconnect(): Promise } export function TelegramConnectionStatus({ isLoading, isSubscribed, - needsAuthorization, - authorize, - toggleSubscription, - subscribeWithData, + connectState, + deepLink, + connect, + cancelConnect, + disconnect, }: TelegramConnectionStatusProps): ReactNode { - const [isAuthorizingInProgress, setIsAuthorizingInProgress] = useState(false) + const [isDisconnecting, setIsDisconnecting] = useState(false) const handleToggle = useCallback(async () => { - if (needsAuthorization) { - // If not authorized, trigger authorization when toggled ON - setIsAuthorizingInProgress(true) + if (isSubscribed) { + setIsDisconnecting(true) try { - const authData = await authorize() - if (authData) { - // Pass the auth data directly to avoid race condition - await subscribeWithData(authData) - } + await disconnect() } catch (error) { - // If authorization fails, the toggle will revert to OFF automatically - console.warn('Telegram authorization failed or was cancelled:', error) + // No dedicated disconnect-error UI - swallow and let the toggle revert to "subscribed". + console.error('[TelegramConnectionStatus] Failed to disconnect Telegram notifications', error) } finally { - setIsAuthorizingInProgress(false) + setIsDisconnecting(false) } } else { - // If already authorized, handle normal subscription toggle - toggleSubscription() + try { + await connect() + } catch (error) { + // connect() handles its own errors internally (connectState becomes 'error'), + // this is just a safety net against an unhandled rejection. + console.error('[TelegramConnectionStatus] Failed to start Telegram connect flow', error) + } } - }, [needsAuthorization, authorize, toggleSubscription, subscribeWithData]) + }, [isSubscribed, connect, disconnect]) - if (isLoading || isAuthorizingInProgress) { + if (isLoading || isDisconnecting) { return } - const checked = !needsAuthorization && isSubscribed - return (
+
) } diff --git a/apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts b/apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts deleted file mode 100644 index 326bd201d6e..00000000000 --- a/apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts +++ /dev/null @@ -1,31 +0,0 @@ -const AUTH_GET_URL = 'https://oauth.telegram.org/auth/get' - -/** - * Reverse engineered from https://telegram.org/js/widget-frame.js?63 - */ -export function getTelegramAuth(botId: number, callback: (data: { user: TelegramData } | false) => void): void { - const xhr = new XMLHttpRequest() - - xhr.open('POST', AUTH_GET_URL + '?bot_id=' + botId) - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8') - xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.responseText) { - try { - callback(JSON.parse(xhr.responseText)) - } catch { - callback(false) - } - } else { - callback(false) - } - } - } - xhr.onerror = function () { - callback(false) - } - xhr.withCredentials = true - xhr.send() -} diff --git a/apps/cowswap-frontend/src/modules/notifications/types.ts b/apps/cowswap-frontend/src/modules/notifications/types.ts deleted file mode 100644 index 3094165b266..00000000000 --- a/apps/cowswap-frontend/src/modules/notifications/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Telegram data shape used across hooks -export interface TelegramData { - auth_date: number - first_name: string - hash: string - id: number - photo_url: string - username: string -} diff --git a/apps/cowswap-frontend/src/react-app-env.d.ts b/apps/cowswap-frontend/src/react-app-env.d.ts index 1a440dc0d1c..db290606626 100644 --- a/apps/cowswap-frontend/src/react-app-env.d.ts +++ b/apps/cowswap-frontend/src/react-app-env.d.ts @@ -10,15 +10,6 @@ interface Console extends Node.Console { force: Node.Console } -interface TelegramData { - auth_date: number - first_name: string - hash: string - id: number - photo_url: string - username: string -} - interface Window { console: Console & { force: Console } // walletLinkExtension is injected by the Coinbase Wallet extension @@ -40,18 +31,6 @@ interface Window { isTrustWallet?: boolean } web3?: Record - Telegram?: { - Login: { - auth( - options: { - bot_id: number - lang?: string - request_access?: string - }, - callback: (data: TelegramData | false) => void, - ): void - } - } } declare module 'content-hash' { diff --git a/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md b/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md deleted file mode 100644 index 85ae30ed1c9..00000000000 --- a/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-frontend.md +++ /dev/null @@ -1,796 +0,0 @@ -# Telegram Bot Deep-Link Frontend Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Telegram Login Widget flow in `apps/cowswap-frontend/src/modules/notifications` with the bot deep-link flow, so connecting/disconnecting Telegram notifications never calls `oauth.telegram.org` and works inside an iframe (CoW Widget embed, Safe Apps). - -**Architecture:** A new `bffTelegramApi` client talks to the three bff routes from the sibling `bff` plan. A new `useTelegramConnect` hook owns all state (subscribed?, connecting?, deep link, expiry) and replaces `useTgAuthorization` + `useTgSubscription`. `useConnectTelegram` becomes a thin adapter so `NotificationSettings.tsx`'s existing consumption (`useConnectTelegram()` → ``) doesn't need to change. The Login Widget's inline button is replaced by a modal (link + QR code), following the existing `AffiliatePartnerQrModal` pattern. - -**Tech Stack:** React hooks, `react-qrcode-logo` (already a dependency), Jest + `@testing-library/react` for hook tests. - -**Spec:** `/Users/shoom/IdeaProjects/cowswap/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md` (section "4. `cowswap-frontend`") - -**Depends on:** the `bff` plan (`/Users/shoom/IdeaProjects/bff/docs/superpowers/plans/2026-08-18-telegram-bot-deeplink-backend.md`) for the three routes this calls. Tasks 1–2 here (API client, hook) can be built and unit-tested against a mocked `bffTelegramApi` before bff ships; Task 4's manual verification needs the real bff + cms deployed. - -## Global Constraints - -- Follow the `bffAffiliateApi.ts` pattern (`apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts`) for the new bff client class — same `fetchWithTimeout`/`JSON_HEADERS`/`parseJsonResponse`/`unwrapOk` helpers from `@cowprotocol/common-utils`, same `BFF_BASE_URL` from `@cowprotocol/common-const`. -- Follow the `AffiliatePartnerQrModal` pattern (`apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerQrModal.tsx`) for the new QR modal — same `react-qrcode-logo` usage, same `CowModal`/`ModalHeader` from `common/pure/Modal` / `@cowprotocol/ui`. -- The frontend never receives or stores a Telegram `hash`/`auth_date`/widget payload again — `TelegramData` shrinks to just what's needed for display. - ---- - -### Task 1: `bffTelegramApi` client + shrink `TelegramData` - -**Files:** -- Create: `apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts` -- Modify: `apps/cowswap-frontend/src/modules/notifications/types.ts` - -**Interfaces:** -- Produces: `bffTelegramApi.getConnectToken(account: string): Promise<{ token: string; deepLink: string }>`, `bffTelegramApi.getConnectStatus(account: string): Promise<{ connected: boolean; username?: string }>`, `bffTelegramApi.disconnect(account: string): Promise` — all consumed by Task 2's `useTelegramConnect`. - -- [ ] **Step 1: Shrink `TelegramData`** - -Replace the contents of `apps/cowswap-frontend/src/modules/notifications/types.ts`: - -```ts -// Telegram data shape used for display only — the frontend no longer holds -// widget auth payloads (hash/auth_date/id); the bot deep-link flow never -// exposes those to the browser. -export interface TelegramData { - username?: string -} -``` - -- [ ] **Step 2: Implement `bffTelegramApi.ts`** - -```ts -import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { - fetchWithTimeout, - JSON_HEADERS, - parseJsonResponse, - stripTrailingSlash, - unwrapOk, -} from '@cowprotocol/common-utils' -import type { FetchJsonResponse } from '@cowprotocol/common-utils' - -export interface TelegramConnectTokenResponse { - token: string - deepLink: string -} - -export interface TelegramConnectStatusResponse { - connected: boolean - username?: string -} - -const TELEGRAM_API_TIMEOUT_MS = 10_000 - -class BffTelegramApi { - private readonly baseUrl: string - - constructor(baseUrl: string) { - this.baseUrl = stripTrailingSlash(baseUrl) - } - - private buildUrl(path: string): string { - return `${this.baseUrl}/${path.replace(/^\//, '')}` - } - - private async fetchJson(path: string, init?: RequestInit): Promise> { - const response = await fetchWithTimeout(this.buildUrl(path), { - method: 'GET', - headers: JSON_HEADERS, - ...init, - timeout: TELEGRAM_API_TIMEOUT_MS, - timeoutMessage: 'Unable to reach notifications service', - }) - return parseJsonResponse(response) - } - - async getConnectToken(account: string): Promise { - const result = await this.fetchJson( - `accounts/${account}/telegram/connect-token`, - { method: 'POST' } - ) - return unwrapOk(result, 'Telegram connect-token response missing') - } - - async getConnectStatus(account: string): Promise { - const result = await this.fetchJson(`accounts/${account}/telegram/connect-status`) - return unwrapOk(result, 'Telegram connect-status response missing') - } - - async disconnect(account: string): Promise { - await this.fetchJson<{ success: boolean }>(`accounts/${account}/telegram/subscription`, { method: 'DELETE' }) - } -} - -export const bffTelegramApi = new BffTelegramApi(BFF_BASE_URL) -``` - -- [ ] **Step 3: Type-check** - -Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` (or whatever the repo's existing type-check command is — check `package.json`'s `scripts` if this path is wrong). -Expected: no new type errors from these two files. (No runtime test here — this file is a thin wire-up, same as `bffAffiliateApi.ts` has no dedicated spec file; Task 2's hook test exercises it through a mock.) - -- [ ] **Step 4: Commit** - -```bash -git add apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts apps/cowswap-frontend/src/modules/notifications/types.ts -git commit -m "feat(notifications): add bffTelegramApi client, shrink TelegramData" -``` - ---- - -### Task 2: `useTelegramConnect` hook - -**Files:** -- Create: `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts` -- Test: `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` (new) - -**Interfaces:** -- Consumes: `bffTelegramApi` (Task 1). -- Produces: `useTelegramConnect(account: string | undefined): TelegramConnectController` where - ```ts - export type ConnectState = 'idle' | 'connecting' | 'expired' - export interface TelegramConnectController { - isLoading: boolean - isSubscribed: boolean - username?: string - connectState: ConnectState - deepLink: string | null - connect(): Promise - cancelConnect(): void - disconnect(): Promise - } - ``` - Consumed by Task 3's `useConnectTelegram`/`ConnectTelegram`/`TelegramConnectionStatus`. - -- [ ] **Step 1: Write the failing test** - -Create `apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts`: - -```ts -import { act, renderHook, waitFor } from '@testing-library/react' - -import { bffTelegramApi } from '../api/bffTelegramApi' -import { useTelegramConnect } from './useTelegramConnect' - -jest.mock('../api/bffTelegramApi') - -const mockedApi = bffTelegramApi as jest.Mocked - -describe('useTelegramConnect', () => { - beforeEach(() => { - jest.resetAllMocks() - jest.useFakeTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('loads the initial connect-status on mount', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) - - const { result } = renderHook(() => useTelegramConnect('0xabc')) - - await waitFor(() => expect(result.current.isLoading).toBe(false)) - - expect(result.current.isSubscribed).toBe(true) - expect(result.current.username).toBe('ada') - }) - - it('does not fetch status when there is no account', async () => { - const { result } = renderHook(() => useTelegramConnect(undefined)) - - await waitFor(() => expect(result.current.isLoading).toBe(false)) - - expect(mockedApi.getConnectStatus).not.toHaveBeenCalled() - }) - - it('connect() fetches a token, exposes the deep link, and polls until connected', async () => { - mockedApi.getConnectStatus.mockResolvedValueOnce({ connected: false }) - mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) - mockedApi.getConnectStatus - .mockResolvedValueOnce({ connected: false }) // first poll tick: still pending - .mockResolvedValueOnce({ connected: true, username: 'ada' }) // second poll tick: connected - - const { result } = renderHook(() => useTelegramConnect('0xabc')) - await waitFor(() => expect(result.current.isLoading).toBe(false)) - - await act(async () => { - await result.current.connect() - }) - - expect(result.current.connectState).toBe('connecting') - expect(result.current.deepLink).toBe('https://t.me/bot?start=tok') - - await act(async () => { - jest.advanceTimersByTime(3_000) - await Promise.resolve() - }) - expect(result.current.isSubscribed).toBe(false) - expect(result.current.connectState).toBe('connecting') - - await act(async () => { - jest.advanceTimersByTime(3_000) - await Promise.resolve() - }) - expect(result.current.isSubscribed).toBe(true) - expect(result.current.connectState).toBe('idle') - expect(result.current.deepLink).toBeNull() - }) - - it('connect() moves to "expired" after the connect timeout elapses without success', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) - mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) - - const { result } = renderHook(() => useTelegramConnect('0xabc')) - await waitFor(() => expect(result.current.isLoading).toBe(false)) - - await act(async () => { - await result.current.connect() - }) - - await act(async () => { - jest.advanceTimersByTime(10 * 60 * 1000) - await Promise.resolve() - }) - - expect(result.current.connectState).toBe('expired') - expect(result.current.deepLink).toBeNull() - }) - - it('cancelConnect() stops polling and resets to idle', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) - mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) - - const { result } = renderHook(() => useTelegramConnect('0xabc')) - await waitFor(() => expect(result.current.isLoading).toBe(false)) - - await act(async () => { - await result.current.connect() - }) - act(() => { - result.current.cancelConnect() - }) - - expect(result.current.connectState).toBe('idle') - expect(result.current.deepLink).toBeNull() - - const callsBeforeAdvance = mockedApi.getConnectStatus.mock.calls.length - await act(async () => { - jest.advanceTimersByTime(10_000) - await Promise.resolve() - }) - expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsBeforeAdvance) - }) - - it('disconnect() calls the API and flips isSubscribed off', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) - mockedApi.disconnect.mockResolvedValue(undefined) - - const { result } = renderHook(() => useTelegramConnect('0xabc')) - await waitFor(() => expect(result.current.isSubscribed).toBe(true)) - - await act(async () => { - await result.current.disconnect() - }) - - expect(mockedApi.disconnect).toHaveBeenCalledWith('0xabc') - expect(result.current.isSubscribed).toBe(false) - }) -}) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm exec jest apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` -Expected: FAIL — cannot find module `./useTelegramConnect`. - -- [ ] **Step 3: Implement `useTelegramConnect.ts`** - -```ts -import { useCallback, useEffect, useRef, useState } from 'react' - -import { bffTelegramApi } from '../api/bffTelegramApi' - -const POLL_INTERVAL_MS = 3_000 -const CONNECT_TIMEOUT_MS = 10 * 60 * 1000 // matches the bff connect-token TTL - -export type ConnectState = 'idle' | 'connecting' | 'expired' - -export interface TelegramConnectController { - isLoading: boolean - isSubscribed: boolean - username?: string - connectState: ConnectState - deepLink: string | null - connect(): Promise - cancelConnect(): void - disconnect(): Promise -} - -export function useTelegramConnect(account: string | undefined): TelegramConnectController { - const [isLoading, setIsLoading] = useState(true) - const [isSubscribed, setIsSubscribed] = useState(false) - const [username, setUsername] = useState(undefined) - const [connectState, setConnectState] = useState('idle') - const [deepLink, setDeepLink] = useState(null) - - const pollTimerRef = useRef | null>(null) - const expiryTimerRef = useRef | null>(null) - - const stopConnecting = useCallback(() => { - if (pollTimerRef.current) clearInterval(pollTimerRef.current) - if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current) - pollTimerRef.current = null - expiryTimerRef.current = null - }, []) - - const refreshStatus = useCallback(async (): Promise => { - if (!account) return false - const status = await bffTelegramApi.getConnectStatus(account) - setIsSubscribed(status.connected) - setUsername(status.username) - return status.connected - }, [account]) - - useEffect(() => { - if (!account) { - setIsLoading(false) - return - } - setIsLoading(true) - refreshStatus().finally(() => setIsLoading(false)) - }, [account, refreshStatus]) - - // Stop any in-flight polling/expiry timers on unmount - useEffect(() => stopConnecting, [stopConnecting]) - - const connect = useCallback(async () => { - if (!account) return - - const { deepLink: link } = await bffTelegramApi.getConnectToken(account) - setDeepLink(link) - setConnectState('connecting') - - pollTimerRef.current = setInterval(() => { - refreshStatus() - .then((connected) => { - if (connected) { - stopConnecting() - setConnectState('idle') - setDeepLink(null) - } - }) - .catch(() => undefined) - }, POLL_INTERVAL_MS) - - expiryTimerRef.current = setTimeout(() => { - stopConnecting() - setConnectState('expired') - setDeepLink(null) - }, CONNECT_TIMEOUT_MS) - }, [account, refreshStatus, stopConnecting]) - - const cancelConnect = useCallback(() => { - stopConnecting() - setConnectState('idle') - setDeepLink(null) - }, [stopConnecting]) - - const disconnect = useCallback(async () => { - if (!account) return - await bffTelegramApi.disconnect(account) - setIsSubscribed(false) - setUsername(undefined) - }, [account]) - - return { isLoading, isSubscribed, username, connectState, deepLink, connect, cancelConnect, disconnect } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm exec jest apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts` -Expected: PASS (7 tests). - -- [ ] **Step 5: Commit** - -```bash -git add apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts -git commit -m "feat(notifications): add useTelegramConnect hook for the bot deep-link flow" -``` - ---- - -### Task 3: Wire the hook into the UI (adapter hook, modal, container, pure component) - -**Files:** -- Modify: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx` -- Modify: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx` -- Create: `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx` -- Modify: `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx` -- Modify: `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx` - -**Interfaces:** -- Consumes: `useTelegramConnect`/`TelegramConnectController`/`ConnectState` (Task 2). -- Produces: `ConnectTelegramController` (same exported name `useConnectTelegram()` that `NotificationSettings.tsx` already imports, now just re-typed to `TelegramConnectController` — that container file needs no changes). - -- [ ] **Step 1: Rewrite `useConnectTelegram.tsx`** - -Replace its contents entirely: - -```tsx -import { useWalletInfo } from '@cowprotocol/wallet' - -import { TelegramConnectController, useTelegramConnect } from '../../hooks/useTelegramConnect' - -export type ConnectTelegramController = TelegramConnectController - -export function useConnectTelegram(): ConnectTelegramController { - const { account } = useWalletInfo() - - return useTelegramConnect(account) -} -``` - -- [ ] **Step 2: Create the QR/deep-link modal** - -Create `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx`: - -```tsx -import { ReactNode } from 'react' - -import { ButtonOutlined, ButtonPrimary, ModalHeader, UI } from '@cowprotocol/ui' - -import QRCode from 'react-qrcode-logo' -import styled from 'styled-components/macro' - -import { CowModal } from 'common/pure/Modal' - -import { ConnectState } from '../../hooks/useTelegramConnect' - -const QR_SIZE_PX = 200 - -interface ConnectTelegramModalProps { - isOpen: boolean - connectState: ConnectState - deepLink: string | null - onRetry(): void - onDismiss(): void -} - -export function ConnectTelegramModal({ - isOpen, - connectState, - deepLink, - onRetry, - onDismiss, -}: ConnectTelegramModalProps): ReactNode { - return ( - - - Connect Telegram - - {connectState === 'expired' ? ( - <> - This link expired before you tapped Start in Telegram. - Get a new link - - ) : deepLink ? ( - <> - Scan this code, or open the link on this device, then tap “Start” in the chat. - - - - - Open in Telegram - - Waiting for you to tap Start… - - ) : ( - Preparing your connect link… - )} - - - - ) -} - -const ModalContent = styled.div` - display: flex; - flex-direction: column; - width: 100%; -` - -const ModalBody = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - padding: 10px 20px 24px; -` - -const QrFrame = styled.div` - border-radius: 16px; - border: 1px solid var(${UI.COLOR_PAPER_DARKER}); - padding: 16px; - background: #fff; -` - -const Message = styled.p` - margin: 0; - text-align: center; - font-size: 13px; - color: var(${UI.COLOR_TEXT_OPACITY_70}); -` - -const Status = styled.p` - margin: 0; - font-size: 12px; - color: var(${UI.COLOR_TEXT_OPACITY_60}); -` -``` - -If `ButtonPrimary` isn't exported from `@cowprotocol/ui` under that exact name, grep the lib for the actual name (`grep -rn "export.*Button" libs/ui/src`) and swap it in — everything else in this file follows `AffiliatePartnerQrModal.tsx`'s already-verified imports. - -- [ ] **Step 3: Update `TelegramConnectionStatus`** - -Replace `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx`: - -```tsx -import { ReactNode, useCallback, useState } from 'react' - -import { Loader, UI, Toggle } from '@cowprotocol/ui' - -import { ConnectTelegramModal } from '../../containers/ConnectTelegram/ConnectTelegramModal' -import { ConnectState } from '../../hooks/useTelegramConnect' - -interface TelegramConnectionStatusProps { - isLoading: boolean - isSubscribed: boolean - connectState: ConnectState - deepLink: string | null - connect(): Promise - cancelConnect(): void - disconnect(): Promise -} - -export function TelegramConnectionStatus({ - isLoading, - isSubscribed, - connectState, - deepLink, - connect, - cancelConnect, - disconnect, -}: TelegramConnectionStatusProps): ReactNode { - const [isDisconnecting, setIsDisconnecting] = useState(false) - - const handleToggle = useCallback(async () => { - if (isSubscribed) { - setIsDisconnecting(true) - try { - await disconnect() - } finally { - setIsDisconnecting(false) - } - } else { - await connect() - } - }, [isSubscribed, connect, disconnect]) - - if (isLoading || isDisconnecting) { - return - } - - return ( -
- - -
- ) -} -``` - -This drops the `needsAuthorization`/`authorize`/`subscribeWithData`/`toggleSubscription` props entirely — connecting and disconnecting are now just `connect()`/`disconnect()`, and "not yet linked" is simply `!isSubscribed`. - -- [ ] **Step 4: Update the Cosmos story** - -Replace `apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx`: - -```tsx -import { TelegramConnectionStatus } from './index' - -const noop = (): void => {} -const asyncNoop = async (): Promise => {} - -const Fixtures = { - loading: () => ( - - ), - subscribed: () => ( - - ), - notConnected: () => ( - - ), - waitingForStart: () => ( - - ), - linkExpired: () => ( - - ), -} - -export default Fixtures -``` - -- [ ] **Step 5: Update `ConnectTelegram.tsx`** - -Replace `apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx`: - -```tsx -import type { ReactElement } from 'react' - -import { ConnectTelegramController } from './ConnectTelegram/useConnectTelegram' - -import { TelegramConnectionStatus } from '../pure/TelegramConnectionStatus' - -interface ConnectTelegramProps { - controller: ConnectTelegramController -} - -export function ConnectTelegram({ controller }: ConnectTelegramProps): ReactElement { - const { isLoading, isSubscribed, connectState, deepLink, connect, cancelConnect, disconnect } = controller - - return ( - - ) -} -``` - -The `Wrapper` styled-div and `wrapperRef` are gone — there's no widget script to mount into anymore. - -- [ ] **Step 6: Type-check and run Cosmos** - -Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` -Expected: no errors. `NotificationSettings.tsx` needs no changes — it only reads `telegramController.username`, which `TelegramConnectController` still exposes. - -Run: `pnpm --filter cowswap-frontend run cosmos` (or the repo's documented Cosmos command) and open `TelegramConnectionStatus` to click through all five fixtures, confirming the modal opens for `waitingForStart` and `linkExpired`. - -- [ ] **Step 7: Commit** - -```bash -git add apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/ -git commit -m "feat(notifications): replace Telegram widget UI with the deep-link/QR modal" -``` - ---- - -### Task 4: Remove the widget-flow code and stale docs - -**Files:** -- Delete: `apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts` -- Delete: `apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts` -- Delete: `apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx` -- Delete: `apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts` -- Modify: `apps/cowswap-frontend/src/modules/notifications/README.md` - -**Interfaces:** None — this task only removes now-dead code confirmed unused by Tasks 1–3. - -- [ ] **Step 1: Confirm nothing else references the files being deleted** - -Run: -```bash -grep -rln "useTgAuthorization\|useTgSubscription\|getTelegramAuth\|tgSubscriptionAtom" apps/cowswap-frontend/src -``` -Expected: only the files listed above (Task 3 already stopped importing them — `useConnectTelegram.tsx` no longer references `useTgAuthorization`/`useTgSubscription`). If anything else still matches, stop and re-check Task 3 before deleting. - -- [ ] **Step 2: Delete the files** - -```bash -git rm apps/cowswap-frontend/src/modules/notifications/services/getTelegramAuth.ts -git rm apps/cowswap-frontend/src/modules/notifications/hooks/useTgAuthorization.ts -git rm apps/cowswap-frontend/src/modules/notifications/hooks/useTgSubscription.tsx -git rm apps/cowswap-frontend/src/modules/notifications/atoms/tgSubscriptionAtom.ts -``` - -If `services/` or `atoms/` are now empty, remove the empty directories too (git doesn't track empty dirs, so no extra command is needed once the last file in each is removed). - -- [ ] **Step 3: Update the module README** - -Replace `apps/cowswap-frontend/src/modules/notifications/README.md`: - -```markdown -# Notifications - -## Local development - -Telegram notifications are connected via a bot deep-link (`/start `), not the Telegram Login Widget — the frontend only talks to the bff, never to `oauth.telegram.org` or the CMS directly. - -To debug/develop Telegram subscriptions locally: -1. Point `REACT_APP_BFF_BASE_URL` at your local `bff` `apps/api` instance in `.env.local`. -2. Follow the `bff` repo's own local-dev instructions for `apps/api` and `apps/telegram` (these need `TELEGRAM_SECRET`, `CMS_BASE_URL`, `CMS_API_KEY`, and a Redis instance shared between the two apps). -3. Launch CoW Swap as usual (`pnpm run start`) — no bot id or Telegram-specific env var is needed on the frontend anymore. -``` - -- [ ] **Step 4: Remove the now-dead `REACT_APP_TG_BOT_ID` comment from `.env`** - -Run `git diff apps/cowswap-frontend/.env` first — this file already has uncommitted local changes from earlier in this session; don't blindly overwrite it. Open the file, find the commented `# REACT_APP_TG_BOT_ID=3713371337` line (around line 146) and delete just that line, leaving the rest of the existing diff intact. - -- [ ] **Step 5: Full check** - -Run: `pnpm exec tsc --noEmit -p apps/cowswap-frontend/tsconfig.json` and `pnpm exec jest apps/cowswap-frontend/src/modules/notifications` -Expected: no errors, all notifications-module tests pass (including `getTrustedNotificationLink.test.ts`, unrelated but in the same directory tree). - -- [ ] **Step 6: Commit** - -```bash -git add -A apps/cowswap-frontend/src/modules/notifications apps/cowswap-frontend/.env -git commit -m "chore(notifications): remove the Telegram Login Widget flow" -``` diff --git a/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md b/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md deleted file mode 100644 index 2f0ac8ce949..00000000000 --- a/docs/superpowers/specs/2026-08-18-telegram-bot-deeplink-notifications-design.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -title: Telegram notifications via bot deep-link (replace Login Widget) -date: 2026-08-18 -status: approved -owner: alexandr@cow.fi -repos: - - cowswap (apps/cowswap-frontend) - - bff (apps/api, apps/telegram) - - cms (src/api/telegram-subscription) ---- - -# Telegram notifications via bot deep-link (replace Login Widget) - -## Why - -`useConnectTelegram` (`apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx`) -currently authorizes the Telegram notification bot via the Telegram Login -Widget: it loads `telegram-widget.js`, calls the documented -`window.Telegram.Login.auth()` popup API, and — critically — also calls a -reverse-engineered, undocumented endpoint (`getTelegramAuth.ts` → -`https://oauth.telegram.org/auth/get`) both as a silent "already logged in" -check on mount/every 3s, and as a fallback inside the popup flow. - -That endpoint is designed to run first-party inside Telegram's own iframe. When -cowswap-frontend itself runs inside an iframe — as the CoW Widget guest -(`libs/widget-lib`, `apps/widget-configurator`) or as a Safe Apps guest -(`useIsSafeApp`) — the request becomes cross-site-in-a-nested-iframe, and -browsers' third-party-cookie/SameSite partitioning blocks it, surfacing as a -CORS error. This is not fixable by changing headers on our side; it's an -unsupported dependency in that context. - -This design replaces the widget flow with a Telegram Bot deep-link -(`/start `) flow, which never touches `oauth.telegram.org` from the -browser — it is either a plain link tap (top-level navigation, immune to -iframe/CORS/cookie restrictions) or a server-to-server call. - -## Constraints decided up front - -| Decision | Choice | Rationale | -|---|---|---| -| Scope | Full replacement of the Login Widget, everywhere (not iframe-only fallback) | One auth code path to maintain; deep-link UX works identically top-level and embedded. | -| CMS write path | New CMS-internal endpoints (`link-via-bot` / `unlink-via-bot`), not a hash-signing hack in bff | CMS repo is available to edit; a dedicated internal endpoint is clearer/more maintainable than replicating Strapi's HMAC verification in bff. | -| Disconnect | New bff `DELETE` endpoint mirrors the connect flow | Keeps the existing in-app toggle-off UX working, rather than pushing users to a bot `/stop` command. | -| Token store | Redis-backed `CacheRepository` (already used elsewhere in bff), shared between `apps/api` and `apps/telegram` | Reuses existing infra; **requires Redis to be enabled in every environment this runs in** — see Risks. | -| CMS auth for new routes | Existing `CMS_API_KEY` bearer token bff already holds, routes excluded from the "Public" Strapi role | No new secret to provision/rotate. Requires one manual Strapi-admin permission grant (see Rollout). | -| Bot username source | `bot.getMe()` fetched once by `apps/api` and cached in-process | Avoids a new env var that must stay in sync with the bot token; falls back to env var if `getMe()` fails at boot. | - -## Architecture - -``` - cowswap-frontend - │ - 1. POST /accounts/:account/telegram/connect-token - ▼ - bff / apps/api (Fastify) - generates random token, stores in Redis - (CacheRepository) as telegram-connect: -> account, TTL 10m - │ - returns { token, deepLink: t.me/?start= } - ▼ - cowswap-frontend - shows "Open in Telegram" link + QR, starts polling - GET /accounts/:account/telegram/connect-status - │ - user taps deepLink (top-level nav) - ▼ - Telegram app - │ - sends "/start " - ▼ - bff / apps/telegram (already polling) - resolves token -> account via same Redis CacheRepository - (deletes token: single-use), then calls - │ - ▼ - cms: POST /telegram-subscription/link-via-bot - { account, chatId, firstName, username } (CMS_API_KEY auth) - │ - writes telegram_subscriptions row directly - (no hash / verifyTgAuthentication needed) - │ - bot replies in Telegram chat: success / expired-token - │ - cowswap-frontend's poll of connect-status flips to { connected: true } -``` - -Disconnect mirrors this: `DELETE /accounts/:account/telegram/subscription` on -`apps/api` calls `cms: POST /telegram-subscription/unlink-via-bot { account }`. - -## Component changes - -### 1. `cms` (`/Users/shoom/IdeaProjects/cms`) - -No schema migration: `telegram_subscriptions` already has `hash`, `authDate`, -`photoUrl` as optional; only `account` is required. - -- `src/api/telegram-subscription/controllers/telegram-subscription.ts`: add - `linkViaBot(context)` and `unlinkViaBot(context)`. Both skip - `verifyTgAuthentication` entirely — trust comes from the route not being - public, not from a per-request hash. `linkViaBot` calls the existing - `service.addSubscription(account, { id: chatId, first_name, username, auth_date: now, hash: null })` - (or a slightly widened service signature that makes `hash`/`photo_url` - optional — trivial, since the DB columns already are). `unlinkViaBot` calls - the existing `service.removeSubscriptions(account)` unchanged. -- `src/api/telegram-subscription/routes/telegram-subscription.ts`: add - `POST /telegram-subscription/link-via-bot` and - `POST /telegram-subscription/unlink-via-bot`. -- These two routes must **not** be granted to the Strapi "Public" role - (unlike `add-tg-subscription`/`remove-tg-subscription`/`check-tg-subscription`, - which are public because the browser calls them directly today). Strapi's - built-in API-token auth then gates them. - -### 2. `bff` — `apps/api` - -New file `apps/api/src/app/routes/accounts/_account/telegram/index.ts` -(sibling of the existing `accounts/_account/notifications.ts`), following the -same `isCmsEnabled` guard and inversify-container lookup pattern: - -- `POST /accounts/:account/telegram/connect-token` — generates a token via - `crypto.randomBytes(16).toString('hex')`, `cacheRepository.set('telegram-connect:' + token, account, 600)`, - returns `{ token, deepLink }` where `deepLink` is built from the bot - username (`bot.getMe()`, cached in-process at startup) and the token. -- `GET /accounts/:account/telegram/connect-status` — calls the existing - `PushSubscriptionsRepository.getAllTelegramSubscriptionsForAccounts([account])`, - returns `{ connected: boolean, username?: string }`. -- `DELETE /accounts/:account/telegram/subscription` — calls the CMS - `unlink-via-bot` endpoint, returns `{ success: boolean }`. - -### 3. `bff` — `apps/telegram` - -`apps/telegram/src/main.ts` currently only sends messages. Add, before/alongside -`mainLoop()`: - -- `telegramBot.on('message', handler)` where `handler` matches `/^\/start\s+(\S+)$/` - against `msg.text`. -- On match: read `telegram-connect:` from the same - `CacheRepository`/Redis instance `apps/api` writes to (via `getCacheRepository()` - from `@cowprotocol/services`), delete the key immediately (single-use), then - call `POST cms/telegram-subscription/link-via-bot` with - `{ account, chatId: msg.chat.id, firstName: msg.from.first_name, username: msg.from.username }`. -- Reply in-chat: success message on link, or "This link has expired — please - reconnect from CoW Swap" if the token was missing/already used. - -### 4. `cowswap-frontend` — `apps/cowswap-frontend/src/modules/notifications/` - -Removed: -- `services/getTelegramAuth.ts` (the `oauth.telegram.org/auth/get` XHR). -- Widget-script loading, `Telegram.Login.auth`/`AUTH_OPTIONS`, and the silent - re-check/poll in `hooks/useTgAuthorization.ts`. -- The `TelegramData`-with-`hash` payload building in `hooks/useTgSubscription.tsx` - and its direct `getCmsClient().POST('/add-tg-subscription' | '/remove-tg-subscription' | '/check-tg-subscription', ...)` - calls — the browser no longer talks to the CMS directly for this flow at all. - -Added: -- `common/services/bff` (or a module-local file, following the - `bffAffiliateApi.ts` pattern) — `bffTelegramApi.ts` with - `getConnectToken(account)`, `getConnectStatus(account)`, `disconnect(account)`. -- A replacement for `useConnectTelegram` that: calls `getConnectToken` on - demand (not eagerly on mount, since there's no more "already logged in" - widget concept to silently probe), exposes the `deepLink` for a - `ConnectTelegram` UI to render as a link + QR code, polls - `getConnectStatus` every 3s while a connect attempt is pending, and stops - polling once `connected: true` or the token's 10-minute TTL has elapsed - (surface a "link expired, try again" state). -- `ConnectTelegram` container drops `wrapperRef` (no more DOM node for the - widget script) and `TelegramConnectionStatus` gets a new "waiting for you to - tap the Telegram link" visual state instead of the widget's inline button. - -`types.ts`'s `TelegramData` shrinks to `{ username?: string }` — the frontend -never sees `hash`/`auth_date`/`id` again. - -## Error handling - -- **Token expiry**: bot replies with an explicit expired-token message; - frontend's own poll times out after the same TTL and shows a "try again" - state rather than polling forever. -- **CMS/bff unreachable during `/start` handling**: bot replies with a generic - "something went wrong, please try again" and does *not* delete the Redis - key before the CMS call succeeds, so a transient failure is retryable by the - user re-sending `/start ` (same token, still valid until TTL). -- **Redis unavailable**: `getCacheRepository()` silently falls back to - `CacheRepositoryMemory()` per-process today. Since `apps/api` and - `apps/telegram` are separate processes, this would make every token - unresolvable. This is treated as a hard deploy requirement, not handled in - code — see Risks. - -## Testing - -- CMS: unit test for `linkViaBot`/`unlinkViaBot` controller actions (mock - `strapi.service`), and a route-level check that the new paths are absent - from the Public role fixture/seed if one exists. -- bff `apps/api`: route tests for the three new endpoints against a fake - `PushSubscriptionsRepository`/`CacheRepository`, following existing tests - under `apps/api/src/app/routes/**/*.spec.ts` conventions. -- bff `apps/telegram`: unit test the `/start` handler against a mocked bot - and `CacheRepository` (token found / not found / CMS call fails). -- cowswap-frontend: hook test for the new connect hook (token fetch → poll → - connected), and a Cosmos story update for `TelegramConnectionStatus`'s new - "waiting" state. -- Manual E2E: cannot fully verify the iframe-CORS fix without an actual - embedded (CoW Widget or Safe App) deployment; note this explicitly when - reporting the fix rather than claiming it's verified end-to-end from - automated tests alone. - -## Rollout - -1. Ship CMS changes first (additive, no behavior change for existing - endpoints) and deploy. -2. Manually grant the bff API token permission for `link-via-bot` / - `unlink-via-bot` in the Strapi admin (Settings → API Tokens). -3. Confirm Redis is enabled (`REDIS_URL`/equivalent) in every environment - `apps/api` and `apps/telegram` run in — required for the token handoff to - work at all. -4. Ship bff changes (`apps/api` routes + `apps/telegram` handler), deploy. -5. Ship cowswap-frontend changes last, once the above are live in the target - environment(s). - -## Risks - -- **Shared Redis is load-bearing and silent-failing.** If it's ever disabled - or the two bff apps end up pointed at different Redis instances, connect - tokens simply never resolve, with no error surfaced beyond "link expired" - to the user. Worth a startup log/assertion in both apps if Redis is - disabled, even though this design doesn't add new fallback logic for it. -- **Manual Strapi permission grant** is an out-of-git operational step and - easy to forget when replicating to a new environment (e.g. a fresh staging - CMS instance). From 04056a5398a968e9c0125c81dfab0ae70cfd8c5f Mon Sep 17 00:00:00 2001 From: "cowswap-release-sync[bot]" <274575433+cowswap-release-sync[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:59:32 +0000 Subject: [PATCH 3/8] chore(i18n): extract i18n strings [automatic] --- apps/cowswap-frontend/src/locales/en-US.po | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 92ae92a0240..bf41df4f8b1 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -1987,6 +1987,10 @@ msgstr "MEV-Protected Slippage" msgid "Closed" msgstr "Closed" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Connect Telegram" +msgstr "Connect Telegram" + #: apps/cowswap-frontend/src/pages/error/AnySwapAffectedUsers/index.tsx msgid "CoW Swap 404 not found" msgstr "CoW Swap 404 not found" @@ -2991,6 +2995,10 @@ msgstr "Insufficient allowance granted for" #~ msgid "Lock this code to your wallet?" #~ msgstr "Lock this code to your wallet?" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Try again" +msgstr "Try again" + #: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx msgid "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again." msgstr "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again." @@ -3400,6 +3408,10 @@ msgstr "TWAP" msgid "Insufficient liquidity for this trade" msgstr "Insufficient liquidity for this trade" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Get a new link" +msgstr "Get a new link" + #: apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderCodeInfo.tsx #: apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderCodeInfo.tsx msgid "Will be updated {approxNextUpdateTimeAgo}" @@ -3601,6 +3613,10 @@ msgstr "Signed: <0>{numConfirmations} out of {gnosisSafeThreshold} signers" msgid "This order can be partially filled" msgstr "This order can be partially filled" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "This link expired before you tapped Start in Telegram." +msgstr "This link expired before you tapped Start in Telegram." + #: libs/hook-dapp-lib/src/hookDappsRegistry.ts #~ msgid "Build your own hook" #~ msgstr "Build your own hook" @@ -5597,6 +5613,10 @@ msgstr "Recreate limit order" msgid "TWAP order split in <0>{numOfParts} equal parts" msgstr "TWAP order split in <0>{numOfParts} equal parts" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Scan this code, or open the link on this device, then tap “Start” in the chat." +msgstr "Scan this code, or open the link on this device, then tap “Start” in the chat." + #: apps/cowswap-frontend/src/modules/account/containers/AccountDetails/SurplusCard.tsx msgid "Your total surplus" msgstr "Your total surplus" @@ -5628,6 +5648,10 @@ msgstr "before that time." msgid "View transaction" msgstr "View transaction" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Preparing your connect link…" +msgstr "Preparing your connect link…" + #: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx #~ msgid "This list requires consent before importing." #~ msgstr "This list requires consent before importing." @@ -5823,6 +5847,10 @@ msgstr "of your sell amount! Therefore, your order is unlikely to execute." #~ msgid "This hook allows you to easily set up vesting contracts with LlamaPay. Enter the recipient’s address or ENS name, then choose how much to transfer: the token buy will be automatically detected by the hook and the contracts will be linked to your LlamaPay dashboard for seamless tracking." #~ msgstr "This hook allows you to easily set up vesting contracts with LlamaPay. Enter the recipient’s address or ENS name, then choose how much to transfer: the token buy will be automatically detected by the hook and the contracts will be linked to your LlamaPay dashboard for seamless tracking." +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Something went wrong. Please try again." +msgstr "Something went wrong. Please try again." + #: apps/cowswap-frontend/src/modules/ordersTable/pure/ContextMenu/OrderContextMenu.pure.tsx #: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/ReceiptModal.modal.tsx msgid "Recreate" @@ -6809,6 +6837,10 @@ msgstr "Executes at" #~ msgid "From (incl. costs)" #~ msgstr "From (incl. costs)" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Open in Telegram" +msgstr "Open in Telegram" + #: apps/cowswap-frontend/src/legacy/components/Header/NotificationAlertPopover/NotificationAlertPopover.pure.tsx msgid "Get trade alerts" msgstr "Get trade alerts" @@ -8154,6 +8186,10 @@ msgstr "Quote ID" msgid "Bridge costs" msgstr "Bridge costs" +#: apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/ConnectTelegramModal.tsx +msgid "Waiting for you to tap Start…" +msgstr "Waiting for you to tap Start…" + #: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx #: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx #~ msgid "Claimable rewards" From 81fe13dfc3e74b297202d6094210169640d0e7c9 Mon Sep 17 00:00:00 2001 From: "cowswap-release-sync[bot]" <274575433+cowswap-release-sync[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:59:32 +0000 Subject: [PATCH 4/8] chore: update bundle sizes [automatic] --- bundle-size.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundle-size.jsonc b/bundle-size.jsonc index ccc142dc377..68890f30478 100644 --- a/bundle-size.jsonc +++ b/bundle-size.jsonc @@ -6,7 +6,7 @@ "schemaVersion": 2, "apps": { "@cowprotocol/cow-fi": 4.9, - "@cowprotocol/cowswap": 10.3, + "@cowprotocol/cowswap": 10.4, "@cowprotocol/explorer": 10.8, "@cowprotocol/sdk-tools": 9.9, "@cowprotocol/storybook": 3.5, From a4b287b2353e5cc8082d54bebdc32d652b8c6870 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 13:43:44 +0200 Subject: [PATCH 5/8] fix: move unsubscribe to bot side --- .../AccountElement/AccountElement.pure.tsx | 4 +- .../notifications/api/bffTelegramApi.ts | 21 +--- .../atoms/telegramSubscriptionAtom.ts | 2 + .../containers/ConnectTelegram.tsx | 4 +- .../NotificationSidebar.container.tsx | 5 +- .../hooks/useTelegramConnect.test.ts | 104 +++++++++++------- .../notifications/hooks/useTelegramConnect.ts | 69 ++++++------ .../TelegramConnectionStatus/index.cosmos.tsx | 10 +- .../pure/TelegramConnectionStatus/index.tsx | 32 +++--- 9 files changed, 135 insertions(+), 116 deletions(-) diff --git a/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx b/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx index 9f82ee61841..707d0bf2797 100644 --- a/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx +++ b/apps/cowswap-frontend/src/legacy/components/Header/AccountElement/AccountElement.pure.tsx @@ -1,5 +1,6 @@ import { ReactNode, useCallback, useRef, useState } from 'react' +import { useFeatureFlags } from '@cowprotocol/common-hooks' import { useWalletInfo } from '@cowprotocol/wallet' import { useToggleAccountModal } from 'modules/account' @@ -29,8 +30,7 @@ export function AccountElement({ className }: AccountElementProps): ReactNode { const shouldShowAffiliateTraderHeaderButton = useShouldShowAffiliateTraderHeaderButton() const unreadNotificationsCount = useUnreadSidebarNotificationsCount() const { isDismissed, dismiss } = useNotificationAlertDismissal() - // FIXME - const areTelegramNotificationsEnabled = true + const { areTelegramNotificationsEnabled } = useFeatureFlags() const { hasSubscription, isLoading } = useHasNotificationSubscription() const [isSidebarOpen, setSidebarOpen] = useState(false) diff --git a/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts index 81b82c88d23..7acfe76bcd8 100644 --- a/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts +++ b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts @@ -1,17 +1,19 @@ import { BFF_BASE_URL } from '@cowprotocol/common-const' import { - ApiError, fetchWithTimeout, JSON_HEADERS, parseJsonResponse, stripTrailingSlash, unwrapOk, } from '@cowprotocol/common-utils' -import type { ApiErrorPayload, FetchJsonResponse } from '@cowprotocol/common-utils' +import type { FetchJsonResponse } from '@cowprotocol/common-utils' export interface TelegramConnectStatusResponse { connected: boolean username?: string + // Static link to the bot chat - unsubscribing only happens there (see the bot's + // "Unsubscribe" button), so the frontend never posts a disconnect request itself. + botDeepLink: string } export interface TelegramConnectTokenResponse { @@ -57,21 +59,6 @@ class BffTelegramApi { const result = await this.fetchJson(`accounts/${account}/telegram/connect-status`) return unwrapOk(result, 'Telegram connect-status response missing') } - - async disconnect(account: string): Promise { - const { response, text, data } = await this.fetchJson<{ success: boolean }>( - `accounts/${account}/telegram/subscription`, - { - method: 'DELETE', - // See the comment in getConnectToken - same bodyless-POST/DELETE 400 applies here. - body: '{}', - }, - ) - - if (response.ok) return - - throw new ApiError(response.status, text, data as ApiErrorPayload) - } } export const bffTelegramApi = new BffTelegramApi(BFF_BASE_URL) diff --git a/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts b/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts index e1f61b3bf2d..ae780266a1b 100644 --- a/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts +++ b/apps/cowswap-frontend/src/modules/notifications/atoms/telegramSubscriptionAtom.ts @@ -3,6 +3,8 @@ import { atom } from 'jotai' export interface TelegramSubscriptionState { isSubscribed: boolean username?: string + // Static link to the bot chat (no connect-token) - unsubscribing only happens there. + botDeepLink?: string } /** diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx index 86eacdff338..f6dd2301a1a 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram.tsx @@ -9,17 +9,17 @@ interface ConnectTelegramProps { } export function ConnectTelegram({ controller }: ConnectTelegramProps): ReactElement { - const { isLoading, isSubscribed, connectState, deepLink, connect, cancelConnect, disconnect } = controller + const { isLoading, isSubscribed, botDeepLink, connectState, deepLink, connect, cancelConnect } = controller return ( ) } diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx index 08f3cd53469..50056bec58f 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from 'jotai' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import iconNotificationSettingsSrc from '@cowprotocol/assets/images/icon-notification-settings.svg' -import { useMediaQuery, useOnClickOutside } from '@cowprotocol/common-hooks' +import { useMediaQuery, useOnClickOutside, useFeatureFlags } from '@cowprotocol/common-hooks' import { Media } from '@cowprotocol/ui' import { t } from '@lingui/core/macro' @@ -60,8 +60,7 @@ export function NotificationSidebar({ const isMobile = useMediaQuery(Media.upToSmall(false)) const isAnyModalOpen = useAtomValue(openModalState) - // FIXME - const areTelegramNotificationsEnabled = true + const { areTelegramNotificationsEnabled } = useFeatureFlags() const { hasSubscription } = useHasNotificationSubscription() const { isDismissed: isSettingsPopoverDismissed, dismiss: dismissSettingsPopover } = useNotificationSettingsPopoverDismissal() diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts index 58bb1504b9b..4ae3d74166f 100644 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.test.ts @@ -31,7 +31,11 @@ describe('useTelegramConnect', () => { }) it('loads the initial connect-status on mount', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + mockedApi.getConnectStatus.mockResolvedValue({ + connected: true, + username: 'ada', + botDeepLink: 'https://t.me/cowNotificationsBot', + }) const { wrapper } = createTestWrapper() const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) @@ -52,11 +56,14 @@ describe('useTelegramConnect', () => { }) it('connect() fetches a token, exposes the deep link, and polls until connected', async () => { - mockedApi.getConnectStatus.mockResolvedValueOnce({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValueOnce({ + connected: false, + botDeepLink: 'https://t.me/cowNotificationsBot', + }) mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) mockedApi.getConnectStatus - .mockResolvedValueOnce({ connected: false }) // first poll tick: still pending - .mockResolvedValueOnce({ connected: true, username: 'ada' }) // second poll tick: connected + .mockResolvedValueOnce({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) // first poll tick: still pending + .mockResolvedValueOnce({ connected: true, username: 'ada', botDeepLink: 'https://t.me/cowNotificationsBot' }) // second poll tick: connected const { wrapper } = createTestWrapper() const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) @@ -85,8 +92,40 @@ describe('useTelegramConnect', () => { expect(result.current.deepLink).toBeNull() }) + it('connect() flips to "connecting" immediately, before getConnectToken resolves', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) + let resolveGetConnectToken: (value: { token: string; deepLink: string }) => void = () => undefined + mockedApi.getConnectToken.mockReturnValue( + new Promise((resolve) => { + resolveGetConnectToken = resolve + }), + ) + + const { wrapper } = createTestWrapper() + const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + let connectPromise: Promise = Promise.resolve() + act(() => { + connectPromise = result.current.connect() + }) + + // The modal (isOpen = connectState !== 'idle') must open right away, showing its + // "preparing" state, instead of only after the token request resolves. + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBeNull() + + await act(async () => { + resolveGetConnectToken({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) + await connectPromise + }) + + expect(result.current.connectState).toBe('connecting') + expect(result.current.deepLink).toBe('https://t.me/bot?start=tok') + }) + it('connect() called again while already connecting does not leave the previous timers running', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) const { wrapper } = createTestWrapper() @@ -129,7 +168,7 @@ describe('useTelegramConnect', () => { }) it('connect() moves to "expired" after the connect timeout elapses without success', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) const { wrapper } = createTestWrapper() @@ -151,7 +190,7 @@ describe('useTelegramConnect', () => { it('connect() moves to "error" when getConnectToken rejects, without an unhandled rejection', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) mockedApi.getConnectToken.mockRejectedValue(new Error('bff unreachable')) const { wrapper } = createTestWrapper() @@ -171,7 +210,7 @@ describe('useTelegramConnect', () => { }) it('cancelConnect() stops polling and resets to idle', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) const { wrapper } = createTestWrapper() @@ -196,39 +235,26 @@ describe('useTelegramConnect', () => { expect(mockedApi.getConnectStatus.mock.calls.length).toBe(callsBeforeAdvance) }) - it('disconnect() calls the API and flips isSubscribed off', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) - mockedApi.disconnect.mockResolvedValue(undefined) - - const { wrapper } = createTestWrapper() - const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) - await waitFor(() => expect(result.current.isSubscribed).toBe(true)) - - await act(async () => { - await result.current.disconnect() + it('exposes botDeepLink from the connect-status response', async () => { + mockedApi.getConnectStatus.mockResolvedValue({ + connected: true, + username: 'ada', + botDeepLink: 'https://t.me/cowNotificationsBot', }) - expect(mockedApi.disconnect).toHaveBeenCalledWith('0xabc') - expect(result.current.isSubscribed).toBe(false) - }) - - it('disconnect() rejecting leaves the subscribed state untouched', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) - mockedApi.disconnect.mockRejectedValue(new Error('failed to disconnect')) - const { wrapper } = createTestWrapper() const { result } = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) await waitFor(() => expect(result.current.isSubscribed).toBe(true)) - await act(async () => { - await expect(result.current.disconnect()).rejects.toThrow('failed to disconnect') - }) - - expect(result.current.isSubscribed).toBe(true) + expect(result.current.botDeepLink).toBe('https://t.me/cowNotificationsBot') }) it('shares subscription state across two hook instances via the atom store', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: true, username: 'ada' }) + mockedApi.getConnectStatus.mockResolvedValue({ + connected: true, + username: 'ada', + botDeepLink: 'https://t.me/cowNotificationsBot', + }) const { wrapper } = createTestWrapper() const first = renderHook(() => useTelegramConnect('0xabc'), { wrapper }) @@ -250,7 +276,7 @@ describe('useTelegramConnect', () => { }) it('resets connectState/deepLink when the account changes mid-connect', async () => { - mockedApi.getConnectStatus.mockResolvedValue({ connected: false }) + mockedApi.getConnectStatus.mockResolvedValue({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' }) mockedApi.getConnectToken.mockResolvedValue({ token: 'tok', deepLink: 'https://t.me/bot?start=tok' }) const { wrapper } = createTestWrapper() @@ -288,8 +314,8 @@ describe('useTelegramConnect', () => { // The stale call has to still be "in flight" when the account switches - // achieved here by deferring its resolution with a manually-controlled // promise instead of letting jest fast-forward past it. - let resolveStaleAPoll: (value: { connected: boolean }) => void = () => undefined - const staleAPollPromise = new Promise<{ connected: boolean }>((resolve) => { + let resolveStaleAPoll: (value: { connected: boolean; botDeepLink: string }) => void = () => undefined + const staleAPollPromise = new Promise<{ connected: boolean; botDeepLink: string }>((resolve) => { resolveStaleAPoll = resolve }) @@ -299,10 +325,10 @@ describe('useTelegramConnect', () => { })) mockedApi.getConnectStatus - .mockImplementationOnce(async () => ({ connected: false })) // mount status check for account A + .mockImplementationOnce(async () => ({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' })) // mount status check for account A .mockImplementationOnce(() => staleAPollPromise) // A's poll tick - deliberately left pending - .mockImplementationOnce(async () => ({ connected: false })) // mount status check for account B - .mockImplementationOnce(async () => ({ connected: true })) // B's own poll tick - the real success + .mockImplementationOnce(async () => ({ connected: false, botDeepLink: 'https://t.me/cowNotificationsBot' })) // mount status check for account B + .mockImplementationOnce(async () => ({ connected: true, botDeepLink: 'https://t.me/cowNotificationsBot' })) // B's own poll tick - the real success const { wrapper } = createTestWrapper() const { result, rerender } = renderHook(({ account }) => useTelegramConnect(account), { @@ -347,7 +373,7 @@ describe('useTelegramConnect', () => { // accountRef.current === accountAtCall guard, this would incorrectly stop // B's brand new polling/expiry timers and reset B's connectState/deepLink. await act(async () => { - resolveStaleAPoll({ connected: true }) + resolveStaleAPoll({ connected: true, botDeepLink: 'https://t.me/cowNotificationsBot' }) await Promise.resolve() await Promise.resolve() }) diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts index 2ed737b9b22..5bb043a5057 100644 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts @@ -1,13 +1,20 @@ import { useAtom } from 'jotai' import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react' +import { normalizeError } from '@cowprotocol/common-utils' +import { getAddressKey } from '@cowprotocol/cow-sdk' + import { bffTelegramApi } from '../api/bffTelegramApi' import { TelegramSubscriptionState, telegramSubscriptionAtom } from '../atoms/telegramSubscriptionAtom' const POLL_INTERVAL_MS = 3_000 const CONNECT_TIMEOUT_MS = 10 * 60 * 1000 // matches the bff connect-token TTL -const DEFAULT_SUBSCRIPTION_STATE: TelegramSubscriptionState = { isSubscribed: false, username: undefined } +const DEFAULT_SUBSCRIPTION_STATE: TelegramSubscriptionState = { + isSubscribed: false, + username: undefined, + botDeepLink: undefined, +} export type ConnectState = 'idle' | 'connecting' | 'expired' | 'error' @@ -15,11 +22,13 @@ export interface TelegramConnectController { isLoading: boolean isSubscribed: boolean username?: string + // Unsubscribing only happens from the bot side (tap its "Unsubscribe" button) - this + // is a static link to open that chat, not a fresh single-use connect-token. + botDeepLink: string | undefined connectState: ConnectState deepLink: string | null connect(): Promise cancelConnect(): void - disconnect(): Promise } interface ConnectFlow { @@ -39,7 +48,8 @@ export function useTelegramConnect(account: string | undefined): TelegramConnect accountRef.current = account const accountKey = getAccountKey(account) - const { isSubscribed, username } = (accountKey && subscriptionByAccount[accountKey]) || DEFAULT_SUBSCRIPTION_STATE + const { isSubscribed, username, botDeepLink } = + (accountKey && subscriptionByAccount[accountKey]) || DEFAULT_SUBSCRIPTION_STATE const refreshStatus = useCallback(async (): Promise => { if (!account) return false @@ -52,7 +62,11 @@ export function useTelegramConnect(account: string | undefined): TelegramConnect setSubscriptionByAccount((prev) => ({ ...prev, - [accountAtCall.toLowerCase()]: { isSubscribed: status.connected, username: status.username }, + [getAddressKey(accountAtCall)]: { + isSubscribed: status.connected, + username: status.username, + botDeepLink: status.botDeepLink, + }, })) return status.connected @@ -76,26 +90,11 @@ export function useTelegramConnect(account: string | undefined): TelegramConnect const { connectState, deepLink, connect, cancelConnect } = useConnectFlow(account, accountRef, refreshStatus) - const disconnect = useCallback(async () => { - if (!account) return - const accountAtCall = account - - await bffTelegramApi.disconnect(accountAtCall) - - // Discard the result if the account changed while this request was in flight. - if (accountRef.current !== accountAtCall) return - - setSubscriptionByAccount((prev) => ({ - ...prev, - [accountAtCall.toLowerCase()]: { isSubscribed: false, username: undefined }, - })) - }, [account, setSubscriptionByAccount]) - - return { isLoading, isSubscribed, username, connectState, deepLink, connect, cancelConnect, disconnect } + return { isLoading, isSubscribed, username, botDeepLink, connectState, deepLink, connect, cancelConnect } } function getAccountKey(account: string | undefined): string | undefined { - return account?.toLowerCase() + return account ? getAddressKey(account) : undefined } /** @@ -134,24 +133,32 @@ function useConnectFlow( if (!account) return const accountAtCall = account - try { - // Clear any previous polling/expiry timers so a re-entrant connect() call - // (e.g. a double-click before the trigger is disabled) can't orphan them. - stopConnecting() - - const { deepLink: link } = await bffTelegramApi.getConnectToken(accountAtCall) + // Clear any previous polling/expiry timers so a re-entrant connect() call + // (e.g. a double-click before the trigger is disabled) can't orphan them. + stopConnecting() - if (accountRef.current !== accountAtCall) return + // Open the modal immediately (it shows a "preparing" message while deepLink is + // still null) instead of waiting for getConnectToken to resolve, so there's no + // silent gap between the click and the QR code appearing. + setDeepLink(null) + setConnectState('connecting') - setDeepLink(link) - setConnectState('connecting') - } catch (error) { + let link: string + try { + const data = await bffTelegramApi.getConnectToken(accountAtCall) + link = data.deepLink + } catch (err: unknown) { if (accountRef.current !== accountAtCall) return + const error = normalizeError(err) console.error('[useTelegramConnect] Failed to start the Telegram connect flow', error) setConnectState('error') return } + if (accountRef.current !== accountAtCall) return + + setDeepLink(link) + pollTimerRef.current = setInterval(() => { refreshStatus() .then((connected) => { diff --git a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx index a22876de053..d83c6835817 100644 --- a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.cosmos.tsx @@ -8,55 +8,55 @@ const Fixtures = { ), subscribed: () => ( ), notConnected: () => ( ), waitingForStart: () => ( ), linkExpired: () => ( ), } diff --git a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx index 7d8de0af037..438cab33e1b 100644 --- a/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/pure/TelegramConnectionStatus/index.tsx @@ -1,5 +1,6 @@ -import { ReactNode, useCallback, useState } from 'react' +import { ReactNode, useCallback } from 'react' +import { normalizeError } from '@cowprotocol/common-utils' import { Loader, UI, Toggle } from '@cowprotocol/ui' import { ConnectTelegramModal } from '../../containers/ConnectTelegram/ConnectTelegramModal' @@ -8,47 +9,44 @@ import { ConnectState } from '../../hooks/useTelegramConnect' interface TelegramConnectionStatusProps { isLoading: boolean isSubscribed: boolean + // Unsubscribing only happens from the bot side (tap its "Unsubscribe" button) - this + // is a static link to open that chat, not a fresh single-use connect-token. + botDeepLink: string | undefined connectState: ConnectState deepLink: string | null connect(): Promise cancelConnect(): void - disconnect(): Promise } export function TelegramConnectionStatus({ isLoading, isSubscribed, + botDeepLink, connectState, deepLink, connect, cancelConnect, - disconnect, }: TelegramConnectionStatusProps): ReactNode { - const [isDisconnecting, setIsDisconnecting] = useState(false) - const handleToggle = useCallback(async () => { if (isSubscribed) { - setIsDisconnecting(true) - try { - await disconnect() - } catch (error) { - // No dedicated disconnect-error UI - swallow and let the toggle revert to "subscribed". - console.error('[TelegramConnectionStatus] Failed to disconnect Telegram notifications', error) - } finally { - setIsDisconnecting(false) + // Unsubscribing only happens from the bot side - send the user there to tap + // "Unsubscribe" instead of toggling anything here. The toggle itself stays on + // until the next status refresh notices they actually unsubscribed. + if (botDeepLink) { + window.open(botDeepLink, '_blank', 'noopener,noreferrer') } } else { try { await connect() - } catch (error) { + } catch (err: unknown) { // connect() handles its own errors internally (connectState becomes 'error'), // this is just a safety net against an unhandled rejection. - console.error('[TelegramConnectionStatus] Failed to start Telegram connect flow', error) + console.error('[TelegramConnectionStatus] Failed to start Telegram connect flow', normalizeError(err)) } } - }, [isSubscribed, connect, disconnect]) + }, [isSubscribed, botDeepLink, connect]) - if (isLoading || isDisconnecting) { + if (isLoading) { return } From 613aa123fa0d57876ac48a1e04bc186f3e948c1e Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 17:52:07 +0200 Subject: [PATCH 6/8] refactor: extract BffApiClient --- .../src/api/gasPrices/index.ts | 3 +- .../modules/affiliate/api/bffAffiliateApi.ts | 97 ++++--------------- .../config/affiliateProgram.const.ts | 5 - .../notifications/api/bffTelegramApi.ts | 32 +----- .../modules/usdAmount/apis/getBffUsdPrice.ts | 4 +- .../usdAmount/apis/getDefillamaUsdPrice.ts | 4 +- libs/common-utils/package.json | 5 +- libs/common-utils/src/bffApiClient.ts | 88 +++++++++++++++++ .../src/fetchWithRateLimit.test.ts | 6 +- .../common-utils/src/fetchWithRateLimit.ts | 24 +---- libs/common-utils/src/index.ts | 2 + pnpm-lock.yaml | 9 ++ 12 files changed, 134 insertions(+), 145 deletions(-) create mode 100644 libs/common-utils/src/bffApiClient.ts rename apps/cowswap-frontend/src/common/utils/fetch.test.ts => libs/common-utils/src/fetchWithRateLimit.test.ts (91%) rename apps/cowswap-frontend/src/common/utils/fetch.ts => libs/common-utils/src/fetchWithRateLimit.ts (68%) diff --git a/apps/cowswap-frontend/src/api/gasPrices/index.ts b/apps/cowswap-frontend/src/api/gasPrices/index.ts index 37e7a9fb7f2..8c309351a1a 100644 --- a/apps/cowswap-frontend/src/api/gasPrices/index.ts +++ b/apps/cowswap-frontend/src/api/gasPrices/index.ts @@ -1,11 +1,10 @@ import { GAS_FEE_ENDPOINTS } from '@cowprotocol/common-const' +import { fetchWithRateLimit } from '@cowprotocol/common-utils' import { isEvmChain, SupportedChainId as ChainId } from '@cowprotocol/cow-sdk' import { wagmiAdapter } from '@cowprotocol/wallet' import { getGasPrice } from '@wagmi/core' -import { fetchWithRateLimit } from 'common/utils/fetch' - const fetchRateLimited = fetchWithRateLimit({ rateLimit: { tokensPerInterval: 3, diff --git a/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts index 2a017e94ec3..cc1087747df 100644 --- a/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts +++ b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts @@ -1,17 +1,7 @@ import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { - ApiError, - fetchWithTimeout, - JSON_HEADERS, - parseJsonResponse, - RetryableResponseError, - STATUS_CODES_TO_RETRY, - stripTrailingSlash, - unwrapOk, -} from '@cowprotocol/common-utils' -import type { ApiErrorPayload, FetchJsonResponse } from '@cowprotocol/common-utils' - -import { fetchWithRateLimit } from 'common/utils/fetch' +import { ApiError, BffApiClient, unwrapOk } from '@cowprotocol/common-utils' +import type { ApiErrorPayload } from '@cowprotocol/common-utils' + import { wait } from 'common/utils/wait' import { @@ -25,62 +15,14 @@ import { import { AFFILIATE_API_TIMEOUT_MS, - BACKOFF_MAX_ATTEMPTS, - BACKOFF_START_DELAY_MS, - BACKOFF_TIME_MULTIPLE, - RATE_LIMIT_INTERVAL_MS, VERIFICATION_MIN_RESPONSE_DELAY_MS, VERIFICATION_RETRY_DELAY_MS, } from '../config/affiliateProgram.const' -class BffAffiliateApi { - private readonly baseUrl: string - private readonly timeoutMs: number - private readonly fetchRateLimited: ReturnType - - /** - * Configuration - */ - +class BffAffiliateApi extends BffApiClient { constructor(baseUrl: string, timeoutMs: number = AFFILIATE_API_TIMEOUT_MS) { - this.baseUrl = stripTrailingSlash(baseUrl) - this.timeoutMs = timeoutMs - this.fetchRateLimited = fetchWithRateLimit({ - rateLimit: { - tokensPerInterval: 1, - interval: RATE_LIMIT_INTERVAL_MS, - }, - backoff: { - numOfAttempts: BACKOFF_MAX_ATTEMPTS, - startingDelay: BACKOFF_START_DELAY_MS, - timeMultiple: BACKOFF_TIME_MULTIPLE, - }, - }) - } - - private buildUrl(path: string): string { - const normalizedPath = path.replace(/^\//, '') - return `${this.baseUrl}/${normalizedPath}` - } - - private async fetchJsonResponse(input: string, init?: RequestInit): Promise> { - const response = await this.fetchRateLimited(async () => { - const requestInit = { - method: 'GET', - headers: JSON_HEADERS, - ...init, - } - const response = await fetchWithTimeout(input, { - ...requestInit, - timeout: this.timeoutMs, - timeoutMessage: 'Unable to reach referral service', - }) - if (STATUS_CODES_TO_RETRY.has(response.status)) { - throw new RetryableResponseError(response.status) - } - return response - }) - return parseJsonResponse(response) + // Rate-limit/retry-on-retryable-status uses BffApiClient's default tuning. + super(baseUrl, timeoutMs, 'Unable to reach referral service') } /** @@ -88,10 +30,10 @@ class BffAffiliateApi { */ async verifyCode(code: string): Promise { - const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`) + const path = `ref-codes/${encodeURIComponent(code)}` try { const [result] = await Promise.all([ - this.fetchJsonResponse(url), + this.fetchJson(path), wait(VERIFICATION_MIN_RESPONSE_DELAY_MS), ]) return unwrapOk(result, 'Trader response missing') @@ -102,8 +44,7 @@ class BffAffiliateApi { } async verifyCodeAvailability(code: string): Promise { - const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`) - const { response, text } = await this.fetchJsonResponse(url) + const { response, text } = await this.fetchJson(`ref-codes/${encodeURIComponent(code)}`) if (response.status === 404) return true if (response.ok || response.status === 403) return false @@ -112,8 +53,7 @@ class BffAffiliateApi { } async createCode(request: PartnerCreateRequest): Promise { - const url = this.buildUrl(`affiliate/${request.walletAddress}`) - const result = await this.fetchJsonResponse(url, { + const result = await this.fetchJson(`affiliate/${request.walletAddress}`, { method: 'POST', body: JSON.stringify(request), }) @@ -121,40 +61,37 @@ class BffAffiliateApi { } async getTraderInfo(code: string): Promise { - const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`) - const { response, data, text } = await this.fetchJsonResponse(url) + const { response, data, text } = await this.fetchJson(`ref-codes/${encodeURIComponent(code)}`) if (response.status === 404) return null if (response.ok) return data ?? null throw new ApiError(response.status, text, data as ApiErrorPayload) } async getPartnerInfo(account: string): Promise { - const url = this.buildUrl(`affiliate/${account}`) - const { response, data, text } = await this.fetchJsonResponse(url) + const { response, data, text } = await this.fetchJson(`affiliate/${account}`) if (response.status === 404) return null if (response.ok) return data ?? null throw new ApiError(response.status, text, data as ApiErrorPayload) } async getTraderStats(account: string): Promise { - const url = this.buildUrl(`affiliate/trader-stats/${account}`) - const { response, data, text } = await this.fetchJsonResponse(url) + const { response, data, text } = await this.fetchJson(`affiliate/trader-stats/${account}`) if (response.status === 404) return null if (response.ok) return data ?? null throw new ApiError(response.status, text, data as ApiErrorPayload) } async getAffiliateStats(account: string): Promise { - const url = this.buildUrl(`affiliate/affiliate-stats/${account}`) - const { response, data, text } = await this.fetchJsonResponse(url) + const { response, data, text } = await this.fetchJson(`affiliate/affiliate-stats/${account}`) if (response.status === 404) return null if (response.ok) return data ?? null throw new ApiError(response.status, text, data as ApiErrorPayload) } async getTraderActivity(account: string): Promise { - const url = this.buildUrl(`affiliate/trader-activity/${account}`) - const { response, data, text } = await this.fetchJsonResponse(url) + const { response, data, text } = await this.fetchJson( + `affiliate/trader-activity/${account}`, + ) if (response.status === 404) return null if (response.ok) return data ?? null throw new ApiError(response.status, text, data as ApiErrorPayload) diff --git a/apps/cowswap-frontend/src/modules/affiliate/config/affiliateProgram.const.ts b/apps/cowswap-frontend/src/modules/affiliate/config/affiliateProgram.const.ts index 67cf64b0259..98c5c90b58b 100644 --- a/apps/cowswap-frontend/src/modules/affiliate/config/affiliateProgram.const.ts +++ b/apps/cowswap-frontend/src/modules/affiliate/config/affiliateProgram.const.ts @@ -53,11 +53,6 @@ export const AFFILIATE_HIDE_REWARDS_ROW_IF_INELIGIBLE = true export const AFFILIATE_ELIGIBILITY_LOADING_WARNING_MS = ms`30s` -export const RATE_LIMIT_INTERVAL_MS = 200 -export const BACKOFF_START_DELAY_MS = ms`1s` -export const BACKOFF_TIME_MULTIPLE = 3 -export const BACKOFF_MAX_ATTEMPTS = 3 - export const REF_CODE_PATTERN = /^[A-Z0-9_-]{5,20}$/ export const REF_CODE_MIN_LENGTH = 5 export const REF_CODE_MAX_LENGTH = 20 diff --git a/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts index 7acfe76bcd8..9bfb0a90eae 100644 --- a/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts +++ b/apps/cowswap-frontend/src/modules/notifications/api/bffTelegramApi.ts @@ -1,12 +1,5 @@ import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { - fetchWithTimeout, - JSON_HEADERS, - parseJsonResponse, - stripTrailingSlash, - unwrapOk, -} from '@cowprotocol/common-utils' -import type { FetchJsonResponse } from '@cowprotocol/common-utils' +import { BffApiClient, NO_RATE_LIMIT, unwrapOk } from '@cowprotocol/common-utils' export interface TelegramConnectStatusResponse { connected: boolean @@ -23,26 +16,11 @@ export interface TelegramConnectTokenResponse { const TELEGRAM_API_TIMEOUT_MS = 10_000 -class BffTelegramApi { - private readonly baseUrl: string - +class BffTelegramApi extends BffApiClient { constructor(baseUrl: string) { - this.baseUrl = stripTrailingSlash(baseUrl) - } - - private buildUrl(path: string): string { - return `${this.baseUrl}/${path.replace(/^\//, '')}` - } - - private async fetchJson(path: string, init?: RequestInit): Promise> { - const response = await fetchWithTimeout(this.buildUrl(path), { - method: 'GET', - headers: JSON_HEADERS, - ...init, - timeout: TELEGRAM_API_TIMEOUT_MS, - timeoutMessage: 'Unable to reach notifications service', - }) - return parseJsonResponse(response) + // Each poll tick already retries on its own schedule (see useTelegramConnect.ts), so this + // opts out of BffApiClient's default rate-limit/backoff instead of stacking both. + super(baseUrl, TELEGRAM_API_TIMEOUT_MS, 'Unable to reach notifications service', NO_RATE_LIMIT) } async getConnectToken(account: string): Promise { diff --git a/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts index 495007fc656..31dd3a5c2c7 100644 --- a/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts +++ b/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts @@ -1,12 +1,10 @@ import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { FractionUtils } from '@cowprotocol/common-utils' +import { fetchWithRateLimit, FractionUtils } from '@cowprotocol/common-utils' import { Fraction, Token } from '@cowprotocol/currency' import { t } from '@lingui/core/macro' import ms from 'ms.macro' -import { fetchWithRateLimit } from 'common/utils/fetch' - import { UnknownCurrencyError } from './errors' type BffResponse = BffUsdPriceResponse | BffUsdErrorResponse diff --git a/apps/cowswap-frontend/src/modules/usdAmount/apis/getDefillamaUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/apis/getDefillamaUsdPrice.ts index f5270730c88..c72d1e078c7 100644 --- a/apps/cowswap-frontend/src/modules/usdAmount/apis/getDefillamaUsdPrice.ts +++ b/apps/cowswap-frontend/src/modules/usdAmount/apis/getDefillamaUsdPrice.ts @@ -1,11 +1,9 @@ -import { FractionUtils } from '@cowprotocol/common-utils' +import { fetchWithRateLimit, FractionUtils } from '@cowprotocol/common-utils' import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' import { Fraction, Token } from '@cowprotocol/currency' import ms from 'ms.macro' -import { fetchWithRateLimit } from 'common/utils/fetch' - import { RateLimitError, UnknownCurrencyError, UnsupportedPlatformError } from './errors' interface DefillamaUsdQuote { diff --git a/libs/common-utils/package.json b/libs/common-utils/package.json index 73fedbd1fa2..ebef009e7bb 100644 --- a/libs/common-utils/package.json +++ b/libs/common-utils/package.json @@ -46,9 +46,11 @@ "@sentry/react": "7.80.0", "bignumber.js": "9.1.2", "cids": "1.1.9", + "exponential-backoff": "3.1.3", "fast-safe-stringify": "2.1.1", "jotai-family": "1.0.1", "jotai": "2.2.0", + "limiter": "3.0.0", "jsbi": "3.2.5", "ms": "2.1.3", "multicodec": "3.2.1", @@ -63,6 +65,7 @@ "devDependencies": { "@types/ms": "2.1.0", "@types/react": "19.1.3", - "@types/ua-parser-js": "0.7.36" + "@types/ua-parser-js": "0.7.36", + "jest-fetch-mock": "3.0.3" } } diff --git a/libs/common-utils/src/bffApiClient.ts b/libs/common-utils/src/bffApiClient.ts new file mode 100644 index 00000000000..6eb64e9454d --- /dev/null +++ b/libs/common-utils/src/bffApiClient.ts @@ -0,0 +1,88 @@ +import { + fetchWithTimeout, + JSON_HEADERS, + parseJsonResponse, + RetryableResponseError, + STATUS_CODES_TO_RETRY, +} from './api-utils' +import { fetchWithRateLimit } from './fetchWithRateLimit' +import { stripTrailingSlash } from './url-utils' + +import type { FetchJsonResponse } from './api-utils' + +// Same shape as `fetchWithRateLimit()`'s result when called with a callback. +export type FetchRateLimited = (fn: () => Promise) => Promise + +type FetchInit = NonNullable[1]> + +// Passed explicitly to opt a client out of the default rate-limit/retry behavior below, +// falling back to a single plain `fetchWithTimeout` call - distinct from omitting the +// constructor argument, which uses that default. +export const NO_RATE_LIMIT = Symbol('NO_RATE_LIMIT') + +const DEFAULT_RATE_LIMIT_INTERVAL_MS = 200 +const DEFAULT_BACKOFF_START_DELAY_MS = 1_000 +const DEFAULT_BACKOFF_TIME_MULTIPLE = 3 +const DEFAULT_BACKOFF_MAX_ATTEMPTS = 3 + +/** + * Base class for BFF JSON API clients: builds the URL, applies a timeout, retries on + * retryable status codes with rate-limiting/backoff by default, and parses the JSON + * response. Pass `NO_RATE_LIMIT` for a client that should just make a single plain + * `fetchWithTimeout` call instead (e.g. a background poll that already retries on its own + * schedule), or a custom `FetchRateLimited` for different rate-limit/backoff tuning. + */ +export abstract class BffApiClient { + protected readonly baseUrl: string + private readonly fetchRateLimited: FetchRateLimited | typeof NO_RATE_LIMIT + + protected constructor( + baseUrl: string, + private readonly timeoutMs: number, + private readonly timeoutMessage: string, + fetchRateLimited: FetchRateLimited | typeof NO_RATE_LIMIT = createDefaultFetchRateLimited(), + ) { + this.baseUrl = stripTrailingSlash(baseUrl) + this.fetchRateLimited = fetchRateLimited + } + + protected buildUrl(path: string): string { + return `${this.baseUrl}/${path.replace(/^\//, '')}` + } + + protected async fetchJson(path: string, init?: RequestInit): Promise> { + const response = await this.performFetch(this.buildUrl(path), { + method: 'GET', + headers: JSON_HEADERS, + ...init, + timeout: this.timeoutMs, + timeoutMessage: this.timeoutMessage, + }) + return parseJsonResponse(response) + } + + private performFetch(url: string, init: FetchInit): Promise { + if (this.fetchRateLimited === NO_RATE_LIMIT) { + return fetchWithTimeout(url, init) + } + + return this.fetchRateLimited(async () => { + const response = await fetchWithTimeout(url, init) + if (STATUS_CODES_TO_RETRY.has(response.status)) { + throw new RetryableResponseError(response.status) + } + return response + }) + } +} + +function createDefaultFetchRateLimited(): FetchRateLimited { + return fetchWithRateLimit({ + rateLimit: { tokensPerInterval: 1, interval: DEFAULT_RATE_LIMIT_INTERVAL_MS }, + backoff: { + numOfAttempts: DEFAULT_BACKOFF_MAX_ATTEMPTS, + startingDelay: DEFAULT_BACKOFF_START_DELAY_MS, + timeMultiple: DEFAULT_BACKOFF_TIME_MULTIPLE, + }, + }) +} diff --git a/apps/cowswap-frontend/src/common/utils/fetch.test.ts b/libs/common-utils/src/fetchWithRateLimit.test.ts similarity index 91% rename from apps/cowswap-frontend/src/common/utils/fetch.test.ts rename to libs/common-utils/src/fetchWithRateLimit.test.ts index 4f1bceb6f74..9acac02a785 100644 --- a/apps/cowswap-frontend/src/common/utils/fetch.test.ts +++ b/libs/common-utils/src/fetchWithRateLimit.test.ts @@ -1,11 +1,8 @@ import fetchMock from 'jest-fetch-mock' -import { fetchWithRateLimit } from './fetch' +import { fetchWithRateLimit } from './fetchWithRateLimit' fetchMock.enableMocks() -// jest.useFakeTimers('modern') -// jest.spyOn(global, 'setTimeout') -// jest.useFakeTimers() const URL = 'https://cow.fi' const ERROR_MESSAGE = '💣💥 Booom!' @@ -33,7 +30,6 @@ function mockAndFailUntilAttempt(attempt: number) { }) as any) } -// We use fetchWithRateLimit instead of fetchWithBackoff, since that is just a default config version of fetchWithBackoff // TODO: Add proper return type annotation // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const fetchUrlWithBackoff = (attempts: number) => diff --git a/apps/cowswap-frontend/src/common/utils/fetch.ts b/libs/common-utils/src/fetchWithRateLimit.ts similarity index 68% rename from apps/cowswap-frontend/src/common/utils/fetch.ts rename to libs/common-utils/src/fetchWithRateLimit.ts index 182003431a1..6bc60d37417 100644 --- a/apps/cowswap-frontend/src/common/utils/fetch.ts +++ b/libs/common-utils/src/fetchWithRateLimit.ts @@ -29,7 +29,7 @@ const DEFAULT_BACKOFF_OPTIONS: BackoffOptions = { numOfAttempts: 10, maxDelay: Infinity, jitter: 'none', - retry: (err) => { + retry: (err: { rawApiError?: { status: number } }) => { if (err?.rawApiError?.status) { return STATUS_CODES_TO_RETRY.includes(err.rawApiError.status) } @@ -47,18 +47,13 @@ const DEFAULT_BACKOFF_OPTIONS: BackoffOptions = { // Types reference: https://stackoverflow.com/questions/55059436/typescript-conditional-return-type-based-on-string-argument export function fetchWithRateLimit( params?: FetchWithRateLimit, - // TODO: Replace any with proper type definitions - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise)>( +): Promise)>( input: T, init?: RequestInit, ) => T extends () => infer R ? R : Promise -export function fetchWithRateLimit(params?: FetchWithRateLimit): ( - // TODO: Replace any with proper type definitions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input: RequestInfo | URL | (() => Promise), - init?: RequestInit, -) => typeof input extends () => infer R ? R : Promise { +export function fetchWithRateLimit( + params?: FetchWithRateLimit, +): (input: RequestInfo | URL | (() => Promise), init?: RequestInit) => Promise { const { backoff, rateLimit } = params || {} // optionally rate limit @@ -80,12 +75,3 @@ export function fetchWithRateLimit(params?: FetchWithRateLimit): ( { ...DEFAULT_BACKOFF_OPTIONS, ...backoff }, ) } - -/** - * - * @param input Request info (as in the fetch method) - * @param init Request options (as in fetch method) - * @param backoffOptions Backoff parameters. By default It will wait a maximum of ~102s. Wait time is 100ms * 2**(attenmpt). Not more than 10 attempts - * @returns A promise of the request - */ -export const fetchWithBackoff = fetchWithRateLimit() diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts index 733e139e51a..9034d95cd71 100644 --- a/libs/common-utils/src/index.ts +++ b/libs/common-utils/src/index.ts @@ -7,6 +7,7 @@ export * from './api-utils' export * from './areFractionsEqual' export * from './areSetsEqual' export * from './async' +export * from './bffApiClient' export * from './buildPriceFromCurrencyAmounts' export * from './cache' export * from './calculateGasMargin' @@ -24,6 +25,7 @@ export * from './environments' export * from './errors' export * from './explorer' export * from './featureFlags' +export * from './fetchWithRateLimit' export * from './finiteMap' export * from './format' export * from './fractionUtils' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9250f2802ea..e03c568b8d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1624,6 +1624,9 @@ importers: cids: specifier: 1.1.9 version: 1.1.9 + exponential-backoff: + specifier: 3.1.3 + version: 3.1.3 fast-safe-stringify: specifier: 2.1.1 version: 2.1.1 @@ -1636,6 +1639,9 @@ importers: jsbi: specifier: 3.2.5 version: 3.2.5 + limiter: + specifier: 3.0.0 + version: 3.0.0 ms: specifier: 2.1.3 version: 2.1.3 @@ -1673,6 +1679,9 @@ importers: '@types/ua-parser-js': specifier: 0.7.36 version: 0.7.36 + jest-fetch-mock: + specifier: 3.0.3 + version: 3.0.3(encoding@0.1.13) libs/core: dependencies: From 714220657c4a077f6bc3eee1cea494ae84518862 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 21:44:18 +0200 Subject: [PATCH 7/8] fix: update state when unsubscribe --- .../ConnectTelegram/useConnectTelegram.tsx | 4 ++-- .../containers/NotificationSettings.tsx | 5 +++-- .../NotificationSidebar.container.tsx | 2 +- .../notifications/hooks/useTelegramConnect.ts | 20 +++++++++++++++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx index 13d464a229f..61c9f1e8c9e 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/ConnectTelegram/useConnectTelegram.tsx @@ -4,8 +4,8 @@ import { TelegramConnectController, useTelegramConnect } from '../../hooks/useTe export type ConnectTelegramController = TelegramConnectController -export function useConnectTelegram(): ConnectTelegramController { +export function useConnectTelegram(isSettingsOpen: boolean): ConnectTelegramController { const { account } = useWalletInfo() - return useTelegramConnect(account) + return useTelegramConnect(account, isSettingsOpen) } diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSettings.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSettings.tsx index de3429fb62c..80c31b1d19f 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSettings.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSettings.tsx @@ -80,10 +80,11 @@ const TelegramUsername = styled.div` interface NotificationSettingsProps { children: ReactNode + isSettingsOpen: boolean } -export function NotificationSettings({ children }: NotificationSettingsProps): ReactNode { - const telegramController = useConnectTelegram() +export function NotificationSettings({ children, isSettingsOpen }: NotificationSettingsProps): ReactNode { + const telegramController = useConnectTelegram(isSettingsOpen) const { username } = telegramController return ( diff --git a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx index 50056bec58f..dbb2429c4ef 100644 --- a/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx +++ b/apps/cowswap-frontend/src/modules/notifications/containers/NotificationSidebar/NotificationSidebar.container.tsx @@ -99,7 +99,7 @@ export function NotificationSidebar({ const notificationSidebarElement = ( {isSettingsOpen ? ( - + ) : ( diff --git a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts index 5bb043a5057..0f11d064125 100644 --- a/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts +++ b/apps/cowswap-frontend/src/modules/notifications/hooks/useTelegramConnect.ts @@ -1,6 +1,7 @@ import { useAtom } from 'jotai' import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useInterval } from '@cowprotocol/common-hooks' import { normalizeError } from '@cowprotocol/common-utils' import { getAddressKey } from '@cowprotocol/cow-sdk' @@ -9,6 +10,10 @@ import { TelegramSubscriptionState, telegramSubscriptionAtom } from '../atoms/te const POLL_INTERVAL_MS = 3_000 const CONNECT_TIMEOUT_MS = 10 * 60 * 1000 // matches the bff connect-token TTL +// Unsubscribing happens on the bot side, so while the caller opts in (via `shouldPoll`, +// e.g. only while the settings panel showing the toggle is open) we keep polling for +// status changes to catch it and flip the toggle off without a page refresh. +const SUBSCRIBED_POLL_INTERVAL_MS = 5_000 const DEFAULT_SUBSCRIPTION_STATE: TelegramSubscriptionState = { isSubscribed: false, @@ -38,7 +43,7 @@ interface ConnectFlow { cancelConnect(): void } -export function useTelegramConnect(account: string | undefined): TelegramConnectController { +export function useTelegramConnect(account: string | undefined, shouldPoll = false): TelegramConnectController { const [isLoading, setIsLoading] = useState(true) const [subscriptionByAccount, setSubscriptionByAccount] = useAtom(telegramSubscriptionAtom) @@ -72,6 +77,8 @@ export function useTelegramConnect(account: string | undefined): TelegramConnect return status.connected }, [account, setSubscriptionByAccount]) + const { connectState, deepLink, connect, cancelConnect } = useConnectFlow(account, accountRef, refreshStatus) + useEffect(() => { const accountAtEffect = account @@ -88,7 +95,16 @@ export function useTelegramConnect(account: string | undefined): TelegramConnect }) }, [account, refreshStatus]) - const { connectState, deepLink, connect, cancelConnect } = useConnectFlow(account, accountRef, refreshStatus) + // Keep polling while subscribed and the caller opts in (e.g. the settings panel + // showing the toggle is open) so an unsubscribe done on the bot side is picked up + // and the toggle flips off without needing a page refresh. + useInterval( + () => { + refreshStatus().catch(() => undefined) + }, + account && isSubscribed && shouldPoll ? SUBSCRIBED_POLL_INTERVAL_MS : null, + false, + ) return { isLoading, isSubscribed, username, botDeepLink, connectState, deepLink, connect, cancelConnect } } From 34784a18ceea506df61b79a6288f15a77d063c61 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 19 Aug 2026 22:07:31 +0200 Subject: [PATCH 8/8] chore: fix --- apps/cowswap-frontend/.env | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cowswap-frontend/.env b/apps/cowswap-frontend/.env index 7bee11d8bfc..a790a77e67f 100644 --- a/apps/cowswap-frontend/.env +++ b/apps/cowswap-frontend/.env @@ -14,7 +14,6 @@ REACT_APP_NETWORK_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com # REACT_APP_NETWORK_URL_42161=https://... # REACT_APP_NETWORK_URL_43114=https://api.avax.network/ext/bc/C/rpc -REACT_APP_BFF_BASE_URL=http://localhost:8080 # INFURA KEY # REACT_APP_INFURA_KEY=your-infura-key