├── .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 |
--------------------------------------------------------------------------------