├── .gitignore
├── .test-setup.js
├── LICENSE
├── README.md
├── app
├── components
│ ├── CandleStickChart
│ │ └── index.js
│ ├── Home
│ │ ├── index.js
│ │ ├── spec.js
│ │ └── style.css
│ └── Ticker
│ │ ├── index.js
│ │ └── style.css
├── index.js
├── js
│ └── phoenix.js
└── styles
│ └── reset.css
├── index.html
├── package.json
├── screen-shot.gif
├── server.js
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | npm-debug.log
4 | dist
5 | coverage
6 | .nyc_output
--------------------------------------------------------------------------------
/.test-setup.js:
--------------------------------------------------------------------------------
1 | const noop = () => {}
2 | const empty = () => ({})
3 |
4 | require.extensions['.css'] = empty
5 | require.extensions['.ico'] = noop
6 | require.extensions['.png'] = noop
7 | require.extensions['.jpg'] = noop
8 | require.extensions['.svg'] = noop
9 |
10 | var jsdom = require('jsdom').jsdom
11 |
12 | var exposedProperties = ['window', 'navigator', 'document']
13 |
14 | global.document = jsdom('')
15 | global.window = document.defaultView
16 | Object.keys(document.defaultView).forEach((property) => {
17 | if (typeof global[property] === 'undefined') {
18 | exposedProperties.push(property)
19 | global[property] = document.defaultView[property]
20 | }
21 | })
22 |
23 | global.navigator = {
24 | userAgent: 'node.js'
25 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Phil Callister
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ticker-react
2 |
3 | **ticker-react** is an example React client which pulls stock quotes from the (defunct but still available) Google Finance API by using the [ticker-phoenix](https://github.com/philcallister/ticker-phoenix) Elixir Phoenix app. This React client app subsribes to Phoenix Channels with stock ticker symbols and is periodically notified of updates.
4 |
5 | To see the **ticker-react** app in action, head over to
6 | - [ticker-elixir](https://github.com/philcallister/ticker-elixir) Elixir OTP app
7 | - [ticker-phoenix] (https://github.com/philcallister/ticker-phoenix) Elixir Phoenix app
8 |
9 | ##### Example screenshot of the three applications being used together
10 | 
11 |
12 | ## Environment
13 |
14 | The sample was developed using the following
15 |
16 | - OS X El Capitan (10.11)
17 |
18 | ## Setup
19 |
20 | Clone Repo
21 | ```bash
22 | git clone https://github.com/philcallister/ticker-react.git
23 | ```
24 |
25 | ##### Dependencies
26 | You'll need [Node](https://nodejs.org/en/) to run the JavaScript server and have access to [NPM](https://www.npmjs.com/). To install on OS X with [Homebrew](http://brew.sh/), run the following brew command
27 | ```bash
28 | brew install node
29 | ```
30 |
31 | ## Run It
32 |
33 | Start the server
34 |
35 | ```bash
36 | npm install && npm start
37 | ```
38 | You'll want to make sure the Elixir Phoenix app is also running. If you haven't done so, follow the instructions for getting it installed and running at [ticker-phoenix](https://github.com/philcallister/ticker-phoenix).
39 |
40 | ## See It
41 | With both the Phoenix and React apps started, you can see it in action from your browser by visiting ```http://localhost:3000```
42 |
43 | ## License
44 |
45 | [MIT License](http://www.opensource.org/licenses/MIT)
46 |
47 | **Free Software, Hell Yeah!**
48 |
--------------------------------------------------------------------------------
/app/components/CandleStickChart/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | import React from "react";
4 | import { format } from "d3-format";
5 | import { timeFormat } from "d3-time-format";
6 | import { scaleTime } from "d3-scale";
7 |
8 |
9 | import { ChartCanvas, Chart, series, scale, coordinates, tooltip, axes, annotation, indicator, helper } from "react-stockcharts";
10 |
11 | var { CandlestickSeries, BarSeries, LineSeries, AreaSeries } = series;
12 | var { discontinuousTimeScaleProvider } = scale;
13 |
14 | var { EdgeIndicator } = coordinates;
15 | var { CrossHairCursor, MouseCoordinateX, MouseCoordinateY, CurrentCoordinate } = coordinates;
16 | var { Annotate, LabelAnnotation, Label } = annotation;
17 |
18 | var { OHLCTooltip, MovingAverageTooltip } = tooltip;
19 | var { XAxis, YAxis } = axes;
20 | var { ema, sma } = indicator;
21 | var { fitWidth } = helper;
22 |
23 | class CandleStickChart extends React.Component {
24 | constructor(props) {
25 | super(props);
26 | this.socket = props.socket;
27 | this.symbol = props.symbol;
28 | this.state = {data: props.data, interval: props.interval};
29 | }
30 |
31 | componentDidMount() {
32 | this.resetChannel(this.state.data, this.state.interval);
33 | }
34 |
35 | componentWillUnmount() {
36 | this.channel.leave();
37 | }
38 |
39 | componentWillReceiveProps(props) {
40 | if (props.interval != this.state.interval) {
41 | this.setState({data: [], interval: props.interval});
42 | this.resetChannel(props.data, props.interval);
43 | }
44 | }
45 |
46 | resetChannel(data, interval) {
47 | if(this.channel) {
48 | this.channel.leave();
49 | }
50 | let that = this;
51 | this.channel = this.socket.channel(`frame:symbol:${this.symbol}:${interval}`);
52 | this.channel.join();
53 |
54 | // No frames loaded -- go get latest historical
55 | if (!data.length) {
56 | this.channel.push('all_frames', {symbol: this.symbol, interval: interval}).receive("ok", function(reply){
57 | if (!!reply.frames.length) {
58 | let elements = reply.frames.map(frame => that.frameToElement(frame));
59 | that.setState({data: elements});
60 | }
61 | });
62 | }
63 |
64 | // Receive new frame
65 | this.channel.on('frame', function(frame){
66 | let element = that.frameToElement(frame);
67 | let current_data = that.state.data;
68 | current_data.push(element);
69 | that.setState({data: current_data});
70 | });
71 | }
72 |
73 | frameToElement(frame) {
74 | let date = new Date(frame.close.lt_dts);
75 | date.setSeconds(0,0);
76 | return({date: date, open: +frame.open.l, high: +frame.high.l, low: +frame.low.l, close: +frame.close.l, volume: 0})
77 | }
78 |
79 | render() {
80 | var { type, width, ratio } = this.props;
81 | var data = this.state.data;
82 |
83 | var ema20 = ema()
84 | .id(0)
85 | .windowSize(20)
86 | .merge((d, c) => {d.ema20 = c})
87 | .accessor(d => d.ema20);
88 |
89 | var ema50 = ema()
90 | .id(2)
91 | .windowSize(50)
92 | .merge((d, c) => {d.ema50 = c})
93 | .accessor(d => d.ema50);
94 |
95 | var margin = { left: 80, right: 80, top: 30, bottom: 50 };
96 | var height = 400;
97 |
98 | var [yAxisLabelX, yAxisLabelY] = [width - margin.left - 40, margin.top + (height - margin.top - margin.bottom) / 2]
99 | if (data.length > 1) {
100 | let label = `${this.state.interval} minutes`;
101 | return (
102 | d.date}
107 | xScale={scaleTime()}>
108 |
109 |
146 | );
147 | }
148 | else {
149 | return null;
150 | }
151 | }
152 | }
153 |
154 | CandleStickChart.propTypes = {
155 | data: React.PropTypes.array.isRequired,
156 | width: React.PropTypes.number.isRequired,
157 | ratio: React.PropTypes.number.isRequired,
158 | type: React.PropTypes.oneOf(["svg", "hybrid"]).isRequired,
159 | };
160 |
161 | CandleStickChart.defaultProps = {
162 | type: "hybrid",
163 | };
164 |
165 | CandleStickChart = fitWidth(CandleStickChart);
166 |
167 | export default CandleStickChart;
--------------------------------------------------------------------------------
/app/components/Home/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import cssModules from "react-css-modules"
3 | import style from "./style.css"
4 |
5 | import { default as Ticker } from "../Ticker"
6 |
7 | export class Home extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | }
11 |
12 | render() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | )
25 | }
26 | }
27 |
28 | export default cssModules(Home, style)
--------------------------------------------------------------------------------
/app/components/Home/spec.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import expect from "expect"
3 | import { shallow } from "enzyme"
4 |
5 | import { Home } from "./"
6 |
7 | describe("", () => {
8 | it("should render", () => {
9 | const renderedComponent = shallow(
10 |
11 | )
12 | expect(renderedComponent.is("div")).toEqual(true)
13 | })
14 | })
--------------------------------------------------------------------------------
/app/components/Home/style.css:
--------------------------------------------------------------------------------
1 | .tickerColumns {
2 | display: flex;
3 | flex-wrap: wrap;
4 | }
--------------------------------------------------------------------------------
/app/components/Ticker/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import cssModules from "react-css-modules";
4 | import classNames from 'classnames/bind';
5 | import style from "./style.css";
6 |
7 | import { default as CandleStickChart } from "../CandleStickChart"
8 |
9 | let cx = classNames.bind(style);
10 |
11 | export class Ticker extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.socket = props.socket;
15 | this.symbol = props.symbol;
16 |
17 | let fakeTick = {l: "##", c: "##", cp: "##"};
18 | this.state = {currentInterval: 1, currentTick: fakeTick};
19 | }
20 |
21 | componentDidMount() {
22 | let that = this;
23 | this.channel = this.socket.channel(`quote:symbol:${this.symbol}`);
24 | this.channel.join();
25 | this.channel.on('quote', function(tick){
26 | that.setState({currentTick: tick})
27 | });
28 | }
29 |
30 | componentWillUnmount() {
31 | this.channel.leave();
32 | }
33 |
34 | render() {
35 | return (
36 |
37 |
38 |
39 |
{this.symbol}
40 | {this.state.currentTick.l}
41 |
42 |
43 | {this.state.currentTick.c}
44 | ({this.state.currentTick.cp}%)
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | )
58 | }
59 |
60 | onClickInterval(interval) {
61 | this.setState({currentInterval: interval});
62 | }
63 |
64 | headerClass() {
65 | let change = this.detectChange();
66 | return cx({
67 | header: true,
68 | noChange: change == null,
69 | plusChange: change == '+',
70 | minusChange: change == '-'
71 | });
72 | }
73 |
74 | detectChange() {
75 | let tick = this.state.currentTick;
76 | let tickChange = tick.c.charAt(0);
77 | return (tickChange == '-' || tickChange == '+') ? tickChange : null
78 | }
79 |
80 | }
81 |
82 | export default cssModules(Ticker, style)
83 |
--------------------------------------------------------------------------------
/app/components/Ticker/style.css:
--------------------------------------------------------------------------------
1 | .ticker {
2 | margin: 5px;
3 | left: 0;
4 | top: 0;
5 | height: 450px;
6 | width: 650px;
7 | min-width: 300px;
8 | background: #eeeeee;
9 | overflow-y: scroll;
10 | border: 1px solid #ccc;
11 | }
12 | .header {
13 | display:flex;
14 | justify-content: space-between;
15 | box-shadow: 0 1px 1px 0 #888;
16 | }
17 | .header.noChange {
18 | background-color: #297ab5;
19 | }
20 | .header.plusChange {
21 | background-color: #3db529;
22 | }
23 | .header.minusChange {
24 | background-color: #b52929;
25 | }
26 | .symbol {
27 | display: inline-block;
28 | color: #000;
29 | background-color: #eee;
30 | }
31 | .value {
32 | padding: 0 0 0 10px;
33 | color: #fff;
34 | }
35 | .change {
36 | margin: 5px;
37 | }
38 | h3 {
39 | padding: 5px;
40 | color: #fff;
41 | }
42 | li {
43 | padding: 5px;
44 | color: #666;
45 | font-size: 12px;
46 | }
47 | li:first-of-type {
48 | border-bottom: 1px solid #ccc;
49 | background-color: #e4edff;
50 | }
51 | ul {
52 | background-color: #ffffff;
53 | border: 1px solid #aaa;
54 | margin: 5px;
55 | }
56 | .interval {
57 | display: flex;
58 | justify-content: left;
59 | margin-left: 78px;
60 | }
61 | .interval > button {
62 | background-color: white;
63 | border: 1px solid #999;
64 | padding: 1px 10px 2px 10px;
65 | margin-right: 5px;
66 | width: 45px;
67 | }
68 | .interval > button:hover {
69 | background-color: #1f77b4;
70 | color: #fff;
71 | cursor: pointer;
72 | }
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import "./styles/reset.css"
2 | import React from "react"
3 | import ReactDOM from "react-dom"
4 | import { Router, Route, IndexRoute, hashHistory } from "react-router"
5 |
6 | import { default as Home } from "./components/Home"
7 | import { Socket } from "./js/phoenix.js"
8 |
9 | let socket = new Socket("ws://localhost:4000/socket");
10 | socket.connect();
11 |
12 | const App = props => ({props.children}
)
13 |
14 | ReactDOM.render(
15 |
16 |
17 |
18 |
19 | ,
20 | document.getElementById("root")
21 | )
--------------------------------------------------------------------------------
/app/js/phoenix.js:
--------------------------------------------------------------------------------
1 | (function(exports){
2 | "use strict";
3 |
4 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; };
5 |
6 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
7 |
8 | Object.defineProperty(exports, "__esModule", {
9 | value: true
10 | });
11 |
12 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
13 |
14 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
15 |
16 | // Phoenix Channels JavaScript client
17 | //
18 | // ## Socket Connection
19 | //
20 | // A single connection is established to the server and
21 | // channels are multiplexed over the connection.
22 | // Connect to the server using the `Socket` class:
23 | //
24 | // let socket = new Socket("/ws", {params: {userToken: "123"}})
25 | // socket.connect()
26 | //
27 | // The `Socket` constructor takes the mount point of the socket,
28 | // the authentication params, as well as options that can be found in
29 | // the Socket docs, such as configuring the `LongPoll` transport, and
30 | // heartbeat.
31 | //
32 | // ## Channels
33 | //
34 | // Channels are isolated, concurrent processes on the server that
35 | // subscribe to topics and broker events between the client and server.
36 | // To join a channel, you must provide the topic, and channel params for
37 | // authorization. Here's an example chat room example where `"new_msg"`
38 | // events are listened for, messages are pushed to the server, and
39 | // the channel is joined with ok/error/timeout matches:
40 | //
41 | // let channel = socket.channel("room:123", {token: roomToken})
42 | // channel.on("new_msg", msg => console.log("Got message", msg) )
43 | // $input.onEnter( e => {
44 | // channel.push("new_msg", {body: e.target.val}, 10000)
45 | // .receive("ok", (msg) => console.log("created message", msg) )
46 | // .receive("error", (reasons) => console.log("create failed", reasons) )
47 | // .receive("timeout", () => console.log("Networking issue...") )
48 | // })
49 | // channel.join()
50 | // .receive("ok", ({messages}) => console.log("catching up", messages) )
51 | // .receive("error", ({reason}) => console.log("failed join", reason) )
52 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") )
53 | //
54 | //
55 | // ## Joining
56 | //
57 | // Creating a channel with `socket.channel(topic, params)`, binds the params to
58 | // `channel.params`, which are sent up on `channel.join()`.
59 | // Subsequent rejoins will send up the modified params for
60 | // updating authorization params, or passing up last_message_id information.
61 | // Successful joins receive an "ok" status, while unsuccessful joins
62 | // receive "error".
63 | //
64 | // ## Duplicate Join Subscriptions
65 | //
66 | // While the client may join any number of topics on any number of channels,
67 | // the client may only hold a single subscription for each unique topic at any
68 | // given time. When attempting to create a duplicate subscription,
69 | // the server will close the existing channel, log a warning, and
70 | // spawn a new channel for the topic. The client will have their
71 | // `channel.onClose` callbacks fired for the existing channel, and the new
72 | // channel join will have its receive hooks processed as normal.
73 | //
74 | // ## Pushing Messages
75 | //
76 | // From the previous example, we can see that pushing messages to the server
77 | // can be done with `channel.push(eventName, payload)` and we can optionally
78 | // receive responses from the push. Additionally, we can use
79 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks
80 | // and take action after some period of waiting. The default timeout is 5000ms.
81 | //
82 | //
83 | // ## Socket Hooks
84 | //
85 | // Lifecycle events of the multiplexed connection can be hooked into via
86 | // `socket.onError()` and `socket.onClose()` events, ie:
87 | //
88 | // socket.onError( () => console.log("there was an error with the connection!") )
89 | // socket.onClose( () => console.log("the connection dropped") )
90 | //
91 | //
92 | // ## Channel Hooks
93 | //
94 | // For each joined channel, you can bind to `onError` and `onClose` events
95 | // to monitor the channel lifecycle, ie:
96 | //
97 | // channel.onError( () => console.log("there was an error!") )
98 | // channel.onClose( () => console.log("the channel has gone away gracefully") )
99 | //
100 | // ### onError hooks
101 | //
102 | // `onError` hooks are invoked if the socket connection drops, or the channel
103 | // crashes on the server. In either case, a channel rejoin is attempted
104 | // automatically in an exponential backoff manner.
105 | //
106 | // ### onClose hooks
107 | //
108 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly
109 | // closed on the server, or 2). The client explicitly closed, by calling
110 | // `channel.leave()`
111 | //
112 | //
113 | // ## Presence
114 | //
115 | // The `Presence` object provides features for syncing presence information
116 | // from the server with the client and handling presences joining and leaving.
117 | //
118 | // ### Syncing initial state from the server
119 | //
120 | // `Presence.syncState` is used to sync the list of presences on the server
121 | // with the client's state. An optional `onJoin` and `onLeave` callback can
122 | // be provided to react to changes in the client's local presences across
123 | // disconnects and reconnects with the server.
124 | //
125 | // `Presence.syncDiff` is used to sync a diff of presence join and leave
126 | // events from the server, as they happen. Like `syncState`, `syncDiff`
127 | // accepts optional `onJoin` and `onLeave` callbacks to react to a user
128 | // joining or leaving from a device.
129 | //
130 | // ### Listing Presences
131 | //
132 | // `Presence.list` is used to return a list of presence information
133 | // based on the local state of metadata. By default, all presence
134 | // metadata is returned, but a `listBy` function can be supplied to
135 | // allow the client to select which metadata to use for a given presence.
136 | // For example, you may have a user online from different devices with a
137 | // a metadata status of "online", but they have set themselves to "away"
138 | // on another device. In this case, they app may choose to use the "away"
139 | // status for what appears on the UI. The example below defines a `listBy`
140 | // function which prioritizes the first metadata which was registered for
141 | // each user. This could be the first tab they opened, or the first device
142 | // they came online from:
143 | //
144 | // let state = {}
145 | // state = Presence.syncState(state, stateFromServer)
146 | // let listBy = (id, {metas: [first, ...rest]}) => {
147 | // first.count = rest.length + 1 // count of this user's presences
148 | // first.id = id
149 | // return first
150 | // }
151 | // let onlineUsers = Presence.list(state, listBy)
152 | //
153 | //
154 | // ### Example Usage
155 | //
156 | // // detect if user has joined for the 1st time or from another tab/device
157 | // let onJoin = (id, current, newPres) => {
158 | // if(!current){
159 | // console.log("user has entered for the first time", newPres)
160 | // } else {
161 | // console.log("user additional presence", newPres)
162 | // }
163 | // }
164 | // // detect if user has left from all tabs/devices, or is still present
165 | // let onLeave = (id, current, leftPres) => {
166 | // if(current.metas.length === 0){
167 | // console.log("user has left from all devices", leftPres)
168 | // } else {
169 | // console.log("user left from a device", leftPres)
170 | // }
171 | // }
172 | // let presences = {} // client's initial empty presence state
173 | // // receive initial presence data from server, sent after join
174 | // myChannel.on("presences", state => {
175 | // presences = Presence.syncState(presences, state, onJoin, onLeave)
176 | // displayUsers(Presence.list(presences))
177 | // })
178 | // // receive "presence_diff" from server, containing join/leave events
179 | // myChannel.on("presence_diff", diff => {
180 | // presences = Presence.syncDiff(presences, diff, onJoin, onLeave)
181 | // this.setState({users: Presence.list(room.presences, listBy)})
182 | // })
183 | //
184 | var VSN = "1.0.0";
185 | var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 };
186 | var DEFAULT_TIMEOUT = 10000;
187 | var CHANNEL_STATES = {
188 | closed: "closed",
189 | errored: "errored",
190 | joined: "joined",
191 | joining: "joining",
192 | leaving: "leaving"
193 | };
194 | var CHANNEL_EVENTS = {
195 | close: "phx_close",
196 | error: "phx_error",
197 | join: "phx_join",
198 | reply: "phx_reply",
199 | leave: "phx_leave"
200 | };
201 | var TRANSPORTS = {
202 | longpoll: "longpoll",
203 | websocket: "websocket"
204 | };
205 |
206 | var Push = function () {
207 |
208 | // Initializes the Push
209 | //
210 | // channel - The Channel
211 | // event - The event, for example `"phx_join"`
212 | // payload - The payload, for example `{user_id: 123}`
213 | // timeout - The push timeout in milliseconds
214 | //
215 |
216 | function Push(channel, event, payload, timeout) {
217 | _classCallCheck(this, Push);
218 |
219 | this.channel = channel;
220 | this.event = event;
221 | this.payload = payload || {};
222 | this.receivedResp = null;
223 | this.timeout = timeout;
224 | this.timeoutTimer = null;
225 | this.recHooks = [];
226 | this.sent = false;
227 | }
228 |
229 | _createClass(Push, [{
230 | key: "resend",
231 | value: function resend(timeout) {
232 | this.timeout = timeout;
233 | this.cancelRefEvent();
234 | this.ref = null;
235 | this.refEvent = null;
236 | this.receivedResp = null;
237 | this.sent = false;
238 | this.send();
239 | }
240 | }, {
241 | key: "send",
242 | value: function send() {
243 | if (this.hasReceived("timeout")) {
244 | return;
245 | }
246 | this.startTimeout();
247 | this.sent = true;
248 | this.channel.socket.push({
249 | topic: this.channel.topic,
250 | event: this.event,
251 | payload: this.payload,
252 | ref: this.ref
253 | });
254 | }
255 | }, {
256 | key: "receive",
257 | value: function receive(status, callback) {
258 | if (this.hasReceived(status)) {
259 | callback(this.receivedResp.response);
260 | }
261 |
262 | this.recHooks.push({ status: status, callback: callback });
263 | return this;
264 | }
265 |
266 | // private
267 |
268 | }, {
269 | key: "matchReceive",
270 | value: function matchReceive(_ref) {
271 | var status = _ref.status;
272 | var response = _ref.response;
273 | var ref = _ref.ref;
274 |
275 | this.recHooks.filter(function (h) {
276 | return h.status === status;
277 | }).forEach(function (h) {
278 | return h.callback(response);
279 | });
280 | }
281 | }, {
282 | key: "cancelRefEvent",
283 | value: function cancelRefEvent() {
284 | if (!this.refEvent) {
285 | return;
286 | }
287 | this.channel.off(this.refEvent);
288 | }
289 | }, {
290 | key: "cancelTimeout",
291 | value: function cancelTimeout() {
292 | clearTimeout(this.timeoutTimer);
293 | this.timeoutTimer = null;
294 | }
295 | }, {
296 | key: "startTimeout",
297 | value: function startTimeout() {
298 | var _this = this;
299 |
300 | if (this.timeoutTimer) {
301 | return;
302 | }
303 | this.ref = this.channel.socket.makeRef();
304 | this.refEvent = this.channel.replyEventName(this.ref);
305 |
306 | this.channel.on(this.refEvent, function (payload) {
307 | _this.cancelRefEvent();
308 | _this.cancelTimeout();
309 | _this.receivedResp = payload;
310 | _this.matchReceive(payload);
311 | });
312 |
313 | this.timeoutTimer = setTimeout(function () {
314 | _this.trigger("timeout", {});
315 | }, this.timeout);
316 | }
317 | }, {
318 | key: "hasReceived",
319 | value: function hasReceived(status) {
320 | return this.receivedResp && this.receivedResp.status === status;
321 | }
322 | }, {
323 | key: "trigger",
324 | value: function trigger(status, response) {
325 | this.channel.trigger(this.refEvent, { status: status, response: response });
326 | }
327 | }]);
328 |
329 | return Push;
330 | }();
331 |
332 | var Channel = exports.Channel = function () {
333 | function Channel(topic, params, socket) {
334 | var _this2 = this;
335 |
336 | _classCallCheck(this, Channel);
337 |
338 | this.state = CHANNEL_STATES.closed;
339 | this.topic = topic;
340 | this.params = params || {};
341 | this.socket = socket;
342 | this.bindings = [];
343 | this.timeout = this.socket.timeout;
344 | this.joinedOnce = false;
345 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout);
346 | this.pushBuffer = [];
347 | this.rejoinTimer = new Timer(function () {
348 | return _this2.rejoinUntilConnected();
349 | }, this.socket.reconnectAfterMs);
350 | this.joinPush.receive("ok", function () {
351 | _this2.state = CHANNEL_STATES.joined;
352 | _this2.rejoinTimer.reset();
353 | _this2.pushBuffer.forEach(function (pushEvent) {
354 | return pushEvent.send();
355 | });
356 | _this2.pushBuffer = [];
357 | });
358 | this.onClose(function () {
359 | _this2.rejoinTimer.reset();
360 | _this2.socket.log("channel", "close " + _this2.topic + " " + _this2.joinRef());
361 | _this2.state = CHANNEL_STATES.closed;
362 | _this2.socket.remove(_this2);
363 | });
364 | this.onError(function (reason) {
365 | if (_this2.isLeaving() || _this2.isClosed()) {
366 | return;
367 | }
368 | _this2.socket.log("channel", "error " + _this2.topic, reason);
369 | _this2.state = CHANNEL_STATES.errored;
370 | _this2.rejoinTimer.scheduleTimeout();
371 | });
372 | this.joinPush.receive("timeout", function () {
373 | if (!_this2.isJoining()) {
374 | return;
375 | }
376 | _this2.socket.log("channel", "timeout " + _this2.topic, _this2.joinPush.timeout);
377 | _this2.state = CHANNEL_STATES.errored;
378 | _this2.rejoinTimer.scheduleTimeout();
379 | });
380 | this.on(CHANNEL_EVENTS.reply, function (payload, ref) {
381 | _this2.trigger(_this2.replyEventName(ref), payload);
382 | });
383 | }
384 |
385 | _createClass(Channel, [{
386 | key: "rejoinUntilConnected",
387 | value: function rejoinUntilConnected() {
388 | this.rejoinTimer.scheduleTimeout();
389 | if (this.socket.isConnected()) {
390 | this.rejoin();
391 | }
392 | }
393 | }, {
394 | key: "join",
395 | value: function join() {
396 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0];
397 |
398 | if (this.joinedOnce) {
399 | throw "tried to join multiple times. 'join' can only be called a single time per channel instance";
400 | } else {
401 | this.joinedOnce = true;
402 | this.rejoin(timeout);
403 | return this.joinPush;
404 | }
405 | }
406 | }, {
407 | key: "onClose",
408 | value: function onClose(callback) {
409 | this.on(CHANNEL_EVENTS.close, callback);
410 | }
411 | }, {
412 | key: "onError",
413 | value: function onError(callback) {
414 | this.on(CHANNEL_EVENTS.error, function (reason) {
415 | return callback(reason);
416 | });
417 | }
418 | }, {
419 | key: "on",
420 | value: function on(event, callback) {
421 | this.bindings.push({ event: event, callback: callback });
422 | }
423 | }, {
424 | key: "off",
425 | value: function off(event) {
426 | this.bindings = this.bindings.filter(function (bind) {
427 | return bind.event !== event;
428 | });
429 | }
430 | }, {
431 | key: "canPush",
432 | value: function canPush() {
433 | return this.socket.isConnected() && this.isJoined();
434 | }
435 | }, {
436 | key: "push",
437 | value: function push(event, payload) {
438 | var timeout = arguments.length <= 2 || arguments[2] === undefined ? this.timeout : arguments[2];
439 |
440 | if (!this.joinedOnce) {
441 | throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use channel.join() before pushing events";
442 | }
443 | var pushEvent = new Push(this, event, payload, timeout);
444 | if (this.canPush()) {
445 | pushEvent.send();
446 | } else {
447 | pushEvent.startTimeout();
448 | this.pushBuffer.push(pushEvent);
449 | }
450 |
451 | return pushEvent;
452 | }
453 |
454 | // Leaves the channel
455 | //
456 | // Unsubscribes from server events, and
457 | // instructs channel to terminate on server
458 | //
459 | // Triggers onClose() hooks
460 | //
461 | // To receive leave acknowledgements, use the a `receive`
462 | // hook to bind to the server ack, ie:
463 | //
464 | // channel.leave().receive("ok", () => alert("left!") )
465 | //
466 |
467 | }, {
468 | key: "leave",
469 | value: function leave() {
470 | var _this3 = this;
471 |
472 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0];
473 |
474 | this.state = CHANNEL_STATES.leaving;
475 | var onClose = function onClose() {
476 | _this3.socket.log("channel", "leave " + _this3.topic);
477 | _this3.trigger(CHANNEL_EVENTS.close, "leave", _this3.joinRef());
478 | };
479 | var leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout);
480 | leavePush.receive("ok", function () {
481 | return onClose();
482 | }).receive("timeout", function () {
483 | return onClose();
484 | });
485 | leavePush.send();
486 | if (!this.canPush()) {
487 | leavePush.trigger("ok", {});
488 | }
489 |
490 | return leavePush;
491 | }
492 |
493 | // Overridable message hook
494 | //
495 | // Receives all events for specialized message handling
496 | // before dispatching to the channel callbacks.
497 | //
498 | // Must return the payload, modified or unmodified
499 |
500 | }, {
501 | key: "onMessage",
502 | value: function onMessage(event, payload, ref) {
503 | return payload;
504 | }
505 |
506 | // private
507 |
508 | }, {
509 | key: "isMember",
510 | value: function isMember(topic) {
511 | return this.topic === topic;
512 | }
513 | }, {
514 | key: "joinRef",
515 | value: function joinRef() {
516 | return this.joinPush.ref;
517 | }
518 | }, {
519 | key: "sendJoin",
520 | value: function sendJoin(timeout) {
521 | this.state = CHANNEL_STATES.joining;
522 | this.joinPush.resend(timeout);
523 | }
524 | }, {
525 | key: "rejoin",
526 | value: function rejoin() {
527 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0];
528 | if (this.isLeaving()) {
529 | return;
530 | }
531 | this.sendJoin(timeout);
532 | }
533 | }, {
534 | key: "trigger",
535 | value: function trigger(event, payload, ref) {
536 | var close = CHANNEL_EVENTS.close;
537 | var error = CHANNEL_EVENTS.error;
538 | var leave = CHANNEL_EVENTS.leave;
539 | var join = CHANNEL_EVENTS.join;
540 |
541 | if (ref && [close, error, leave, join].indexOf(event) >= 0 && ref !== this.joinRef()) {
542 | return;
543 | }
544 | var handledPayload = this.onMessage(event, payload, ref);
545 | if (payload && !handledPayload) {
546 | throw "channel onMessage callbacks must return the payload, modified or unmodified";
547 | }
548 |
549 | this.bindings.filter(function (bind) {
550 | return bind.event === event;
551 | }).map(function (bind) {
552 | return bind.callback(handledPayload, ref);
553 | });
554 | }
555 | }, {
556 | key: "replyEventName",
557 | value: function replyEventName(ref) {
558 | return "chan_reply_" + ref;
559 | }
560 | }, {
561 | key: "isClosed",
562 | value: function isClosed() {
563 | return this.state === CHANNEL_STATES.closed;
564 | }
565 | }, {
566 | key: "isErrored",
567 | value: function isErrored() {
568 | return this.state === CHANNEL_STATES.errored;
569 | }
570 | }, {
571 | key: "isJoined",
572 | value: function isJoined() {
573 | return this.state === CHANNEL_STATES.joined;
574 | }
575 | }, {
576 | key: "isJoining",
577 | value: function isJoining() {
578 | return this.state === CHANNEL_STATES.joining;
579 | }
580 | }, {
581 | key: "isLeaving",
582 | value: function isLeaving() {
583 | return this.state === CHANNEL_STATES.leaving;
584 | }
585 | }]);
586 |
587 | return Channel;
588 | }();
589 |
590 | var Socket = exports.Socket = function () {
591 |
592 | // Initializes the Socket
593 | //
594 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws",
595 | // "wss://example.com"
596 | // "/ws" (inherited host & protocol)
597 | // opts - Optional configuration
598 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll.
599 | // Defaults to WebSocket with automatic LongPoll fallback.
600 | // timeout - The default timeout in milliseconds to trigger push timeouts.
601 | // Defaults `DEFAULT_TIMEOUT`
602 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message
603 | // reconnectAfterMs - The optional function that returns the millsec
604 | // reconnect interval. Defaults to stepped backoff of:
605 | //
606 | // function(tries){
607 | // return [1000, 5000, 10000][tries - 1] || 10000
608 | // }
609 | //
610 | // logger - The optional function for specialized logging, ie:
611 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }
612 | //
613 | // longpollerTimeout - The maximum timeout of a long poll AJAX request.
614 | // Defaults to 20s (double the server long poll timer).
615 | //
616 | // params - The optional params to pass when connecting
617 | //
618 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim)
619 | //
620 |
621 | function Socket(endPoint) {
622 | var _this4 = this;
623 |
624 | var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
625 |
626 | _classCallCheck(this, Socket);
627 |
628 | this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] };
629 | this.channels = [];
630 | this.sendBuffer = [];
631 | this.ref = 0;
632 | this.timeout = opts.timeout || DEFAULT_TIMEOUT;
633 | this.transport = opts.transport || window.WebSocket || LongPoll;
634 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000;
635 | this.reconnectAfterMs = opts.reconnectAfterMs || function (tries) {
636 | return [1000, 2000, 5000, 10000][tries - 1] || 10000;
637 | };
638 | this.logger = opts.logger || function () {}; // noop
639 | this.longpollerTimeout = opts.longpollerTimeout || 20000;
640 | this.params = opts.params || {};
641 | this.endPoint = endPoint + "/" + TRANSPORTS.websocket;
642 | this.reconnectTimer = new Timer(function () {
643 | _this4.disconnect(function () {
644 | return _this4.connect();
645 | });
646 | }, this.reconnectAfterMs);
647 | }
648 |
649 | _createClass(Socket, [{
650 | key: "protocol",
651 | value: function protocol() {
652 | return location.protocol.match(/^https/) ? "wss" : "ws";
653 | }
654 | }, {
655 | key: "endPointURL",
656 | value: function endPointURL() {
657 | var uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), { vsn: VSN });
658 | if (uri.charAt(0) !== "/") {
659 | return uri;
660 | }
661 | if (uri.charAt(1) === "/") {
662 | return this.protocol() + ":" + uri;
663 | }
664 |
665 | return this.protocol() + "://" + location.host + uri;
666 | }
667 | }, {
668 | key: "disconnect",
669 | value: function disconnect(callback, code, reason) {
670 | if (this.conn) {
671 | this.conn.onclose = function () {}; // noop
672 | if (code) {
673 | this.conn.close(code, reason || "");
674 | } else {
675 | this.conn.close();
676 | }
677 | this.conn = null;
678 | }
679 | callback && callback();
680 | }
681 |
682 | // params - The params to send when connecting, for example `{user_id: userToken}`
683 |
684 | }, {
685 | key: "connect",
686 | value: function connect(params) {
687 | var _this5 = this;
688 |
689 | if (params) {
690 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor");
691 | this.params = params;
692 | }
693 | if (this.conn) {
694 | return;
695 | }
696 |
697 | this.conn = new this.transport(this.endPointURL());
698 | this.conn.timeout = this.longpollerTimeout;
699 | this.conn.onopen = function () {
700 | return _this5.onConnOpen();
701 | };
702 | this.conn.onerror = function (error) {
703 | return _this5.onConnError(error);
704 | };
705 | this.conn.onmessage = function (event) {
706 | return _this5.onConnMessage(event);
707 | };
708 | this.conn.onclose = function (event) {
709 | return _this5.onConnClose(event);
710 | };
711 | }
712 |
713 | // Logs the message. Override `this.logger` for specialized logging. noops by default
714 |
715 | }, {
716 | key: "log",
717 | value: function log(kind, msg, data) {
718 | this.logger(kind, msg, data);
719 | }
720 |
721 | // Registers callbacks for connection state change events
722 | //
723 | // Examples
724 | //
725 | // socket.onError(function(error){ alert("An error occurred") })
726 | //
727 |
728 | }, {
729 | key: "onOpen",
730 | value: function onOpen(callback) {
731 | this.stateChangeCallbacks.open.push(callback);
732 | }
733 | }, {
734 | key: "onClose",
735 | value: function onClose(callback) {
736 | this.stateChangeCallbacks.close.push(callback);
737 | }
738 | }, {
739 | key: "onError",
740 | value: function onError(callback) {
741 | this.stateChangeCallbacks.error.push(callback);
742 | }
743 | }, {
744 | key: "onMessage",
745 | value: function onMessage(callback) {
746 | this.stateChangeCallbacks.message.push(callback);
747 | }
748 | }, {
749 | key: "onConnOpen",
750 | value: function onConnOpen() {
751 | var _this6 = this;
752 |
753 | this.log("transport", "connected to " + this.endPointURL(), this.transport.prototype);
754 | this.flushSendBuffer();
755 | this.reconnectTimer.reset();
756 | if (!this.conn.skipHeartbeat) {
757 | clearInterval(this.heartbeatTimer);
758 | this.heartbeatTimer = setInterval(function () {
759 | return _this6.sendHeartbeat();
760 | }, this.heartbeatIntervalMs);
761 | }
762 | this.stateChangeCallbacks.open.forEach(function (callback) {
763 | return callback();
764 | });
765 | }
766 | }, {
767 | key: "onConnClose",
768 | value: function onConnClose(event) {
769 | this.log("transport", "close", event);
770 | this.triggerChanError();
771 | clearInterval(this.heartbeatTimer);
772 | this.reconnectTimer.scheduleTimeout();
773 | this.stateChangeCallbacks.close.forEach(function (callback) {
774 | return callback(event);
775 | });
776 | }
777 | }, {
778 | key: "onConnError",
779 | value: function onConnError(error) {
780 | this.log("transport", error);
781 | this.triggerChanError();
782 | this.stateChangeCallbacks.error.forEach(function (callback) {
783 | return callback(error);
784 | });
785 | }
786 | }, {
787 | key: "triggerChanError",
788 | value: function triggerChanError() {
789 | this.channels.forEach(function (channel) {
790 | return channel.trigger(CHANNEL_EVENTS.error);
791 | });
792 | }
793 | }, {
794 | key: "connectionState",
795 | value: function connectionState() {
796 | switch (this.conn && this.conn.readyState) {
797 | case SOCKET_STATES.connecting:
798 | return "connecting";
799 | case SOCKET_STATES.open:
800 | return "open";
801 | case SOCKET_STATES.closing:
802 | return "closing";
803 | default:
804 | return "closed";
805 | }
806 | }
807 | }, {
808 | key: "isConnected",
809 | value: function isConnected() {
810 | return this.connectionState() === "open";
811 | }
812 | }, {
813 | key: "remove",
814 | value: function remove(channel) {
815 | this.channels = this.channels.filter(function (c) {
816 | return c.joinRef() !== channel.joinRef();
817 | });
818 | }
819 | }, {
820 | key: "channel",
821 | value: function channel(topic) {
822 | var chanParams = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];
823 |
824 | var chan = new Channel(topic, chanParams, this);
825 | this.channels.push(chan);
826 | return chan;
827 | }
828 | }, {
829 | key: "push",
830 | value: function push(data) {
831 | var _this7 = this;
832 |
833 | var topic = data.topic;
834 | var event = data.event;
835 | var payload = data.payload;
836 | var ref = data.ref;
837 |
838 | var callback = function callback() {
839 | return _this7.conn.send(JSON.stringify(data));
840 | };
841 | this.log("push", topic + " " + event + " (" + ref + ")", payload);
842 | if (this.isConnected()) {
843 | callback();
844 | } else {
845 | this.sendBuffer.push(callback);
846 | }
847 | }
848 |
849 | // Return the next message ref, accounting for overflows
850 |
851 | }, {
852 | key: "makeRef",
853 | value: function makeRef() {
854 | var newRef = this.ref + 1;
855 | if (newRef === this.ref) {
856 | this.ref = 0;
857 | } else {
858 | this.ref = newRef;
859 | }
860 |
861 | return this.ref.toString();
862 | }
863 | }, {
864 | key: "sendHeartbeat",
865 | value: function sendHeartbeat() {
866 | if (!this.isConnected()) {
867 | return;
868 | }
869 | this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() });
870 | }
871 | }, {
872 | key: "flushSendBuffer",
873 | value: function flushSendBuffer() {
874 | if (this.isConnected() && this.sendBuffer.length > 0) {
875 | this.sendBuffer.forEach(function (callback) {
876 | return callback();
877 | });
878 | this.sendBuffer = [];
879 | }
880 | }
881 | }, {
882 | key: "onConnMessage",
883 | value: function onConnMessage(rawMessage) {
884 | var msg = JSON.parse(rawMessage.data);
885 | var topic = msg.topic;
886 | var event = msg.event;
887 | var payload = msg.payload;
888 | var ref = msg.ref;
889 |
890 | this.log("receive", (payload.status || "") + " " + topic + " " + event + " " + (ref && "(" + ref + ")" || ""), payload);
891 | this.channels.filter(function (channel) {
892 | return channel.isMember(topic);
893 | }).forEach(function (channel) {
894 | return channel.trigger(event, payload, ref);
895 | });
896 | this.stateChangeCallbacks.message.forEach(function (callback) {
897 | return callback(msg);
898 | });
899 | }
900 | }]);
901 |
902 | return Socket;
903 | }();
904 |
905 | var LongPoll = exports.LongPoll = function () {
906 | function LongPoll(endPoint) {
907 | _classCallCheck(this, LongPoll);
908 |
909 | this.endPoint = null;
910 | this.token = null;
911 | this.skipHeartbeat = true;
912 | this.onopen = function () {}; // noop
913 | this.onerror = function () {}; // noop
914 | this.onmessage = function () {}; // noop
915 | this.onclose = function () {}; // noop
916 | this.pollEndpoint = this.normalizeEndpoint(endPoint);
917 | this.readyState = SOCKET_STATES.connecting;
918 |
919 | this.poll();
920 | }
921 |
922 | _createClass(LongPoll, [{
923 | key: "normalizeEndpoint",
924 | value: function normalizeEndpoint(endPoint) {
925 | return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll);
926 | }
927 | }, {
928 | key: "endpointURL",
929 | value: function endpointURL() {
930 | return Ajax.appendParams(this.pollEndpoint, { token: this.token });
931 | }
932 | }, {
933 | key: "closeAndRetry",
934 | value: function closeAndRetry() {
935 | this.close();
936 | this.readyState = SOCKET_STATES.connecting;
937 | }
938 | }, {
939 | key: "ontimeout",
940 | value: function ontimeout() {
941 | this.onerror("timeout");
942 | this.closeAndRetry();
943 | }
944 | }, {
945 | key: "poll",
946 | value: function poll() {
947 | var _this8 = this;
948 |
949 | if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) {
950 | return;
951 | }
952 |
953 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) {
954 | if (resp) {
955 | var status = resp.status;
956 | var token = resp.token;
957 | var messages = resp.messages;
958 |
959 | _this8.token = token;
960 | } else {
961 | var status = 0;
962 | }
963 |
964 | switch (status) {
965 | case 200:
966 | messages.forEach(function (msg) {
967 | return _this8.onmessage({ data: JSON.stringify(msg) });
968 | });
969 | _this8.poll();
970 | break;
971 | case 204:
972 | _this8.poll();
973 | break;
974 | case 410:
975 | _this8.readyState = SOCKET_STATES.open;
976 | _this8.onopen();
977 | _this8.poll();
978 | break;
979 | case 0:
980 | case 500:
981 | _this8.onerror();
982 | _this8.closeAndRetry();
983 | break;
984 | default:
985 | throw "unhandled poll status " + status;
986 | }
987 | });
988 | }
989 | }, {
990 | key: "send",
991 | value: function send(body) {
992 | var _this9 = this;
993 |
994 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) {
995 | if (!resp || resp.status !== 200) {
996 | _this9.onerror(status);
997 | _this9.closeAndRetry();
998 | }
999 | });
1000 | }
1001 | }, {
1002 | key: "close",
1003 | value: function close(code, reason) {
1004 | this.readyState = SOCKET_STATES.closed;
1005 | this.onclose();
1006 | }
1007 | }]);
1008 |
1009 | return LongPoll;
1010 | }();
1011 |
1012 | var Ajax = exports.Ajax = function () {
1013 | function Ajax() {
1014 | _classCallCheck(this, Ajax);
1015 | }
1016 |
1017 | _createClass(Ajax, null, [{
1018 | key: "request",
1019 | value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) {
1020 | if (window.XDomainRequest) {
1021 | var req = new XDomainRequest(); // IE8, IE9
1022 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback);
1023 | } else {
1024 | var req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari
1025 | new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5
1026 | this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback);
1027 | }
1028 | }
1029 | }, {
1030 | key: "xdomainRequest",
1031 | value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) {
1032 | var _this10 = this;
1033 |
1034 | req.timeout = timeout;
1035 | req.open(method, endPoint);
1036 | req.onload = function () {
1037 | var response = _this10.parseJSON(req.responseText);
1038 | callback && callback(response);
1039 | };
1040 | if (ontimeout) {
1041 | req.ontimeout = ontimeout;
1042 | }
1043 |
1044 | // Work around bug in IE9 that requires an attached onprogress handler
1045 | req.onprogress = function () {};
1046 |
1047 | req.send(body);
1048 | }
1049 | }, {
1050 | key: "xhrRequest",
1051 | value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) {
1052 | var _this11 = this;
1053 |
1054 | req.timeout = timeout;
1055 | req.open(method, endPoint, true);
1056 | req.setRequestHeader("Content-Type", accept);
1057 | req.onerror = function () {
1058 | callback && callback(null);
1059 | };
1060 | req.onreadystatechange = function () {
1061 | if (req.readyState === _this11.states.complete && callback) {
1062 | var response = _this11.parseJSON(req.responseText);
1063 | callback(response);
1064 | }
1065 | };
1066 | if (ontimeout) {
1067 | req.ontimeout = ontimeout;
1068 | }
1069 |
1070 | req.send(body);
1071 | }
1072 | }, {
1073 | key: "parseJSON",
1074 | value: function parseJSON(resp) {
1075 | return resp && resp !== "" ? JSON.parse(resp) : null;
1076 | }
1077 | }, {
1078 | key: "serialize",
1079 | value: function serialize(obj, parentKey) {
1080 | var queryStr = [];
1081 | for (var key in obj) {
1082 | if (!obj.hasOwnProperty(key)) {
1083 | continue;
1084 | }
1085 | var paramKey = parentKey ? parentKey + "[" + key + "]" : key;
1086 | var paramVal = obj[key];
1087 | if ((typeof paramVal === "undefined" ? "undefined" : _typeof(paramVal)) === "object") {
1088 | queryStr.push(this.serialize(paramVal, paramKey));
1089 | } else {
1090 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal));
1091 | }
1092 | }
1093 | return queryStr.join("&");
1094 | }
1095 | }, {
1096 | key: "appendParams",
1097 | value: function appendParams(url, params) {
1098 | if (Object.keys(params).length === 0) {
1099 | return url;
1100 | }
1101 |
1102 | var prefix = url.match(/\?/) ? "&" : "?";
1103 | return "" + url + prefix + this.serialize(params);
1104 | }
1105 | }]);
1106 |
1107 | return Ajax;
1108 | }();
1109 |
1110 | Ajax.states = { complete: 4 };
1111 |
1112 | var Presence = exports.Presence = {
1113 | syncState: function syncState(currentState, newState, onJoin, onLeave) {
1114 | var _this12 = this;
1115 |
1116 | var state = this.clone(currentState);
1117 | var joins = {};
1118 | var leaves = {};
1119 |
1120 | this.map(state, function (key, presence) {
1121 | if (!newState[key]) {
1122 | leaves[key] = presence;
1123 | }
1124 | });
1125 | this.map(newState, function (key, newPresence) {
1126 | var currentPresence = state[key];
1127 | if (currentPresence) {
1128 | (function () {
1129 | var newRefs = newPresence.metas.map(function (m) {
1130 | return m.phx_ref;
1131 | });
1132 | var curRefs = currentPresence.metas.map(function (m) {
1133 | return m.phx_ref;
1134 | });
1135 | var joinedMetas = newPresence.metas.filter(function (m) {
1136 | return curRefs.indexOf(m.phx_ref) < 0;
1137 | });
1138 | var leftMetas = currentPresence.metas.filter(function (m) {
1139 | return newRefs.indexOf(m.phx_ref) < 0;
1140 | });
1141 | if (joinedMetas.length > 0) {
1142 | joins[key] = newPresence;
1143 | joins[key].metas = joinedMetas;
1144 | }
1145 | if (leftMetas.length > 0) {
1146 | leaves[key] = _this12.clone(currentPresence);
1147 | leaves[key].metas = leftMetas;
1148 | }
1149 | })();
1150 | } else {
1151 | joins[key] = newPresence;
1152 | }
1153 | });
1154 | return this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave);
1155 | },
1156 | syncDiff: function syncDiff(currentState, _ref2, onJoin, onLeave) {
1157 | var joins = _ref2.joins;
1158 | var leaves = _ref2.leaves;
1159 |
1160 | var state = this.clone(currentState);
1161 | if (!onJoin) {
1162 | onJoin = function onJoin() {};
1163 | }
1164 | if (!onLeave) {
1165 | onLeave = function onLeave() {};
1166 | }
1167 |
1168 | this.map(joins, function (key, newPresence) {
1169 | var currentPresence = state[key];
1170 | state[key] = newPresence;
1171 | if (currentPresence) {
1172 | var _state$key$metas;
1173 |
1174 | (_state$key$metas = state[key].metas).unshift.apply(_state$key$metas, _toConsumableArray(currentPresence.metas));
1175 | }
1176 | onJoin(key, currentPresence, newPresence);
1177 | });
1178 | this.map(leaves, function (key, leftPresence) {
1179 | var currentPresence = state[key];
1180 | if (!currentPresence) {
1181 | return;
1182 | }
1183 | var refsToRemove = leftPresence.metas.map(function (m) {
1184 | return m.phx_ref;
1185 | });
1186 | currentPresence.metas = currentPresence.metas.filter(function (p) {
1187 | return refsToRemove.indexOf(p.phx_ref) < 0;
1188 | });
1189 | onLeave(key, currentPresence, leftPresence);
1190 | if (currentPresence.metas.length === 0) {
1191 | delete state[key];
1192 | }
1193 | });
1194 | return state;
1195 | },
1196 | list: function list(presences, chooser) {
1197 | if (!chooser) {
1198 | chooser = function chooser(key, pres) {
1199 | return pres;
1200 | };
1201 | }
1202 |
1203 | return this.map(presences, function (key, presence) {
1204 | return chooser(key, presence);
1205 | });
1206 | },
1207 |
1208 | // private
1209 |
1210 | map: function map(obj, func) {
1211 | return Object.getOwnPropertyNames(obj).map(function (key) {
1212 | return func(key, obj[key]);
1213 | });
1214 | },
1215 | clone: function clone(obj) {
1216 | return JSON.parse(JSON.stringify(obj));
1217 | }
1218 | };
1219 |
1220 | // Creates a timer that accepts a `timerCalc` function to perform
1221 | // calculated timeout retries, such as exponential backoff.
1222 | //
1223 | // ## Examples
1224 | //
1225 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){
1226 | // return [1000, 5000, 10000][tries - 1] || 10000
1227 | // })
1228 | // reconnectTimer.scheduleTimeout() // fires after 1000
1229 | // reconnectTimer.scheduleTimeout() // fires after 5000
1230 | // reconnectTimer.reset()
1231 | // reconnectTimer.scheduleTimeout() // fires after 1000
1232 | //
1233 |
1234 | var Timer = function () {
1235 | function Timer(callback, timerCalc) {
1236 | _classCallCheck(this, Timer);
1237 |
1238 | this.callback = callback;
1239 | this.timerCalc = timerCalc;
1240 | this.timer = null;
1241 | this.tries = 0;
1242 | }
1243 |
1244 | _createClass(Timer, [{
1245 | key: "reset",
1246 | value: function reset() {
1247 | this.tries = 0;
1248 | clearTimeout(this.timer);
1249 | }
1250 |
1251 | // Cancels any previous scheduleTimeout and schedules callback
1252 |
1253 | }, {
1254 | key: "scheduleTimeout",
1255 | value: function scheduleTimeout() {
1256 | var _this13 = this;
1257 |
1258 | clearTimeout(this.timer);
1259 |
1260 | this.timer = setTimeout(function () {
1261 | _this13.tries = _this13.tries + 1;
1262 | _this13.callback();
1263 | }, this.timerCalc(this.tries + 1));
1264 | }
1265 | }]);
1266 |
1267 | return Timer;
1268 | }();
1269 |
1270 | })(typeof(exports) === "undefined" ? window.Phoenix = window.Phoenix || {} : exports);
1271 |
1272 |
--------------------------------------------------------------------------------
/app/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | color: #333;
21 | padding: 0;
22 | border: 0;
23 | font-size: 100%;
24 | font-family: "Roboto", arial, sans-serif;
25 | vertical-align: baseline;
26 | box-sizing: border-box;
27 | }
28 | /* HTML5 display-role reset for older browsers */
29 | article, aside, details, figcaption, figure,
30 | footer, header, hgroup, menu, nav, section {
31 | display: block;
32 | }
33 | body {
34 | line-height: 1;
35 | }
36 | ol, ul {
37 | list-style: none;
38 | }
39 | blockquote, q {
40 | quotes: none;
41 | }
42 | blockquote:before, blockquote:after,
43 | q:before, q:after {
44 | content: '';
45 | content: none;
46 | }
47 | table {
48 | border-collapse: collapse;
49 | border-spacing: 0;
50 | }
51 |
52 | a {
53 | color: #42A5F5;
54 | text-decoration: none;
55 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Ticker.io
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ticker-react",
3 | "version": "1.0.0",
4 | "description": "ticker react frontend",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node server.js",
8 | "test": "mocha --require babel-register --require .test-setup.js -R spec app/**/spec.js",
9 | "cover": "nyc -x '**/*spec.js' -n 'app' -r text -r html -r lcov npm test"
10 | },
11 | "author": "Phil Callister",
12 | "license": "ISC",
13 | "devDependencies": {
14 | "babel-core": "^6.18.0",
15 | "babel-loader": "^6.2.6",
16 | "babel-preset-es2015": "^6.18.0",
17 | "babel-preset-react": "^6.16.0",
18 | "babel-preset-stage-0": "^6.16.0",
19 | "babel-register": "^6.18.0",
20 | "classnames": "^2.2.5",
21 | "css-loader": "^0.25.0",
22 | "d3": "^4.4.0",
23 | "enzyme": "^2.5.1",
24 | "expect": "^1.20.2",
25 | "jsdom": "^9.8.3",
26 | "mocha": "^3.1.2",
27 | "nyc": "^8.3.2",
28 | "postcss-cssnext": "^2.8.0",
29 | "postcss-loader": "^1.0.0",
30 | "react-addons-test-utils": "^15.3.2",
31 | "react-stockcharts": "^0.6.0-beta.11",
32 | "redux-devtools": "^3.3.1",
33 | "style-loader": "^0.13.1",
34 | "webpack": "^1.13.3",
35 | "webpack-dev-server": "^1.16.2"
36 | },
37 | "babel": {
38 | "presets": [
39 | "es2015",
40 | "react",
41 | "stage-0"
42 | ]
43 | },
44 | "dependencies": {
45 | "history": "^4.3.0",
46 | "react": "^15.3.2",
47 | "react-css-modules": "^3.7.10",
48 | "react-dom": "^15.3.2",
49 | "react-redux": "^5.0.1",
50 | "react-router": "^3.0.0",
51 | "redux": "^3.6.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/screen-shot.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/philcallister/ticker-react/a954e612abb443b85b42953fc4589c2b3d6a7538/screen-shot.gif
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack')
2 | var WebpackDevServer = require('webpack-dev-server')
3 | var config = require('./webpack.config')
4 |
5 | new WebpackDevServer(webpack(config), {
6 | historyApiFallback: true
7 | }).listen(3000, 'localhost', function (err, res) {
8 | err ? console.log(err) : console.log("Listening at localhost:3000")
9 | })
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var cssnext = require('postcss-cssnext')
2 |
3 | var path = require('path')
4 | var webpack = require('webpack')
5 |
6 | module.exports = {
7 | devtool: 'eval',
8 | entry: [
9 | 'webpack-dev-server/client?http://localhost:3000',
10 | './app/index'
11 | ],
12 | output: {
13 | path: path.join(__dirname, 'dist'),
14 | filename: 'bundle.js'
15 | },
16 | module: {
17 | loaders: [
18 | {
19 | test: /\.js$/,
20 | loaders: ['babel'],
21 | exclude: /node_modules/,
22 | include: path.join(__dirname, 'app')
23 | },
24 | {
25 | test: /\.css$/,
26 | loader: 'style!css?modules&importLoaders=1&localIdentName=[local]_[hash:base64:5]!postcss',
27 | include: path.join(__dirname, 'app'),
28 | exclude: /node_modules/
29 | }
30 | ]
31 | },
32 | resolve: {
33 | extensions: [ '', '.js' ]
34 | },
35 | postcss: function () {
36 | return [cssnext]
37 | }
38 | }
--------------------------------------------------------------------------------