diff --git a/bun.lock b/bun.lock index 6997e93..307a274 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "@mtcute/dispatcher": "^0.24.3", "@mtcute/tl": "*", "@react-telegram/core": "workspace:*", + "immer": "^10.1.1", "react": "^19.1.0", }, "devDependencies": { @@ -405,6 +406,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], diff --git a/packages/examples/SPA_README.md b/packages/examples/SPA_README.md new file mode 100644 index 0000000..8c6a073 --- /dev/null +++ b/packages/examples/SPA_README.md @@ -0,0 +1,115 @@ +# SPA Todo Bot Example + +This example demonstrates the new MtcuteSPAAdapter which provides a true Single Page Application experience for Telegram bots. + +## Key Features + +### 1. **Single Message Paradigm** +- All interactions happen within a single message per chat +- Commands don't create new messages - they update the existing one +- Provides a seamless, app-like experience + +### 2. **Per-Chat State Management** +- Each chat has its own isolated state +- State persists across bot restarts +- Built-in React hooks for state management + +### 3. **Button Persistence** +- Buttons continue to work after bot restarts +- No "session expired" errors +- Automatic session restoration + +### 4. **React Hooks API** +- `useTgState()` - Similar to useState but with automatic persistence +- `useTgChatId()` - Get the current chat ID +- State changes are automatically saved + +## Usage + +```tsx +import { MtcuteSPAAdapter, useTgState } from '@react-telegram/mtcute-adapter'; + +const TodoApp = () => { + // State is automatically persisted per chat + const [todos, setTodos] = useTgState('todos', []); + const [view, setView] = useTgState('view', 'list'); + + return ( + <> + My SPA Bot + {/* Your UI here */} + + ); +}; + +// Set up the adapter +const adapter = new MtcuteSPAAdapter(client, { + storageAdapter: myStorageAdapter +}); + +adapter.registerApp(); +await adapter.start(botToken); +``` + +## Architecture + +### State Storage +The adapter uses a pluggable storage system: + +```typescript +interface SPAStorageAdapter { + getState(chatId: string): Promise; + setState(chatId: string, state: SPAState): Promise; + deleteState(chatId: string): Promise; +} +``` + +### State Structure +Each chat's state includes: +- `messageId` - The Telegram message ID to edit +- `lastContent` - The last rendered content +- `lastKeyboard` - The last rendered keyboard +- `appState` - Your application's state + +### TgStateProvider +The adapter automatically wraps your app with `TgStateProvider`: +- Provides React context for state management +- Handles state persistence automatically +- Isolates state per chat + +## Running the Example + +1. Set environment variables: +```bash +export API_ID=your_api_id +export API_HASH=your_api_hash +export BOT_TOKEN=your_bot_token +``` + +2. Run the bot: +```bash +bun run spa +``` + +3. Send any message to your bot to start + +## Differences from Traditional Bots + +1. **No Command Registration** - Just send any message to interact +2. **Stateful** - The bot remembers your state between messages +3. **Single Message** - All updates happen in one message +4. **Session Persistence** - Works seamlessly across restarts + +## Best Practices + +1. Use `useTgState` for any data that should persist +2. Keep state minimal for better performance +3. Handle loading states while data is being restored +4. Test button functionality after restarts + +## Future Enhancements + +- Multi-step flows with navigation +- Animation support +- File upload handling +- Inline query support \ No newline at end of file diff --git a/packages/examples/package.json b/packages/examples/package.json index 97c4e4f..5f811f9 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -13,6 +13,7 @@ "autodelete": "bun run src/input-autodelete-demo.tsx", "br-demo": "bun run src/br-demo.tsx", "persist": "bun run src/persist-todo-bot.tsx", + "spa": "bun run src/spa-todo-bot.tsx", "test": "vitest", "test:ui": "vitest --ui" }, diff --git a/packages/examples/src/spa-todo-bot.tsx b/packages/examples/src/spa-todo-bot.tsx new file mode 100644 index 0000000..73783c7 --- /dev/null +++ b/packages/examples/src/spa-todo-bot.tsx @@ -0,0 +1,189 @@ +/// +import React, { useState } from 'react'; +import { TelegramClient } from '@mtcute/bun'; +import { MtcuteSPAAdapter, useTgState, type SPAStorageAdapter } from '@react-telegram/mtcute-adapter'; +import { FileStorage } from './storage/FileStorage'; + +// Create storage adapter for SPA state +class FileSPAStorage implements SPAStorageAdapter { + constructor(private storage: FileStorage) {} + + async getState(chatId: string) { + const data = await this.storage.get(`spa_${chatId}`); + return data ? JSON.parse(data) : null; + } + + async setState(chatId: string, state: any) { + await this.storage.set(`spa_${chatId}`, JSON.stringify(state)); + } + + async deleteState(chatId: string) { + await this.storage.delete(`spa_${chatId}`); + } +} + +// Todo item component +interface TodoItem { + id: string; + text: string; + completed: boolean; +} + +const TodoApp = () => { + const [todos = [], setTodos] = useTgState('todos', []); + const [view = 'list', setView] = useTgState<'list' | 'add'>('view', 'list'); + const [inputText, setInputText] = useState(''); + + const addTodo = (text: string) => { + if (text.trim().length < 3) return; + + const newTodo: TodoItem = { + id: Date.now().toString(), + text: text.trim(), + completed: false + }; + + setTodos([...todos, newTodo]); + setView('list'); + }; + + const toggleTodo = (id: string) => { + setTodos(todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + )); + }; + + const deleteTodo = (id: string) => { + setTodos(todos.filter(todo => todo.id !== id)); + }; + + const clearCompleted = () => { + setTodos(todos.filter(todo => !todo.completed)); + }; + + const activeTodos = todos.filter(t => !t.completed); + const completedTodos = todos.filter(t => t.completed); + + if (view === 'add') { + return ( + <> + โž• Add New Todo +
+
+ Enter todo text (min 3 characters): +
+
+ { + addTodo(text); + }} + autoDelete + /> +
+ + + + + ); + } + + return ( + <> + ๐Ÿ“ SPA Todo List ({activeTodos.length} active, {completedTodos.length} completed) +
+ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +
+
+ + {todos.length === 0 ? ( + No todos yet! Add your first todo. + ) : ( + <> + {activeTodos.map(todo => ( + + + + + + + ))} + + {completedTodos.length > 0 && activeTodos.length > 0 && ( + <> +
+ โ”€โ”€โ”€โ”€โ”€ Completed โ”€โ”€โ”€โ”€โ”€ +
+ + )} + + {completedTodos.map(todo => ( + + + + + + + ))} + + )} + +
+ + + {completedTodos.length > 0 && ( + + )} + + +
+ ๐Ÿ’ก Send any message to refresh the UI + + ); +}; + +async function main() { + const config = { + apiId: parseInt(process.env.API_ID || '0'), + apiHash: process.env.API_HASH || '', + botToken: process.env.BOT_TOKEN || '', + storage: process.env.STORAGE_PATH || '.mtcute' + }; + + if (!config.apiId || !config.apiHash || !config.botToken) { + console.error('Please set API_ID, API_HASH, and BOT_TOKEN environment variables'); + process.exit(1); + } + + // Create storage + const fileStorage = new FileStorage('spa-states.json'); + const spaStorage = new FileSPAStorage(fileStorage); + + // Create client + const client = new TelegramClient({ + apiId: config.apiId, + apiHash: config.apiHash, + storage: config.storage, + }); + + // Create SPA adapter + const adapter = new MtcuteSPAAdapter(client, { + storageAdapter: spaStorage + }); + + // Register the app + adapter.registerApp(); + + // Start the bot + await adapter.start(config.botToken); + + console.log('SPA Todo Bot is running!'); + console.log('Send any message to start using the bot.'); + console.log('State will persist across restarts.'); +} + +// Run the bot +main().catch(console.error); \ No newline at end of file diff --git a/packages/mtcute-adapter/README.md b/packages/mtcute-adapter/README.md index da6e9b3..5509df6 100644 --- a/packages/mtcute-adapter/README.md +++ b/packages/mtcute-adapter/README.md @@ -2,6 +2,10 @@ MTCute adapter for React Telegram bots. This package provides the integration between React Telegram's core reconciler and the MTCute Telegram client library. +This package includes two adapters: +- **MtcuteAdapter** - Traditional command-based bot adapter +- **MtcuteSPAAdapter** - Single Page Application adapter for app-like experiences + ## Installation ```bash @@ -120,6 +124,64 @@ interface MtcuteAdapterOptions { - `getClient()` - Get the underlying MTCute client - `getDispatcher()` - Get the MTCute dispatcher +## MtcuteSPAAdapter + +The SPA adapter provides a single-page application experience where all interactions happen within one message per chat. + +### Usage + +```tsx +import React from 'react'; +import { MtcuteSPAAdapter, useTgState } from '@react-telegram/mtcute-adapter'; + +const MyApp = () => { + // State is automatically persisted per chat + const [count, setCount] = useTgState('count', 0); + + return ( + <> + Count: {count} +
+ + + + + + ); +}; + +// Create adapter +const adapter = new MtcuteSPAAdapter({ + apiId: YOUR_API_ID, + apiHash: 'YOUR_API_HASH' +}); + +// Register your app +adapter.registerApp(); + +// Start the bot +await adapter.start(process.env.BOT_TOKEN!); +``` + +### Key Features + +1. **Single Message Interface** - All updates happen in one message +2. **Automatic State Persistence** - State is saved and restored across restarts +3. **Per-Chat Isolation** - Each chat has its own state +4. **React Hooks** - `useTgState` and `useTgChatId` for easy state management + +### Storage Adapter + +```typescript +const adapter = new MtcuteSPAAdapter(client, { + storageAdapter: { + getState: async (chatId) => { /* load state */ }, + setState: async (chatId, state) => { /* save state */ }, + deleteState: async (chatId) => { /* delete state */ } + } +}); +``` + ## License MIT \ No newline at end of file diff --git a/packages/mtcute-adapter/package.json b/packages/mtcute-adapter/package.json index 085b2cd..6cdea6b 100644 --- a/packages/mtcute-adapter/package.json +++ b/packages/mtcute-adapter/package.json @@ -30,6 +30,7 @@ "@mtcute/dispatcher": "^0.24.3", "@mtcute/tl": "*", "@react-telegram/core": "workspace:*", + "immer": "^10.1.1", "react": "^19.1.0" }, "devDependencies": { diff --git a/packages/mtcute-adapter/src/index.ts b/packages/mtcute-adapter/src/index.ts index f49da89..52c2862 100644 --- a/packages/mtcute-adapter/src/index.ts +++ b/packages/mtcute-adapter/src/index.ts @@ -1 +1,20 @@ -export { MtcuteAdapter, type MtcuteAdapterConfig } from './mtcute-adapter'; \ No newline at end of file +export { MtcuteAdapter, type MtcuteAdapterConfig, type MtcuteAdapterOptions, type MessagePersistenceOptions } from './mtcute-adapter'; +export { MtcuteSPAAdapter } from './mtcute-spa-adapter'; +export { + TgProvider, + useTgSelector, + useTgUpdater, + useTgState, + useTgStore, + useTgChatId, + clearTgStore, + clearAllTgStores +} from './spa-hooks'; +export type { TgGlobalState } from './spa-hooks'; +export type { + MtcuteSPAAdapterConfig, + MtcuteSPAAdapterOptions, + SPAState, + SPAStorageAdapter, + TgStateContextValue +} from './mtcute-spa-adapter-types'; \ No newline at end of file diff --git a/packages/mtcute-adapter/src/mtcute-spa-adapter-types.ts b/packages/mtcute-adapter/src/mtcute-spa-adapter-types.ts new file mode 100644 index 0000000..1686ad2 --- /dev/null +++ b/packages/mtcute-adapter/src/mtcute-spa-adapter-types.ts @@ -0,0 +1,30 @@ +import type { ReactElement } from 'react'; + +export interface SPAState { + messageId: number | null; + lastContent: string | null; + lastKeyboard: any | null; + appState: Record; +} + +export interface SPAStorageAdapter { + getState(chatId: string): Promise; + setState(chatId: string, state: SPAState): Promise; + deleteState(chatId: string): Promise; +} + +export interface MtcuteSPAAdapterConfig { + apiId: number; + apiHash: string; + storage?: string; +} + +export interface MtcuteSPAAdapterOptions { + storageAdapter?: SPAStorageAdapter; +} + +export interface TgStateContextValue { + chatId: number; + getState: (key: string) => T | undefined; + setState: (key: string, value: T | ((prev: T | undefined) => T)) => void; +} \ No newline at end of file diff --git a/packages/mtcute-adapter/src/mtcute-spa-adapter.tsx b/packages/mtcute-adapter/src/mtcute-spa-adapter.tsx new file mode 100644 index 0000000..e4e5fb3 --- /dev/null +++ b/packages/mtcute-adapter/src/mtcute-spa-adapter.tsx @@ -0,0 +1,218 @@ +import React, { type ReactElement } from 'react'; +import { TelegramClient } from '@mtcute/bun'; +import { Dispatcher, type MessageContext } from '@mtcute/dispatcher'; +import { tl } from '@mtcute/tl'; +import { createContainer } from '@react-telegram/core'; +import type { RootNode } from '@react-telegram/core'; +import { rootNodeToTextWithEntities, rootNodeToInlineKeyboard } from './shared/telegram-node-converter'; +import { retryOnRpcError } from './shared/retry-utils'; +import { TgProvider } from './spa-hooks'; +import type { + MtcuteSPAAdapterConfig, + MtcuteSPAAdapterOptions, + SPAState, + SPAStorageAdapter +} from './mtcute-spa-adapter-types'; + +export class MtcuteSPAAdapter { + private client: TelegramClient; + private dispatcher: Dispatcher; + private options: MtcuteSPAAdapterOptions; + private app: ReactElement | null = null; + private activeChats: Map; + state: SPAState; + }> = new Map(); + + constructor(clientOrConfig: TelegramClient | MtcuteSPAAdapterConfig, options: MtcuteSPAAdapterOptions = {}) { + if (clientOrConfig instanceof TelegramClient) { + this.client = clientOrConfig; + } else { + this.client = new TelegramClient({ + apiId: clientOrConfig.apiId, + apiHash: clientOrConfig.apiHash, + storage: clientOrConfig.storage || '.mtcute', + }); + } + + this.options = options; + this.dispatcher = Dispatcher.for(this.client); + this.setupHandlers(); + } + + async start(botToken: string) { + await this.client.start({ botToken }); + console.log('SPA Bot started successfully'); + } + + registerApp(app: ReactElement) { + this.app = app; + } + + private async getChatState(chatId: number): Promise { + const chatIdStr = chatId.toString(); + + // Try to get from active chats first + const active = this.activeChats.get(chatId); + if (active) { + return active.state; + } + + // Try to load from storage + if (this.options.storageAdapter) { + const saved = await this.options.storageAdapter.getState(chatIdStr); + if (saved) { + return saved; + } + } + + // Return default state + return { + messageId: null, + lastContent: null, + lastKeyboard: null, + appState: {} + }; + } + + private async saveChatState(chatId: number, state: SPAState) { + if (this.options.storageAdapter) { + await this.options.storageAdapter.setState(chatId.toString(), state); + } + } + + private async renderApp(chatId: number) { + if (!this.app) { + throw new Error('No app registered. Call registerApp() first.'); + } + + let chatData = this.activeChats.get(chatId); + + if (!chatData) { + const state = await this.getChatState(chatId); + const container = createContainer(); + + chatData = { container, state }; + this.activeChats.set(chatId, chatData); + + // Set up render callback + container.container.onRenderContainer = async (root) => { + await this.handleRender(chatId, root); + }; + + // Restore button handlers if we have a keyboard + if (state.lastKeyboard) { + this.restoreButtonHandlers(chatId, container); + } + } + + const { container, state } = chatData; + + // Render the app wrapped with TgStateProvider + container.render( + + {this.app} + + ); + } + + private async handleRender(chatId: number, root: RootNode) { + const chatData = this.activeChats.get(chatId); + if (!chatData) return; + + const { state } = chatData; + const textWithEntities = rootNodeToTextWithEntities(root); + const replyMarkup = rootNodeToInlineKeyboard(root, chatId.toString()); + + // Store the content for restoration + state.lastContent = textWithEntities.text; + state.lastKeyboard = replyMarkup; + + await retryOnRpcError(async () => { + if (state.messageId === null) { + // First render: send a new message + const sentMessage = await this.client.sendText(chatId, textWithEntities, { + replyMarkup + }); + state.messageId = sentMessage.id; + await this.saveChatState(chatId, state); + } else { + // Subsequent renders: edit the existing message + await this.client.editMessage({ + chatId, + message: state.messageId, + text: textWithEntities, + replyMarkup + }).catch(async e => { + if (tl.RpcError.is(e) && e.code === 400 && e.text === "MESSAGE_NOT_MODIFIED") { + return; + } + // If message not found, send a new one + if (tl.RpcError.is(e) && e.code === 400 && e.text === "MESSAGE_ID_INVALID") { + const sentMessage = await this.client.sendText(chatId, textWithEntities, { + replyMarkup + }); + state.messageId = sentMessage.id; + await this.saveChatState(chatId, state); + return; + } + throw e; + }); + } + }); + } + + private restoreButtonHandlers(chatId: number, container: ReturnType) { + // The container already has button click handling built in + // We just need to make sure the buttons are mapped correctly + } + + private setupHandlers() { + // Handle all messages (including commands) + this.dispatcher.onNewMessage(async (msg) => { + if (!msg.text) return; + + const chatId = msg.chat.id; + + // Always update the existing message instead of creating new ones + await this.renderApp(chatId); + }); + + // Handle callback queries (button clicks) + this.dispatcher.onCallbackQuery(async (query) => { + if (!query.data) { + await query.answer({ text: 'No data provided' }); + return; + } + + const dataStr = typeof query.data === 'string' ? query.data : new TextDecoder().decode(query.data); + const [chatIdStr, buttonId] = dataStr.split(':'); + const chatId = parseInt(chatIdStr); + + const chatData = this.activeChats.get(chatId); + + if (chatData && buttonId) { + // Click the button in our React app + chatData.container.clickButton(buttonId); + + // Answer the callback query + await query.answer({ text: '' }); + } else { + // Try to restore the session + await this.renderApp(chatId); + await query.answer({ text: 'Session restored. Please try again.' }); + } + }); + } + + getClient() { + return this.client; + } + + getDispatcher() { + return this.dispatcher; + } +} \ No newline at end of file diff --git a/packages/mtcute-adapter/src/shared/retry-utils.ts b/packages/mtcute-adapter/src/shared/retry-utils.ts new file mode 100644 index 0000000..c61513b --- /dev/null +++ b/packages/mtcute-adapter/src/shared/retry-utils.ts @@ -0,0 +1,21 @@ +import { tl } from '@mtcute/tl'; + +const RETRY_ERRORS = [ + 'MSG_WAIT_FAILED', + 'MSG_ID_INVALID' +]; + +export async function retryOnRpcError(fn: () => Promise, maxRetries = 3): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await fn(); + } catch (error) { + if (tl.RpcError.is(error) && RETRY_ERRORS.includes(error.text) && i < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + continue; + } + throw error; + } + } + throw new Error('Max retries exceeded'); +} \ No newline at end of file diff --git a/packages/mtcute-adapter/src/shared/telegram-node-converter.ts b/packages/mtcute-adapter/src/shared/telegram-node-converter.ts new file mode 100644 index 0000000..fb10872 --- /dev/null +++ b/packages/mtcute-adapter/src/shared/telegram-node-converter.ts @@ -0,0 +1,130 @@ +import type { TextWithEntities } from '@mtcute/bun'; +import { tl } from '@mtcute/tl'; +import type { RootNode, RowNode } from '@react-telegram/core'; + +export function rootNodeToTextWithEntities(root: RootNode): TextWithEntities { + const text: string[] = []; + const entities: tl.TypeMessageEntity[] = []; + + const processNode = (node: any) => { + switch (node.type) { + case 'text': + text.push(node.content); + break; + + case 'formatted': + const startOffset = text.join('').length; + node.children.forEach((child: any) => processNode(child)); + const length = text.join('').length - startOffset; + + if (length > 0) { + switch (node.format) { + case 'bold': + entities.push({ _: 'messageEntityBold', offset: startOffset, length }); + break; + case 'italic': + entities.push({ _: 'messageEntityItalic', offset: startOffset, length }); + break; + case 'underline': + entities.push({ _: 'messageEntityUnderline', offset: startOffset, length }); + break; + case 'strikethrough': + entities.push({ _: 'messageEntityStrike', offset: startOffset, length }); + break; + case 'spoiler': + entities.push({ _: 'messageEntitySpoiler', offset: startOffset, length }); + break; + case 'code': + entities.push({ _: 'messageEntityCode', offset: startOffset, length }); + break; + } + } + break; + + case 'link': + const linkStartOffset = text.join('').length; + node.children.forEach((child: any) => processNode(child)); + const linkLength = text.join('').length - linkStartOffset; + + if (linkLength > 0) { + entities.push({ + _: 'messageEntityTextUrl', + offset: linkStartOffset, + length: linkLength, + url: node.href + }); + } + break; + + case 'custom-emoji': + text.push(node.emoji); + entities.push({ + _: 'messageEntityCustomEmoji', + offset: text.join('').length - node.emoji.length, + length: node.emoji.length, + documentId: node.documentId + }); + break; + + case 'br': + text.push('\n'); + break; + + case 'pre': + const preStartOffset = text.join('').length; + text.push(node.content); + entities.push({ + _: 'messageEntityPre', + offset: preStartOffset, + length: node.content.length, + language: node.language || '' + }); + break; + + case 'blockquote': + const quoteStartOffset = text.join('').length; + node.children.forEach((child: any) => processNode(child)); + const quoteLength = text.join('').length - quoteStartOffset; + + if (quoteLength > 0) { + entities.push({ + _: 'messageEntityBlockquote', + offset: quoteStartOffset, + length: quoteLength, + collapsed: node.expandable + }); + } + break; + + case 'row': + // Rows are handled separately for inline keyboard + break; + } + }; + + // Process all non-row children + root.children + .filter(child => child.type !== 'row') + .forEach(child => processNode(child)); + + return { + text: text.join(''), + entities + }; +} + +export function rootNodeToInlineKeyboard(root: RootNode, containerId: string): tl.RawReplyInlineMarkup | undefined { + const rows = root.children.filter(child => child.type === 'row') as RowNode[]; + + if (rows.length === 0) return undefined; + + const keyboard: tl.TypeKeyboardButton[][] = rows.map(row => + row.children.map(button => ({ + _: 'keyboardButtonCallback', + text: button.text, + data: Buffer.from(`${containerId}:${button.id}`) + }) as tl.TypeKeyboardButton) + ); + + return { _: 'replyInlineMarkup', rows: keyboard.map(row => ({ _: 'keyboardButtonRow', buttons: row })) }; +} \ No newline at end of file diff --git a/packages/mtcute-adapter/src/spa-hooks.tsx b/packages/mtcute-adapter/src/spa-hooks.tsx new file mode 100644 index 0000000..a425348 --- /dev/null +++ b/packages/mtcute-adapter/src/spa-hooks.tsx @@ -0,0 +1,154 @@ +import React, { createContext, useContext, useCallback, useSyncExternalStore } from 'react'; +import { produce, Draft } from 'immer'; + +// Base interface that users can extend +export interface TgGlobalState { + [key: string]: any; +} + +// Store implementation +class TgStore { + private state: T; + private listeners: Set<() => void> = new Set(); + private chatId: number; + + constructor(chatId: number, initialState: T = {} as T) { + this.chatId = chatId; + this.state = initialState; + } + + getState = (): T => { + return this.state; + }; + + setState = (updater: (draft: Draft) => void) => { + this.state = produce(this.state, updater); + this.listeners.forEach(listener => listener()); + }; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getChatId = () => { + return this.chatId; + }; +} + +// Global store registry +const stores = new Map>(); + +// Get or create store for a specific chat +function getStore(chatId: number, initialState?: T): TgStore { + if (!stores.has(chatId)) { + stores.set(chatId, new TgStore(chatId, initialState)); + } + return stores.get(chatId)!; +} + +// Context for current chat +interface TgChatContextValue { + chatId: number; +} + +const TgChatContext = createContext(null); + +// Provider component +interface TgProviderProps { + chatId: number; + children: React.ReactNode; + initialState?: TgGlobalState; +} + +export function TgProvider({ + chatId, + children, + initialState +}: TgProviderProps & { initialState?: T }) { + // Initialize store for this chat if needed + React.useEffect(() => { + getStore(chatId, initialState); + }, [chatId, initialState]); + + return ( + + {children} + + ); +} + +// Hook to get current store +function useCurrentStore(): TgStore { + const context = useContext(TgChatContext); + if (!context) { + throw new Error('Tg hooks must be used within TgProvider'); + } + return getStore(context.chatId); +} + +// Hook to use a selector from the state +export function useTgSelector( + selector: (state: T) => R +): R { + const store = useCurrentStore(); + + const selectValue = useCallback(() => { + return selector(store.getState()); + }, [selector, store]); + + return useSyncExternalStore( + store.subscribe, + selectValue, + selectValue + ); +} + +// Hook to get the state updater +export function useTgUpdater(): (updater: (draft: Draft) => void) => void { + const store = useCurrentStore(); + return store.setState; +} + +// Combined hook for both reading and updating +export function useTgState( + selector: (state: T) => R +): [R, (updater: (draft: Draft) => void) => void] { + const value = useTgSelector(selector); + const updater = useTgUpdater(); + return [value, updater]; +} + +// Hook to get the entire state (use sparingly) +export function useTgStore(): [T, (updater: (draft: Draft) => void) => void] { + const store = useCurrentStore(); + + const state = useSyncExternalStore( + store.subscribe, + store.getState, + store.getState + ); + + return [state, store.setState]; +} + +// Hook to get the current chat ID +export function useTgChatId(): number { + const context = useContext(TgChatContext); + if (!context) { + throw new Error('useTgChatId must be used within TgProvider'); + } + return context.chatId; +} + +// Utility to clear a store (useful for cleanup) +export function clearTgStore(chatId: number) { + stores.delete(chatId); +} + +// Utility to clear all stores +export function clearAllTgStores() { + stores.clear(); +} \ No newline at end of file