├── server ├── static │ ├── bundle.js │ └── style.css ├── stripe │ ├── index.ts │ ├── webhook.ts │ └── subscribe.ts ├── slack │ ├── constants.ts │ ├── index.ts │ ├── greet.ts │ ├── users.ts │ ├── postMessages.ts │ ├── addNewTeam.ts │ ├── handlers.ts │ ├── actionHandler.ts │ ├── getThreadHistory.ts │ └── eventHandler.ts ├── tsconfig.json ├── _parseQuery.ts ├── .ebextensions │ └── enable-websockets.config ├── .elasticbeanstalk │ └── config.yml ├── views │ ├── _header.html │ ├── index.ts │ ├── _footer.html │ ├── add-to-slack.html │ ├── logged-in.html │ ├── upsell.html │ ├── privacy.html │ └── index.html ├── deployed-package.json ├── serve-html.ts ├── cors.ts ├── package.json ├── index.ts ├── monk.ts ├── email.ts └── websocket-server.ts ├── .gitignore ├── client ├── tsconfig.json ├── src │ ├── storage.ts │ ├── SpecialMessage.tsx │ ├── index.tsx │ ├── PigeonIcon.tsx │ ├── ToggleButton.tsx │ ├── special │ │ └── EmailInput.tsx │ ├── Input.tsx │ ├── Message.tsx │ ├── Chat.tsx │ ├── NetworkHOC.tsx │ └── emojis.ts ├── package.json ├── index.html └── webpack.config.js ├── deploy.sh ├── MessageTypes.ts ├── .env.sample ├── package.json ├── README.md └── tslint.json /server/static/bundle.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /server/.localdb 3 | dist 4 | .env 5 | .env.prod 6 | *.sketch 7 | .awcache 8 | -------------------------------------------------------------------------------- /server/stripe/index.ts: -------------------------------------------------------------------------------- 1 | export { subscribeToPremium } from './subscribe' 2 | export { handler } from './webhook' 3 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "sourceMap": true, 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | source .env.prod 2 | SERVER_HOST=$SERVER_HOST COLOR=$DEFAULT_COLOR npm run build 3 | cd server/dist 4 | git init 5 | git add . 6 | git commit -m 'deploying' 7 | eb deploy slacking-pigeons-prod2 8 | -------------------------------------------------------------------------------- /server/slack/constants.ts: -------------------------------------------------------------------------------- 1 | export const CLIENT_ID = process.env.SLACK_CLIENT_ID 2 | export const CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET 3 | export const VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN 4 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "strict": true, 7 | "moduleResolution": "node" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/_parseQuery.ts: -------------------------------------------------------------------------------- 1 | export function parseQuery(q: string) { 2 | const result: { [key: string]: string } = {} 3 | return q.split('&').reduce((prev, s) => { 4 | const part = s.split('=') 5 | prev[part[0]] = decodeURIComponent(part[1]) 6 | return prev 7 | }, result) 8 | } 9 | -------------------------------------------------------------------------------- /server/.ebextensions/enable-websockets.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/etc/nginx/conf.d/websocketupgrade.conf" : 3 | mode: "000755" 4 | owner: root 5 | group: root 6 | content: | 7 | proxy_set_header Upgrade $http_upgrade; 8 | proxy_set_header Connection "upgrade"; 9 | -------------------------------------------------------------------------------- /server/slack/index.ts: -------------------------------------------------------------------------------- 1 | export { slackActionHandler } from './actionHandler' 2 | export { addNewTeam } from './addNewTeam' 3 | export { slackEventHandler } from './eventHandler' 4 | export { getThreadHistory } from './getThreadHistory' 5 | export { on } from './handlers' 6 | export { postNewMessage, answerInThread } from './postMessages' 7 | export { User } from './users' 8 | -------------------------------------------------------------------------------- /MessageTypes.ts: -------------------------------------------------------------------------------- 1 | export enum SpecialMessageType { 2 | EmailInput = 'email-input', 3 | } 4 | 5 | export type TUser = { 6 | id: string 7 | name: string 8 | avatar: string 9 | } 10 | 11 | export type TMessage = { 12 | text: string 13 | id: string 14 | user: TUser 15 | sent?: boolean 16 | received?: boolean 17 | read?: boolean 18 | special?: SpecialMessageType 19 | } 20 | 21 | export type TMessages = Array 22 | -------------------------------------------------------------------------------- /server/slack/greet.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { IChannel } from '../monk' 3 | 4 | export const GREET_MESSAGE = 5 | "Hello there! I'll post the questions from the users here, stay tune." 6 | 7 | export async function greet(team: IChannel) { 8 | return axios({ 9 | method: 'post', 10 | url: `https://slack.com/api/chat.postMessage?token=${team.token}&text=${GREET_MESSAGE}&channel=${team.channel}`, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /server/.elasticbeanstalk/config.yml: -------------------------------------------------------------------------------- 1 | branch-defaults: 2 | default: 3 | environment: slacking-pigeons-prod2 4 | group_suffix: null 5 | global: 6 | application_name: slacking-pigeons 7 | branch: null 8 | default_ec2_keyname: null 9 | default_platform: Node.js 10 | default_region: eu-west-1 11 | include_git_submodules: true 12 | instance_profile: null 13 | platform_name: null 14 | platform_version: null 15 | profile: null 16 | repository: null 17 | sc: null 18 | workspace_type: Application 19 | -------------------------------------------------------------------------------- /server/views/_header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/deployed-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slacking-pigeons", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "server/index.js", 6 | "scripts": { 7 | "start": "node server/index.js" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "aws-sdk": "^2.95.0", 13 | "axios": "^0.16.2", 14 | "micro": "^7.3.3", 15 | "microrouter": "^2.2.1", 16 | "monk": "^6.0.3", 17 | "nodemailer": "^4.0.1", 18 | "socket.io": "^2.0.3", 19 | "stripe": "^4.23.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | SLACK_VERIFICATION_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXX 2 | SLACK_CLIENT_ID=XXXXXXXXXXXXXXXXXX.XXXXXXXXXXXXX 3 | SLACK_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 4 | MONGO_URL=localhost:27017 5 | SERVER_HOST=http://localhost:4000 6 | DEFAULT_TEAM_ID=XXXXXXXXXX 7 | DEFAULT_COLOR=#3ead3f 8 | STRIPE_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 9 | STRIPE_CLIENT=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 10 | STRIPE_ENDPOINT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 11 | AWS_KEY_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXX 12 | AWS_ACCESS_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 13 | -------------------------------------------------------------------------------- /server/serve-html.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | 4 | const assets = { 5 | header: fs.readFileSync( 6 | path.join(__dirname, './views/_header.html'), 7 | 'utf-8' 8 | ), 9 | footer: fs.readFileSync( 10 | path.join(__dirname, './views/_footer.html'), 11 | 'utf-8' 12 | ), 13 | } 14 | 15 | export function serveHTML(html: string, vars: { [keys: string]: string }) { 16 | return ( 17 | assets.header + 18 | Object.keys(vars).reduce((prev, k) => { 19 | return prev.replace(new RegExp('{{{' + k + '}}}', 'g'), vars[k]) 20 | }, html) + 21 | assets.footer 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /client/src/storage.ts: -------------------------------------------------------------------------------- 1 | let socketId: string 2 | 3 | const STORAGE_KEY = '__slacking_pigeons_socketId' 4 | 5 | export function getSocketId() { 6 | if (socketId) { 7 | return socketId 8 | } 9 | 10 | if (location.search && location.search.indexOf(STORAGE_KEY) !== -1) { 11 | // load from url 12 | socketId = location.search.split(STORAGE_KEY + '=')[1].split('&')[0] 13 | } else if (localStorage[STORAGE_KEY]) { 14 | // load from localStorage 15 | socketId = localStorage[STORAGE_KEY] 16 | } else { 17 | // create a new ID 18 | // tslint:disable-next-line:insecure-random 19 | socketId = (Date.now() + Date.now() * Math.random()).toFixed(0) 20 | localStorage[STORAGE_KEY] = socketId 21 | } 22 | 23 | return socketId 24 | } 25 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slacking-pigeons", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "watch": "webpack-dev-server", 9 | "build": "webpack", 10 | "build:prod": "webpack -p --optimize-minimize" 11 | }, 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "preact": "^8.2.1", 16 | "preact-compat": "^3.16.0", 17 | "react-motion": "^0.5.0", 18 | "react-textarea-autosize": "^5.1.0", 19 | "socket.io-client": "^2.0.3", 20 | "styled-components": "^2.1.1" 21 | }, 22 | "devDependencies": { 23 | "awesome-typescript-loader": "^3.2.2", 24 | "source-map-loader": "^0.2.1", 25 | "webpack": "^3.4.1", 26 | "webpack-dev-server": "^2.6.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/slack/users.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { ITeam } from '../monk' 3 | 4 | export type User = { 5 | id: string 6 | name: string 7 | avatar: string 8 | } 9 | 10 | export const users: { 11 | [TeamIdPlususerId: string]: User 12 | } = {} 13 | 14 | export function getSlackUser(team: ITeam, userId: string) { 15 | const id = team.teamId + '-' + userId 16 | if (users[id]) { 17 | return users[id] 18 | } 19 | return axios({ 20 | method: 'post', 21 | url: `https://slack.com/api/users.info?token=${team.token}&user=${userId}`, 22 | }) 23 | .then(res => res.data.user) 24 | .then(res => { 25 | return { 26 | name: res.name, 27 | id: res.id, 28 | avatar: res.profile.image_32, 29 | } 30 | }) 31 | .then(res => { 32 | users[res.id] = res 33 | return res 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /client/src/SpecialMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { SpecialMessageType } from '../../MessageTypes' 4 | import { EmailInput } from './special/EmailInput' 5 | 6 | type Props = { 7 | id: string 8 | text: string 9 | special: SpecialMessageType 10 | color?: string 11 | onSendEmail: (email: string) => void 12 | } 13 | 14 | export class SpecialMessage extends React.Component { 15 | public constructor(props: Props) { 16 | super(props) 17 | } 18 | 19 | public shouldComponentUpdate(nextProps: Props) { 20 | return nextProps.special !== this.props.special 21 | } 22 | 23 | public render() { 24 | if (this.props.special === SpecialMessageType.EmailInput) { 25 | return 26 | } 27 | return false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render } from 'react-dom' 3 | import { HookedChat } from './Chat' 4 | import { Props as ChatOptions } from './NetworkHOC' 5 | 6 | type Options = ChatOptions & { 7 | div?: Element 8 | } 9 | 10 | function renderHook(options: Options) { 11 | if (!options.div) { 12 | options.div = document.createElement('div') 13 | document.body.insertBefore(options.div, document.body.firstChild) 14 | } 15 | 16 | return render(, options.div) 17 | } 18 | 19 | declare var onSlackingPigeonsReady: 20 | | ((renderFunc: (options: Options) => void) => void) 21 | | undefined 22 | 23 | if (typeof onSlackingPigeonsReady === 'undefined') { 24 | console.error( 25 | '[Slacking Pigeons] Missing `window.renderSlackingPigeons` function' 26 | ) 27 | } else { 28 | onSlackingPigeonsReady(renderHook) 29 | } 30 | -------------------------------------------------------------------------------- /server/cors.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http' 2 | import { RequestHandler } from 'micro' 3 | 4 | const DEFAULT_ALLOW_METHODS = [ 5 | 'POST', 6 | 'GET', 7 | 'PUT', 8 | 'PATCH', 9 | 'DELETE', 10 | 'OPTIONS', 11 | ].join(',') 12 | 13 | const DEFAULT_ALLOW_HEADERS = [ 14 | 'X-Requested-With', 15 | 'Access-Control-Allow-Origin', 16 | 'X-HTTP-Method-Override', 17 | 'Content-Type', 18 | 'Authorization', 19 | 'Accept', 20 | ].join(',') 21 | 22 | const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 hours 23 | 24 | export function cors(handler: RequestHandler) { 25 | return (req: IncomingMessage, res: ServerResponse) => { 26 | res.setHeader('Access-Control-Max-Age', '' + DEFAULT_MAX_AGE_SECONDS) 27 | 28 | res.setHeader('Access-Control-Allow-Origin', '*') 29 | 30 | res.setHeader('Access-Control-Allow-Methods', DEFAULT_ALLOW_METHODS) 31 | 32 | res.setHeader('Access-Control-Allow-Headers', DEFAULT_ALLOW_HEADERS) 33 | 34 | res.setHeader('Access-Control-Allow-Credentials', 'true') 35 | 36 | if (req.method === 'OPTIONS') { 37 | return {} 38 | } 39 | 40 | return handler(req, res) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/views/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { serveHTML } from '../serve-html' 4 | 5 | const assets = { 6 | addToSlack: fs.readFileSync( 7 | path.join(__dirname, './add-to-slack.html'), 8 | 'utf-8' 9 | ), 10 | loggedIn: fs.readFileSync(path.join(__dirname, './logged-in.html'), 'utf-8'), 11 | upsell: fs.readFileSync(path.join(__dirname, './upsell.html'), 'utf-8'), 12 | index: fs.readFileSync(path.join(__dirname, './index.html'), 'utf-8'), 13 | privacy: fs.readFileSync(path.join(__dirname, './privacy.html'), 'utf-8'), 14 | } 15 | 16 | export function index() { 17 | return serveHTML(assets.index, { 18 | SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID!, 19 | }) 20 | } 21 | 22 | export function privacy() { 23 | return serveHTML(assets.privacy, {}) 24 | } 25 | 26 | export function addToSlack() { 27 | return serveHTML(assets.addToSlack, { 28 | SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID!, 29 | }) 30 | } 31 | 32 | export function loggedIn(teamId: string, channelId: string) { 33 | return serveHTML(assets.loggedIn, { 34 | TEAM_ID: teamId, 35 | CHANNEL_ID: channelId, 36 | }) 37 | } 38 | 39 | export function upsell(teamId: string, channelId: string, error?: string) { 40 | return serveHTML(assets.upsell, { 41 | TEAM_ID: teamId, 42 | STRIPE_CLIENT: process.env.STRIPE_CLIENT!, 43 | ERROR: error || '', 44 | CHANNEL_ID: channelId, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slacking-pigeons", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "postinstall": "cd client && npm install && cd .. && cd server && npm install && cd .. && pip install --upgrade --user awsebcli", 9 | "lint": "find . -name \"*.tsx\" -o -name \"*.ts\" | grep -v -f .gitignore | xargs tslint", 10 | "prettier:base": "prettier --single-quote --trailing-comma es5 --no-semi --write", 11 | "prettify": "find . -name \"*.tsx\" -o -name \"*.ts\" | grep -v -f .gitignore | xargs npm run prettier:base", 12 | "start": "cd server && npm run start", 13 | "build": "cd server && npm run build && cd ../client && npm run build:prod", 14 | "deploy": "./deploy.sh" 15 | }, 16 | "author": "", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/micro": "^7.3.0", 20 | "@types/microrouter": "^2.2.0", 21 | "@types/nodemailer": "^3.1.1", 22 | "@types/react-dom": "^15.5.1", 23 | "@types/react-textarea-autosize": "^4.3.1", 24 | "@types/socket.io": "^1.4.29", 25 | "@types/socket.io-client": "^1.4.29", 26 | "@types/stripe-node": "^4.6.7", 27 | "prettier": "^1.5.3", 28 | "tslint": "^5.5.0", 29 | "tslint-config-prettier": "^1.3.0", 30 | "tslint-microsoft-contrib": "^5.0.1", 31 | "tslint-react": "^3.1.0", 32 | "typescript": "^2.4.2", 33 | "vrsource-tslint-rules": "^5.1.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/stripe/webhook.ts: -------------------------------------------------------------------------------- 1 | import * as Stripe from 'stripe' 2 | import { IncomingMessage, ServerResponse } from 'http' 3 | import { text, send } from 'micro' 4 | import { findTeam, updateTeam } from '../monk' 5 | 6 | const stripe: StripeNode.Stripe & { 7 | webhooks?: { 8 | constructEvent: ( 9 | body: string, 10 | sig: string | string[], 11 | secret: string 12 | ) => any 13 | } 14 | } = Stripe(process.env.STRIPE_SECRET!) 15 | 16 | export async function handler(req: IncomingMessage, res: ServerResponse) { 17 | const sig = req.headers['stripe-signature'] 18 | const body = await text(req) 19 | const stripeEvent = stripe.webhooks!.constructEvent( 20 | body, 21 | sig, 22 | process.env.STRIPE_ENDPOINT_SECRET! 23 | ) 24 | 25 | const subscription = stripeEvent.data.object 26 | if ( 27 | subscription.object === 'subscription' && 28 | subscription.status !== 'active' 29 | ) { 30 | const customer = await stripe.customers.retrieve(subscription.customer) 31 | 32 | if (!customer) { 33 | send(res, 204, 'nothing to do') 34 | return 35 | } 36 | 37 | const team = await findTeam( 38 | (customer.metadata as { teamId: string }).teamId 39 | ) 40 | 41 | if (!team) { 42 | send(res, 204, 'nothing to do') 43 | return 44 | } 45 | 46 | await updateTeam(team.teamId, { 47 | premium: false, 48 | }) 49 | 50 | send(res, 202, 'done') 51 | } else { 52 | send(res, 204, 'nothing to do') 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slacking-pigeons-server", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "rm -rf dist && mkdir -p dist/server && cp -r views dist/server/views && cp -r .elasticbeanstalk dist/.elasticbeanstalk && cp -r .ebextensions dist/.ebextensions && cp deployed-package.json dist/package.json && cp -r static dist/server/static && ../node_modules/.bin/tsc --p ./tsconfig.json", 9 | "start": "ts-node index.ts", 10 | "watch": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"cyan.bold,green.bold\" \"npm run build -- -w\" \"npm run serve\"", 11 | "serve": "source ../.env && AWS_KEY_ID=$AWS_KEY_ID AWS_ACCESS_KEY=$AWS_ACCESS_KEY STRIPE_SECRET=$STRIPE_SECRET STRIPE_CLIENT=$STRIPE_CLIENT STRIPE_ENDPOINT_SECRET=$STRIPE_ENDPOINT_SECRET SLACK_CLIENT_ID=$SLACK_CLIENT_ID SLACK_VERIFICATION_TOKEN=$SLACK_VERIFICATION_TOKEN SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET MONGO_URL=$MONGO_URL nodemon dist/server/index.js" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "aws-sdk": "^2.95.0", 17 | "axios": "^0.16.2", 18 | "micro": "^7.3.3", 19 | "microrouter": "^2.2.1", 20 | "monk": "^6.0.3", 21 | "nodemailer": "^4.0.1", 22 | "socket.io": "^2.0.3", 23 | "stripe": "^4.23.2" 24 | }, 25 | "devDependencies": { 26 | "concurrently": "^3.5.0", 27 | "nodemon": "^1.11.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Slacking Pigeons Example 6 | 7 | 8 | 9 | 13 | Add to Slack 14 | Sign in with Slack 15 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /server/views/_footer.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | with 6 | 7 | 8 | 9 | by Mathieu 10 |
11 | 20 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | const PRODUCTION = process.argv.indexOf('-p') !== -1; 5 | const config = { 6 | entry: "./src/index.tsx", 7 | output: { 8 | filename: 'bundle.js', 9 | path: path.resolve(__dirname, '../server/dist/server/static') 10 | }, 11 | resolve: { 12 | "alias": { 13 | "react": "preact-compat", 14 | "react-dom": "preact-compat" 15 | }, 16 | // Add '.ts' and '.tsx' as resolvable extensions. 17 | extensions: [".ts", ".tsx", ".js", ".json"] 18 | }, 19 | module: { 20 | rules: [ 21 | // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. 22 | { test: /\.tsx?$/, use: [ 23 | { 24 | loader: 'awesome-typescript-loader', 25 | options: { 26 | useCache: true, 27 | }, 28 | }, 29 | ] 30 | }, 31 | 32 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 33 | { enforce: "pre", test: /\.js$/, loader: "source-map-loader" }, 34 | ] 35 | }, 36 | plugins: [ 37 | new webpack.DefinePlugin({ 38 | 'process.env': { 39 | 'NODE_ENV': JSON.stringify(PRODUCTION ? 'production' : 'development'), 40 | 'SERVER_HOST': JSON.stringify(process.env.SERVER_HOST || 'http://localhost:4000'), 41 | 'COLOR': JSON.stringify(process.env.COLOR || '#08AEEA; background-image: linear-gradient(0deg, #08AEEA 0%, #2AF5CA 100%)') 42 | } 43 | }), 44 | ] 45 | }; 46 | 47 | if (!PRODUCTION) { 48 | config.devtool = 'inline-source-map' 49 | config.devServer = { 50 | hot: true, 51 | inline: true, 52 | host: "0.0.0.0", 53 | port: 2708 54 | } 55 | config.plugins.push( 56 | new webpack.HotModuleReplacementPlugin() 57 | ) 58 | } 59 | 60 | module.exports = config 61 | -------------------------------------------------------------------------------- /server/slack/postMessages.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { ITeam, IThread } from '../monk' 4 | 5 | export function postNewMessage( 6 | team: ITeam, 7 | message: string, 8 | socketId: string, 9 | channelId: string, 10 | labels: {[key: string]: string} 11 | ) { 12 | return axios({ 13 | method: 'post', 14 | url: `https://slack.com/api/chat.postMessage?token=${team.token}&attachments=${encodeURIComponent( 15 | JSON.stringify([ 16 | { 17 | fallback: message, 18 | text: message, 19 | footer: socketId, 20 | callback_id: 'slacking-pigeons-actions', 21 | attachment_type: 'default', 22 | actions: [ 23 | { 24 | name: 'resolve', 25 | style: 'primary', 26 | text: 'Resolve', 27 | type: 'button', 28 | value: 'resolve', 29 | }, 30 | { 31 | name: 'ignore', 32 | text: 'Ignore', 33 | style: 'danger', 34 | type: 'button', 35 | value: 'ignore', 36 | confirm: { 37 | title: 'Are you sure?', 38 | text: "You won't be able to answer this user then.", 39 | ok_text: 'Yes', 40 | dismiss_text: 'No', 41 | }, 42 | }, 43 | ], 44 | fields: Object.keys(labels).map(k => ({ 45 | title: k, 46 | value: labels[k], 47 | short: true 48 | })), 49 | }, 50 | ]) 51 | )}&channel=${channelId}`, 52 | }) 53 | } 54 | 55 | export function answerInThread(team: ITeam, message: string, thread: IThread) { 56 | return axios({ 57 | method: 'post', 58 | url: `https://slack.com/api/chat.postMessage?token=${team.token}&text=${encodeURIComponent( 59 | message 60 | )}&thread_ts=${thread.threadId}&channel=${thread.channel}`, 61 | }).catch(console.error) 62 | } 63 | -------------------------------------------------------------------------------- /client/src/PigeonIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export class PigeonIcon extends React.Component< 4 | { color?: string; size?: number }, 5 | {} 6 | > { 7 | public render() { 8 | return ( 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/slack/addNewTeam.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { IncomingMessage, ServerResponse } from 'http' 3 | import { json, send } from 'micro' 4 | import { VERIFICATION_TOKEN } from './constants' 5 | import { CLIENT_ID, CLIENT_SECRET } from './constants' 6 | import { greet } from './greet' 7 | import { createOrUpdateNewTeam, findTeam } from '../monk' 8 | import { addToSlack, loggedIn, upsell } from '../views' 9 | 10 | export async function addNewTeam( 11 | req: IncomingMessage & { query: { [key: string]: string } }, 12 | res: ServerResponse 13 | ) { 14 | if (req.query.error) { 15 | res.end(req.query.error) 16 | return 17 | } 18 | 19 | const body = (await axios({ 20 | method: 'post', 21 | url: `https://slack.com/api/oauth.access?code=${req.query 22 | .code}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`, 23 | }).then(res => res.data)) as { 24 | error?: string 25 | access_token: string 26 | team_id: string 27 | incoming_webhook: { 28 | // only with Add to slack 29 | channel_id: string 30 | } 31 | team: { 32 | // only with Sign in with Slack 33 | id: string 34 | } 35 | } 36 | 37 | if (body.error) { 38 | res.end(body.error) 39 | return 40 | } 41 | 42 | res.setHeader('Content-Type', 'text/html') 43 | 44 | if (!body.incoming_webhook) { 45 | // sign in with Slack 46 | const team = await findTeam(body.team.id) 47 | 48 | if (!team) { 49 | res.end(addToSlack()) 50 | return 51 | } 52 | 53 | if (team.premium) { 54 | res.end(loggedIn(body.team.id, team.channels[0])) 55 | } else { 56 | res.end(upsell(body.team.id, team.channels[0])) 57 | } 58 | } else { 59 | // add to slack 60 | const team = { 61 | teamId: body.team_id, 62 | token: body.access_token, 63 | channel: body.incoming_webhook.channel_id, 64 | } 65 | 66 | await createOrUpdateNewTeam(team) 67 | await greet(team) 68 | 69 | res.end(upsell(body.team_id, body.incoming_webhook.channel_id)) 70 | } 71 | 72 | return 73 | } 74 | -------------------------------------------------------------------------------- /server/slack/handlers.ts: -------------------------------------------------------------------------------- 1 | type HANDLER_TYPE = 2 | | 'newThread' 3 | | 'newMessage' 4 | | 'removeThread' 5 | | 'receivedMessage' 6 | 7 | type NEW_THREAD_HANDLER_TYPE = ( 8 | thread: { 9 | teamId: string 10 | text: string 11 | id: string 12 | socketId: string 13 | threadId: string 14 | channel: string 15 | } 16 | ) => void 17 | type NEW_MESSAGE_HANDLER_TYPE = ( 18 | message: { 19 | teamId: string 20 | user: User 21 | text: string 22 | threadId: string 23 | id: string 24 | } 25 | ) => void 26 | type REMOVE_THREAD_HANDLER_TYPE = (teamId: string, threadId: string) => void 27 | type RECEIVED_MESSAGE_HANDLER_TYPE = ( 28 | message: { 29 | teamId: string 30 | text: string 31 | id: string 32 | threadId: string 33 | channel: string 34 | } 35 | ) => void 36 | 37 | export type User = { 38 | id: string 39 | name: string 40 | avatar: string 41 | } 42 | 43 | export const handlers: { 44 | newThread: Array 45 | newMessage: Array 46 | removeThread: Array 47 | receivedMessage: Array 48 | } = { 49 | newThread: [], 50 | newMessage: [], 51 | removeThread: [], 52 | receivedMessage: [], 53 | } 54 | 55 | function error(message: string): never { 56 | throw new Error(message) 57 | } 58 | 59 | export function on( 60 | type: HANDLER_TYPE, 61 | callback: 62 | | NEW_THREAD_HANDLER_TYPE 63 | | NEW_MESSAGE_HANDLER_TYPE 64 | | REMOVE_THREAD_HANDLER_TYPE 65 | | RECEIVED_MESSAGE_HANDLER_TYPE 66 | ) { 67 | if (type === 'newThread') { 68 | handlers.newThread.push(callback as NEW_THREAD_HANDLER_TYPE) 69 | } else if (type === 'newMessage') { 70 | handlers.newMessage.push(callback as NEW_MESSAGE_HANDLER_TYPE) 71 | } else if (type === 'removeThread') { 72 | handlers.removeThread.push(callback as REMOVE_THREAD_HANDLER_TYPE) 73 | } else if (type === 'receivedMessage') { 74 | handlers.receivedMessage.push(callback as RECEIVED_MESSAGE_HANDLER_TYPE) 75 | } else { 76 | return error('unknown handler type') 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /client/src/ToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import {PigeonIcon} from './PigeonIcon' 4 | 5 | const Wrapper = styled.div` 6 | z-index: 2147483000; 7 | position: fixed; 8 | bottom: 20px; 9 | right: 20px; 10 | width: 60px; 11 | height: 60px; 12 | border-radius: 50%; 13 | transition: box-shadow 80ms ease-in-out; 14 | box-shadow: 0 1px 6px rgba(0, 0, 0, .06), 0 2px 32px rgba(0, 0, 0, .16); 15 | cursor: pointer; 16 | background: ${process.env.COLOR!}; 17 | ` 18 | 19 | const Cross = styled.div` 20 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcBAMAAACAI8KnAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAIVBMVEUAAAD///////////////////////////////////8AAADPn83rAAAACXRSTlMACq47u/I8r7wWzHxoAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEgAACxIB0t1+/AAAAJJJREFUGNNdzzEKhDAQBdAvwtYWW9hbbSdCDrBnWBDS2Sx7A8HSKwgic1tNxj/jmirDC5P/UTSw01V4ri2nMr7xkg/HIAu+Qi6j9HhEcpB1gHFGGCuSTyQTlQ2Vg3ic4x49TVpzwcQXvI+3x/+r0p9eLAfyYhrIWNOSmfZkVlH2Kpm9Z+bJeh68oSYmnlGMnv1X7RZ2SET5id+LAAAAAElFTkSuQmCC); 21 | transform: rotate(${props => (props.open ? 0 : -30)}deg); 22 | transition: transform .16s linear, opacity .08s linear; 23 | background-position: 50%; 24 | background-size: 14px 14px; 25 | background-repeat: no-repeat; 26 | opacity: ${props => (props.open ? 1 : 0)}; 27 | position: absolute; 28 | top: 0; 29 | bottom: 0; 30 | width: 100%; 31 | ` 32 | 33 | const Pigeon = styled.div` 34 | transform: rotate(${props => (props.open ? 30 : 0)}deg) 35 | scale(${props => (props.open ? 0 : 1)}); 36 | transition: transform .16s linear, opacity .08s linear; 37 | opacity: ${props => (props.open ? 0 : 1)}; 38 | position: absolute; 39 | top: 0; 40 | bottom: 0; 41 | width: 100%; 42 | padding: ${(60 - 40) / 2}px; 43 | ` 44 | 45 | export class ToggleButton extends React.Component< 46 | { open: boolean; onClick: () => void; color?: string }, 47 | {} 48 | > { 49 | public render() { 50 | return ( 51 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/slack/actionHandler.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { IncomingMessage, ServerResponse } from 'http' 3 | import { text, send } from 'micro' 4 | import { VERIFICATION_TOKEN } from './constants' 5 | import { handlers } from './handlers' 6 | import { parseQuery } from '../_parseQuery' 7 | 8 | import { findTeam } from '../monk' 9 | 10 | export async function slackActionHandler( 11 | req: IncomingMessage, 12 | res: ServerResponse 13 | ) { 14 | const body = JSON.parse( 15 | (parseQuery(await text(req)) as { payload: string }).payload 16 | ) as { 17 | token: string 18 | team: { 19 | id: string 20 | } 21 | callback_id: string 22 | actions: Array<{ 23 | name: string 24 | }> 25 | original_message: { 26 | ts: string 27 | attachments: Array<{ 28 | actions: Array 29 | attachment_type: string 30 | callback_id: string 31 | color?: string 32 | }> 33 | } 34 | channel: { 35 | name: string 36 | id: string 37 | } 38 | } 39 | 40 | if ( 41 | body.token !== VERIFICATION_TOKEN || 42 | body.callback_id !== 'slacking-pigeons-actions' 43 | ) { 44 | send(res, 200) 45 | return 46 | } 47 | 48 | const team = await findTeam(body.team.id) 49 | 50 | if (!team) { 51 | send(res, 200) 52 | return 53 | } 54 | 55 | if (body.actions[0].name === 'resolve') { 56 | send(res, 200) 57 | const message = body.original_message 58 | delete message.attachments[0].actions 59 | delete message.attachments[0].attachment_type 60 | delete message.attachments[0].callback_id 61 | message.attachments[0].color = 'good' 62 | axios({ 63 | method: 'post', 64 | url: `https://slack.com/api/chat.update?token=${team.token}&ts=${body 65 | .original_message.ts}&channel=${body.channel 66 | .id}&text=Done&attachments=${encodeURIComponent( 67 | JSON.stringify(message.attachments) 68 | )}`, 69 | }) 70 | } else { 71 | send(res, 200) 72 | axios({ 73 | method: 'post', 74 | url: `https://slack.com/api/chat.delete?token=${team.token}&ts=${body 75 | .original_message.ts}&channel=${body.channel.id}`, 76 | }) 77 | handlers.removeThread.forEach(handler => { 78 | handler(team.teamId, body.original_message.ts) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/special/EmailInput.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const KEY_ENTER = 13 5 | 6 | const Wrapper = styled.div`margin-bottom: 20px;` 7 | 8 | const Copy = styled.p` 9 | text-align: center; 10 | color: #263238; 11 | padding: 0 30px 10px; 12 | opacity: 0.7; 13 | ` 14 | 15 | const Input = styled.input` 16 | width: 80%; 17 | color: #565867; 18 | background-color: #f4f7f9; 19 | resize: none; 20 | border: none; 21 | transition: background-color .2s ease, box-shadow .2s ease; 22 | box-sizing: border-box; 23 | padding: 18px; 24 | padding-right: 30px; 25 | padding-left: 30px; 26 | font-size: 15px; 27 | font-weight: 400; 28 | line-height: 1.33; 29 | margin-left: 10%; 30 | 31 | &:focus { 32 | outline: none; 33 | background-color: #fff; 34 | box-shadow: 0 0 100px 0 rgba(150, 165, 190, .24); 35 | } 36 | ` 37 | 38 | type Props = { 39 | onSendEmail: (email: string) => void 40 | } 41 | 42 | export class EmailInput extends React.Component< 43 | Props, 44 | { email: string; sent: boolean } 45 | > { 46 | public constructor(props: Props) { 47 | super(props) 48 | 49 | this.state = { 50 | email: '', 51 | sent: false, 52 | } 53 | } 54 | 55 | public render() { 56 | if (this.state.sent) { 57 | return ( 58 | 59 | Cool, we will ping you! 60 | 61 | ) 62 | } 63 | return ( 64 | 65 | 66 | If you want you can enter your email and we will ping you when we 67 | answer. 68 | 69 | 76 | 77 | ) 78 | } 79 | 80 | private _onChange = (e: React.FormEvent) => { 81 | this.setState({ 82 | email: e.currentTarget.value, 83 | }) 84 | } 85 | 86 | private _onKeyDown = (e: React.KeyboardEvent) => { 87 | if ( 88 | e.keyCode === KEY_ENTER && 89 | this.state.email && 90 | this.state.email.indexOf('@') !== -1 91 | ) { 92 | e.preventDefault() 93 | this.props.onSendEmail(this.state.email) 94 | this.setState({ 95 | sent: true, 96 | }) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /server/views/add-to-slack.html: -------------------------------------------------------------------------------- 1 | 2 | Slacking Pigeons 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |

Add Slacking Pigeons to your slack team

24 |

Click on the button below, choose the team you want connect and a pigeon will come to confirm that the connection worked. It's that simple.

25 | Add to Slack 26 |
27 | 28 |
29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Slacking Pigeons

2 | 3 |
4 | 5 |
6 |
7 |
8 | Chat in real time with your users directly from Slack 9 |
10 | 11 | Slacking Pigeons lets chat in real time with your users directly from Slack. No need for another tool, everybody can jump in to answer a question. 12 | 13 | - Each new user's question will create a new thread, keeping everything cleanly in one place 14 | - You can invite some people to join the support channel and help orient newcomers. There is no operator's limitations! 15 | - Every bits is completely open source. Build the features you need and become a part of future releases. 16 | 17 | ![Slacking pigeons demo](https://user-images.githubusercontent.com/3254314/29005469-057cd2f2-7a91-11e7-95ba-1c4a49401eb3.gif) 18 | 19 | ## :bird: How can I get it? 20 | 21 | Just click here: 22 | 23 | - [Add to slack](https://slack.com/oauth/authorize?&client_id=218949921206.219415872899&scope=incoming-webhook,channels:history,chat:write:bot,users:read,groups:history) 24 | 25 | 26 | ## :bird: I have a problem with Slacking Pigeons 27 | 28 | First, please search the [open issues](https://github.com/mathieudutour/slacking-pigeons/issues?q=is%3Aopen) 29 | and [closed issues](https://github.com/mathieudutour/slacking-pigeons/issues?q=is%3Aclosed) 30 | to see if your issue hasn't already been reported (it may also be fixed). 31 | 32 | If you can't find an issue that matches what you're seeing, open a [new issue](https://github.com/mathieudutour/slacking-pigeons/issues/new). 33 | 34 | ## :bird: How can I contribute to Slacking Pigeons? 35 | 36 | If you're looking for something to work on, check out the [accepting-prs](https://github.com/mathieudutour/slacking-pigeons/issues?q=is%3Aopen+is%3Aissue+label%3Aaccepting-prs) label. 37 | 38 | Here is how to get started with a developer environment: 39 | ``` 40 | # install deps 41 | npm i 42 | 43 | # replace environment variables 44 | cp .env.sample .env 45 | source .env 46 | 47 | cd client && npm run watch 48 | cd server && npm run watch && npm run start 49 | open http://localhost:4000 50 | open http://localhost:2708 51 | 52 | # install ngrok to expose localhost 53 | npm i -g ngrok 54 | ngrok http 4000 55 | ``` 56 | 57 | ## :bird: More Resources 58 | 59 | See [slacking-pigeons.com](https://slacking-pigeons.com) for more product-oriented 60 | information about Slacking Pigeons. 61 | 62 | ## :bird: License 63 | 64 | **[GPLv3](http://www.gnu.org/licenses/gpl-3.0.html)** 65 | 66 | 67 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-react", 4 | "tslint-config-prettier" 5 | ], 6 | "rulesDirectory": [ 7 | "node_modules/vrsource-tslint-rules/rules", 8 | "node_modules/tslint-microsoft-contrib/" 9 | ], 10 | "rules": { 11 | "class-name": true, 12 | "curly": true, 13 | "indent": [ 14 | true, 15 | "spaces" 16 | ], 17 | "insecure-random": [ true ], 18 | "interface-name": [ true, "always-prefix"], 19 | "jsdoc-format": true, 20 | "literal-spacing": [ 21 | false, 22 | { 23 | "array": ["never"], 24 | "object": ["always"] 25 | } 26 | ], 27 | "member-access": [ 28 | true, 29 | "check-accessor", 30 | "check-constructor" 31 | ], 32 | "member-ordering": [ 33 | true, 34 | "static-before-instance", 35 | "variables-before-functions" 36 | ], 37 | "no-construct": true, 38 | "no-default-export": true, 39 | "no-duplicate-variable": true, 40 | "no-eval": true, 41 | "no-internal-module": true, 42 | "no-invalid-this": true, 43 | "no-stateless-class": true, 44 | "no-trailing-whitespace": [ true, "ignore-comments", "ignore-jsdoc" ], 45 | "no-unused-expression": true, 46 | "no-unused-variable": [true, "react"], 47 | "no-use-before-declare": true, 48 | "no-var-keyword": true, 49 | "one-line": [ 50 | true, 51 | "check-open-brace", 52 | "check-whitespace" 53 | ], 54 | "prefer-const": true, 55 | "promise-must-complete": true, 56 | "react-unused-props-and-state": [ true, 57 | { 58 | "props-interface-regex": "Props$", 59 | "state-interface-regex": "State$" 60 | } 61 | ], 62 | "react-this-binding-issue": true, 63 | "semicolon": [ 64 | true, 65 | "never" 66 | ], 67 | "trailing-comma": [ 68 | false, 69 | { 70 | "multiline": "always", 71 | "singleline": "never" 72 | } 73 | ], 74 | "triple-equals": [ 75 | true, 76 | "allow-null-check" 77 | ], 78 | "typedef-whitespace": [ 79 | true, 80 | { 81 | "call-signature": "nospace", 82 | "index-signature": "nospace", 83 | "parameter": "nospace", 84 | "property-declaration": "nospace", 85 | "variable-declaration": "nospace" 86 | } 87 | ], 88 | "variable-name": [ 89 | true, 90 | "ban-keywords" 91 | ], 92 | "whitespace": [ 93 | true, 94 | "check-branch", 95 | "check-decl", 96 | "check-operator", 97 | "check-separator", 98 | "check-type" 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /client/src/Input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import textarea from 'react-textarea-autosize' 4 | import {PigeonIcon} from './PigeonIcon' 5 | 6 | const KEY_ENTER = 13 7 | 8 | const Textarea = styled(textarea)` 9 | position: absolute; 10 | bottom: 0; 11 | left: 0; 12 | color: #565867; 13 | background-color: #f4f7f9; 14 | resize: none; 15 | border: none; 16 | transition: background-color .2s ease, box-shadow .2s ease; 17 | box-sizing: border-box; 18 | padding: 18px; 19 | padding-right: 45px; 20 | padding-left: 30px; 21 | width: 100%; 22 | font-size: 15px; 23 | font-weight: 400; 24 | line-height: 1.33; 25 | white-space: pre-wrap; 26 | word-wrap: break-word; 27 | 28 | &:focus { 29 | outline: none; 30 | background-color: #fff; 31 | box-shadow: 0 0 100px 0 rgba(150, 165, 190, .24) 32 | } 33 | ` 34 | 35 | const SendButton = styled.button` 36 | background: transparent; 37 | border: none; 38 | outline: none; 39 | position: absolute; 40 | bottom: 0; 41 | right: 0; 42 | padding: 5px; 43 | opacity: 0.4; 44 | cursor: pointer; 45 | ` 46 | 47 | type Props = { 48 | onSendMessage: (msg: string) => void 49 | alreadyAMessage: boolean 50 | } 51 | 52 | export class Input extends React.Component< 53 | Props, 54 | { 55 | message: string 56 | } 57 | > { 58 | public constructor(props: Props) { 59 | super(props) 60 | 61 | this.state = { 62 | message: '', 63 | } 64 | } 65 | 66 | public render() { 67 | return ( 68 |
69 |