├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── examples ├── store.js └── test.jsx ├── package.json ├── src ├── api │ ├── index.js │ └── widgets │ │ ├── InlineButton.js │ │ ├── InlineKeyboardMarkup.js │ │ ├── Message.js │ │ ├── Widget.js │ │ └── index.js ├── historyEnhance.js ├── index.js └── renderer │ ├── ReactTelegramComponent.js │ ├── ReactTelegramEmptyComponent.js │ ├── ReactTelegramIDOperations.js │ ├── ReactTelegramInjection.js │ ├── ReactTelegramReconcileTransaction.js │ ├── render.js │ ├── solveClass.js │ └── update.js └── test ├── endpoint.js └── suites └── solveClass.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react"], 3 | "plugins": [ 4 | "transform-es2015-modules-commonjs", 5 | "transform-object-rest-spread", 6 | "transform-async-to-generator" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ 4 | "react", 5 | "standard", 6 | "promise" 7 | ] 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | lib 5 | .vscode 6 | .compiled 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | test 5 | examples 6 | *.png 7 | .npmignore 8 | .gitignore 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 goodmind 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-telegram 2 | 3 | A [React](https://facebook.github.io/react/) custom renderer for the [Telegram Bot API](https://core.telegram.org/bots/api). 4 | 5 | This renderer should currently be considered as experimental, is subject to change and will only work with the React's latest version (`15.0.x`). 6 | 7 | ## Summary 8 | 9 | * [Installation](#installation) 10 | * [Demo](#demo) 11 | * [Usage](#usage) 12 | * [Rendering a basic application](#rendering-a-basic-application) 13 | * [Contribution](#contribution) 14 | * [License](#license) 15 | 16 | ## Installation 17 | 18 | You can install `react-telegram` through npm: 19 | 20 | ```bash 21 | # Be sure to install react>=15.0.0 before 22 | npm install react@latest 23 | npm install react-telegram 24 | ``` 25 | 26 | ## Usage 27 | 28 | See [examples](examples/test.jsx) 29 | 30 | ## Contribution 31 | 32 | Contributions are obviously welcome. 33 | 34 | Be sure to add unit tests if relevant and pass them all before submitting your pull request. 35 | 36 | ```bash 37 | # Installing the dev environment 38 | git clone git@github.com:goodmind/react-telegram.git 39 | cd react-telegram 40 | npm install 41 | 42 | # Running the tests 43 | npm test 44 | ``` 45 | 46 | ## License 47 | 48 | [MIT](LICENSE) (c) goodmind 49 | -------------------------------------------------------------------------------- /examples/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux' 2 | import { routerMiddleware, routerReducer } from 'react-router-redux' 3 | 4 | const rootReducer = combineReducers({ 5 | routing: routerReducer 6 | }) 7 | 8 | export default function configureStore (history) { 9 | let middlewares = [ 10 | routerMiddleware(history) 11 | ] 12 | 13 | const store = createStore(rootReducer, {}, compose( 14 | applyMiddleware(...middlewares) 15 | )) 16 | 17 | return store 18 | } 19 | -------------------------------------------------------------------------------- /examples/test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Telegram, Screen } from '../lib' 3 | import createMemoryHistory from 'history/lib/createMemoryHistory' 4 | import { syncHistoryWithStore } from 'react-router-redux' 5 | import RouterContext from 'react-router/lib/RouterContext' 6 | import { Router, Route, Link, match } from 'react-router' 7 | import { Provider } from 'react-redux' 8 | import configureStore from './store' 9 | 10 | const App = ({ children }) => children 11 | const Start = ({ location: { state } }) => { 12 | return ( 13 | 14 | Hello, world! 15 | 16 | Ping 17 | 18 | 19 | ) 20 | } 21 | const NoMatch = ({ location: { state } }) => ( 22 | 23 | Unknown route 24 | 25 | ) 26 | const Pong = ({ location: { state } }) => ( 27 | 28 | Pong 29 | {!state.new && 30 | Back 31 | } 32 | 33 | ) 34 | 35 | const ACCESS_TOKEN = process.env.TOKEN 36 | const bot = new Telegram(ACCESS_TOKEN) 37 | 38 | const contexts = {} 39 | const getRoutes = () => ( 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | 47 | // TODO: better solution for session rendering 48 | 49 | bot.connect() 50 | bot.on('/start', msg => { 51 | const id = `${msg.from.id}_${msg.chat.id}` 52 | if (contexts[id]) { 53 | console.log('Context already created, handling history...') 54 | return 55 | } else { 56 | console.log('Creating context...', msg.from, contexts) 57 | contexts[id] = () => { 58 | const botHistory = bot.useHistory(createMemoryHistory()) 59 | bot.pushHistory(botHistory, '/start', { data: undefined, msg, new: true }) 60 | const store = configureStore(botHistory) 61 | const history = syncHistoryWithStore(botHistory, store) 62 | 63 | bot.render(( 64 | 65 | 66 | {getRoutes()} 67 | 68 | 69 | ), new Screen(bot)) 70 | } 71 | contexts[id]() 72 | } 73 | }) 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-telegram", 3 | "version": "0.0.1", 4 | "description": "A react renderer for Telegram Bot API", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "example": "rm -rf .compiled && babel examples --out-dir .compiled", 8 | "lint": "eslint 'src/**/*.js'", 9 | "watch": "babel -w ./src --out-dir ./lib", 10 | "build": "babel ./src --out-dir ./lib", 11 | "prepublish": "npm run build", 12 | "test": "mocha -R spec --compilers js:babel-register ./test/endpoint.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/goodmind/react-telegram.git" 17 | }, 18 | "keywords": [ 19 | "telegram", 20 | "react", 21 | "renderer" 22 | ], 23 | "author": "goodmind", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/goodmind/react-telegram/issues" 27 | }, 28 | "homepage": "https://github.com/goodmind/react-telegram#readme", 29 | "peerDependencies": { 30 | "react": "^15.4.2", 31 | "react-dom": "^15.4.2" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "^6.6.4", 35 | "babel-plugin-transform-async-to-generator": "^6.22.0", 36 | "babel-plugin-transform-object-rest-spread": "^6.22.0", 37 | "babel-preset-es2015": "^6.6.0", 38 | "babel-preset-react": "^6.5.0", 39 | "babel-register": "^6.6.0", 40 | "eslint": "^3.15.0", 41 | "eslint-config-standard": "^6.2.1", 42 | "eslint-plugin-promise": "^3.4.1", 43 | "eslint-plugin-react": "^6.9.0", 44 | "eslint-plugin-standard": "^2.0.1", 45 | "history": "^3.0.0", 46 | "mocha": "^2.4.5", 47 | "react": "^15.4.2", 48 | "react-devtools": "^2.0.12", 49 | "react-dom": "^15.4.2", 50 | "react-redux": "^5.0.2", 51 | "react-router": "^3.0.2", 52 | "react-router-redux": "^4.0.8", 53 | "redux": "^3.6.0", 54 | "ws": "^2.0.3" 55 | }, 56 | "dependencies": { 57 | "invariant": "^2.2.0", 58 | "lodash": "^4.17.4", 59 | "telebot": "^1.0.7" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import { Message } from './widgets/Message' 2 | import { InlineKeyboardMarkup } from './widgets/InlineKeyboardMarkup' 3 | import { InlineButton } from './widgets/InlineButton' 4 | 5 | export class Screen { 6 | constructor (bot) { 7 | this.__telegram_screen = true 8 | this.bot = bot 9 | this.children = [] 10 | this.msgs = {} 11 | } 12 | 13 | async send (chatId, text, opts = {}) { 14 | let m 15 | let msg = this.msgs[chatId] 16 | if (msg && opts.edit) { 17 | const o = { chatId, messageId: msg.message_id } 18 | await this.bot.editText(o, text) 19 | m = await this.bot.editMarkup(o, opts.markup) 20 | } else { 21 | m = await this.bot.sendMessage(chatId, text, opts) 22 | } 23 | this.msgs[chatId] = m.result 24 | return m 25 | } 26 | 27 | async renderMessage (msg) { 28 | const markup = msg.children[0] 29 | const edit = msg.edit 30 | 31 | if (markup) { 32 | const { result: m } = await this.send(msg.to.id, msg.content, { edit, markup: this.renderNode(markup) }) 33 | this.bot.callbackStorage[m.message_id] = cb => { 34 | const btn = markup.children[0] 35 | const event = { 36 | button: 0, 37 | defaultPrevented: false, 38 | preventDefault: () => {} 39 | } 40 | btn.emit('event', 'click', event) 41 | } 42 | return m 43 | } 44 | 45 | return this.send(msg.to.id, msg.content, { edit }) 46 | } 47 | 48 | renderNode (node) { 49 | if (node instanceof Message) { 50 | return this.renderMessage(node) 51 | } 52 | if (node instanceof InlineKeyboardMarkup) { 53 | return this.bot.inlineKeyboard([ 54 | node.children.map(this.renderNode, this) 55 | ]) 56 | } 57 | if (node instanceof InlineButton) { 58 | return this.bot.inlineButton(node.content, { callback: '/hello' }) 59 | } 60 | return undefined 61 | } 62 | 63 | append (node) { 64 | node.parent = this 65 | this.children.push(node) 66 | } 67 | 68 | remove (node) { 69 | if (node.parent !== this) return 70 | const i = this.children.indexOf(node) 71 | if (!~i) return 72 | 73 | node.parent = null 74 | this.children.splice(i, 1) 75 | } 76 | 77 | render (...args) { 78 | const node = this.children[0] 79 | return this.renderNode(node) 80 | } 81 | } 82 | 83 | export { widgets } from './widgets' 84 | 85 | -------------------------------------------------------------------------------- /src/api/widgets/InlineButton.js: -------------------------------------------------------------------------------- 1 | import { Widget } from './Widget' 2 | 3 | export class InlineButton extends Widget { 4 | 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/api/widgets/InlineKeyboardMarkup.js: -------------------------------------------------------------------------------- 1 | import { Widget } from './Widget' 2 | 3 | export class InlineKeyboardMarkup extends Widget { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/api/widgets/Message.js: -------------------------------------------------------------------------------- 1 | import { Widget } from './Widget' 2 | 3 | export class Message extends Widget { 4 | constructor (props) { 5 | super(props) 6 | this.to = props.to 7 | this.edit = props.edit 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/api/widgets/Widget.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | export class Widget extends EventEmitter { 3 | constructor (props) { 4 | super() 5 | this.content = null 6 | this.children = [] 7 | this.props = props 8 | } 9 | 10 | off (eventName, listener) { 11 | this.removeListener(eventName, listener) 12 | } 13 | 14 | setContent (text) { 15 | this.content = text 16 | } 17 | 18 | append (node) { 19 | node.parent = this 20 | this.children.push(node) 21 | } 22 | 23 | remove (node) { 24 | if (node.parent !== this) return 25 | const i = this.children.indexOf(node) 26 | if (!~i) return 27 | 28 | node.parent = null 29 | this.children.splice(i, 1) 30 | } 31 | 32 | destroy () { 33 | if (this.parent) this.parent.remove(this) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/api/widgets/index.js: -------------------------------------------------------------------------------- 1 | import { Message } from './Message' 2 | import { InlineButton } from './InlineButton' 3 | import { InlineKeyboardMarkup } from './InlineKeyboardMarkup' 4 | import { Widget } from './Widget' 5 | 6 | const h = (Class = Widget) => props => new Class(props) 7 | 8 | export const widgets = { 9 | 'message': h(Message), 10 | 'inline-keyboard-markup': h(InlineKeyboardMarkup), 11 | 'inline-button': h(InlineButton), 12 | 'inline-row': h(), 13 | 'a': h(InlineButton) 14 | } 15 | -------------------------------------------------------------------------------- /src/historyEnhance.js: -------------------------------------------------------------------------------- 1 | export default function historyEnhance (history, version) { 2 | let prevState = { 3 | msg: null 4 | } 5 | 6 | const enhanceV4 = history => ({ 7 | ...history, 8 | push (path, state = prevState) { 9 | const r = history.push(path, state) 10 | prevState.msg = state.msg 11 | return r 12 | } 13 | }) 14 | 15 | const enhanceV3 = history => ({ 16 | ...history, 17 | push (input) { 18 | const newInput = typeof input === 'string' ? { pathname: input, state: prevState } : input 19 | const r = history.push(newInput) 20 | prevState.msg = (newInput.state || prevState).msg 21 | return r 22 | } 23 | }) 24 | 25 | const enhance = version === 3 ? enhanceV3 : enhanceV4 26 | return enhance(history) 27 | } 28 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import TeleBot from 'telebot' 2 | import invariant from 'invariant' 3 | import historyEnhance from './historyEnhance' 4 | import { render } from './renderer/render' 5 | 6 | const splitIn = str => str.indexOf(' ') !== -1 ? [ 7 | str.substr(0, str.indexOf(' ')), 8 | str.substr(str.indexOf(' ') + 1) 9 | ] : [str] 10 | 11 | export class Telegram extends TeleBot { 12 | constructor (options) { 13 | super(options) 14 | this.callbackStorage = {} 15 | this.render = render 16 | this.registerCallbacks() 17 | } 18 | 19 | registerCallbacks () { 20 | this.on('callbackQuery', cb => { 21 | const callback = this.callbackStorage[cb.message.message_id] 22 | if (callback) callback(cb) 23 | }) 24 | } 25 | 26 | pushHistory (history, url, state) { 27 | if (!history.getCurrentLocation) { 28 | history.push(url, state) 29 | } else { 30 | history.push({ 31 | pathname: url, 32 | state 33 | }) 34 | } 35 | } 36 | 37 | useHistory (history) { 38 | invariant( 39 | history !== undefined, 40 | 'Telegram: You must pass a valid History.' 41 | ) 42 | 43 | history = historyEnhance(history, history.getCurrentLocation ? 3 : 4) 44 | this.on('/*', msg => { 45 | const [url, data] = splitIn(msg.text) 46 | const state = { data, msg, new: true } 47 | this.pushHistory(history, url, state) 48 | }) 49 | 50 | return history 51 | } 52 | } 53 | 54 | export { Screen } from './api' 55 | -------------------------------------------------------------------------------- /src/renderer/ReactTelegramComponent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Component 3 | * ======================== 4 | * 5 | * React component abstraction for the Telegram Bot API. 6 | */ 7 | import * as telegram from '../api' 8 | import ReactMultiChild from 'react-dom/lib/ReactMultiChild' 9 | import ReactTelegramIDOperations from './ReactTelegramIDOperations' 10 | import invariant from 'invariant' 11 | import update from './update' 12 | import solveClass from './solveClass' 13 | import {extend, groupBy, startCase} from 'lodash' 14 | 15 | let globalIdCounter = 1 16 | 17 | /** 18 | * Variable types that must be solved as content rather than real children. 19 | */ 20 | const CONTENT_TYPES = {string: true, number: true} 21 | 22 | /** 23 | * Renders the given react element with Telegram Bot API. 24 | * 25 | * @constructor ReactTelegramComponent 26 | * @extends ReactMultiChild 27 | */ 28 | export default class ReactTelegramComponent { 29 | constructor (element) { 30 | this.construct(element) 31 | this._tag = element.type.toLowerCase() 32 | this._updating = false 33 | this._renderedChildren = null 34 | this._previousStyle = null 35 | this._previousStyleCopy = null 36 | this._rootNodeID = null 37 | this._wrapperState = null 38 | this._topLevelWrapper = null 39 | this._nodeWithLegacyProperties = null 40 | this._currentNode = null 41 | } 42 | 43 | construct (element) { 44 | // Setting some properties 45 | this._currentElement = element 46 | this._eventListener = (type, ...args) => { 47 | if (this._updating) return 48 | 49 | const handler = this._currentElement.props['on' + startCase(type).replace(/ /g, '')] 50 | 51 | if (typeof handler === 'function') { 52 | if (type === 'focus' || type === 'blur') { 53 | args[0] = ReactTelegramIDOperations.get(this._rootNodeID) 54 | } 55 | handler(...args) 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Mounting the root component. 62 | * 63 | * @internal 64 | * @param {ReactReconcileTransaction|ReactServerRenderingTransaction} transaction 65 | * @param {?ReactTelegramComponent} the parent component instance 66 | * @param {?object} info about the host container 67 | * @param {object} context 68 | * @return {string} The computed markup. 69 | */ 70 | mountComponent ( 71 | transaction, 72 | hostParent = { _currentNode: ReactTelegramIDOperations.screen }, 73 | hostContainerInfo, 74 | context 75 | ) { 76 | const rootID = globalIdCounter++ 77 | this._rootNodeID = rootID 78 | 79 | // Mounting Telegram node 80 | const node = this.mountNode( 81 | hostParent._currentNode, 82 | this._currentElement 83 | ) 84 | 85 | this._currentNode = node 86 | ReactTelegramIDOperations.add(this._rootNodeID, node) 87 | 88 | // Mounting children 89 | let childrenToUse = this._currentElement.props.children 90 | childrenToUse = childrenToUse === null ? [] : [].concat(childrenToUse) 91 | 92 | if (childrenToUse.length) { 93 | // Discriminating content components from real children 94 | const {content = null, realChildren = []} = groupBy(childrenToUse, (c) => { 95 | return CONTENT_TYPES[typeof c] ? 'content' : 'realChildren' 96 | }) 97 | 98 | // Setting textual content 99 | if (content) { node.setContent('' + content.join('')) } 100 | 101 | // Mounting real children 102 | this.mountChildren( 103 | realChildren, 104 | transaction, 105 | context 106 | ) 107 | } 108 | 109 | // Rendering the screen 110 | ReactTelegramIDOperations.screen.debouncedRender() 111 | } 112 | 113 | /** 114 | * Mounting the Telegram node itself. 115 | * 116 | * @param {TelegramNode|TelegramScreen} parent - The parent node. 117 | * @param {ReactElement} element - The element to mount. 118 | * @return {TelegramNode} - The mounted node. 119 | */ 120 | mountNode (parent, element) { 121 | const {props, type} = element 122 | const {children, ...options} = props // eslint-disable-line 123 | const telegramElement = telegram.widgets[type] 124 | 125 | invariant( 126 | !!telegramElement, 127 | `Invalid telegram element "${type}".` 128 | ) 129 | 130 | const node = telegram.widgets[type](solveClass(options)) 131 | 132 | node.on('event', this._eventListener) 133 | parent.append(node) 134 | 135 | return node 136 | } 137 | 138 | /** 139 | * Receive a component update. 140 | * 141 | * @param {ReactElement} nextElement 142 | * @param {ReactReconcileTransaction} transaction 143 | * @param {object} context 144 | * @internal 145 | * @overridable 146 | */ 147 | receiveComponent (nextElement, transaction, context) { 148 | const {props: {children, ...options}} = nextElement 149 | const node = ReactTelegramIDOperations.get(this._rootNodeID) 150 | 151 | this._updating = true 152 | update(node, solveClass(options)) 153 | this._updating = false 154 | 155 | // Updating children 156 | const childrenToUse = children === null ? [] : [].concat(children) 157 | 158 | // Discriminating content components from real children 159 | const {content = null, realChildren = []} = groupBy(childrenToUse, (c) => { 160 | return CONTENT_TYPES[typeof c] ? 'content' : 'realChildren' 161 | }) 162 | 163 | // Setting textual content 164 | if (content) { node.setContent('' + content.join('')) } 165 | 166 | this.updateChildren(realChildren, transaction, context) 167 | 168 | ReactTelegramIDOperations.screen.debouncedRender() 169 | } 170 | 171 | /** 172 | * Dropping the component. 173 | */ 174 | unmountComponent () { 175 | this.unmountChildren() 176 | 177 | const node = ReactTelegramIDOperations.get(this._rootNodeID) 178 | 179 | node.off('event', this._eventListener) 180 | node.destroy() 181 | 182 | ReactTelegramIDOperations.drop(this._rootNodeID) 183 | 184 | this._rootNodeID = null 185 | 186 | ReactTelegramIDOperations.screen.debouncedRender() 187 | } 188 | 189 | /** 190 | * Getting a public instance of the component for refs. 191 | * 192 | * @return {TelegramNode} - The instance's node. 193 | */ 194 | getPublicInstance () { 195 | return ReactTelegramIDOperations.get(this._rootNodeID) 196 | } 197 | 198 | getHostNode () { 199 | return ReactTelegramIDOperations.get(this._rootNodeID) 200 | } 201 | } 202 | 203 | /** 204 | * Extending the component with the MultiChild mixin. 205 | */ 206 | extend( 207 | ReactTelegramComponent.prototype, 208 | ReactMultiChild.Mixin 209 | ) 210 | -------------------------------------------------------------------------------- /src/renderer/ReactTelegramEmptyComponent.js: -------------------------------------------------------------------------------- 1 | export default class ReactTelegramEmptyComponent { 2 | constructor () { 3 | this._currentElement = null 4 | } 5 | receiveComponent () {} 6 | mountComponent () {} 7 | getHostNode () {} 8 | unmountComponent () {} 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/ReactTelegramIDOperations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram ID Operations 3 | * ============================ 4 | * 5 | * Cache register for Telegram nodes stored by ID. 6 | */ 7 | import {debounce} from 'lodash' 8 | 9 | /** 10 | * The Telegram nodes internal index; 11 | */ 12 | const telegramNodes = {} 13 | 14 | /** 15 | * Backend for Telegram ID operations. 16 | * 17 | * @constructor ReactTelegramIDOperations 18 | */ 19 | class ReactTelegramIDOperations { 20 | constructor () { 21 | this.screen = null 22 | } 23 | 24 | /** 25 | * Set the current screen. 26 | * 27 | * @param {TelegramScreen} screen - The screen to attach. 28 | * @return {ReactTelegramIDOperations} - Returns itself. 29 | */ 30 | setScreen (screen) { 31 | this.screen = screen 32 | 33 | // Creating a debounced version of the render method so we won't render 34 | // multiple time per frame, in vain. 35 | screen.debouncedRender = debounce(() => screen.render(), 0) 36 | 37 | return this 38 | } 39 | 40 | /** 41 | * Add a new node to the index. 42 | * 43 | * @param {string} ID - The node's id. 44 | * @param {TelegramNode} node - The node itself. 45 | * @return {ReactTelegramIDOperations} - Returns itself. 46 | */ 47 | add (ID, node) { 48 | telegramNodes[ID] = node 49 | return this 50 | } 51 | 52 | /** 53 | * Get a node from the index. 54 | * 55 | * @param {string} ID - The node's id. 56 | * @return {TelegramNode} - The node. 57 | */ 58 | get (ID) { 59 | return telegramNodes[ID] 60 | } 61 | 62 | /** 63 | * Get the parent of a node from the index. 64 | * 65 | * @param {string} ID - The node's id. 66 | * @return {TelegramScreen|TelegramNode} - The node. 67 | */ 68 | getParent (ID) { 69 | // If the node is root, we return the screen itself 70 | if (ID.match(/\./g).length === 1) { return this.screen } 71 | 72 | const parentID = ID.split('.').slice(0, -1).join('.') 73 | return this.get(parentID) 74 | } 75 | 76 | /** 77 | * Drop a node from the index. 78 | * 79 | * @param {string} ID - The node's id. 80 | * @return {ReactTelegramIDOperations} - Returns itself. 81 | */ 82 | drop (ID) { 83 | delete telegramNodes[ID] 84 | return this 85 | } 86 | } 87 | 88 | export default new ReactTelegramIDOperations() 89 | -------------------------------------------------------------------------------- /src/renderer/ReactTelegramInjection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Dependency Injection 3 | * =================================== 4 | * 5 | * Injecting the renderer's needed dependencies into React's internals. 6 | */ 7 | import ReactInjection from 'react-dom/lib/ReactInjection' 8 | import ReactComponentEnvironment from 'react-dom/lib/ReactComponentEnvironment' 9 | import ReactTelegramReconcileTransaction from './ReactTelegramReconcileTransaction' 10 | import ReactTelegramComponent from './ReactTelegramComponent' 11 | import ReactTelegramEmptyComponent from './ReactTelegramEmptyComponent' 12 | import ReactDefaultBatchingStrategy from 'react-dom/lib/ReactDefaultBatchingStrategy' 13 | 14 | export default function inject () { 15 | ReactInjection.HostComponent.injectGenericComponentClass( 16 | ReactTelegramComponent 17 | ) 18 | 19 | ReactInjection.EmptyComponent.injectEmptyComponentFactory(instantiate => { 20 | return new ReactTelegramEmptyComponent(instantiate) 21 | }) 22 | 23 | ReactInjection.Updates.injectReconcileTransaction( 24 | ReactTelegramReconcileTransaction 25 | ) 26 | 27 | ReactInjection.Updates.injectBatchingStrategy( 28 | ReactDefaultBatchingStrategy 29 | ) 30 | 31 | ReactComponentEnvironment.injection.injectEnvironment({ 32 | processChildrenUpdates: function () {}, 33 | replaceNodeWithMarkup: function () {} 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/ReactTelegramReconcileTransaction.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Specific React Transaction 3 | * ========================================= 4 | * 5 | * React custom reconcile transaction injected by the renderer to enable 6 | * updates. 7 | * 8 | * NOTE: This looks more like a shim than the proper thing actually. 9 | */ 10 | import CallbackQueue from 'react-dom/lib/CallbackQueue' 11 | import PooledClass from 'react-dom/lib/PooledClass' 12 | import Transaction from 'react-dom/lib/Transaction' 13 | import ReactUpdateQueue from 'react-dom/lib/ReactUpdateQueue' 14 | import {extend} from 'lodash' 15 | 16 | const ON_TELEGRAM_READY_QUEUEING = { 17 | initialize: function () { 18 | this.reactMountReady.reset() 19 | }, 20 | close: function () { 21 | this.reactMountReady.notifyAll() 22 | } 23 | } 24 | 25 | function ReactTelegramReconcileTransaction () { 26 | this.reinitializeTransaction() 27 | this.reactMountReady = CallbackQueue.getPooled(null) 28 | } 29 | 30 | const Mixin = { 31 | getTransactionWrappers: function () { 32 | return [ON_TELEGRAM_READY_QUEUEING] 33 | }, 34 | getReactMountReady: function () { 35 | return this.reactMountReady 36 | }, 37 | getUpdateQueue: function () { 38 | return ReactUpdateQueue 39 | }, 40 | destructor: function () { 41 | CallbackQueue.release(this.reactMountReady) 42 | this.reactMountReady = null 43 | } 44 | } 45 | 46 | extend( 47 | ReactTelegramReconcileTransaction.prototype, 48 | Transaction, 49 | Mixin 50 | ) 51 | 52 | PooledClass.addPoolingTo(ReactTelegramReconcileTransaction) 53 | 54 | export default ReactTelegramReconcileTransaction 55 | -------------------------------------------------------------------------------- /src/renderer/render.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram 3 | * ============== 4 | * 5 | * Exposing the renderer's API. 6 | */ 7 | import ReactElement from 'react/lib/ReactElement' 8 | import ReactUpdates from 'react-dom/lib/ReactUpdates' 9 | import ReactTelegramIDOperations from './ReactTelegramIDOperations' 10 | import invariant from 'invariant' 11 | import instantiateReactComponent from 'react-dom/lib/instantiateReactComponent' 12 | import inject from './ReactTelegramInjection' 13 | import {Screen} from '../api' 14 | 15 | /** 16 | * Injecting dependencies. 17 | */ 18 | inject() 19 | 20 | /** 21 | * Renders the given react element with Telegram Bot API. 22 | * 23 | * @param {ReactElement} element - Node to update. 24 | * @param {TelegramScreen} screen - The screen used to render the app. 25 | * @return {ReactComponent} - The rendered component instance. 26 | */ 27 | function render (element, screen) { 28 | // Is the given element valid? 29 | invariant( 30 | ReactElement.isValidElement(element), 31 | 'render(): You must pass a valid ReactElement.' 32 | ) 33 | 34 | // Is the given screen valid? 35 | invariant( 36 | screen instanceof Screen, 37 | 'render(): You must pass a valid TelegramScreen.' 38 | ) 39 | 40 | // Mounting the app 41 | const component = instantiateReactComponent(element) 42 | 43 | // Injecting the screen 44 | ReactTelegramIDOperations.setScreen(screen) 45 | 46 | // The initial render is synchronous but any updates that happen during 47 | // rendering, in componentWillMount or componentDidMount, will be batched 48 | // according to the current batching strategy. 49 | ReactUpdates.batchedUpdates(() => { 50 | // Batched mount component 51 | const transaction = ReactUpdates.ReactReconcileTransaction.getPooled() 52 | transaction.perform(() => { 53 | component.mountComponent(transaction) 54 | }) 55 | ReactUpdates.ReactReconcileTransaction.release(transaction) 56 | }) 57 | 58 | // Returning the screen so the user can attach listeners etc. 59 | return component._instance 60 | } 61 | 62 | export {render} 63 | -------------------------------------------------------------------------------- /src/renderer/solveClass.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Classes Solving 3 | * ============================== 4 | * 5 | * Solving a component's classes to apply correct props to an element. 6 | */ 7 | import {merge, compact} from 'lodash' 8 | 9 | /** 10 | * Solves the given props by applying classes. 11 | * 12 | * @param {object} props - The component's props. 13 | * @return {object} - The solved props. 14 | */ 15 | export default function solveClass (props) { 16 | let {class: classes, ...rest} = props 17 | 18 | // Coercing to array & compacting 19 | classes = compact([].concat(classes)) 20 | 21 | return merge.apply(null, [{}].concat(classes).concat(rest)) 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/update.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Update Schemes 3 | * ============================= 4 | * 5 | * Applying updates to Telegram nodes correctly. 6 | */ 7 | import _ from 'lodash' 8 | 9 | const RAW_ATTRIBUTES = new Set([ 10 | 11 | // Alignment, Orientation & Presentation 12 | 'align', 13 | 'valign', 14 | 'orientation', 15 | 'shrink', 16 | 'padding', 17 | 'tags', 18 | 'shadow', 19 | 20 | // Font-related 21 | 'font', 22 | 'fontBold', 23 | 'fch', 24 | 'ch', 25 | 'bold', 26 | 'underline', 27 | 28 | // Flags 29 | 'clickable', 30 | 'input', 31 | 'keyable', 32 | 'hidden', 33 | 'visible', 34 | 'scrollable', 35 | 'draggable', 36 | 'interactive', 37 | 38 | // Position 39 | 'left', 40 | 'right', 41 | 'top', 42 | 'bottom', 43 | 'aleft', 44 | 'aright', 45 | 'atop', 46 | 'abottom', 47 | 48 | // Size 49 | 'width', 50 | 'height', 51 | 52 | // Checkbox 53 | 'checked', 54 | 55 | // Misc 56 | 'name' 57 | ]) 58 | 59 | /** 60 | * Updates the given Telegram node. 61 | * 62 | * @param {TelegramNode} node - Node to update. 63 | * @param {object} options - Props of the component without children. 64 | */ 65 | export default function update (node, options) { 66 | // TODO: enforce some kind of shallow equality? 67 | // TODO: handle position 68 | 69 | const selectQue = [] 70 | 71 | /* eslint-disable brace-style */ 72 | for (let key in options) { 73 | let value = options[key] 74 | 75 | if (key === 'selected' && node.select) { 76 | selectQue.push({ 77 | node, 78 | value: (typeof value === 'string' ? +value : value) 79 | }) 80 | } 81 | 82 | // Setting label 83 | else if (key === 'label') { node.setLabel(value) } 84 | 85 | // Removing hoverText 86 | else if (key === 'hoverText' && !value) node.removeHover() 87 | 88 | // Setting hoverText 89 | else if (key === 'hoverText' && value) node.setHover(value) 90 | 91 | // Setting content 92 | else if (key === 'content') { node.setContent(value) } 93 | 94 | // Updating style 95 | else if (key === 'style') { node.style = _.merge({}, node.style, value) } 96 | 97 | // Updating items 98 | else if (key === 'items') { node.setItems(value) } 99 | 100 | // Border edge case 101 | else if (key === 'border') { node.border = _.merge({}, node.border, value) } 102 | 103 | // Textarea value 104 | else if (key === 'value' && node.setValue) { node.setValue(value) } 105 | 106 | // Progress bar 107 | else if (key === 'filled' && node.filled !== value) { node.setProgress(value) } 108 | 109 | // Table / ListTable rows / data 110 | else if ((key === 'rows' || key === 'data') && node.setData) { node.setData(value) } else if (key === 'focused' && value && !node[key]) node.focus() 111 | 112 | // Raw attributes 113 | else if (RAW_ATTRIBUTES.has(key)) { node[key] = value } 114 | } 115 | /* eslint-enable brace-style */ 116 | 117 | selectQue.forEach(({node, value}) => node.select(value)) 118 | } 119 | -------------------------------------------------------------------------------- /test/endpoint.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React Telegram Unit Testing Endpoint 3 | * ==================================== 4 | * 5 | * Requiring the tests. 6 | */ 7 | require('./suites/solveClass') 8 | -------------------------------------------------------------------------------- /test/suites/solveClass.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import solveClass from '../../src/solveClass'; 3 | 4 | describe('solveClass', function() { 5 | 6 | it('should merge a single class into props.', function() { 7 | assert.deepEqual( 8 | solveClass({ 9 | class: { 10 | border: { 11 | type: 'line' 12 | }, 13 | style: { 14 | border: { 15 | fg: 'red' 16 | } 17 | } 18 | }, 19 | border: { 20 | type: 'dashed' 21 | }, 22 | style: { 23 | bg: 'green' 24 | } 25 | }), 26 | { 27 | border: { 28 | type: 'dashed' 29 | }, 30 | style: { 31 | border: { 32 | fg: 'red' 33 | }, 34 | bg: 'green' 35 | } 36 | } 37 | ); 38 | 39 | assert.deepEqual( 40 | solveClass({ 41 | class: { 42 | border: { 43 | type: 'line' 44 | }, 45 | style: { 46 | border: { 47 | fg: 'red' 48 | } 49 | } 50 | }, 51 | style: { 52 | bg: 'green' 53 | } 54 | }), 55 | { 56 | border: { 57 | type: 'line' 58 | }, 59 | style: { 60 | border: { 61 | fg: 'red' 62 | }, 63 | bg: 'green' 64 | } 65 | } 66 | ); 67 | }); 68 | 69 | it('should be possible to merge several classes into props.', function() { 70 | assert.deepEqual( 71 | solveClass({ 72 | class: [ 73 | { 74 | style: { 75 | border: { 76 | fg: 'red' 77 | } 78 | } 79 | }, 80 | { 81 | border: { 82 | type: 'line' 83 | } 84 | } 85 | ], 86 | border: { 87 | type: 'dashed' 88 | }, 89 | style: { 90 | bg: 'green' 91 | } 92 | }), 93 | { 94 | border: { 95 | type: 'dashed' 96 | }, 97 | style: { 98 | border: { 99 | fg: 'red' 100 | }, 101 | bg: 'green' 102 | } 103 | } 104 | ); 105 | }); 106 | 107 | it('the given class array should be compacted.', function() { 108 | assert.deepEqual( 109 | solveClass({ 110 | class: [ 111 | { 112 | style: { 113 | border: { 114 | fg: 'red' 115 | } 116 | } 117 | }, 118 | { 119 | border: { 120 | type: 'line' 121 | } 122 | }, 123 | false && { 124 | fg: 'yellow' 125 | } 126 | ], 127 | border: { 128 | type: 'dashed' 129 | }, 130 | style: { 131 | bg: 'green' 132 | } 133 | }), 134 | { 135 | border: { 136 | type: 'dashed' 137 | }, 138 | style: { 139 | border: { 140 | fg: 'red' 141 | }, 142 | bg: 'green' 143 | } 144 | } 145 | ); 146 | }); 147 | }); 148 | --------------------------------------------------------------------------------