├── .editorconfig
├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── public
├── embed.html
├── favicon.ico
├── images
│ ├── logo-color-144.png
│ ├── logo-color-192.png
│ ├── logo-color-256.png
│ ├── logo-color-512.png
│ ├── logo-color-96.png
│ ├── logo-color.svg
│ └── logo-white.svg
├── index.html
└── manifest.json
├── src
├── Components
│ ├── ChatApp
│ │ ├── ChatApp.jsx
│ │ └── ChatApp.styl
│ ├── HomeView
│ │ ├── HomeView.jsx
│ │ └── HomeView.styl
│ ├── Menus
│ │ ├── Menus.jsx
│ │ ├── Menus.styl
│ │ └── Menus
│ │ │ ├── Moderation.jsx
│ │ │ └── User.jsx
│ ├── Message
│ │ ├── Message.jsx
│ │ ├── Message.styl
│ │ ├── Model.js
│ │ ├── Parser.js
│ │ ├── embed.js
│ │ └── embed
│ │ │ └── embedly.jsx
│ ├── MessageRoom
│ │ ├── AutoComplete.jsx
│ │ ├── MessageRoom.jsx
│ │ ├── MessageRoom.styl
│ │ └── Views
│ │ │ ├── Alert.jsx
│ │ │ ├── GroupSettings.js
│ │ │ └── ModSettings.jsx
│ ├── Nicklist
│ │ ├── Nicklist.jsx
│ │ └── Nicklist.styl
│ ├── OptionsMenu
│ │ ├── OptionsMenu.jsx
│ │ └── OptionsMenu.styl
│ ├── Settings
│ │ ├── Settings.js
│ │ └── Settings.styl
│ ├── Sidebar
│ │ ├── ChannelSearch.jsx
│ │ ├── Sidebar.jsx
│ │ └── Sidebar.styl
│ └── Topbar
│ │ ├── Topbar.js
│ │ └── Topbar.styl
├── Config
│ └── Config.js
├── Helpers
│ ├── Colour.js
│ ├── Helpers.js
│ └── md5.js
├── Services
│ ├── ChannelManager.js
│ ├── EventBus.js
│ ├── ExtensionSync.js
│ ├── ModeratorToolbox.js
│ ├── Notifications.js
│ ├── Orangechat.js
│ ├── Reddit.js
│ ├── Settings.js
│ ├── Storage.js
│ └── Transport.js
├── index.js
└── stylus
│ └── variables.styl
└── webpack.config.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_style = tab
3 | end_of_line = lf
4 | charset = utf-8
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 |
8 | [*.md]
9 | trim_trailing_whitespace = false
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.pem
3 | builds/
4 | *.sh
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # orangechat webapp
2 |
3 | It's a chat for subreddits.
4 |
5 | ### Status
6 | The project has been morphing around into different things until it has settled into its current state. It is now currently being tidied up and documented so that other developers can understand how everything is put together.
7 |
8 | This is only the frontend to orangechat that you may run locally to contribute to the project.
9 |
10 | ### Embedding into reddit.com
11 |
12 | The main place this will be used it emebedded into https://reddit.com. This means:
13 | * The widget must be initialised with the DOM fully created as quickly as possible for when users click between pages.
14 | * All HTTP requests and assets must be done over HTTPS.
15 | * CSS styles must be prefixed as not to clash with existing styles.
16 | * Any libraries and frameworks in use on reddit.com may be re-used to our advantage.
17 | * All requests to our own servers must be fully CORS capable
18 |
19 | ### Tools, libraries and frameworks
20 |
21 | * mithril.js framework for it's low footprint (with MSX for views)
22 | * SockJS websocket library
23 |
24 | Already in use on reddit.com that we may re-use:
25 | * jQuery
26 | * Underscore (not lodash!)
27 | * Backbone
28 | * Jed
29 |
30 | Need events? Backbone.Events
31 |
32 | ### Development
33 |
34 | This repository comes with a local development server that may be used to test out the frontend during development.
35 |
36 | Usage: `npm start` or `PORT=8080 npm start` to specify the port.
37 |
38 | The frontend communicates to app.orangechat.io production services using CORS, so to make things easier we have whitelisted common
39 | development hostnames. You may access the development server at either `127.0.0.1` or any hostname ending in `.local` if you add it to your hosts file. Both HTTP/HTTPS and any port are supported.
40 |
41 | To build your changes, run `npm run build`.
42 |
43 | ### License
44 |
45 | Copyright (c) 2016 Darren Whitlen & orangechat.io Licensed under the Apache License.
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "orangechat",
3 | "version": "0.1.0",
4 | "description": "A chat for reddit",
5 | "scripts": {
6 | "start": "webpack-dev-server",
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "build": "node node_modules/webpack/bin/webpack.js"
9 | },
10 | "author": "orangechat.io",
11 | "license": "Apache-2.0",
12 | "devDependencies": {
13 | "babel-core": "^6.7.2",
14 | "babel-loader": "^6.2.4",
15 | "babel-plugin-mjsx": "^4.1.1",
16 | "babel-preset-es2015": "^6.6.0",
17 | "css-loader": "^0.23.1",
18 | "html-loader": "^0.4.3",
19 | "poststylus": "^0.2.3",
20 | "style-loader": "^0.13.0",
21 | "stylus": "^0.54.5",
22 | "stylus-loader": "^1.5.1",
23 | "webpack": "^1.12.14",
24 | "webpack-dev-server": "^1.14.1"
25 | },
26 | "dependencies": {
27 | "mithril": "^0.2.3",
28 | "sockjs-client": "^1.1.0",
29 | "strftime": "^0.9.2"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/public/embed.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/logo-color-144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/images/logo-color-144.png
--------------------------------------------------------------------------------
/public/images/logo-color-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/images/logo-color-192.png
--------------------------------------------------------------------------------
/public/images/logo-color-256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/images/logo-color-256.png
--------------------------------------------------------------------------------
/public/images/logo-color-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/images/logo-color-512.png
--------------------------------------------------------------------------------
/public/images/logo-color-96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/orangechat/webapp/47044b350203230a8387e2ae1a6662fc17079fbe/public/images/logo-color-96.png
--------------------------------------------------------------------------------
/public/images/logo-color.svg:
--------------------------------------------------------------------------------
1 | logo-1-noio-solid
--------------------------------------------------------------------------------
/public/images/logo-white.svg:
--------------------------------------------------------------------------------
1 | logo-1-white-noio-solid
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | orangechat.io - chat on reddit
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Please enable javascript in your browser and refresh the page
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "OrangeChat",
3 | "description": "A chat for reddit",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "16x16 32x32 48x48",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "images/logo-color-96.png",
12 | "sizes": "96x96",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "images/logo-color-144.png",
17 | "sizes": "144x144",
18 | "type": "image/png"
19 | },
20 | {
21 | "src": "images/logo-color-192.png",
22 | "sizes": "192x192",
23 | "type": "image/png"
24 | },
25 | {
26 | "src": "images/logo-color-256.png",
27 | "sizes": "256x256",
28 | "type": "image/png"
29 | },
30 | {
31 | "src": "images/logo-color-512.png",
32 | "sizes": "512x512",
33 | "type": "image/png"
34 | }
35 | ],
36 | "display": "standalone",
37 | "start_url": "/",
38 | "theme_color": "#ff5722"
39 | }
40 |
--------------------------------------------------------------------------------
/src/Components/ChatApp/ChatApp.jsx:
--------------------------------------------------------------------------------
1 | import './ChatApp.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 | import Config from 'Config/Config';
5 |
6 | import Sidebar from '../Sidebar/Sidebar.jsx';
7 | import Topbar from '../Topbar/Topbar.js';
8 | import HomeView from '../HomeView/HomeView.jsx';
9 | import Settings from '../Settings/Settings.js';
10 |
11 | import Orangechat from '../../Services/Orangechat.js';
12 | import Reddit from '../../Services/Reddit.js';
13 | import Transport from '../../Services/Transport.js';
14 | import ChannelManager from '../../Services/ChannelManager.js';
15 | import ModeratorToolbox from '../../Services/ModeratorToolbox.js';
16 |
17 |
18 | /**
19 | * Top level application controller
20 | */
21 | var ChatApp = {};
22 |
23 | ChatApp.controller = function(args) {
24 | Helpers.setAppInstance(this);
25 |
26 | this.is_in_reddit = !!window.location.host.match(/reddit.com$/i);
27 | this.has_extension = args.has_extension;
28 |
29 | this.bus = args.bus;
30 | this.state = args.state;
31 |
32 | this.orangechat = new Orangechat.instance(this.state);
33 | this.transport = new Transport(this.orangechat.sid, this.bus);
34 |
35 | this.top_bar = Helpers.subModule(Topbar, {app: this});
36 | this.side_bar = Helpers.subModule(Sidebar, {app: this});
37 |
38 | // TODO: .home will be the default homescreen
39 | this.home = m(HomeView, {app: this});
40 |
41 | // The active workspace instance. If null, the channel manager will takeover
42 | this.workspace_instance = null;
43 |
44 | this.rooms = new ChannelManager({
45 | bus: this.bus,
46 | transport: this.transport,
47 | state: this.state
48 | });
49 |
50 | // The subreddits we are subscribed to
51 | this.orangechat.loadSubreddits();
52 | this.subreddits = this.orangechat.subreddits;
53 | this.subreddits.refresh = () => {
54 | this.orangechat.loadSubreddits(true);
55 | };
56 |
57 | // Keep note if we have loaded the channels from storage or elsewhere yet
58 | this.channels_loaded = false;
59 |
60 | this.setWorkspace = (instance) => {
61 | this.toggleSidebar(false);
62 | this.workspace_instance = instance;
63 | };
64 |
65 | // Get the view function for the active room. (generates the DOM structure)
66 | this.activeWorkspaceView = () => {
67 | if (this.workspace_instance) {
68 | return this.workspace_instance;
69 | }
70 |
71 | // Not logged in? Show the homepage only
72 | if (!this.orangechat.username()) {
73 | return this.home;
74 | }
75 |
76 | var active_room = this.rooms.active();
77 | return active_room ?
78 | active_room :
79 | this.home;
80 | };
81 |
82 | // Toggle the app
83 | this.toggle = () => {
84 | this.state.set('is_open', !this.state('is_open'));
85 | this.bus.trigger('app.toggle', this.state('is_open'));
86 | };
87 |
88 | this.toggleSidebar = (should_open) => {
89 | if (typeof should_open === 'undefined') {
90 | should_open = !this.state('is_sidebar_open');
91 | }
92 |
93 | this.state.set('is_sidebar_open', !!should_open);
94 | this.bus.trigger('app.toggle_sidebar', this.state('is_sidebar_open'));
95 | }
96 |
97 | // Determine the classes for the app
98 | this.appClasses = () => {
99 | var classes = 'OC__ui';
100 |
101 | if(!this.state('is_open')) {
102 | classes = classes + ' OC__ui--closed ';
103 | }
104 |
105 | if(!this.state('is_sidebar_open')) {
106 | classes = classes + ' OC__ui--sidebar-collapsed ';
107 | }
108 |
109 | if(ModeratorToolbox.isActive()) {
110 | classes = classes + ' OC__ui--toolbox ';
111 | }
112 |
113 | if (!this.is_in_reddit) {
114 | classes = classes + ' OC__ui--standalone';
115 | }
116 |
117 | if (!this.orangechat.username()) {
118 | classes = classes + ' OC__ui--loggedout';
119 | }
120 |
121 | return classes;
122 | }
123 |
124 | // Once we're ready (logged in, app is ready) then we show the active rooms
125 | this.addInitialRooms = () => {
126 | var default_channel = Config.channels.default[0];
127 | var channel_list = this.state.get('channel_list') || [];
128 | var active_channel = this.state.get('active_channel');
129 | var channel_in_url = (!this.is_in_reddit && window.location.hash) ?
130 | window.location.hash.substring(1).split(',')[0] :
131 | null;
132 |
133 | if (!channel_list.length) {
134 | _.each([].concat(default_channel), (channel_name) => {
135 | this.rooms.createRoom(channel_name);
136 | });
137 | } else {
138 | _.each(channel_list, (channel_state) => {
139 | // Upgrade the list of string based channel names from older OC versions to objects
140 | if (typeof channel_state === 'string') {
141 | channel_state = {
142 | name: channel_state,
143 | };
144 | }
145 |
146 | var channel = this.rooms.createRoom(channel_state.name, {
147 | label: channel_state.label,
148 | read_upto: channel_state.read_upto || 0,
149 | access: channel_state.access,
150 | linked_channels: channel_state.linked_channels,
151 | flags: channel_state.flags
152 | });
153 | });
154 | }
155 |
156 | //if (Reddit.currentSubreddit()) {
157 | // this.rooms.createRoom('/r/' + Reddit.currentSubreddit());
158 | //}
159 |
160 | // If we had a channel specified in the URL, make sure it exists and set it as the defualt channel
161 | if (channel_in_url) {
162 | if (!this.rooms.getRoom(channel_in_url)) {
163 | this.rooms.createRoom(channel_in_url);
164 | }
165 | active_channel = channel_in_url;
166 | }
167 |
168 | // If our active channel no longer exists, set the first channel active instead
169 | if (!this.rooms.setActive(active_channel || default_channel) === false) {
170 | this.rooms.setIndexActive(0);
171 | }
172 |
173 | this.channels_loaded = true;
174 | this.bus.trigger('channels.loaded');
175 | };
176 |
177 | this.saveChannelState = (new_channel) => {
178 | // Don't save channel state is we haven't got it yet
179 | if (!this.channels_loaded) {
180 | return;
181 | }
182 |
183 | var channels = _.map(this.rooms.rooms, function(channel) {
184 | return {
185 | name: channel.instance.name(),
186 | label: channel.instance.label(),
187 | read_upto: channel.instance.read_upto,
188 | access: channel.instance.access,
189 | linked_channels: channel.instance.linked_channels,
190 | flags: channel.instance.flags
191 | };
192 | });
193 |
194 | this.state.set('channel_list', channels);
195 | };
196 |
197 | // Harsh hack to speed up redrawing
198 | // The way the CSS is structured leaves huge white breaks between redraws
199 | // if the height is increased. This forces a redraw to mask it.
200 | // TODO: Have the CSS background colours on the wrapping elemements, not the
201 | // individual content elements
202 | window.onresize = () => {
203 | m.redraw();
204 | };
205 |
206 | // Keep track of our channels state on a few events
207 | this.bus.on('channel.created', this.saveChannelState);
208 | this.bus.on('channel.close', this.saveChannelState);
209 | window.onunload = this.saveChannelState;
210 |
211 | // Toggle the app
212 | this.bus.on('action.toggle_app', this.toggle);
213 |
214 | // Toggle the sidebar
215 | this.bus.on('action.toggle_sidebar', this.toggleSidebar);
216 |
217 | this.bus.on('action.close_workspace', () => {
218 | this.workspace_instance = null;
219 | // Go back to the channel view, with the channel list
220 | this.toggleSidebar(true);
221 | });
222 |
223 | this.bus.on('action.show_settings', () => {
224 | this.setWorkspace(Helpers.subModule(Settings, {
225 | bus: this.bus
226 | }));
227 | });
228 |
229 | // Keep track of the active channel between page refreshes
230 | this.bus.on('channel.active', (active_channel, previous_channel) => {
231 | if (active_channel) {
232 | this.state.set('active_channel', active_channel.name());
233 |
234 | // Remove any active workspace so this channel can be shown
235 | this.workspace_instance = null;
236 | }
237 | });
238 |
239 | // Keep a few components updated when our state changes
240 | this.bus.on('state.change', (changed, new_value, old_value, values) => {
241 | // If our session ID has changed, update the transport so it's in sync
242 | // Eg. First logging in, the session ID is not available for the transport. After
243 | // logged in and the session is created, the transport is then safe to connect.
244 | if (changed === 'sid' && new_value !== old_value) {
245 | this.transport.setSessionId(new_value);
246 | }
247 |
248 | // The channel list state may have been updated by the extension, so lets
249 | // go through it and merge any changes as needed
250 | if (changed === 'channel_list') {
251 | var channel_list = this.state.get('channel_list') || [];
252 | // First, remove any channels not in the state
253 | _.each(this.rooms.rooms, (channel) => {
254 | var chan_in_state = !!_.find(channel_list, (item) => {
255 | return item.name.toLowerCase() === channel.instance.name().toLowerCase();
256 | });
257 |
258 | if (!chan_in_state) {
259 | this.rooms.closeRoom(channel.instance.name());
260 | }
261 | });
262 |
263 | // Now add any new channels
264 | _.each(channel_list, (channel_state) => {
265 | var channel = this.rooms.createRoom(channel_state.name, {
266 | label: channel_state.label,
267 | read_upto: channel_state.read_upto || 0,
268 | access: channel_state.access,
269 | linked_channels: channel_state.linked_channels
270 | });
271 |
272 | // Make sure we have the most recent read_upto value
273 | if (channel.instance.read_upto < channel_state.read_upto) {
274 | channel.instance.read_upto = channel_state.read_upto;
275 | }
276 | });
277 | }
278 | });
279 |
280 | // Pipe some messages from transport into relevant bus events
281 | this.bus.on('transport.message', (message) => {
282 | if (message.author && message.target) {
283 | // Add some user-application specific properties to the message
284 | if (message.content.match(new RegExp('\\b' + this.orangechat.username() + '\\b', 'i'))) {
285 | message.is_highlight = true;
286 | }
287 |
288 | this.bus.trigger('im.message', message);
289 | }
290 |
291 | // Messages sent specifically for this user
292 | if (message.type && message.payload) {
293 | this.bus.trigger('message.' + message.type, message.payload);
294 | }
295 | });
296 | this.bus.on('transport.groupmeta', (groups) => {
297 | this.bus.trigger('im.meta', groups);
298 | });
299 |
300 | // Convert some document events into bus events
301 | $(document).on('click', (event) => {
302 | // This event comes from outside the mithrill application so we need to handle
303 | // the redraw ourselves
304 | m.startComputation();
305 | this.bus.trigger('action.document_click', event);
306 | m.endComputation();
307 | });
308 |
309 | if (Helpers.isInReddit()) {
310 | Reddit.injectUserbarIcon(this, this.bus);
311 | Reddit.hookOcButtons(this.bus);
312 | }
313 |
314 | // The ping call will check that we are logged in, but since the mojority of the time
315 | // the user wouldn't have been logged out for no reason then we start by checking the local
316 | // state (orangechat.username()) below while the ping call is in progress
317 | this.orangechat.ping().then((user_data) => {
318 | this.orangechat.pingLoop();
319 |
320 | if (user_data.just_logged_in) {
321 | this.addInitialRooms();
322 | }
323 |
324 | // The user channel lets us broadcast+receive data between all of the instances the
325 | // user has open. Browser tabs, devices, different browsers, etc.
326 | if (user_data.user_channel) {
327 | this.transport.join(user_data.user_channel);
328 | }
329 |
330 | // Update our existing channels if any differ
331 | _.map(user_data.channels, (channel) => {
332 | var our_channel = this.rooms.getRoom(channel.name);
333 | // TODO: Replace the 2 with the ACCESS_TYPE_* constants
334 | if (!our_channel && channel.access_type === 2) {
335 | // Do we want to add all of our invited channels from other devices/tabs
336 | // to this channel list? Then uncomment below
337 | //our_channel = this.rooms.createRoom(channel.name, {label: channel.label});
338 |
339 | }
340 |
341 | if (!our_channel) {
342 | return;
343 | }
344 |
345 | if (channel.type === 3 && channel.access_type === 2 && channel.other_user) {
346 | // Private channels which we have an invite for along with 1 other person (PM) will
347 | // have .other_user as the other persons username
348 | our_channel.instance.label(channel.other_user);
349 |
350 | } else if (channel.label !== our_channel.instance.label()) {
351 | our_channel.instance.label(channel.label);
352 | }
353 | });
354 | });
355 |
356 | // Start checking for the user auth
357 | if (!this.orangechat.username()) {
358 | } else {
359 | // Add a few channels/rooms
360 | this.addInitialRooms();
361 | }
362 | };
363 |
364 | ChatApp.view = function(controller) {
365 | var content = [
366 | controller.top_bar.view()
367 | ];
368 |
369 | if (controller.state('is_open')) {
370 | content.push(
371 |
372 | );
373 |
374 | if(controller.orangechat.username()) {
375 | content.push(controller.side_bar.view());
376 | }
377 |
378 | content.push(
379 |
380 | {controller.activeWorkspaceView()}
381 |
382 | );
383 | }
384 |
385 | return (
386 |
387 | {content}
388 |
389 | );
390 | };
391 |
392 | export default ChatApp;
393 |
--------------------------------------------------------------------------------
/src/Components/ChatApp/ChatApp.styl:
--------------------------------------------------------------------------------
1 | .{prefix}
2 |
3 | &-list
4 | li
5 | padding-left: 1em
6 | list-style-type: disc
7 | list-style-position: inside
8 |
9 | &-link
10 | text-decoration: none
11 | color: #006D9A
12 | cursor: pointer
13 |
14 | &-button
15 | color: white-text
16 | padding: 3px 8px
17 | border: 1px solid white-text
18 | border-radius: 3px
19 | line-height: 1.1
20 | cursor: pointer
21 | display: inline-block
22 |
23 | &__dark
24 | color: black-text
25 | padding: 3px 8px
26 | border: 1px solid black-text
27 | border-radius: 3px
28 | line-height: 1.1
29 | cursor: pointer
30 | display: inline-block
31 |
32 | &__ui
33 | position: fixed
34 | bottom: 0
35 | right: 0
36 | width: 536px // width of &--sidebar-collapsed + sidebar width
37 | height: 360px
38 | background-color: #FFFFFF
39 | box-shadow: 0 0 28px rgba(0,0,0,0.3)
40 | z-index: 2147483647
41 | transition: width 175ms ease
42 | overflow: hidden
43 | text-size-adjust: 100%
44 | -webkit-font-smoothing: subpixel-antialiased
45 | text-rendering: optimizelegibility
46 | font-family: font-family
47 | font-size: base-font-size - 1px
48 | color: black-text
49 | line-height: base-line-height
50 | line-height: em(base-line-height)
51 | border-radius: 5px 0 0 0
52 |
53 | *, *:before, *:after
54 | box-sizing: border-box
55 | -webkit-tap-highlight-color: rgba(0,0,0,0)
56 |
57 | &--sidebar-collapsed, &--loggedout
58 | width: 316px
59 |
60 | &--loggedout
61 | height: 200px
62 |
63 | &--closed
64 | width: auto
65 | height: auto
66 | background-color: brand-primary
67 |
68 | &--toolbox
69 | bottom: 25px
70 | z-index: 2147483645
71 |
72 | &--standalone
73 | width: 100%
74 | height: 100%
75 | right: 0
76 | box-shadow: none
77 | transition: none
78 | z-index: 1
79 | font-size: base-font-size
80 | border-radius: 0
81 |
82 | &__shadow-underlay
83 | overflow: hidden
84 | position: absolute
85 | z-index: 79
86 | left: 0
87 | top: 0
88 | right: 0
89 | height: topbar-height
90 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.3)
91 |
92 | &__workspace
93 | position: absolute
94 | width: calc(100% - 220px) // 220px = sidebar-width
95 | height: 100%
96 | left: sidebar-width
97 | top: 0
98 | background-color: #FFFFFF
99 | transition: left 175ms ease, width 175ms ease
100 |
101 | &__ui--standalone &-Sidebar__header-collapse
102 | transform: rotate(180deg)
103 |
104 | &__ui--loggedout &__shadow-underlay
105 | display: none
106 |
107 | &__ui--sidebar-collapsed &__workspace, &__ui--loggedout &__workspace
108 | left: 0
109 | width: 100%
110 |
111 | &__ui--sidebar-collapsed &-MessageRoom__header
112 | background-color: brand-primary
113 |
114 | &-collapse
115 | left: 16px
116 |
117 | svg
118 | fill: white-hint
119 |
120 | &-info
121 | left: 56px
122 |
123 | h4
124 | color: white-text
125 |
126 | &-tabs-item
127 |
128 | svg
129 | fill: white-hint
130 |
131 | &--active svg
132 | fill: white-text
133 |
134 | &-info
135 | color: white-text
136 |
137 | &__workspace-content
138 | position: absolute
139 | left: 0
140 | right: 0
141 | bottom: 0
142 | top: 46px
143 | overflow-y: auto
144 | padding: base-hs
145 |
--------------------------------------------------------------------------------
/src/Components/HomeView/HomeView.jsx:
--------------------------------------------------------------------------------
1 | import './HomeView.styl';
2 |
3 | import Orangechat from '../../Services/Orangechat.js';
4 |
5 | var HomeView = {};
6 |
7 | HomeView.controller = function(args) {
8 | var app = args.app;
9 |
10 | this.getStarted = () => {
11 | app.orangechat.auth()
12 | .then((result) => {
13 | // Logged in OK...
14 | console.log('Logged in ok.', result);
15 | app.addInitialRooms();
16 | })
17 | .then(null, (err) => {
18 | // Logging in failed...
19 | //console.log('Failed to login.', err);
20 | });
21 | };
22 | };
23 |
24 | HomeView.view = function(controller) {
25 | return (
26 |
27 |
28 |
29 |
Welcome to orangechat.io
30 |
Get Started
31 |
32 |
33 | );
34 | };
35 |
36 | export default HomeView;
37 |
--------------------------------------------------------------------------------
/src/Components/HomeView/HomeView.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-HomeView
2 | position: absolute
3 | top: 0
4 | left: 0
5 | bottom: 0
6 | right: 0
7 | display: flex
8 | align-items: center
9 | justify-content: center
10 | flex-direction: column
11 | text-align: center
12 | background: brand-primary
13 |
14 | &__get-started
15 | padding: 0 25px
16 |
17 | &-branding-logo
18 | height: 35px
19 | width: auto
20 |
21 | h4
22 | font-size: em(15px)
23 | font-weight: 500
24 | color: white-text
25 | margin: 10px 0
26 |
27 | &-button
28 | display: inline-block
29 | text-align: center
30 | font-size: em(14px)
31 | align-self: center
32 | color: white-text
33 | padding: 3px 8px
34 | font-weight: 500
35 | border: 1px solid white-text
36 | border-radius: 3px
37 | line-height: 1.1
38 | cursor: pointer
39 | background-color: brand-primary
40 |
41 | &:hover
42 | border-color: white-secondary
43 | color: white-secondary
44 | text-decoration: none
45 |
46 | .{prefix}__ui--standalone .{prefix}-HomeView
47 | &__get-started
48 | h4, &-button
49 | font-size: em(27px)
50 |
51 | &-button
52 | margin-top: 30px
53 |
54 | &-branding-logo
55 | height: 60px
56 | margin-bottom: 10px
57 |
--------------------------------------------------------------------------------
/src/Components/Menus/Menus.jsx:
--------------------------------------------------------------------------------
1 | import './Menus.styl';
2 |
3 | var Menus = {};
4 |
5 | Menus.controller = function(args) {
6 | this.active_menu = null;
7 | this.active_menu_pos = {};
8 |
9 | this.open = (event, menu_instance) => {
10 | args.bus.trigger('panel.opening');
11 | this.active_menu = menu_instance;
12 |
13 | // Render once so we can get the sizing of the panel after it's rendered.
14 | m.redraw(true);
15 |
16 | this.active_menu_pos = {
17 | left: this.calculateOffsetLeft(event),
18 | top: this.calculateOffsetTop(event)
19 | };
20 |
21 | args.bus.once('action.document_click', () => {
22 | this.close();
23 | });
24 |
25 | args.bus.trigger('panel.opened');
26 | };
27 |
28 | this.close = () => {
29 | this.active_menu = null;
30 | args.bus.trigger('panel.closed');
31 | };
32 |
33 | this.calculateOffsetTop = event => {
34 | var workspace_height = $('.OC__workspace').height();
35 | var panel_height = $('.OC-Menu').height();
36 |
37 | if((event.clientY + panel_height) > window.innerHeight) {
38 | return ((window.innerHeight - (window.innerHeight - workspace_height)) - panel_height - 20);
39 | }
40 |
41 | return (event.clientY - (window.innerHeight - workspace_height));
42 | };
43 |
44 | this.calculateOffsetLeft = event => {
45 | var workspace_width = $('.OC__workspace').width();
46 | var panel_width = $('.OC-Menu').width();
47 | var left = (event.clientX - $('.OC__workspace').offset().left);
48 |
49 | if((event.clientX + panel_width) > window.innerWidth) {
50 | left = ((window.innerWidth - (window.innerWidth - workspace_width)) - panel_width - 20);
51 | }
52 |
53 | return left;
54 | };
55 |
56 | args.bus.on('panel.open', this.open);
57 | args.bus.on('panel.close', this.close);
58 | };
59 |
60 | Menus.view = function(controller) {
61 | if (!controller.active_menu) {
62 | return null;
63 | }
64 |
65 | var style_tag = 'display: block;';
66 | style_tag += 'left:' + controller.active_menu_pos.left + 'px;';
67 | style_tag += 'top:' + controller.active_menu_pos.top + 'px;';
68 |
69 | var title = controller.active_menu.instance.title ?
70 | controller.active_menu.instance.title() :
71 | '';
72 |
73 | return (
74 |
78 | );
79 | };
80 |
81 | export default Menus;
82 |
--------------------------------------------------------------------------------
/src/Components/Menus/Menus.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-Menu
2 | position: absolute
3 | left: 0
4 | top: 0
5 | display: none
6 | z-index: 100
7 | width: 215px
8 | background-color: #FFFFFF
9 | border-radius: 3px
10 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.14), 0 0px 5px 0 rgba(0, 0, 0, 0.12), 0 2px 1px -2px rgba(0, 0, 0, 0.2)
11 | overflow: hidden
12 | padding: base-vs 0
13 |
14 | &__title
15 | font-size: em(15px)
16 | font-weight: 500
17 | color: black-text
18 | padding: base-vs base-hs
19 | padding-top: 0
20 |
21 | &__link
22 | cursor: pointer
23 | color: black-text
24 | font-size: em(14px)
25 | padding: base-vs base-hs
26 | display: block
27 |
28 | &:hover
29 | background-color: black-underlays
30 |
31 | hr
32 | margin: base-vs 0
33 | border: 0
34 | border-top: 1px solid black-dividers
35 |
--------------------------------------------------------------------------------
/src/Components/Menus/Menus/Moderation.jsx:
--------------------------------------------------------------------------------
1 | import ModeratorToolbox from '../../../Services/ModeratorToolbox.js';
2 |
3 | var ModerationMenu = {};
4 |
5 | ModerationMenu.controller = function(args) {
6 | this.room = args.room;
7 | this.room_manager = args.room_manager;
8 | this.bus = args.bus;
9 |
10 | this.title = m.prop('Moderation');
11 |
12 | this.openModChannel = (event) => {
13 | if (!this.room.access.is_reddit_mod) {
14 | return;
15 | }
16 | if (!this.room.linked_channels.reddit_mod) {
17 | return;
18 | }
19 |
20 | var channel = this.room_manager.createRoom(this.room.linked_channels.reddit_mod);
21 | this.room_manager.setActive(channel.instance.name());
22 | };
23 |
24 | this.openModSettings = () => {
25 | this.room.openModSettings();
26 | };
27 | };
28 |
29 | ModerationMenu.view = function(controller) {
30 | return (
31 |
35 | );
36 | };
37 |
38 | export default ModerationMenu;
39 |
--------------------------------------------------------------------------------
/src/Components/Menus/Menus/User.jsx:
--------------------------------------------------------------------------------
1 | import Orangechat from '../../../Services/Orangechat.js';
2 | import ModeratorToolbox from '../../../Services/ModeratorToolbox.js';
3 |
4 | var UserMenu = {};
5 |
6 | UserMenu.controller = function(args) {
7 | this.username = args.username;
8 | this.source = args.source;
9 | this.room = args.room;
10 | this.room_manager = args.room_manager;
11 | this.orangechat = Orangechat.instance();
12 |
13 | if (this.source === 'irc') {
14 | this.title = m.prop(this.username);
15 | } else {
16 | this.title = m.prop('/u/' + this.username);
17 | }
18 |
19 | this.openPrivateChannel = () => {
20 | this.orangechat.createChannel(this.username).then((resp) => {
21 | if (!resp.channel_name) {
22 | return;
23 | }
24 |
25 | var label = resp.channel_label;
26 | // Since we only have 1 user in this invite, set the label to
27 | // the user name.
28 | label = this.username;
29 |
30 | var channel = this.room_manager.createRoom(resp.channel_name, {
31 | label: label
32 | });
33 |
34 | this.room_manager.setActive(channel.instance.name());
35 | args.bus.trigger('panel.close');
36 | });
37 | };
38 |
39 | this.openPrivateIrcChannel = () => {
40 | // Since we only have 1 user in this invite, set the label to
41 | // the user name.
42 | var label = '[irc] ' + this.username;
43 | var channel_name = 'irc_' + this.orangechat.uid() + '_' + this.username;
44 |
45 | var channel = this.room_manager.createRoom(channel_name, {
46 | label: label
47 | });
48 |
49 | this.room_manager.setActive(channel.instance.name());
50 | args.bus.trigger('panel.close');
51 | };
52 |
53 | this.inviteToChannel = channel => {
54 | this.orangechat.inviteToChannel(channel.name(), this.username).then((resp) => {
55 | if (resp.status == 'ok') {
56 | args.bus.trigger('panel.close');
57 | return;
58 | }
59 |
60 | console.log('inviteToChannel() Something went wrong...', resp);
61 | });
62 | };
63 |
64 | this.banFromChannel = () => {
65 | if (!confirm('Ban ' + this.username + ' from ' + this.room.name() + '?')) {
66 | return;
67 | }
68 |
69 | this.orangechat.banFromChannel(this.room.name(), this.username).then((resp) => {
70 | if (resp.status == 'ok') {
71 | args.bus.trigger('panel.close');
72 | return;
73 | }
74 |
75 | console.log('banFromChannel() Something went wrong...', resp);
76 | });
77 | };
78 |
79 | this.toolboxShowUser = () => {
80 | function doFn() {
81 | var $lastPopup = $('.mod-toolbox .tb-popup:last-of-type');
82 | if (!$lastPopup.length) {
83 | setTimeout(doFn, 200);
84 | return;
85 | }
86 |
87 | var popupOffset = $lastPopup.offset();
88 | var newTop = popupOffset.top-300;
89 | var newLeft = popupOffset.left-200;
90 | $lastPopup.css({
91 | 'top': newTop + 'px',
92 | 'left': newLeft + 'px',
93 | 'z-index': '2147483675'
94 | });
95 | }
96 |
97 | doFn();
98 | };
99 |
100 | // TODO: Move this from a timer to when the DOM is updated
101 | // Let the toolbox extension know that there are toolbox buttons to add its events to
102 | setTimeout(() => {
103 | var event = new CustomEvent('TBNewThings');
104 | window.dispatchEvent(event);
105 | }, 200);
106 | };
107 |
108 | UserMenu.view = function(controller) {
109 | var items = [];
110 |
111 | if (controller.source === 'irc') {
112 | //items = [m('p', {class: 'OC-Menu__link'}, 'This person is talking via IRC')];
113 | items = [
114 | m('p', {
115 | class: 'OC-Menu__link'
116 | }, 'This person is talking via IRC'),
117 |
118 | m('a', {
119 | class: 'OC-Menu__link',
120 | onclick: controller.openPrivateIrcChannel
121 | }, 'Send private message'),
122 | ];
123 |
124 | } else {
125 | items = [
126 | m('a', {
127 | class: 'OC-Menu__link',
128 | onclick: controller.openPrivateChannel
129 | }, 'Send private message'),
130 |
131 | m('a', {
132 | class: 'OC-Menu__link',
133 | href: 'https://www.reddit.com/u/' + controller.username
134 | }, 'Reddit profile'),
135 |
136 | UserMenu.viewInviteToChannels(controller),
137 | ];
138 | }
139 |
140 | items.push(UserMenu.viewModAction(controller));
141 |
142 | return m('div', {class: 'OC-Menu__content'}, items);
143 | };
144 |
145 | UserMenu.viewInviteToChannels = function(controller) {
146 | var content;
147 | var chan_list;
148 | var channels = _.filter(controller.room_manager.rooms, channel => {
149 | return channel.instance.access.is_invite;
150 | });
151 |
152 | if (!channels.length) {
153 | return;
154 | }
155 |
156 | if (channels.length === 1) {
157 | content = m('a', {
158 | class: 'OC-Menu__link',
159 | onclick: inviteFn(channels[0].instance)
160 | }, 'Invite to ' + channels[0].instance.displayLabel());
161 | } else {
162 | chan_list = _.map(channels, channel => {
163 | return m('a', {
164 | class: 'OC-Menu__link',
165 | onclick: inviteFn(channel.instance)
166 | }, channel.instance.displayLabel());
167 | });
168 |
169 | content = [
170 | m('hr')
171 | ].concat(chan_list);
172 | };
173 |
174 | return m('div', {
175 | class: 'OC-Menu__invite-list OC-Menu__invite-list--' + (channels.length === 1 ? 'single' : 'multiple')
176 | }, content);
177 |
178 | function inviteFn(channel) {
179 | return () => {
180 | controller.inviteToChannel(channel);
181 | };
182 | };
183 | };
184 |
185 | UserMenu.viewModAction = function(controller) {
186 | if (!controller.room.access.is_reddit_mod) {
187 | return;
188 | }
189 |
190 | var content = [
191 | m('hr'),
192 | m('a', {
193 | class: 'OC-Menu__link',
194 | onclick: controller.banFromChannel
195 | }, 'Ban user')
196 | ];
197 |
198 | if(window.tb_oc && ModeratorToolbox.isActive()) {
199 | // TODO: Hacky way to get just the sub name, rethink this
200 | var subreddit_name = (controller.room.name() || '')
201 | .replace('reddit_sub_', '')
202 | .replace('reddit_mod_', '')
203 | .replace('/r/', '');
204 |
205 | content.push( );
206 | content.push(
207 |
208 |
209 |
{subreddit_name}
210 |
{controller.username}
211 |
channel link
212 |
mod
213 |
214 |
222 |
223 |
227 |
235 |
236 |
237 |
238 | );
239 | }
240 |
241 | return m('div', {
242 | class: 'OC-Menu__mod-actions'
243 | }, content);
244 | };
245 |
246 | export default UserMenu;
247 |
--------------------------------------------------------------------------------
/src/Components/Message/Message.jsx:
--------------------------------------------------------------------------------
1 | import './Message.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 | import Embed from './embed.js';
5 |
6 | var Message = {};
7 |
8 | Message.controller = function(args) {
9 | // The currently embeded media
10 | this.embed = null;
11 |
12 | this.cssClass = () => {
13 | var css_class = 'OC-Message';
14 | if (args.message.author === args.message_room.orangechat.username()) {
15 | css_class += ' OC-Message--own';
16 | }
17 | if (!args.message.id) {
18 | css_class += ' OC-Message--pending';
19 | }
20 | if (args.message.is_highlight) {
21 | css_class += ' OC-Message--highlight';
22 | }
23 | if (args.message.type === 'action') {
24 | css_class += ' OC-Message--action';
25 | }
26 | return css_class;
27 | };
28 |
29 | this.openUserMenu = (event) => {
30 | event = $.event.fix(event);
31 | event.stopPropagation();
32 | args.message_room.openUserMenu(event, args.message.author, {
33 | source: args.message.source
34 | });
35 | };
36 |
37 | this.onMessageClick = (event) => {
38 | // Clicking the embed link/media button
39 | if(event.target.className === 'OC-Message__content--embed') {
40 | var el = event.target;
41 |
42 | // < IE11 does not support el.dataset, el.getAttribute works in all cases
43 | var url = el.getAttribute('data-url');
44 | var embed_type = el.getAttribute('data-embed-type');
45 | var embed = _.findWhere(Embed.all, {name: embed_type});
46 | if (embed) {
47 | this.embed = Helpers.subModule(embed, {url: url});
48 | }
49 | }
50 |
51 | // Clicking a channel link
52 | if(event.target.className === 'OC-Message__content--channel') {
53 | let el = event.target;
54 | let channel_name = el.getAttribute('data-channel');
55 | if (channel_name) {
56 | let channel = args.room_manager.createRoom(channel_name);
57 | args.room_manager.setActive(channel.instance.name());
58 | }
59 | }
60 | };
61 |
62 | this.closeEmbed = () => {
63 | this.embed = null;
64 | };
65 | };
66 |
67 | Message.view = function(controller, args, ext) {
68 | var view = null;
69 |
70 | if (ext.style === 'inline') {
71 | view = Message.viewInline(controller, args, ext);
72 | } else {
73 | view = Message.viewBlock(controller, args, ext);
74 | }
75 |
76 | return view;
77 | };
78 |
79 | Message.viewInline = function(controller, args, ext) {
80 | var error = null;
81 |
82 | if (args.message.error) {
83 | error = (
84 |
85 | {args.message.error}
86 | {(() => {
87 | // If were able to retry sending the message
88 | if (!args.message.retry) {
89 | return;
90 | }
91 |
92 | if (args.message.is_sending) {
93 | return (
[retrying...] );
94 | } else {
95 | return (
[retry] );
96 | }
97 | })()}
98 |
99 | );
100 | }
101 |
102 | return (
103 |
104 |
108 |
113 | {m.trust(args.message.display.author)}
114 |
115 | {m.trust(args.message.display.content)}
116 |
117 | {error}
118 | {Message.viewEmbed(controller, args, ext)}
119 |
120 | );
121 | };
122 |
123 | Message.viewBlock = function(controller, args, ext) {
124 | var error = null;
125 |
126 | if (args.message.error) {
127 | error = (
128 |
129 | {args.message.error}
130 | {(() => {
131 | // If were able to retry sending the message
132 | if (!args.message.retry) {
133 | return;
134 | }
135 |
136 | if (args.message.is_sending) {
137 | return (
[retrying...] );
138 | } else {
139 | return (
[retry] );
140 | }
141 | })()}
142 |
143 | );
144 | }
145 |
146 | return (
147 |
148 |
153 | {m.trust(args.message.display.author)}
154 |
155 |
156 | {m.trust(args.message.display.created)}
157 |
158 |
162 | {m.trust(args.message.display.content)}
163 |
164 | {error}
165 | {Message.viewEmbed(controller, args, ext)}
166 |
167 | );
168 | };
169 |
170 | Message.viewEmbed = function(controller, args, ext) {
171 | if (!controller.embed) {
172 | return;
173 | }
174 |
175 | return (
176 |
177 |
178 |
179 |
180 |
181 | {controller.embed.view()}
182 |
183 |
184 | );
185 | }
186 |
187 | export default Message;
188 |
--------------------------------------------------------------------------------
/src/Components/Message/Message.styl:
--------------------------------------------------------------------------------
1 | .{OC}-Message
2 | padding: (base-vs / 2) base-hs
3 | word-break: break-word
4 | overflow-wrap: break-word
5 | word-wrap: break-word
6 | position: relative
7 |
8 | &__content
9 | &--embed
10 | padding: 0 0.5em
11 | color: #006d9a
12 | cursor: pointer
13 | transform: scale(0.7, 1)
14 | display: inline-block
15 | font-weight: bold
16 |
17 | &--embed-wrapper
18 | overflow: hidden
19 | position: relative
20 | left: -(base-hs)
21 | margin-right: -(base-hs * 2)
22 | background: #fff
23 | border: 3px solid #ff5722
24 | border-width: 3px 0
25 | margin-top: base-vs
26 | padding: base-vs 0
27 |
28 | &--embed-content
29 | clear: both
30 |
31 | &--embed-loading
32 | display: block
33 | text-align: center
34 | text-decoration: none
35 | color: black-text
36 | cursor: default
37 |
38 | &--embed-close
39 | top: base-vs
40 | right: base-hs
41 | position: absolute
42 | z-index: 1
43 |
44 | &--highlight
45 | background-color: #FBE9E7
46 |
47 | &--action &__content
48 | font-style: italic
49 |
50 | &-author
51 |
52 | &:before
53 | content: '~ '
54 |
55 | &--pending
56 | .{OC}-Message__author, .{OC}-Message__content, .{OC}-Message__timestamp
57 | opacity: .4
58 |
59 | .{OC}-Message--inline
60 | .{OC}-Message
61 | &__timestamp
62 | color: black-hint
63 | position: absolute
64 | font-size: em(14px)
65 | left: base-hs
66 | top: base-vs / 2
67 |
68 | &__author
69 | font-weight: 500
70 | cursor: pointer
71 | font-size: em(14px)
72 | margin-right: 8px
73 |
74 | &:hover
75 | opacity: .7
76 |
77 | &__content
78 | font-size: em(14px)
79 |
80 | &--username
81 | font-style: italic
82 |
83 | &__error
84 | background: #DC7B7B
85 | color: #754141
86 | padding: 8px
87 | border: 1px solid #9C6464
88 |
89 | &-retry
90 | cursor: pointer
91 | margin-left: 1em
92 |
93 | .{OC}-Message--block
94 | .{OC}-Message
95 | border-bottom: 1px solid #eaeaea;
96 |
97 | &__timestamp
98 | color: black-hint
99 | font-size: em(12px)
100 | margin-left: 9px
101 | display: inline
102 | float: right
103 |
104 | &__content
105 | font-size: em(14px)
106 | display: block
107 |
108 | &--username
109 | font-style: italic
110 |
111 | &__author
112 | font-weight: 500
113 | cursor: pointer
114 | font-size: em(14px)
115 |
116 | &:hover
117 | opacity: .7
118 |
119 | &__error
120 | background: #DC7B7B
121 | color: #754141
122 | padding: 8px
123 | border: 1px solid #9C6464
124 |
125 | &-retry
126 | cursor: pointer
127 | margin-left: 1em
128 |
--------------------------------------------------------------------------------
/src/Components/Message/Model.js:
--------------------------------------------------------------------------------
1 | export default class MessageModel {
2 | constructor(message) {
3 | this.fromObj(message);
4 | }
5 |
6 | fromObj(obj) {
7 | this.id = obj.id;
8 | this.matchid = obj.matchid;
9 | this.author = obj.author;
10 | this.content = obj.content;
11 | this.channel = obj.channel;
12 | this.created = obj.created;
13 | this.source = obj.source;
14 | this.type = obj.type;
15 | this.is_highlight = obj.is_highlight;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Components/Message/Parser.js:
--------------------------------------------------------------------------------
1 | import strftime from 'strftime';
2 | import * as Helpers from '../../Helpers/Helpers.js';
3 | import Embed from './embed.js';
4 |
5 | /**
6 | * Parse messages into displayable formats. Parses URLs, embedded media, etc.
7 | */
8 | class MessageParser {
9 |
10 | constructor(filters = null, usernames = null) {
11 | // Word replacments in message content
12 | this.filters = Object.create(filters);
13 |
14 | // A mithril prop function returning an array of usernames
15 | this.usernames = usernames;
16 | }
17 |
18 | parseAll(message) {
19 | var display_obj = {
20 | author: this.parseAuthor(_.escape(message.author), message.source),
21 | content: message.content,
22 | created: message.created ? this.parseTimestamp(message.created) : '',
23 | created_short: message.created ? this.parseShortTimestamp(message.created) : ''
24 | };
25 |
26 | var words = display_obj.content.split(' ');
27 |
28 | // Go through each word and parse individually. If nothing is returned from a
29 | // parser function, continue to the next one.
30 | words = words.map((word) => {
31 | var parsed;
32 |
33 | parsed = this.parseUrls(word);
34 | if (typeof parsed === 'string') return parsed;
35 |
36 | parsed = this.parseFilters(word);
37 | if (typeof parsed === 'string') return parsed;
38 |
39 | parsed = this.parseRedditPhrases(word);
40 | if (typeof parsed === 'string') return parsed;
41 |
42 | if (typeof this.usernames === 'function') {
43 | parsed = this.parseUsernames(word);
44 | if (typeof parsed === 'string') return parsed;
45 | }
46 |
47 | return _.escape(word);
48 | });
49 |
50 | display_obj.content = words.join(' ');
51 |
52 | return display_obj;
53 | }
54 |
55 | parseUrls(word) {
56 | var found_a_url = false,
57 | parsed_url;
58 |
59 | parsed_url = word.replace(/^(([A-Za-z][A-Za-z0-9\-]*\:\/\/)|(www\.))([\w\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF.\-]+)([a-zA-Z]{2,6})(:[0-9]+)?(\/[\w\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF!:.?$'()[\]*,;~+=&%@!\-\/]*)?(#.*)?$/gi, function (url) {
60 | var nice = url,
61 | extra_html = '',
62 | embed_type;
63 |
64 | // Don't allow javascript execution
65 | if (url.match(/^javascript:/i)) {
66 | return url;
67 | }
68 |
69 | found_a_url = true;
70 |
71 | // Add the http if no protoocol was found
72 | if (url.match(/^www\./i)) {
73 | url = 'http://' + url;
74 | }
75 |
76 | // Shorten the displayed URL if it's going to be too long
77 | if (nice.length > 100) {
78 | nice = nice.substr(0, 100) + '...';
79 | }
80 |
81 | // Check if we can embed this URL
82 | embed_type = _.find(Embed.all, (embed) => {
83 | if (embed.match(url)) {
84 | return embed;
85 | }
86 | });
87 | if (embed_type) {
88 | extra_html += '> ';
89 | }
90 |
91 | // Make the link clickable
92 | return '' + _.escape(nice) + ' ' + extra_html;
93 | });
94 |
95 | return found_a_url ? parsed_url : false;
96 | }
97 |
98 | parseFilters(word) {
99 | if (this.filters[word.toLowerCase()]) {
100 | return this.filters[word.toLowerCase()];
101 | }
102 | }
103 |
104 | parseRedditPhrases(word) {
105 | var replacement_made = false;
106 | var ret = word;
107 | // Convert /r/sub into channel links
108 | ret = ret.replace(/(?:^|\s)(\/?(r\/[a-zA-Z0-9_]+))/, (match, group1, group2) => {
109 | replacement_made = true;
110 | return '' + group1 + ' ';
111 | });
112 |
113 | // Convert /u/user into reddit links
114 | ret = ret.replace(/(?:^|\s)(\/?(u\/[a-zA-Z0-9_\-]+))/, (match, group1, group2) => {
115 | replacement_made = true;
116 | return '' + group1 + ' ';
117 | });
118 |
119 | return replacement_made ?
120 | ret :
121 | false;
122 | }
123 |
124 | parseUsernames(word) {
125 | var usernames = this.usernames();
126 | if (!usernames && !usernames.length) {
127 | return;
128 | }
129 |
130 | var match = word.match(/^([a-z0-9_\-]+)([^a-z0-9_\-]+)?$/i);
131 | if (!match) {
132 | return;
133 | }
134 |
135 | // If this word isn't a recognised username, return
136 | if (usernames.indexOf(match[1].toLowerCase()) === -1) {
137 | return;
138 | }
139 |
140 | var colour = Helpers.nickColour(match[1]);
141 | var ret = '' + _.escape(match[1]) + ' ';
142 |
143 | // Add any trailing characters back on
144 | if (match[2]) {
145 | ret += _.escape(match[2]);
146 | }
147 |
148 | return ret;
149 | }
150 |
151 | parseTimestamp(timestamp) {
152 | return strftime('%H:%M:%S', new Date(timestamp));
153 | }
154 |
155 | parseShortTimestamp(timestamp) {
156 | return strftime('%H:%M', new Date(timestamp));
157 | }
158 |
159 | parseAuthor(author, source) {
160 | if(source == 'irc') {
161 | author += '*';
162 | }
163 |
164 | return author;
165 | }
166 |
167 | }
168 |
169 | export default MessageParser;
170 |
--------------------------------------------------------------------------------
/src/Components/Message/embed.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Multiple embedding sources can be used. Import each one and include it into
3 | * the exported array.
4 | */
5 |
6 | import embedly from './embed/embedly.jsx';
7 |
8 | var all = [
9 | embedly
10 | ];
11 |
12 | export default {all};
13 |
--------------------------------------------------------------------------------
/src/Components/Message/embed/embedly.jsx:
--------------------------------------------------------------------------------
1 | import Config from 'Config/Config';
2 |
3 | // Sets to true once the embedly script tag has been included to the page
4 | var embedly_script_included = false;
5 |
6 | export default {
7 | name: 'embedly',
8 |
9 | match: function(url) {
10 | // Embedly can embed any URL, so just match them all
11 | return true;
12 | },
13 |
14 | controller: function(args) {
15 | this.url = args.url;
16 | this.class_id = 'embed_' + Math.round(Math.random() * 1000000);
17 | this.embedly_key = Config.embedding.embedly.api_key || '';
18 |
19 |
20 | var checkEmbedlyAndShowCard = () => {
21 | // if the embedly function doesn't exist it's probably still loading the embedly script
22 | if (typeof window.embedly !== 'function') {
23 | setTimeout(checkEmbedlyAndShowCard, 100);
24 | return;
25 | }
26 |
27 | embedly('card', {selector: '.' + this.class_id});
28 | };
29 |
30 | if (embedly_script_included) {
31 | checkEmbedlyAndShowCard();
32 |
33 | } else {
34 | $('').appendTo($('body'));
35 | embedly_script_included = true;
36 | checkEmbedlyAndShowCard()
37 | }
38 | },
39 |
40 | view: function(controller) {
41 | return (
42 |
48 | Loading...
49 |
50 | );
51 | }
52 | };
53 |
--------------------------------------------------------------------------------
/src/Components/MessageRoom/AutoComplete.jsx:
--------------------------------------------------------------------------------
1 | var AutoComplete = {};
2 |
3 | AutoComplete.controller = function(args) {
4 | _.extend(this, Backbone.Events);
5 |
6 | this.state = {
7 | el: args.el,
8 | start_pos: args.start_pos || 0,
9 | current_word: '',
10 | selected_idx: 0,
11 | options: args.options || [],
12 | filtered: [],
13 |
14 | // Before the selection
15 | prefix: '',
16 |
17 | // After the selection
18 | suffix: ''
19 | };
20 |
21 | this.handleKeyDown = (event) => {
22 | var el = event.target;
23 |
24 | // If the cursor has moved out of range, stop autocompleting
25 | if (el.selectionStart < this.state.start_pos) {
26 | this.close();
27 | return;
28 | }
29 |
30 | if (event.which === 38 || (event.which === 9 && event.shiftKey)) {
31 | // up or tab+shift
32 | this.state.selected_idx--;
33 | if (this.state.selected_idx < 0) {
34 | this.state.selected_idx = this.state.filtered.length-1;
35 | }
36 | event.preventDefault();
37 | } else if (event.which === 40 || event.which === 9) {
38 | // down or tab
39 | this.state.selected_idx++;
40 | if (this.state.selected_idx > this.state.filtered.length-1) {
41 | this.state.selected_idx = 0;
42 | }
43 | event.preventDefault();
44 | } else if (event.which === 13) {
45 | // return
46 | this.selectOption(this.state.selected_idx);
47 | this.close();
48 | event.preventDefault();
49 | } else if (event.which === 27) {
50 | // esc
51 | this.close();
52 | event.preventDefault();
53 | } else if (event.which === 32) {
54 | // space
55 | this.close();
56 | }
57 | };
58 |
59 |
60 | this.handleKeyUp = (event) => {
61 | var el = event.target;
62 |
63 | // If the cursor has moved out of range, stop autocompleting
64 | if (el.selectionStart < this.state.start_pos) {
65 | this.close();
66 | return;
67 | }
68 |
69 | var current_word = el.value.substring(this.state.start_pos);
70 | current_word = current_word.match(/^[a-z0-9_\-]+/i) || [];
71 | this.state.current_word = current_word[0] || '';
72 | this.filterOptions();
73 |
74 | this.ensureItemInView();
75 | };
76 |
77 |
78 | this.close = () => {
79 | this.trigger('close');
80 | };
81 |
82 |
83 | this.selectOption = (item_idx) => {
84 | var insert = this.state.filtered[item_idx];
85 | var new_pos = this.state.start_pos + insert.length;
86 |
87 | this.state.el.value = this.state.prefix + insert + this.state.suffix;
88 | this.state.el.setSelectionRange(new_pos, new_pos);
89 | this.state.el.focus();
90 | };
91 |
92 |
93 | this.ensureItemInView = () => {
94 | var el = $('.OC-MessageRoom__autocomplete-item-idx' + this.state.selected_idx)[0];
95 | if (el) {
96 | el.scrollIntoView();
97 | }
98 | };
99 |
100 |
101 | this.filterOptions = () => {
102 | var filtered = _.filter(this.state.options, (option) => {
103 | return option.toLowerCase().indexOf(this.state.current_word.toLowerCase()) === 0;
104 | });
105 |
106 | this.state.filtered = filtered;
107 | };
108 |
109 |
110 | this.extractPrefixAndSuffix = () => {
111 | var idx = this.state.start_pos;
112 | var val = this.state.el.value;
113 | this.state.prefix = val.slice(0, idx);
114 | this.state.suffix = val.slice(idx);
115 | };
116 |
117 | this.extractPrefixAndSuffix();
118 | };
119 |
120 |
121 | AutoComplete.view = function(controller, args) {
122 | var state = controller.state;
123 | var current_word = state.current_word;
124 | var filtered = state.filtered;
125 | var items = [];
126 |
127 | if (filtered.length === 0) {
128 | items.push(
129 |
130 | No usernames found starting with {current_word}...
131 |
132 | );
133 |
134 | } else {
135 | _.map(filtered, (option, idx) => {
136 | var item_classes = 'OC-MessageRoom__autocomplete-item OC-MessageRoom__autocomplete-item-idx' + idx;
137 | if (idx === state.selected_idx) {
138 | item_classes += ' OC-MessageRoom__autocomplete-item--selected';
139 | }
140 |
141 | items.push(
142 |
149 | {option}
150 |
151 | );
152 | });
153 | }
154 |
155 | var list = ();
156 | return list;
157 | };
158 |
159 |
160 | export default AutoComplete;
161 |
--------------------------------------------------------------------------------
/src/Components/MessageRoom/MessageRoom.jsx:
--------------------------------------------------------------------------------
1 | import './MessageRoom.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 |
5 | import Reddit from '../../Services/Reddit.js';
6 | import Orangechat from '../../Services/Orangechat.js';
7 | import AutoComplete from './AutoComplete.jsx';
8 | import MessageModel from '../Message/Model.js';
9 | import Message from '../Message/Message.jsx';
10 | import MessageParser from '../Message/Parser.js';
11 | import Nicklist from '../Nicklist/Nicklist.jsx';
12 | import * as Notifications from '../../Services/Notifications.js';
13 |
14 | import Menus from '../Menus/Menus.jsx';
15 | import UserMenu from '../Menus/Menus/User.jsx';
16 | import ModerationMenu from '../Menus/Menus/Moderation.jsx';
17 |
18 | import Alert from './Views/Alert.jsx';
19 | import GroupSettings from './Views/GroupSettings.js';
20 | import ModSettings from './Views/ModSettings.jsx';
21 |
22 | var ACCESS_TYPE_BAN = 1;
23 | var ACCESS_TYPE_INVITE = 2;
24 | var ACCESS_TYPE_REDDIT_MOD = 3;
25 |
26 | /**
27 | * The message room (channel) UI.
28 | * Contains thread listing
29 | */
30 | var MessageRoom = {};
31 |
32 | MessageRoom.controller = function(args) {
33 | // Some consts..
34 | this.FLAG_ALERT_IRC = 'alert_irc';
35 |
36 | this.name = m.prop(args.name);
37 | this.label = m.prop(args.label);
38 | this.current_message = m.prop('');
39 | this.num_users = m.prop(0);
40 | this.known_usernames = m.prop([]);
41 | this.known_irc_usernames = m.prop([]);
42 | this.bus = args.bus;
43 | this.room_manager = args.room_manager;
44 | this.orangechat = Orangechat.instance();
45 | this.message_parser = new MessageParser(null, this.known_usernames);
46 | this.alerts = [];
47 |
48 | this.access = args.access || {
49 | is_reddit_mod: false,
50 | is_admin: false,
51 | is_invite: false
52 | };
53 |
54 | // Associated channels such as the mod only channel
55 | this.linked_channels = args.linked_channels || {};
56 |
57 | // Misc. flags that may be set
58 | this.flags = args.flags || {};
59 |
60 | this.is_active = false;
61 | this.messages = m.prop([
62 | //{author: 'prawnsalad', content: 'ello '+this.name()+'! This is my room. There are many like it, but this one is mine.'}
63 | ]);
64 |
65 | // Unread messages
66 | this.unread_counter = 0;
67 |
68 | // An unread message mentions us
69 | this.unread_highlight = false;
70 |
71 | // Timestamp of the last message we read
72 | this.read_upto = args.read_upto || 0;
73 |
74 | // Timestamp of the last message we have received
75 | this.received_upto = 0;
76 |
77 | // Keep track of the thread scroll position between renders
78 | this.thread_scrolltop = m.prop(0);
79 | this.thread_autoscroll = true;
80 |
81 | this.menu_container = Helpers.subModule(Menus, {
82 | bus: this.bus
83 | });
84 |
85 | this.nicklist = Helpers.subModule(Nicklist, {
86 | channel: this
87 | });
88 | this.is_nicklist_open = false;
89 |
90 | // Tabs in the top right. When hidden, a 'Back to chat' button is shown instead
91 | this.header_tabs_hidden = false;
92 |
93 | // A component instance that gets rendered instead of the message thread
94 | this.open_panel = null;
95 |
96 | this.transportSafeRoomName = () => {
97 | return this.name().toLowerCase().replace('/r/', 'reddit_sub_');
98 | };
99 |
100 | this.displayLabel = () => {
101 | return this.label() || this.name();
102 | };
103 |
104 | this.resetCounters = () => {
105 | this.unread_counter = 0;
106 | this.unread_highlight = false;
107 |
108 | var last_message = _.last(this.messages());
109 | if (last_message) {
110 | this.read_upto = last_message.data.created;
111 | }
112 | };
113 |
114 | // To keep storage space at a minimum, wrap handling of flags to make sure
115 | // they get deleted properly when needed.
116 | // Delete a flag: flag(FLAG_ALERT_IRC, null)
117 | // Set a flag: flag(FLAG_ALERT_IRC, true);
118 | // Get a flag: flag(FLAG_ALERT_IRC);
119 | this.flag = (flag_name, flag_val) => {
120 | if (typeof flag_val !== 'undefined') {
121 | if (flag_val === null) {
122 | delete this.flags[flag_name];
123 | } else {
124 | this.flags[flag_name] = flag_val;
125 | }
126 | } else {
127 | return this.flags[flag_name];
128 | }
129 | };
130 |
131 | // When we send a message we attach a random ID (match ID) that we can identify as
132 | // it comes back. This lets us add a message to the view before it gets sent for instant
133 | // user feedback and detect it as we see it come back to update our displayed
134 | // version of the message. Each channel gets a random prefix so that we can safely
135 | // use an incrementing counter easily.
136 | this.generateMessageMatchId = (function() {
137 | var next_id = 0;
138 | var prefix = Math.floor(Math.random() * 100000000000).toString(36);
139 | return function() {
140 | var id = next_id++;
141 | return prefix + next_id.toString(36);
142 | };
143 | })();
144 |
145 | this.bus.on('channel.active', (new_channel, old_channel) => {
146 | if (new_channel === this) {
147 | this.resetCounters();
148 | this.thread_autoscroll = true;
149 | }
150 | });
151 |
152 | this.bus.on('app.toggle', (is_open) => {
153 | if (is_open && this.is_active) {
154 | this.resetCounters();
155 | }
156 | });
157 |
158 | this.bus.on('im.message', (message_raw) => {
159 | if (message_raw.target !== this.transportSafeRoomName()) {
160 | return;
161 | }
162 |
163 | var messages = this.messages();
164 |
165 | var new_message = new MessageModel(message_raw);
166 | new_message.display = this.message_parser.parseAll(new_message);
167 |
168 | // Any messages we send will have a .matchid property only known to us. Check
169 | // if we have that matchid in our messages so we don't add it again
170 | var existing_message;
171 | if (new_message.matchid) {
172 | existing_message = _.find(messages, function(message) {
173 | return message.data.matchid === new_message.matchid;
174 | });
175 | }
176 |
177 | // This message may already exist if we sent it ourselves, so update it
178 | if (existing_message) {
179 | existing_message.data.fromObj(new_message);
180 | } else if (new_message.created > this.received_upto) {
181 | // Only add new messages if they don't appear from the past
182 | this.addMessage(new_message);
183 |
184 | if (this.known_usernames().indexOf(new_message.author.toLowerCase()) === -1) {
185 | this.known_usernames().push(new_message.author.toLowerCase());
186 | }
187 |
188 | // Temporary way to get a rough number of active IRC users to be added to
189 | // the user count for this channel
190 | if (new_message.source === 'irc') {
191 | let irc_usernames = this.known_irc_usernames();
192 | if (irc_usernames.indexOf(new_message.author.toLowerCase()) === -1) {
193 | irc_usernames.push(new_message.author.toLowerCase());
194 | }
195 | }
196 | }
197 |
198 | m.redraw();
199 |
200 | var our_username = this.orangechat.username();
201 | var is_our_message = our_username.toLowerCase() === new_message.author.toLowerCase();
202 | var has_focus = Helpers.isAppActive() && this.is_active;
203 |
204 | // We know the time of the last message we read was, so anything before it
205 | // is also considered as read
206 | if (!is_our_message && !has_focus && new_message.created > this.read_upto) {
207 | this.unread_counter++;
208 |
209 | if (new_message.content.toLowerCase().indexOf(our_username.toLowerCase()) > -1) {
210 | this.unread_highlight = true;
211 |
212 | // We only need the desktop notification if this page is not in focus + in standalone
213 | if (!Helpers.isAppActive() && !Helpers.isInReddit()) {
214 | this.notify('Somebody mentioned you!', new_message.author + ': ' + new_message.content);
215 | }
216 | }
217 | }
218 |
219 | // Only move our read_upto position forward if this channel is active.
220 | // Never move it backwards... that just makes no sense.
221 | if (has_focus && new_message.created > this.read_upto) {
222 | this.read_upto = new_message.created;
223 | }
224 |
225 | this.received_upto = _.last(messages).data.created;
226 | });
227 |
228 | this.bus.on('im.meta', (groups) => {
229 | var this_name = this.transportSafeRoomName().toLowerCase();
230 | if (!groups[this_name]) {
231 | return;
232 | }
233 |
234 | this.num_users(groups[this_name].num_users);
235 | });
236 |
237 | this.bus.on('message.chan_access', (message) => {
238 | if (message.target !== this.transportSafeRoomName()) {
239 | return;
240 | }
241 | if (message.access === ACCESS_TYPE_REDDIT_MOD) {
242 | this.access.is_reddit_mod = true;
243 | this.linked_channels.reddit_mod = message.mod_channel;
244 | }
245 | if (message.access === ACCESS_TYPE_INVITE) {
246 | this.access.is_invite = true;
247 | }
248 | });
249 |
250 | this.bus.on('message.channel.meta', (message) => {
251 | var do_redraw = false;
252 | var alert = null;
253 |
254 | if (message.target !== this.transportSafeRoomName()) {
255 | return;
256 | }
257 |
258 | if (message.label) {
259 | this.label(message.label);
260 | do_redraw = true;
261 | }
262 | if (message.parent) {
263 | this.linked_channels.parent = message.parent;
264 | do_redraw = true;
265 | }
266 | if (message.irc) {
267 | this.linked_channels.irc = message.irc;
268 |
269 | if (!this.flag(this.FLAG_ALERT_IRC)) {
270 | alert = Helpers.subModule(Alert, {
271 | channel: this,
272 | irc_channel: message.irc
273 | });
274 | alert.instance.destroyIn(7000);
275 | this.alerts.push(alert);
276 | }
277 | }
278 |
279 | if (do_redraw) {
280 | m.redraw();
281 | }
282 | });
283 |
284 | // If we get banned
285 | this.bus.on('message.channel.ban', (event) => {
286 | if (event.channel_name !== this.transportSafeRoomName()) {
287 | return;
288 | }
289 |
290 | var message = new MessageModel({
291 | author: '*',
292 | content: 'You have been banned from this channel! There will be no more channel updates here.'
293 | });
294 |
295 | message.display = this.message_parser.parseAll(message);
296 | this.addMessage(message);
297 | m.redraw();
298 | });
299 |
300 | this.notify = (title, body) => {
301 | var notification = Notifications.notify(title, body);
302 | if (!notification) {
303 | return;
304 | }
305 |
306 | notification.onclick = () => {
307 | // Some older browsers use .cancel instead of .close
308 | var closeFn = (notification.close || notification.cancel || function(){});
309 | closeFn.call(notification);
310 |
311 | this.room_manager.setActive(this.name());
312 | window.focus();
313 | };
314 | }
315 | this.onFormSubmit = (event) => {
316 | event = $.event.fix(event);
317 | event.preventDefault();
318 |
319 | var message_type;
320 | var message = this.current_message();
321 | if (!message) {
322 | return;
323 | }
324 |
325 | // Lets keep some IRC users happy... support /me
326 | if (message.toLowerCase().indexOf('/me ') === 0) {
327 | message_type = 'action';
328 | message = message.substring(4);
329 | }
330 |
331 | this.sendMessage(message, message_type);
332 | this.current_message('');
333 |
334 | // We only use one way binding for the input box so we need to update the input
335 | // manually. Two way binding causes issues with vdom diffing
336 | $(event.currentTarget).find('input[type="text"]').val('');
337 |
338 | m.redraw();
339 | };
340 |
341 | this.sendMessage = (message_content, message_type) => {
342 | var message = new MessageModel({
343 | author: this.orangechat.username(),
344 | content: message_content,
345 | matchid: this.generateMessageMatchId(),
346 | created: (new Date()).getTime(),
347 | type: message_type
348 | });
349 |
350 | message.display = this.message_parser.parseAll(message);
351 | this.addMessage(message);
352 |
353 | var attemptSend = () => {
354 | message.is_sending = true;
355 |
356 | return m.request({
357 | background: true,
358 | method: 'POST',
359 | url: this.orangechat.apiUrl('/send'),
360 | data: {
361 | target: this.transportSafeRoomName(),
362 | content: message_content,
363 | matchid: message.matchid,
364 | type: message_type
365 | }
366 | }).then(function(api_response) {
367 | message.is_sending = false;
368 |
369 | if (api_response.status !== 'ok') {
370 | var err_map = {
371 | 'requires_auth': 'You must be logged in to send a message here',
372 | 'not_allowed': 'You do not have permission to send messages here',
373 | 'blocked': 'This message was blocked from being sent'
374 | };
375 |
376 | var blocked_reasons = {
377 | flood: 'Message has not been sent - slow down!',
378 | repeat: 'Message has not been sent - repeating yourself?',
379 | similar: 'Message has not been sent - repeating yourself?'
380 | };
381 |
382 | var err_reason;
383 | if (api_response.error === 'blocked') {
384 | err_reason = blocked_reasons[api_response.reason];
385 | } else {
386 | err_reason = err_map[api_response.error];
387 | }
388 |
389 | message.error = err_reason || 'There was an error sending this message :(';
390 | message.retry = attemptSend;
391 |
392 | } else {
393 | // Message sent just fine, remove any errors if they existed
394 | delete message.error;
395 | delete message.retry;
396 | }
397 |
398 | m.redraw();
399 | }).catch(function(err) {
400 | message.is_sending = false;
401 |
402 | // TODO: Message failed to send so add a .error property to the
403 | // message so that the view can show it didn't send.
404 | message.error = 'There was an error sending this message :(';
405 | message.retry = attemptSend;
406 |
407 | m.redraw();
408 | });
409 | };
410 |
411 | return attemptSend();
412 | };
413 |
414 | this.openChat = () => {
415 | this.open_panel = null;
416 | };
417 |
418 | this.openGroupSettings = () => {
419 | this.open_panel = Helpers.subModule(GroupSettings, {
420 | bus: this.bus,
421 | room: this,
422 | room_manager: this.room_manager
423 | });
424 | };
425 |
426 | this.openUserMenu = (event, username, opts) => {
427 | this.bus.trigger('panel.open', event, Helpers.subModule(UserMenu, {
428 | bus: this.bus,
429 | username: username,
430 | source: opts.source,
431 | room: this,
432 | room_manager: this.room_manager
433 | }));
434 | };
435 |
436 | this.openModerationMenu = (event) => {
437 | event = $.event.fix(event);
438 | event.stopPropagation();
439 |
440 | this.bus.trigger('panel.open', event, Helpers.subModule(ModerationMenu, {
441 | bus: this.bus,
442 | room: this,
443 | room_manager: this.room_manager
444 | }));
445 | };
446 |
447 | // this.openGroupPanel = () => {
448 | // this.bus.trigger('panel.open', Helpers.subModule(GroupPanel, {
449 | // bus: this.bus,
450 | // room: this,
451 | // room_manager: this.room_manager
452 | // }));
453 | // };
454 |
455 | // this.bus.on('panel.opened', () => {
456 | // this.isPanelOpen = true;
457 | // });
458 |
459 | // this.bus.on('panel.closed', () => {
460 | // this.isPanelOpen = false;
461 | // });
462 |
463 | this.close = () => {
464 | this.room_manager.closeRoom(this.name());
465 | };
466 |
467 | this.toggleApp = () => {
468 | this.bus.trigger('action.toggle_app');
469 | };
470 |
471 | this.addMessage = (message_model) => {
472 | var max_messages = 200;
473 |
474 | var component = Helpers.subModule(Message, {
475 | message: message_model,
476 | message_room: this,
477 | room_manager: this.room_manager
478 | });
479 |
480 | var message = {
481 | data: message_model,
482 | view: component.view
483 | };
484 |
485 | var messages = this.messages();
486 | messages.push(message);
487 |
488 | // Keep our thread pruned to a suitable number so not to balloon memory usage
489 | if (messages.length > max_messages) {
490 | this.messages(messages.slice(messages.length - max_messages));
491 | }
492 |
493 | return message;
494 | };
495 |
496 | this.toggleSidebar = () => {
497 | this.bus.trigger('action.toggle_sidebar');
498 | };
499 |
500 | this.is_options_overlay_open = false;
501 |
502 | this.openOptionsOverlay = (event) => {
503 | event = $.event.fix(event);
504 | event.stopPropagation();
505 |
506 | this.is_options_overlay_open = true;
507 |
508 | args.bus.once('action.document_click', () => {
509 | this.closeOptionsOverlay();
510 | });
511 | };
512 |
513 | this.closeOptionsOverlay = () => {
514 | this.is_options_overlay_open = false;
515 | };
516 |
517 | this.openModSettings = (event) => {
518 | this.open_panel = Helpers.subModule(ModSettings, {
519 | bus: this.bus,
520 | room: this,
521 | room_manager: this.room_manager
522 | });
523 | };
524 |
525 | this.openNicklist = () => {
526 | this.is_nicklist_open = true;
527 | m.redraw();
528 | };
529 |
530 | this.closeNicklist = () => {
531 | this.is_nicklist_open = false;
532 | m.redraw();
533 | };
534 |
535 | this.toggleNicklist = () => {
536 | this.is_nicklist_open ?
537 | this.closeNicklist() :
538 | this.openNicklist();
539 | };
540 | };
541 |
542 | MessageRoom.view = function(controller) {
543 | var room_content = [];
544 |
545 | room_content.push(controller.menu_container.view());
546 |
547 | if(!controller.open_panel) {
548 | room_content.push(MessageRoom.viewChat(controller));
549 | } else {
550 | room_content.push(controller.open_panel.view());
551 | }
552 |
553 | return (
554 |
555 | {MessageRoom.viewHeader(controller)}
556 | {MessageRoom.viewAlerts(controller)}
557 | {room_content}
558 |
559 | );
560 | };
561 |
562 | MessageRoom.viewAlerts = function(controller) {
563 | return _.map(controller.alerts, (alert) => {
564 | return alert.view();
565 | });
566 | };
567 |
568 | MessageRoom.viewChat = function(controller) {
569 | // TODO: Only render the last X messages
570 | var thread_style = Helpers.isInReddit() ? 'inline' : 'block';
571 | var thread_items = [];
572 | var last_message = null;
573 | var mins_20 = 60 * 20 * 1000;
574 |
575 | _.each(controller.messages(), function(message, idx, list) {
576 | if (thread_style === 'inline') {
577 | // Show the message time if it's the first or there was a gap of 20mins
578 | // since the last message
579 | if (!last_message || message.data.created - last_message.data.created > mins_20) {
580 | thread_items.push(
581 |
582 | {m.trust(message.data.display.created_short)}
583 |
584 | );
585 | }
586 | }
587 |
588 | thread_items.push(message.view({
589 | style: thread_style,
590 | thread: list,
591 | message_idx: idx
592 | }));
593 |
594 | last_message = message;
595 | });
596 |
597 | if (thread_items.length === 0) {
598 | thread_items.push(No messages here recently... :(
);
599 | }
600 |
601 | return [
602 | this.scrollHeight - 20) {
609 | controller.thread_autoscroll = true;
610 | } else {
611 | controller.thread_autoscroll = false;
612 | }
613 |
614 | controller.thread_scrolltop(this.scrollTop);
615 |
616 | // We don't need to redraw on every scroll event
617 | m.redraw.strategy('none');
618 | }}
619 | config={function(el, already_initialised) {
620 | if (controller.thread_autoscroll) {
621 | el.scrollTop = el.scrollHeight;
622 | }
623 |
624 | // Only set the scroll position when the element is first being initialised/created
625 | if (!already_initialised && !controller.thread_autoscroll) {
626 | el.scrollTop = controller.thread_scrolltop();
627 | }
628 | }}
629 | >
630 | {thread_items}
631 | ,
632 |
633 | controller.is_nicklist_open ? controller.nicklist.view() : null,
634 |
635 | controller.auto_complete ? controller.auto_complete.view() : null,
636 |
637 |
685 | ];
686 | };
687 |
688 | MessageRoom.viewHeader = function(controller) {
689 | var tabs = [];
690 | var title = '';
691 | var parent_channel;
692 |
693 | if (controller.access.is_invite) {
694 | tabs.push(
695 |
703 | );
704 | }
705 |
706 | if(controller.access.is_reddit_mod) {
707 | tabs.push(
708 |
713 | );
714 | }
715 |
716 | tabs.push(
717 |
725 | );
726 |
727 | title = controller.displayLabel();
728 | if (controller.linked_channels.parent) {
729 | parent_channel = controller.room_manager.getRoom(controller.linked_channels.parent.name);
730 | if (parent_channel) {
731 | title = parent_channel.instance.displayLabel() + ' - ' + title;
732 | }
733 | }
734 |
735 | return (
736 |
756 | );
757 | };
758 |
759 | export default MessageRoom;
760 |
--------------------------------------------------------------------------------
/src/Components/MessageRoom/MessageRoom.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-MessageRoom
2 | position: absolute
3 | top: 0
4 | left: 0
5 | height: 100%
6 | width: 100%
7 |
8 | &__header
9 | position: absolute
10 | top: 0
11 | right: 0
12 | left: 0
13 | height: topbar-height
14 | background-color: #FFFFFF
15 | overflow: hidden
16 | z-index: 80
17 |
18 | &-collapse
19 | position: absolute
20 | left: base-hs
21 | top: 50%
22 | margin-top: -12px
23 | cursor: pointer
24 | transition: left 175ms ease
25 |
26 | svg
27 | fill: black-secondary
28 | display: inline-block
29 | vertical-align: middle
30 |
31 | &-info
32 | position: absolute
33 | left: base-hs + 40px
34 | top: 50%
35 | margin-top: -7px
36 | transition: left 175ms ease
37 | cursor: pointer
38 |
39 | h4
40 | font-size: em(15px)
41 | line-height: 1
42 | display: block
43 | font-weight: 500
44 |
45 | &-tabs
46 | position: relative
47 | top: 0
48 | transition: top 0.2s
49 | text-align: right
50 |
51 | &-item
52 | display: inline-block
53 | cursor: pointer
54 | margin-right: base-hs
55 | height: 24px
56 | overflow: hidden
57 |
58 | svg
59 | fill: black-hint
60 | display: inline-block
61 | vertical-align: middle
62 |
63 | &--active svg
64 | fill: black-secondary
65 |
66 | &-tabs-wrap
67 | position: absolute
68 | top: 50%
69 | margin-top: -12px
70 | right: 0
71 | height: 24px
72 | overflow: hidden
73 |
74 | &-tabs-wrap-subrow
75 | > ul
76 | top: -28px
77 |
78 | &__no-messages
79 | padding: base-vs base-hs
80 | font-weight: 500
81 | color: black-hint
82 |
83 | &__group-settings
84 | position: absolute
85 | padding: base-vs base-hs
86 | top: topbar-height
87 | bottom: 0
88 | left: 0
89 | right: 0
90 | overflow-y: auto
91 |
92 | &-section
93 | margin-bottom: base-vs
94 | position: relative
95 |
96 | label
97 | position: absolute
98 | top: 0
99 | left: 0
100 | color: black-hint
101 | font-size: em(12px)
102 | transition: color 175ms ease
103 |
104 | button
105 | border: 0
106 | background-color: transparent
107 | display: block
108 | position: absolute
109 | right: 0
110 | bottom: 4px
111 |
112 | svg
113 | fill: black-hint
114 | display: block
115 |
116 | input
117 | border: 0
118 | border-bottom: 1px solid black-dividers
119 | padding: round(2.5 * base-vs) 0 base-vs 0
120 | font-size: em(14px)
121 | display: block
122 | width: 100%
123 | margin: 0 0 base-vs 0
124 | transition: border-color 175ms ease
125 |
126 | &:focus
127 | outline: 0
128 | border-color: brand-primary
129 |
130 | &:focus ~ label
131 | color: brand-primary
132 |
133 | &:focus ~ button svg
134 | fill: brand-primary
135 |
136 | &::placeholder
137 | color: black-secondary
138 |
139 | &__mod-settings
140 | &-header
141 | padding: base-vs base-hs
142 | margin-bottom: base-vs
143 | border-bottom: 1px solid black-dividers;
144 |
145 | &-section
146 | margin-bottom: round(base-vs * 4)
147 |
148 | &-section-header
149 | font-weight: bold
150 | font-size: 1.1em
151 | margin-bottom: 5px
152 |
153 | &-status-active
154 | border-left: 5px solid #4EB34E
155 | padding-left: 10px
156 | margin-top: base-vs
157 |
158 | &-status-inactive
159 | border-left: 5px solid #B34E4E
160 | padding-left: 10px
161 | margin-top: base-vs
162 |
163 | &-userlist
164 | margin: base-vs base-hs
165 |
166 | td
167 | padding: 3px 10px
168 |
169 | input, button
170 | padding: 3px 5px
171 |
172 | &__thread
173 | position: absolute
174 | left: 0
175 | right: 0
176 | bottom: 45px /* height of the __footer */
177 | top: topbar-height
178 | overflow-y: auto
179 |
180 | &--time-separator
181 | text-align: center
182 | font-size: 0.8em
183 | font-weight: bold
184 | font-style: italic
185 | margin: 6px
186 | color: black-secondary
187 |
188 | &__footer
189 | background-color: alpha(#000000, 6%)
190 | border-top: 1px solid black-dividers
191 | position: absolute
192 | left: 0
193 | right: 0
194 | bottom: 0
195 | height: 45px
196 |
197 | button[type="submit"]
198 | border: none !important
199 | background: none !important
200 | display: block
201 | position: absolute
202 | right: base-hs * 2
203 | top: 50%
204 | margin: 0
205 | margin-top: -12px
206 | padding: 0 !important
207 | box-shadow: none
208 | height: auto
209 | min-width: inherit
210 |
211 | :hover
212 | background: none !important
213 |
214 | svg
215 | fill: black-hint
216 | display: block
217 |
218 | input
219 | position: absolute
220 | top: 50%
221 | margin-top: -17px
222 | left: (base-hs / 2)
223 | width: "calc(100% - 2 * %s)" % (base-hs / 2)
224 | border: 0
225 | border: 2px solid black-dividers
226 | padding: 0 (24px + 2 * base-hs) 0 base-hs
227 | height: 34px
228 | font-size: em(14px)
229 | display: block
230 | border-radius: 3px
231 |
232 | &:focus
233 | outline: 0
234 |
235 | &:focus ~ button svg
236 | fill: brand-primary
237 |
238 | &::placeholder
239 | color: black-hint
240 |
241 | &__autocomplete
242 | position: absolute
243 | background: #F0F0F0
244 | color: black-text
245 | border: 1px solid #D1D1D1
246 | border-bottom-width: 0
247 | bottom: 45px /* height of the __footer */
248 | left: base-hs
249 | width: 40%
250 | padding: 0;
251 | overflow-y: auto;
252 | max-height: 50%;
253 |
254 | &-message
255 | padding: 6px 16px
256 |
257 | &-item
258 | padding: 6px 16px
259 | cursor: pointer
260 |
261 | &--selected, &:hover
262 | background: #FF5722
263 | color: white-text
264 |
265 | &__alert
266 | position: absolute
267 | top: topbar-height
268 | right: 20px
269 | z-index: 1
270 | background: #fff
271 | padding: base-vs
272 | width: auto;
273 | max-width: 70%
274 | overflow: hidden
275 | transition: 1s
276 | box-shadow: 0 2px 4px 0 rgba(0,0,0,0.2)
277 |
278 | &--header
279 | font-weight: bold
280 | cursor: pointer
281 |
282 | &--body
283 | display: none
284 |
285 | &--footer
286 | display: none
287 |
288 | &--close
289 | display: inline-block
290 | margin-top: base-vs
291 |
292 | &__alert--destroy
293 | height: 0
294 | padding: 0 base-vs
295 |
296 | &__alert--open
297 | width: 300px;
298 |
299 | .{prefix}-MessageRoom__alert--body,
300 | .{prefix}-MessageRoom__alert--footer
301 | display: block
302 |
303 |
--------------------------------------------------------------------------------
/src/Components/MessageRoom/Views/Alert.jsx:
--------------------------------------------------------------------------------
1 | var Alert = {};
2 |
3 | Alert.controller = function(args) {
4 | this.is_open = false;
5 | this.is_destroyed = false;
6 | this.destroying_in_progress = false;
7 |
8 | this.ttl = 0;
9 | this.destroy_tmr = null;
10 |
11 | this.toggle = () => {
12 | this.is_open = !this.is_open;
13 |
14 | // Remove any destroy timers so it doesn't close while we're reading it
15 | this.killDestroyTimer();
16 | };
17 |
18 | this.killDestroyTimer = () => {
19 | clearTimeout(this.destroy_tmr);
20 | this.destroy_tmr = null;
21 | };
22 |
23 | this.destroy = () => {
24 | // Flag that this alert has been shown
25 | args.channel.flag(args.channel.FLAG_ALERT_IRC, true);
26 |
27 | this.killDestroyTimer();
28 |
29 | // Give some time for the CSS transitions to complete
30 | setTimeout(() => {
31 | this.is_destroyed = true;
32 | }, 1000);
33 |
34 | this.destroying_in_progress = true;
35 | m.redraw();
36 | };
37 |
38 | // Timeout before this alert automatically destroys itself
39 | this.destroyIn = (ttl) => {
40 | this.ttl = ttl;
41 | };
42 |
43 | this.domCreated = (el, isInit, context) => {
44 | if (isInit) return;
45 |
46 | if (this.ttl && !this.destroy_tmr) {
47 | this.destroy_tmr = setTimeout(this.destroy, this.ttl);
48 | }
49 |
50 | // When the alert is removed off-screen, stop the destroy timer so that
51 | // it doesn't get detroyed while not on-screen
52 | context.onunload = () => {
53 | // Remove any destroy timers so it doesn't destroy while the alert is not in view
54 | this.killDestroyTimer();
55 | };
56 | };
57 | };
58 |
59 | Alert.view = function(controller, args) {
60 | if (controller.is_destroyed) {
61 | return;
62 | }
63 |
64 | var css_class = 'OC-MessageRoom__alert ';
65 | if (controller.is_open) {
66 | css_class += 'OC-MessageRoom__alert--open ';
67 | }
68 | if (controller.destroying_in_progress) {
69 | css_class += 'OC-MessageRoom__alert--destroy';
70 | }
71 |
72 | return (
73 |
82 | );
83 | };
84 |
85 |
86 | export default Alert;
--------------------------------------------------------------------------------
/src/Components/MessageRoom/Views/GroupSettings.js:
--------------------------------------------------------------------------------
1 | import Orangechat from '../../../Services/Orangechat.js';
2 |
3 | var GroupSettings = {};
4 |
5 | GroupSettings.controller = function(args) {
6 | this.room = args.room;
7 | this.room_manager = args.room_manager;
8 | this.orangechat = Orangechat.instance();
9 |
10 | this.target_username = m.prop('');
11 | this.channel_label = m.prop(this.room.label());
12 |
13 | this.title = m.prop('group options');
14 |
15 | this.sendInvite = e => {
16 | var event = $.event.fix(e);
17 | event.preventDefault();
18 |
19 | var usernames = this.target_username().split(/[ ,]/);
20 | usernames = _.compact(usernames).join(',');
21 |
22 | this.orangechat.inviteToChannel(this.room.name(), usernames).then((resp) => {
23 | if (resp.status == 'ok') {
24 | args.bus.trigger('panel.close');
25 | return;
26 | }
27 |
28 | console.log('sendInvite() Something went wrong...', resp);
29 | });
30 | };
31 |
32 | this.saveName = e => {
33 | var event = $.event.fix(e);
34 | event.preventDefault();
35 |
36 | var new_name = this.channel_label();
37 | if (!new_name) {
38 | // TODO: Show an error or highlight the name field in red or something
39 | return;
40 | }
41 |
42 | var updates = {
43 | label: new_name
44 | };
45 |
46 | this.orangechat.updateChannel(this.room.name(), updates).then((resp) => {
47 | if (resp.status == 'ok') {
48 | args.bus.trigger('messageroom.renamed', this.room);
49 | return;
50 | }
51 |
52 | console.log('updateChannel() Something went wrong...', resp);
53 | });
54 | };
55 | };
56 |
57 | GroupSettings.view = function(controller) {
58 | return m('div', {class: 'OC-MessageRoom__group-settings'}, [
59 |
60 | m('form', {class: 'OC-MessageRoom__group-settings-section', onsubmit: controller.sendInvite}, [
61 | m('input', {
62 | placeholder: 'type any username',
63 | id: 'groupinvite',
64 | onkeyup: m.withAttr('value', controller.target_username)
65 | }),
66 | m('button[type="submit"]', [
67 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="24"][height="24"][viewBox="0 0 24 24"]', [
68 | m('path[d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"]')
69 | ])
70 | ]),
71 | m('label[for="groupinvite"]', 'Invite someone to this channel')
72 | ]),
73 |
74 | m('form', {class: 'OC-MessageRoom__group-settings-section', onsubmit: controller.saveName}, [
75 | m('input', {
76 | placeholder: 'think of a cool name',
77 | id: 'groupname',
78 | onkeyup: m.withAttr('value', controller.channel_label),
79 | //config: GroupSettings.viewConfigChannelname
80 | value: controller.channel_label()
81 | }),
82 | m('button[type="submit"]', [
83 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="24"][height="24"][viewBox="0 0 24 24"]', [
84 | m('path[d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"]')
85 | ])
86 | ]),
87 | m('label[for="groupname"]', 'Change the group name')
88 | ])
89 | ]);
90 | };
91 |
92 | GroupSettings.viewConfigChannelname = function(el, already_init, ctx, vdom) {
93 | if (!already_init) {
94 | el._created = (new Date()).toString();
95 | //vdom.attrs.value = ctx.channel_label
96 | };
97 | };
98 |
99 | export default GroupSettings;
100 |
--------------------------------------------------------------------------------
/src/Components/MessageRoom/Views/ModSettings.jsx:
--------------------------------------------------------------------------------
1 | import strftime from 'strftime';
2 | import Orangechat from '../../../Services/Orangechat.js';
3 |
4 | var ModSettings = {};
5 |
6 | ModSettings.controller = function(args) {
7 | this.room = args.room;
8 | this.room_manager = args.room_manager;
9 | this.orangechat = Orangechat.instance();
10 |
11 | this.banlist_status = 'loading';
12 | this.banlist = m.prop([]);
13 |
14 | this.close = () => {
15 | this.room.openChat();
16 | };
17 |
18 | this.refreshBanlist = () => {
19 | this.banlist_status = 'loading';
20 | this.orangechat.getBanlist(this.room.name())
21 | .then((response) => {
22 | var users = [];
23 | _.each(response.banlist, (user) => {
24 | users.push({
25 | status: 'banned',
26 | user: user
27 | });
28 | });
29 |
30 | this.banlist(users);
31 | this.banlist_status = 'loaded';
32 |
33 | m.redraw();
34 | });
35 | };
36 |
37 | this.unbanUser = (username) => {
38 | var ban = _.find(this.banlist(), (ban) => {
39 | return ban.user.username === username;
40 | });
41 |
42 | if (!ban) {
43 | return;
44 | }
45 |
46 | ban.status = 'unbanning';
47 |
48 | this.orangechat.unbanFromChannel(this.room.name(), ban.user.username)
49 | .then((response) => {
50 | if (response.status === 'ok') {
51 | ban.status = 'unbanned';
52 | } else {
53 | ban.status = 'banned';
54 | }
55 |
56 | m.redraw();
57 | });
58 | };
59 |
60 | this.banUser = (username) => {
61 | var ban = _.find(this.banlist(), (ban) => {
62 | return ban.user.username === username;
63 | });
64 |
65 | if (!ban) {
66 | return;
67 | }
68 |
69 | ban.status = 'banning';
70 |
71 | this.orangechat.banFromChannel(this.room.name(), ban.user.username)
72 | .then((response) => {
73 | if (response.status === 'ok') {
74 | ban.status = 'banned';
75 | } else {
76 | ban.status = 'unbanned';
77 | }
78 |
79 | m.redraw();
80 | });
81 | };
82 |
83 | this.refreshBanlist();
84 | };
85 |
86 |
87 |
88 | ModSettings.view = function(controller) {
89 | return (
90 |
91 |
94 | {ModSettings.viewFloodControl(controller)}
95 | {ModSettings.viewIrcLink(controller)}
96 | {ModSettings.viewBanlist(controller)}
97 |
98 | );
99 | };
100 |
101 | ModSettings.viewBanlist = function(controller) {
102 | var list = [];
103 | var status_map = {
104 | 'unbanning': 'Unbanning...',
105 | 'unbanned': 'Ban removed',
106 | 'banning': 'Banning...',
107 | 'banned': 'Banned'
108 | };
109 |
110 | if (controller.banlist_status === 'loaded' && controller.banlist().length > 0) {
111 | _.each(controller.banlist(), (ban) => {
112 | if (ban.status === 'unbanned') {
113 | list.push(
114 |
115 | {ban.user.username}
116 |
117 | Ban removed.
118 | Undo
119 |
120 |
121 | );
122 |
123 | } else if (ban.status === 'banned') {
124 | list.push(
125 |
126 | {ban.user.username}
127 | {strftime('%B %o, %k:%M', new Date(ban.user.created))}
128 | remove
129 |
130 | );
131 |
132 | } else {
133 | list.push(
134 |
135 | {ban.user.username}
136 | {status_map[ban.status] || ban.status || ''}
137 |
138 | );
139 |
140 | }
141 | });
142 |
143 | } else if(controller.banlist_status === 'loaded' && controller.banlist().length === 0) {
144 | list.push(
145 |
146 | No current bans here
147 |
148 | );
149 | } else {
150 | list.push(
151 |
152 | Loading...
153 |
154 | );
155 | }
156 |
157 | return (
158 |
159 |
160 |
Users banned from this channel.
161 |
164 |
165 | );
166 | };
167 |
168 | ModSettings.viewOperators = function(controller) {
169 | return (
170 |
171 |
172 |
Promote non-moderators to help operate the orangechat channel.
173 |
191 |
192 | );
193 | };
194 |
195 | ModSettings.viewIrcLink = function(controller) {
196 | var irc_channel = controller.room.linked_channels.irc;
197 | var status = irc_channel ?
198 | (Active - {irc_channel}
) :
199 | (Disabled
);
200 |
201 | return (
202 |
203 |
204 |
Link this channel to an IRC channel on irc.snoonet.org.
205 |
206 | Messages will be synced between both channels
207 | orangechat users will appear on IRC when they start talking
208 | Anybody logged into orangechat and has access to the subreddit will see the IRC channel messages
209 |
210 | {status}
211 |
212 | );
213 | };
214 |
215 | ModSettings.viewFloodControl = function(controller) {
216 | return (
217 |
218 |
219 |
Prevents people from flooding a channel with messages or repeating the same message.
220 |
Active
221 |
222 | );
223 | };
224 |
225 |
226 | export default ModSettings;
227 |
--------------------------------------------------------------------------------
/src/Components/Nicklist/Nicklist.jsx:
--------------------------------------------------------------------------------
1 | import './Nicklist.styl';
2 |
3 | import Orangechat from '../../Services/Orangechat.js';
4 | import * as Helpers from '../../Helpers/Helpers.js';
5 |
6 | var Nicklist = {};
7 |
8 | Nicklist.controller = function(args) {
9 | this.orangechat = Orangechat.instance();
10 |
11 | this.has_loaded = false;
12 | this.users = m.prop([]);
13 |
14 | this.last_refreshed = null;
15 | this.refreshList = () => {
16 | // Only refresh the nicklist once every 10 seconds
17 | if (this.last_refreshed && (new Date()).getTime() - this.last_refreshed < 10000) {
18 | return;
19 | }
20 |
21 | this.last_refreshed = (new Date()).getTime();
22 |
23 | this.orangechat.channelUserlist(args.channel.transportSafeRoomName())
24 | .then((resp) => {
25 | var new_list = [];
26 | _.each(resp.userlist, (user) => {
27 | new_list.push({
28 | name: user.name,
29 | source: user.source
30 | });
31 | });
32 |
33 | var ordered_list = new_list.sort(function compare(a, b) {
34 | if (a.name.toLowerCase() < b.name.toLowerCase()) {
35 | return -1;
36 | }
37 | if (a.name.toLowerCase() > b.name.toLowerCase()) {
38 | return 1;
39 | }
40 |
41 | return 0;
42 | });
43 |
44 | this.users(ordered_list);
45 | this.has_loaded = true;
46 |
47 | m.redraw();
48 | });
49 | };
50 |
51 | this.nickClick = (mouse_event, user) => {
52 | args.channel.openUserMenu(mouse_event, user.name.replace('*', ''), {
53 | source: user.source
54 | });
55 | };
56 | };
57 |
58 | Nicklist.view = function(controller, args) {
59 | function userMenuFn(user) {
60 | return function(event) {
61 | event = $.event.fix(event);
62 | event.stopPropagation();
63 | controller.nickClick(event, user);
64 | };
65 | }
66 |
67 | var users = controller.users();
68 | var list = [];
69 |
70 | if (!controller.has_loaded) {
71 | list.push(
72 |
73 |
74 |
75 |
76 |
77 | );
78 |
79 | } else if (users.length > 0) {
80 | _.each(users, (user) => {
81 | var colour = Helpers.nickColour(user.name.replace('*', ''));
82 | list.push(
83 |
84 |
85 | {user.name}
86 |
87 | );
88 | });
89 |
90 | } else {
91 | list.push(
92 | Nobody to be seen here..
93 | );
94 | }
95 |
96 | function nicklistConfig(el, isInitialized, context) {
97 | if (!isInitialized) {
98 | $(el).addClass('OC-Nicklist-open');
99 |
100 | // Give time for the CSS animation to complete before loading the users.
101 | // Doing both at the same time causes jank on slower devices
102 | setTimeout(() => {
103 | controller.refreshList();
104 | }, 200);
105 | }
106 | }
107 |
108 | return (
109 |
112 | );
113 | };
114 |
115 |
116 | export default Nicklist;
117 |
--------------------------------------------------------------------------------
/src/Components/Nicklist/Nicklist.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-Nicklist
2 | background-color: #FFFFFF
3 | position: absolute
4 | right: 0
5 | top: topbar-height
6 | bottom: 45px
7 | z-index: 1
8 | min-width: 200px
9 | overflow-y: auto
10 | transform: translateX(100%)
11 | transition: 0.2s
12 | box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 0px 5px 0 rgba(0, 0, 0, 0.12), 0 2px 1px -2px rgba(0, 0, 0, 0.2)
13 |
14 | &-open
15 | transform: translateX(0)
16 |
17 | &__Info
18 | text-align: center
19 | margin: 16px 0
20 |
21 | &__User
22 | padding: base-vs base-hs
23 | border: 1px solid #d4d4d4
24 | border-width: 0 0 1px 1px
25 | border-left: 3px solid brand-primary
26 | color: black-text
27 | cursor: pointer
28 |
29 | &:hover
30 | border-left: 6px solid #4caf50
31 | transition: border 0.1s
32 |
33 | &:last-child
34 | border-bottom: none
35 |
36 | &--Avatar
37 | display: none
38 | width: 50px
39 | height: 50px
40 | border-radius: 25px
41 | background: blue
42 |
43 | &--Nick
44 | display: inline-block
45 |
46 | &__Loader
47 | position: relative
48 | margin: 20px auto
49 | width: 24px
50 | padding: base-vs 0
51 |
52 | &:before
53 | content:''
54 | display: block
55 | padding-top: 100%
56 |
57 | svg
58 | animation: rotate 2s linear infinite
59 | transform-origin: center center
60 | position: absolute
61 | top: 0
62 | bottom: 0
63 | left: 0
64 | right: 0
65 | margin: auto
66 |
67 | circle
68 | stroke-dasharray: 1,200
69 | stroke-dashoffset: 0
70 | animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
71 | stroke-linecap: round
--------------------------------------------------------------------------------
/src/Components/OptionsMenu/OptionsMenu.jsx:
--------------------------------------------------------------------------------
1 | import './OptionsMenu.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 | import Orangechat from '../../Services/Orangechat.js';
5 |
6 | /**
7 | * The sidebar UI
8 | */
9 | var OptionsMenu = {};
10 |
11 | OptionsMenu.controller = function(args) {
12 | _.extend(this, Backbone.Events);
13 | this.bus = args.bus;
14 | this.orangechat = Orangechat.instance();
15 |
16 | this.listenTo(this.bus, 'action.document_click', (event) => {
17 | this.close();
18 | });
19 |
20 | this.onLogoutItemClick = () => {
21 | if (!confirm('Are you sure you want to logout of OrangeChat? This will close any conversations you are currently in')) {
22 | return;
23 | }
24 | this.orangechat.logout().then(() => {
25 | window.location.reload();
26 | });
27 | };
28 |
29 | this.onPreferencesClick = () => {
30 | this.bus.trigger('action.show_settings');
31 | };
32 |
33 | this.close = () => {
34 | this.trigger('close');
35 | this.stopListening();
36 | };
37 |
38 | this.onClick = (event) => {
39 | event = $.event.fix(event);
40 | event.stopPropagation();
41 | this.close();
42 | };
43 | };
44 |
45 | OptionsMenu.view = function(controller) {
46 | var items = [];
47 |
48 | items.push(
49 |
52 | );
53 |
54 | items.push(
55 |
59 | );
60 |
61 | var is_logged_in = !!controller.orangechat.username();
62 | if (is_logged_in) {
63 | items.push(
64 |
68 | );
69 | }
70 |
71 | items.push( );
72 |
73 | if (!Helpers.hasExtension() && !Helpers.isInReddit()) {
74 | items.push(
75 |
80 | );
81 | }
82 |
83 | items.push(
84 |
89 | );
90 | items.push(
91 |
96 | );
97 |
98 | return (
99 |
102 | );
103 | };
104 |
105 | export default OptionsMenu;
106 |
--------------------------------------------------------------------------------
/src/Components/OptionsMenu/OptionsMenu.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-OptionsMenu
2 | position: absolute
3 | left: 10px
4 | top: 4px
5 | z-index: 120
6 | right: 10px
7 | background-color: #FFFFFF
8 | border-radius: 3px
9 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.14), 0 0px 5px 0 rgba(0, 0, 0, 0.12), 0 2px 1px -2px rgba(0, 0, 0, 0.2)
10 | overflow: hidden
11 | padding: base-vs 0
12 |
13 | &__title
14 | font-size: em(15px)
15 | font-weight: 500
16 | color: black-text
17 | padding: base-vs base-hs
18 | padding-top: 0
19 |
20 | &__link
21 | cursor: pointer
22 | color: black-text
23 | font-size: em(14px)
24 | padding: base-vs base-hs
25 | display: block
26 |
27 | &:hover
28 | background-color: black-underlays
29 |
30 | hr
31 | margin: base-vs 0
32 | border: 0
33 | border-top: 1px solid black-dividers
34 |
--------------------------------------------------------------------------------
/src/Components/Settings/Settings.js:
--------------------------------------------------------------------------------
1 | import './Settings.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 | import * as Notifications from '../../Services/Notifications.js';
5 |
6 |
7 | var Settings = {};
8 |
9 | Settings.controller = function(args) {
10 | this.bus = args.bus;
11 |
12 | this.toggleApp = () => {
13 | this.bus.trigger('action.toggle_app');
14 | };
15 |
16 | this.close = () => {
17 | this.bus.trigger('action.close_workspace');
18 | };
19 |
20 | /**
21 | * Notifications
22 | */
23 | this.notificationState = () => {
24 | return Notifications.notificationState();
25 | };
26 |
27 | this.requestNotificationPermission = () => {
28 | Notifications.requestPermission((permission) => {
29 | m.redraw();
30 | });
31 | };
32 | };
33 |
34 |
35 | Settings.view = function(controller) {
36 | var header = Settings.viewHeader(controller);
37 | var content = Settings.viewContent(controller);
38 |
39 | return m('div', {class: 'OC-MessageRoom'}, [
40 | header,
41 | m('div', {class: 'OC__workspace-content'}, content)
42 | ]);
43 | };
44 |
45 | Settings.viewHeader = function(controller) {
46 | return m('div', {class: 'OC-MessageRoom__header'}, [
47 | m('div', {class: 'OC-MessageRoom__header-collapse', title: 'Toggle sidebar', onclick: controller.close}, [
48 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="24"][height="24"][viewBox="0 0 24 24"]', [
49 | m('path[d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"]')
50 | ])
51 | ]),
52 | m('div', {class: 'OC-MessageRoom__header-info', onclick: Helpers.isInReddit() ? controller.toggleApp : null}, [
53 | m('h4', 'Preferences')
54 | ])
55 | ]);
56 | };
57 |
58 | Settings.viewContent = function(controller) {
59 | var sections = [];
60 |
61 | sections.push(m('div', {
62 | class: 'OC-Settings__section'
63 | }, Settings.viewSectionNotifications(controller)));
64 |
65 | sections.push(m('div', {
66 | class: 'OC-Settings__section'
67 | }, Settings.viewSectionVersion(controller)));
68 |
69 | return sections;
70 | };
71 |
72 | Settings.viewSectionNotifications = function(controller) {
73 | var state = controller.notificationState();
74 | var action = null;
75 |
76 | if (state === 'needs_request') {
77 | action = m('button', {onclick: controller.requestNotificationPermission}, 'Enable Notifications');
78 | } else if (state === 'requesting') {
79 | action = m('button', {disabled:true}, 'Requesting permission...');
80 | } else if (state === 'not_supported') {
81 | action = m('span', 'Your browser does not support desktop notifications :(');
82 | } else if (state === 'denied') {
83 | action = m('span', 'Your browser has denied access to notifications. You may enable them in your browser settings');
84 | } else if (state === 'ok') {
85 | action = m('span', 'Notifications enabled :)');
86 | }
87 |
88 | return [
89 | m('h5', 'Enable message notifications'),
90 | m('p', 'Recieve a notification when somebody mentions you'),
91 | m('div', {style: 'text-align:right;'}, [
92 | action
93 | ])
94 | ];
95 | };
96 |
97 | Settings.viewSectionVersion = function(controller) {
98 | return [
99 | m('h5', 'Application version'),
100 | m('p', 'You are using version 1.3.1 of orangechat')
101 | ];
102 | };
103 |
104 | export default Settings;
105 |
--------------------------------------------------------------------------------
/src/Components/Settings/Settings.styl:
--------------------------------------------------------------------------------
1 | .OC-Settings
2 | &__header
3 | height: 46px
4 |
--------------------------------------------------------------------------------
/src/Components/Sidebar/ChannelSearch.jsx:
--------------------------------------------------------------------------------
1 | import Orangechat from '../../Services/Orangechat.js';
2 | import Reddit from '../../Services/Reddit.js';
3 |
4 | var ChannelSearch = {};
5 |
6 | ChannelSearch.controller = function(args) {
7 | this.bus = args.bus;
8 | this.orangechat = Orangechat.instance();
9 | this.app = args.app;
10 |
11 | this.trending_channels = m.prop([]);
12 | this.trending_last_updated = 0;
13 |
14 | // Keep track of the join channel scroll position between renders
15 | this.join_channels_scrolltop = m.prop(0);
16 | this.join_channel_input = m.prop('');
17 |
18 | this.list_open = false;
19 |
20 | // Either 'trending' or 'subscribed'
21 | this.list_type = 'subscribed';
22 |
23 | this.refreshTrendingChannels = () => {
24 | if (Date.now() - this.trending_last_updated < 10000) {
25 | return;
26 | }
27 |
28 | this.trending_last_updated = Date.now();
29 | this.orangechat.trendingChannels().then((resp) => {
30 | this.trending_channels(resp.channels || []);
31 | m.redraw();
32 | });
33 | };
34 |
35 | this.showList = (give_focus) => {
36 | this.refreshTrendingChannels();
37 |
38 | if (this.app.subreddits().length === 0) {
39 | this.app.subreddits.refresh();
40 | }
41 |
42 | this.bus.once('action.document_click', this.hideList);
43 | this.list_open = true;
44 |
45 | if (give_focus) {
46 | $('.OC-Sidebar__header-search-input').focus();
47 | }
48 | };
49 |
50 | this.hideList = () => {
51 | this.list_open = false;
52 | };
53 |
54 | this.onSearchInputFocus = (event) => {
55 | this.stopEventPropagation(event);
56 | this.showList();
57 | };
58 |
59 | this.joinChannelFormSubmit = (event) => {
60 | this.stopEventPropagation(event);
61 | var sub_name = this.join_channel_input();
62 | this.join_channel_input('');
63 |
64 | // Make sure we actually have characters
65 | if ((sub_name||'').replace(/[^a-z0-9]/, '')) {
66 | // Normalise the /r/ formatting
67 | if (sub_name.toLowerCase().indexOf('r/') === 0) {
68 | sub_name = '/' + sub_name;
69 | } else if (sub_name.toLowerCase().indexOf('/r/') !== 0) {
70 | sub_name = '/r/' + sub_name;
71 | }
72 |
73 | var channel = this.app.rooms.createRoom(sub_name);
74 | this.app.rooms.setActive(channel.instance.name());
75 | this.hideList();
76 |
77 | // Clear the entry box
78 | $(event.target).find('input').val('');
79 | }
80 |
81 | // Make sure the form does not actually submit anywhere
82 | return false;
83 | };
84 |
85 | this.onTrendingClick = (event) => {
86 | this.stopEventPropagation(event);
87 | this.list_type = 'trending';
88 | };
89 | this.onSubscribedClick = (event) => {
90 | this.stopEventPropagation(event);
91 | this.list_type = 'subscribed';
92 | };
93 |
94 | this.stopEventPropagation = (event) => {
95 | event = $.event.fix(event);
96 | event.stopPropagation();
97 | return event;
98 | };
99 | };
100 |
101 |
102 | ChannelSearch.view = function(controller) {
103 | // stopEventPropagation on all click events so clicking the background of the menu doesn't
104 | // trigger it to close
105 | return m('div', {onclick: controller.stopEventPropagation}, [
106 | ChannelSearch.viewSearch(controller),
107 | controller.list_open ?
108 | ChannelSearch.viewList(controller) :
109 | null
110 | ]);
111 | };
112 |
113 |
114 | ChannelSearch.viewSearch = function(controller) {
115 | return m('form', {class: 'OC-Sidebar__header-search', onsubmit: controller.joinChannelFormSubmit}, [
116 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="16"][height="16"][viewBox="0 0 24 24"]', [
117 | m('path[d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"]')
118 | ]),
119 | m('input', {
120 | type: 'text',
121 | class: 'OC-Sidebar__header-search-input',
122 | placeholder: '/r/subreddit',
123 | onfocus: controller.onSearchInputFocus,
124 | onclick: controller.stopEventPropagation,
125 | onkeyup: m.withAttr('value', controller.join_channel_input)
126 | })
127 | ]);
128 | };
129 |
130 |
131 | ChannelSearch.viewList = function(controller) {
132 | var current_search = controller.join_channel_input().toLowerCase();
133 |
134 | var subreddit_list = [];
135 |
136 | if (controller.list_type === 'subscribed') {
137 | subreddit_list = _.map(controller.app.subreddits(), function(sub) {
138 | var sub_compare = sub.name.toLowerCase();
139 | if (sub_compare.indexOf(current_search.replace('/r/', '')) > -1) {
140 | return sub.name;
141 | }
142 | });
143 |
144 | if (Reddit.currentSubreddit()) {
145 | subreddit_list.unshift('/r/' + Reddit.currentSubreddit());
146 | }
147 | }
148 |
149 | if (controller.list_type === 'trending') {
150 | subreddit_list = _.map(controller.trending_channels(), (sub) => {
151 | // TODO: This is hacky and shouldn't need to replace things here. Rethink it
152 | return sub.replace('reddit_sub_', '/r/');
153 | });
154 | }
155 |
156 | subreddit_list = _.compact(subreddit_list);
157 | subreddit_list = _.map(subreddit_list, function(sub_name) {
158 | return m('li', {
159 | class: 'OC-Sidebar__join-dialog-channels-item',
160 | onclick: function(event) {
161 | controller.stopEventPropagation(event);
162 | var channel = controller.app.rooms.createRoom(sub_name);
163 | controller.app.rooms.setActive(channel.instance.name());
164 | controller.hideList();
165 | }
166 | }, sub_name);
167 | });
168 |
169 | if (subreddit_list.length === 0) {
170 | subreddit_list.push(m('div', {class: 'OC-Sidebar__join-dialog-channels-loading'}, [
171 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="30"][height="30"][viewBox="25 25 50 50"]', [
172 | m('circle[fill="none"][stroke-width="4"][stroke-miterlimit="10"][cx="50"][cy="50"][r="20"]')
173 | ])
174 | ]));
175 | }
176 |
177 | return m('div', {class: 'OC-Sidebar__join-dialog'}, [
178 | m('.OC-Sidebar__join-dialog-subswitcher', [
179 | m('a', {class: 'OC-link', onclick:controller.onSubscribedClick}, 'Subbed'),
180 | m('a', {class: 'OC-link', onclick:controller.onTrendingClick}, 'Trending'),
181 | m('div', {
182 | class: 'OC-Sidebar__join-dialog-subswitcher-bar OC-Sidebar__join-dialog-subswitcher-bar--' + controller.list_type.toLowerCase()
183 | })
184 | ]),
185 | m('ul', {
186 | class: 'OC-Sidebar__join-dialog-channels',
187 | onscroll: m.withAttr('scrollTop', controller.join_channels_scrolltop),
188 | config: function(el, already_initialised) {
189 | if (already_initialised) {
190 | return;
191 | }
192 | // Keep our scroll position when we get redrawn
193 | el.scrollTop = controller.join_channels_scrolltop();
194 | }
195 | }, subreddit_list)
196 | ]);
197 | };
198 |
199 | export default ChannelSearch;
200 |
--------------------------------------------------------------------------------
/src/Components/Sidebar/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | import './Sidebar.styl';
2 |
3 | import * as Helpers from '../../Helpers/Helpers.js';
4 | import OptionsMenu from '../OptionsMenu/OptionsMenu.jsx';
5 | import ChannelSearch from './ChannelSearch.jsx';
6 |
7 |
8 | /**
9 | * The sidebar UI
10 | */
11 | var Sidebar = {};
12 |
13 | Sidebar.controller = function(args) {
14 | this.app = args.app;
15 | this.mods = m.prop(0);
16 | this.join_dialog = Helpers.subModule(ChannelSearch, {
17 | bus: args.app.bus,
18 | app: args.app
19 | });
20 |
21 | // When there's a menu to show, this will be it
22 | this.options_menu = null;
23 |
24 | this.closeChannel = (channel_name) => {
25 | this.app.rooms.closeRoom(channel_name);
26 | };
27 |
28 | this.onOptionsMenuItemClick = (event) => {
29 | this.stopEventPropagation(event);
30 | this.options_menu = Helpers.subModule(OptionsMenu, {
31 | bus: this.app.bus
32 | });
33 | this.options_menu.instance.once('close', () => {
34 | this.options_menu = null;
35 | });
36 | };
37 |
38 | this.onFindChannelsClick = (event) => {
39 | this.stopEventPropagation(event);
40 | event.preventDefault();
41 | this.join_dialog.instance.showList(true);
42 | };
43 |
44 | this.stopEventPropagation = (event) => {
45 | event = $.event.fix(event);
46 | event.stopPropagation();
47 | return event;
48 | };
49 | };
50 |
51 | /**
52 | * Organise the channel list into a structured list
53 | * Eg.
54 | * * Top level channel
55 | * * Top level channel
56 | * * Sub channel
57 | * * Sub channel without a parent
58 | *
59 | * People may link directly to a subchannel such as #/r/live-rnc without joining its
60 | * parent channel, so these must then be treated like a top level channel until they
61 | * do join the parent channel.
62 | */
63 | Sidebar.view = function(controller) {
64 | var channels = [];
65 | var sub_channels = Object.create(null);
66 | var list = [];
67 |
68 | // Separate the main channels and their subchannels
69 | _.each(controller.app.rooms.rooms, function(channel) {
70 | var chan = channel.instance;
71 | var parent_chan_name;
72 |
73 | // Main channels don't have a parent channel
74 | if (!chan.linked_channels.parent) {
75 | channels.push(channel);
76 | } else {
77 | parent_chan_name = chan.linked_channels.parent.name;
78 | sub_channels[parent_chan_name] = sub_channels[parent_chan_name] || [];
79 | sub_channels[parent_chan_name].push(channel);
80 | }
81 | });
82 |
83 | // Add each channel entry to the list, followed by its subchannels
84 | if (channels.length > 0) {
85 | _.each(channels, function(channel, idx) {
86 | var this_chans_subchannels = sub_channels[channel.instance.transportSafeRoomName()] || [];
87 |
88 | // The parent channel...
89 | list.push(Sidebar.viewChannelListItem(controller, channel));
90 |
91 | // The channels subchannels...
92 | _.each(this_chans_subchannels, function(channel) {
93 | list.push(Sidebar.viewChannelListItem(controller, channel, {
94 | subchannel: true
95 | }));
96 | });
97 |
98 | this_chans_subchannels.added_to_list = true;
99 | });
100 |
101 | // Add any remaning subchannels that haven't already been added
102 | _.each(sub_channels, function(sub_channels, parent_chan_name) {
103 | if (sub_channels.added_to_list) {
104 | return;
105 | }
106 |
107 | _.each(sub_channels, function (channel) {
108 | list.push(Sidebar.viewChannelListItem(controller, channel, {
109 | //subchannel: true
110 | }));
111 | });
112 | });
113 |
114 | } else {
115 | list.push(You're not in any channels yet
);
116 | }
117 |
118 | // The message underneath the channels explaining where to find channels
119 | list.push(
120 |
125 | );
126 |
127 | return m('div', {class: !controller.join_dialog.instance.list_open ? 'OC-Sidebar' : 'OC-Sidebar OC-Sidebar--join-dialog-open'}, [
128 | m('div', {class: 'OC-Sidebar__header'}, [
129 | controller.join_dialog.view(),
130 |
131 | m('div[class="OC-Sidebar__header-user-options"]', {onclick: controller.onOptionsMenuItemClick}, [
132 | m('svg[version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="24"][height="24"][viewBox="0 0 24 24"]', [
133 | m('path[d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"]')
134 | ])
135 | ]),
136 | controller.options_menu ? controller.options_menu.view() : null
137 | ]),
138 |
139 | m('ul', {class: controller.app.rooms.rooms.length > 0 ? 'OC-Sidebar__channels' : 'OC-Sidebar__channels OC-Sidebar__channels--no-channels'}, [
140 | list
141 | ]),
142 | ]);
143 | };
144 |
145 | Sidebar.viewChannelListItem = function(controller, channel, opts) {
146 | var unread_badge;
147 | var list_item;
148 | var item_classes = '';
149 |
150 | opts = opts || {};
151 |
152 | if (channel.instance.unread_counter > 0) {
153 | unread_badge = m('span', {
154 | class: 'OC-Sidebar__channels-item-badge',
155 | title: 'Unread messages'
156 | }, channel.instance.unread_counter);
157 | }
158 |
159 | item_classes = 'OC-Sidebar__channels-item';
160 | if (controller.app.rooms.active() === channel) {
161 | item_classes += ' OC-Sidebar__channels-item--active';
162 | }
163 | if (opts.subchannel) {
164 | item_classes += ' OC-Sidebar__channels-item--sub-channel';
165 | }
166 |
167 | var num_users_text = channel.instance.num_users() + channel.instance.known_irc_usernames().length;
168 | num_users_text = num_users_text === 1 ?
169 | num_users_text + ' person here' :
170 | num_users_text + ' people here';
171 |
172 | list_item = m('li', {
173 | class: item_classes,
174 | onclick: function() { controller.app.rooms.setActive(channel.instance.name()); }
175 | }, [
176 | m('div', {class: 'OC-Sidebar__channels-item-left'}, [
177 | m('span', {class: 'OC-Sidebar__channels-item-name'}, channel.instance.displayLabel()),
178 | m('span', {class: 'OC-Sidebar__channels-item-sub-name'}, num_users_text)
179 | ]),
180 | unread_badge,
181 | m('svg[class="OC-Sidebar__channels-item-close"][version="1.1"][xmlns="http://www.w3.org/2000/svg"][xmlns:xlink="http://www.w3.org/1999/xlink"][width="24"][height="24"][viewBox="0 0 24 24"]', {
182 | onclick: channel.instance.close
183 | }, [
184 | m('path[d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"]')
185 | ])
186 | ]);
187 |
188 | return list_item;
189 | };
190 |
191 | export default Sidebar;
192 |
--------------------------------------------------------------------------------
/src/Components/Sidebar/Sidebar.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-Sidebar
2 | width: sidebar-width
3 | position: absolute
4 | top: 0
5 | left: 0
6 | height: 100%
7 | background-color: brand-primary
8 |
9 | &__header
10 | position: absolute
11 | height: topbar-height
12 | left: 0
13 | right: 0
14 | top: 0
15 | background-color: brand-primary
16 | z-index: 80
17 | color: white-text
18 |
19 | &-search
20 | padding: 4px 56px 0 base-hs
21 | position: relative
22 | z-index: 101
23 |
24 | svg
25 | fill: white-secondary
26 | position: absolute
27 | top: 12px
28 | left: base-hs + 10px
29 | pointer-events: none
30 |
31 | &-input
32 | height: 30px
33 | font-size: em(14px)
34 |
35 | input
36 | display: block
37 | width: 100%
38 | border-radius: 3px
39 | padding-left: base-hs * 2 + 5px
40 | padding-right: base-hs
41 | border: 0
42 | color: white-secondary
43 | background-color: white-underlays
44 | position: relative
45 | outline: none
46 |
47 | &::placeholder
48 | color: white-secondary
49 |
50 | &-user-options
51 | position: absolute
52 | top: 0
53 | right: 0
54 | bottom: 0
55 | width: 24px
56 | margin: 7px base-hs 0 base-hs
57 | cursor: pointer
58 | z-index: 102
59 |
60 | svg
61 | fill: white-hint
62 | height: auto
63 | display: inline-block
64 |
65 | &-collapse
66 | position: absolute
67 | right: base-hs
68 | top: 50%
69 | margin-top: -12px
70 | fill: white-hint
71 | cursor: pointer
72 | display: inline-block
73 |
74 | &--join-dialog-open &__header-search svg
75 | fill: black-secondary
76 |
77 | &--join-dialog-open &__header
78 | &-search-input
79 | outline: 0
80 | color: black-secondary
81 |
82 | &::placeholder
83 | color: black-secondary
84 |
85 | &-search
86 | opacity: 1
87 | pointer-events: auto
88 |
89 | &-search-control
90 | fill: black-text
91 | left: base-hs * 2
92 | margin-top: -2px
93 |
94 | &__channels
95 | position: absolute
96 | top: topbar-height
97 | bottom: 0
98 | left: 0
99 | right: 0
100 | overflow-y: auto
101 |
102 | &--no-channels p
103 | padding: base-vs base-hs
104 | font-size: em(14px)
105 | color: white-hint
106 |
107 | &-item
108 | padding: base-vs base-hs
109 | cursor: pointer
110 | position: relative
111 |
112 | &-name
113 | font-size: em(14px)
114 | display: block
115 | color: white-text
116 | font-weight: 500
117 |
118 | &-sub-name
119 | font-size: em(12px)
120 | color: white-secondary
121 | display: block
122 |
123 | &-close
124 | position: absolute
125 | right: base-hs
126 | top: 50%
127 | margin-top: -12px
128 | fill: white-secondary
129 | cursor: pointer
130 | display: none
131 | padding: 2px
132 | border: 1px solid white-secondary
133 | border-radius: 50%
134 |
135 | &-badge
136 | display: inline-block
137 | color: white-secondary
138 | font-size: em(12px)
139 | height: 18px
140 | padding: 0 (base-hs / 2)
141 | border: 1px solid white-secondary
142 | border-radius: 10px
143 | line-height: 1
144 | padding-top: 2px
145 | position: absolute
146 | right: base-hs
147 | top: 50%
148 | margin-top: -9px
149 | text-align: center
150 |
151 | &:hover
152 | background-color: brand-hover
153 |
154 | &:hover &-close
155 | display: block
156 |
157 | &:hover &-badge
158 | display: none
159 |
160 | &--sub-channel
161 | padding: base-vs base-hs base-vs (2 * base-hs)
162 | margin-bottom: 8px
163 |
164 | &--sub-channel &-name
165 | font-size: em(14px)
166 |
167 | &--sub-channel &-sub-name
168 | display: none
169 |
170 | &--active
171 | background-color: brand-active
172 |
173 | &:hover
174 | background-color: brand-active
175 |
176 | &__join-dialog
177 | z-index: 101
178 | display: block
179 | position: absolute
180 | z-index: 100
181 | background-color: #FFFFFF
182 | border-radius: 3px
183 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.14), 0 0px 5px 0 rgba(0, 0, 0, 0.12), 0 2px 1px -2px rgba(0, 0, 0, 0.2)
184 | padding: base-vs 0
185 | padding-top: 30px
186 | left: base-hs
187 | top: 4px
188 | right: base-hs
189 |
190 | // The bar under the subscribed / trending selectors
191 | &-subswitcher
192 | margin-top: 5px
193 |
194 | > a
195 | width: 50%
196 | border: none
197 | outline: none
198 | display: inline-block
199 | text-align: center
200 | font-size: 0.9em
201 | padding-bottom: 4px
202 |
203 | &-bar
204 | display: block
205 | height: 5px
206 | background: #4caf50
207 | width: 50%
208 | position: relative
209 | transition: 0.3s
210 |
211 | &--subscribed
212 | margin-left: 0
213 | &--trending
214 | margin-left: 50%
215 |
216 | &-channels
217 | max-height: 250px
218 | border-top: 1px solid black-dividers
219 | overflow-y: auto
220 | overflow-x: hidden
221 | padding-top: 5px
222 |
223 | &-loading
224 | position: relative
225 | margin: 0px auto
226 | width: 24px
227 | padding: base-vs 0
228 |
229 | &:before
230 | content:''
231 | display: block
232 | padding-top: 100%
233 |
234 | svg
235 | animation: rotate 2s linear infinite
236 | transform-origin: center center
237 | position: absolute
238 | top: 0
239 | bottom: 0
240 | left: 0
241 | right: 0
242 | margin: auto
243 |
244 | circle
245 | stroke-dasharray: 1,200
246 | stroke-dashoffset: 0
247 | animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
248 | stroke-linecap: round
249 |
250 | &-item
251 | cursor: pointer
252 | color: black-text
253 | font-size: em(14px)
254 | padding: base-vs base-hs
255 | display: block
256 |
257 | &:hover
258 | background-color: black-underlays
259 |
260 | &__join-something
261 | padding: 6px
262 | margin: 6px
263 | background-color: rgba(255,255,255,0.25)
264 | border-radius: 3px
265 |
266 | &-go
267 | cursor: pointer
268 | color: rgba(255,255,255,0.7)
269 | display: block
270 | text-align: center
271 |
272 | @keyframes rotate
273 | 100%
274 | transform: rotate(360deg)
275 |
276 | @keyframes dash
277 | 0%
278 | stroke-dasharray: 1,200
279 | stroke-dashoffset: 0
280 | 50%
281 | stroke-dasharray: 89,200
282 | stroke-dashoffset: -35px
283 | 100%
284 | stroke-dasharray: 89,200
285 | stroke-dashoffset: -124px
286 |
287 | @keyframes color
288 | 100%, 0%
289 | stroke: #F44336
290 | 40%
291 | stroke: #3F51B5
292 | 66%
293 | stroke: #4CAF50
294 | 80%, 90%
295 | stroke: #FFC107
296 |
--------------------------------------------------------------------------------
/src/Components/Topbar/Topbar.js:
--------------------------------------------------------------------------------
1 | import './Topbar.styl';
2 |
3 | /**
4 | * The topbar UI
5 | */
6 | var Topbar = {};
7 |
8 | Topbar.controller = function(args) {
9 | this.app = args.app;
10 |
11 | this.toggleApp = () => {
12 | this.app.bus.trigger('action.toggle_app');
13 | };
14 | };
15 |
16 | Topbar.view = function(controller) {
17 | var active_room = controller.app.rooms.active();
18 | var total_unread = 0;
19 | var have_highlight = false;
20 | var title = [
21 | m('img', {
22 | class: 'OC-Topbar__branding-logo',
23 | src: 'https://app.orangechat.io/assets/logo-white.svg'
24 | })
25 | ];
26 |
27 | have_highlight = active_room ?
28 | active_room.instance.unread_highlight :
29 | false;
30 |
31 | controller.app.rooms.rooms.map(function(room) {
32 | total_unread += room.instance.unread_counter;
33 | });
34 |
35 | if (total_unread > 0) {
36 | title.push(m('div', {
37 | class: 'OC-Topbar__badge',
38 | title: 'Unread messages'
39 | }, total_unread));
40 | }
41 |
42 | return m('div', {
43 | class: (total_unread > 0 && have_highlight) ?
44 | 'OC-Topbar OC-Topbar--new-messages' :
45 | 'OC-Topbar',
46 | onclick: controller.toggleApp
47 | }, title);
48 | };
49 |
50 | export default Topbar;
51 |
--------------------------------------------------------------------------------
/src/Components/Topbar/Topbar.styl:
--------------------------------------------------------------------------------
1 | .{prefix}-Topbar
2 | display: block
3 | padding: base-vs base-hs
4 | cursor: pointer
5 | clearfix()
6 |
7 | &__branding-logo
8 | height: 24px
9 | width: auto
10 | display: block
11 | float: left
12 |
13 | &__badge
14 | display: block
15 | color: white-text
16 | font-size: em(12px)
17 | height: 18px
18 | padding: 0 (base-hs / 2)
19 | border: 1px solid white-text
20 | border-radius: 10px
21 | line-height: 1
22 | padding-top: 2px
23 | text-align: center
24 | float: right
25 | margin-left: base-hs
26 | margin-top: 4px
27 |
--------------------------------------------------------------------------------
/src/Config/Config.js:
--------------------------------------------------------------------------------
1 | var config = {
2 | embedding: {
3 | embedly: {
4 | api_key: ''
5 | }
6 | },
7 | channels: {
8 | default: ['/r/OrangeChat']
9 | }
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/src/Helpers/Colour.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Converts a hex CSS color value to RGB.
4 | * Adapted from http://stackoverflow.com/a/5624139.
5 | *
6 | * @param String hex The hexadecimal color value
7 | * @return Object The RGB representation
8 | */
9 | module.exports.hex2rgb = function(hex) {
10 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
11 | var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
12 | hex = hex.replace(shorthandRegex, function (m, r, g, b) {
13 | return r + r + g + g + b + b;
14 | });
15 |
16 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
17 | return result ? {
18 | r: parseInt(result[1], 16),
19 | g: parseInt(result[2], 16),
20 | b: parseInt(result[3], 16)
21 | } : null;
22 | };
23 |
24 |
25 | /**
26 | * Converts an RGB color value to a hex string.
27 | * @param Object rgb RGB as r, g, and b keys
28 | * @return String Hex color string
29 | */
30 | module.exports.rgb2hex = function(rgb) {
31 | return '#' + ['r', 'g', 'b'].map(function (key) {
32 | return ('0' + rgb[key].toString(16)).slice(-2);
33 | }).join('');
34 | };
35 |
36 |
37 | /**
38 | * Converts an RGB color value to HSL. Conversion formula adapted from
39 | * http://en.wikipedia.org/wiki/HSL_color_space. This function adapted
40 | * from http://stackoverflow.com/a/9493060.
41 | * Assumes r, g, and b are contained in the set [0, 255] and
42 | * returns h, s, and l in the set [0, 1].
43 | *
44 | * @param Object rgb RGB as r, g, and b keys
45 | * @return Object HSL as h, s, and l keys
46 | */
47 | module.exports.rgb2hsl = function(rgb) {
48 | var r = rgb.r, g = rgb.g, b = rgb.b;
49 | r /= 255; g /= 255; b /= 255;
50 | var max = Math.max(r, g, b), min = Math.min(r, g, b);
51 | var h, s, l = (max + min) / 2;
52 |
53 | if (max === min) {
54 | h = s = 0; // achromatic
55 | } else {
56 | var d = max - min;
57 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
58 | switch (max) {
59 | case r: h = (g - b) / d + (g < b ? 6 : 0); break;
60 | case g: h = (b - r) / d + 2; break;
61 | case b: h = (r - g) / d + 4; break;
62 | }
63 | h /= 6;
64 | }
65 |
66 | return { h: h, s: s, l: l };
67 | };
68 |
69 |
70 | /**
71 | * Converts an HSL color value to RGB. Conversion formula adapted from
72 | * http://en.wikipedia.org/wiki/HSL_color_space. This function adapted
73 | * from http://stackoverflow.com/a/9493060.
74 | * Assumes h, s, and l are contained in the set [0, 1] and
75 | * returns r, g, and b in the set [0, 255].
76 | *
77 | * @param Object hsl HSL as h, s, and l keys
78 | * @return Object RGB as r, g, and b values
79 | */
80 | module.exports.hsl2rgb = function(hsl) {
81 |
82 | function hue2rgb(p, q, t) {
83 | if (t < 0) t += 1;
84 | if (t > 1) t -= 1;
85 | if (t < 1 / 6) return p + (q - p) * 6 * t;
86 | if (t < 1 / 2) return q;
87 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
88 | return p;
89 | }
90 |
91 | var h = hsl.h, s = hsl.s, l = hsl.l;
92 | var r, g, b;
93 |
94 | if(s === 0){
95 | r = g = b = l; // achromatic
96 | }else{
97 |
98 | var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
99 | var p = 2 * l - q;
100 | r = hue2rgb(p, q, h + 1 / 3);
101 | g = hue2rgb(p, q, h);
102 | b = hue2rgb(p, q, h - 1 / 3);
103 | }
104 |
105 | return {
106 | r: Math.round(r * 255),
107 | g: Math.round(g * 255),
108 | b: Math.round(b * 255)
109 | };
110 | };
111 |
112 |
113 | module.exports.rgb2rgbString = function (rgb) {
114 | return 'rgb(' + [rgb.r, rgb.g, rgb.b].join(',') + ')';
115 | };
116 |
--------------------------------------------------------------------------------
/src/Helpers/Helpers.js:
--------------------------------------------------------------------------------
1 | import * as Colour from './Colour';
2 | import {md5} from './md5';
3 |
4 | // Some of the helper functions require the app instance. Hacky and bad, but for now it'l do
5 | var app;
6 |
7 | export function setAppInstance(app_instance) {
8 | app = app_instance;
9 | }
10 |
11 |
12 | export function isAppActive() {
13 | var is_app_open = (app && app.state('is_open'));
14 | var is_browser_active;
15 |
16 | if ('hidden' in window.document) {
17 | is_browser_active = !document.hidden;
18 | } else if (window.document.hasFocus) {
19 | is_browser_active = window.document.hasFocus();
20 | } else {
21 | is_browser_active = true;
22 | }
23 |
24 | return is_app_open && is_browser_active;
25 | }
26 |
27 |
28 | var nickColour = (function() {
29 | var cache = Object.create(null);
30 |
31 | return function nickColour(nick) {
32 | if (cache[nick]) {
33 | return cache[nick];
34 | }
35 |
36 | // The HSL properties are based on this specific colour
37 | var starting_colour = '#36809B'; // '#449fc1';
38 |
39 |
40 | var hash = md5(nick);
41 | var hue_offset = mapRange(hexVal(hash, 14, 3), 0, 4095, 0, 359);
42 | var sat_offset = hexVal(hash, 17);
43 | var base_colour = Colour.rgb2hsl(Colour.hex2rgb(starting_colour));
44 | base_colour.h = (((base_colour.h * 360 - hue_offset) + 360) % 360) / 360;
45 |
46 | if (sat_offset % 2 === 0) {
47 | base_colour.s = Math.min(1, ((base_colour.s * 100) + sat_offset) / 100);
48 | } else {
49 | base_colour.s = Math.max(0, ((base_colour.s * 100) - sat_offset) / 100);
50 | }
51 |
52 | var rgb = Colour.hsl2rgb(base_colour);
53 | var nick_colour = Colour.rgb2hex(rgb);
54 |
55 | cache[nick] = nick_colour;
56 |
57 | return nick_colour;
58 | }
59 | })();
60 | export { nickColour };
61 |
62 |
63 | /**
64 | * Extract a substring from a hex string and parse it as an integer
65 | * @param {string} hash - Source hex string
66 | * @param {number} index - Start index of substring
67 | * @param {number} [length] - Length of substring. Defaults to 1.
68 | */
69 | export function hexVal(hash, index, len) {
70 | return parseInt(hash.substr(index, len || 1), 16);
71 | }
72 |
73 | /*
74 | * Re-maps a number from one range to another
75 | * http://processing.org/reference/map_.html
76 | */
77 | export function mapRange(value, vMin, vMax, dMin, dMax) {
78 | var vValue = parseFloat(value);
79 | var vRange = vMax - vMin;
80 | var dRange = dMax - dMin;
81 |
82 | return (vValue - vMin) * dRange / vRange + dMin;
83 | }
84 |
85 |
86 | export function isInReddit() {
87 | return !!window.location.host.match(/reddit.com$/i);
88 | }
89 |
90 |
91 | export function hasExtension() {
92 | return app.has_extension;
93 | }
94 |
95 |
96 | export function subModule(module, args) {
97 | var instance = new module.controller(args);
98 | return {
99 | instance: instance,
100 | view: function() {
101 | var fn_args = Array.prototype.slice.call(arguments);
102 | return module.view.apply(module, [instance, args].concat(fn_args));
103 | }
104 | };
105 | }
106 |
--------------------------------------------------------------------------------
/src/Helpers/md5.js:
--------------------------------------------------------------------------------
1 | // http://www.myersdaily.org/joseph/javascript/md5-text.html
2 |
3 | function md5cycle(x, k) {
4 | var a = x[0], b = x[1], c = x[2], d = x[3];
5 |
6 | a = ff(a, b, c, d, k[0], 7, -680876936);
7 | d = ff(d, a, b, c, k[1], 12, -389564586);
8 | c = ff(c, d, a, b, k[2], 17, 606105819);
9 | b = ff(b, c, d, a, k[3], 22, -1044525330);
10 | a = ff(a, b, c, d, k[4], 7, -176418897);
11 | d = ff(d, a, b, c, k[5], 12, 1200080426);
12 | c = ff(c, d, a, b, k[6], 17, -1473231341);
13 | b = ff(b, c, d, a, k[7], 22, -45705983);
14 | a = ff(a, b, c, d, k[8], 7, 1770035416);
15 | d = ff(d, a, b, c, k[9], 12, -1958414417);
16 | c = ff(c, d, a, b, k[10], 17, -42063);
17 | b = ff(b, c, d, a, k[11], 22, -1990404162);
18 | a = ff(a, b, c, d, k[12], 7, 1804603682);
19 | d = ff(d, a, b, c, k[13], 12, -40341101);
20 | c = ff(c, d, a, b, k[14], 17, -1502002290);
21 | b = ff(b, c, d, a, k[15], 22, 1236535329);
22 |
23 | a = gg(a, b, c, d, k[1], 5, -165796510);
24 | d = gg(d, a, b, c, k[6], 9, -1069501632);
25 | c = gg(c, d, a, b, k[11], 14, 643717713);
26 | b = gg(b, c, d, a, k[0], 20, -373897302);
27 | a = gg(a, b, c, d, k[5], 5, -701558691);
28 | d = gg(d, a, b, c, k[10], 9, 38016083);
29 | c = gg(c, d, a, b, k[15], 14, -660478335);
30 | b = gg(b, c, d, a, k[4], 20, -405537848);
31 | a = gg(a, b, c, d, k[9], 5, 568446438);
32 | d = gg(d, a, b, c, k[14], 9, -1019803690);
33 | c = gg(c, d, a, b, k[3], 14, -187363961);
34 | b = gg(b, c, d, a, k[8], 20, 1163531501);
35 | a = gg(a, b, c, d, k[13], 5, -1444681467);
36 | d = gg(d, a, b, c, k[2], 9, -51403784);
37 | c = gg(c, d, a, b, k[7], 14, 1735328473);
38 | b = gg(b, c, d, a, k[12], 20, -1926607734);
39 |
40 | a = hh(a, b, c, d, k[5], 4, -378558);
41 | d = hh(d, a, b, c, k[8], 11, -2022574463);
42 | c = hh(c, d, a, b, k[11], 16, 1839030562);
43 | b = hh(b, c, d, a, k[14], 23, -35309556);
44 | a = hh(a, b, c, d, k[1], 4, -1530992060);
45 | d = hh(d, a, b, c, k[4], 11, 1272893353);
46 | c = hh(c, d, a, b, k[7], 16, -155497632);
47 | b = hh(b, c, d, a, k[10], 23, -1094730640);
48 | a = hh(a, b, c, d, k[13], 4, 681279174);
49 | d = hh(d, a, b, c, k[0], 11, -358537222);
50 | c = hh(c, d, a, b, k[3], 16, -722521979);
51 | b = hh(b, c, d, a, k[6], 23, 76029189);
52 | a = hh(a, b, c, d, k[9], 4, -640364487);
53 | d = hh(d, a, b, c, k[12], 11, -421815835);
54 | c = hh(c, d, a, b, k[15], 16, 530742520);
55 | b = hh(b, c, d, a, k[2], 23, -995338651);
56 |
57 | a = ii(a, b, c, d, k[0], 6, -198630844);
58 | d = ii(d, a, b, c, k[7], 10, 1126891415);
59 | c = ii(c, d, a, b, k[14], 15, -1416354905);
60 | b = ii(b, c, d, a, k[5], 21, -57434055);
61 | a = ii(a, b, c, d, k[12], 6, 1700485571);
62 | d = ii(d, a, b, c, k[3], 10, -1894986606);
63 | c = ii(c, d, a, b, k[10], 15, -1051523);
64 | b = ii(b, c, d, a, k[1], 21, -2054922799);
65 | a = ii(a, b, c, d, k[8], 6, 1873313359);
66 | d = ii(d, a, b, c, k[15], 10, -30611744);
67 | c = ii(c, d, a, b, k[6], 15, -1560198380);
68 | b = ii(b, c, d, a, k[13], 21, 1309151649);
69 | a = ii(a, b, c, d, k[4], 6, -145523070);
70 | d = ii(d, a, b, c, k[11], 10, -1120210379);
71 | c = ii(c, d, a, b, k[2], 15, 718787259);
72 | b = ii(b, c, d, a, k[9], 21, -343485551);
73 |
74 | x[0] = add32(a, x[0]);
75 | x[1] = add32(b, x[1]);
76 | x[2] = add32(c, x[2]);
77 | x[3] = add32(d, x[3]);
78 |
79 | }
80 |
81 | function cmn(q, a, b, x, s, t) {
82 | a = add32(add32(a, q), add32(x, t));
83 | return add32((a << s) | (a >>> (32 - s)), b);
84 | }
85 |
86 | function ff(a, b, c, d, x, s, t) {
87 | return cmn((b & c) | ((~b) & d), a, b, x, s, t);
88 | }
89 |
90 | function gg(a, b, c, d, x, s, t) {
91 | return cmn((b & d) | (c & (~d)), a, b, x, s, t);
92 | }
93 |
94 | function hh(a, b, c, d, x, s, t) {
95 | return cmn(b ^ c ^ d, a, b, x, s, t);
96 | }
97 |
98 | function ii(a, b, c, d, x, s, t) {
99 | return cmn(c ^ (b | (~d)), a, b, x, s, t);
100 | }
101 |
102 | function md51(s) {
103 | //txt = '';
104 | var n = s.length,
105 | state = [1732584193, -271733879, -1732584194, 271733878], i;
106 | for (i=64; i<=s.length; i+=64) {
107 | md5cycle(state, md5blk(s.substring(i-64, i)));
108 | }
109 | s = s.substring(i-64);
110 | var tail = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0];
111 | for (i=0; i>2] |= s.charCodeAt(i) << ((i%4) << 3);
113 | tail[i>>2] |= 0x80 << ((i%4) << 3);
114 | if (i > 55) {
115 | md5cycle(state, tail);
116 | for (i=0; i<16; i++) tail[i] = 0;
117 | }
118 | tail[14] = n*8;
119 | md5cycle(state, tail);
120 | return state;
121 | }
122 |
123 | /* there needs to be support for Unicode here,
124 | * unless we pretend that we can redefine the MD-5
125 | * algorithm for multi-byte characters (perhaps
126 | * by adding every four 16-bit characters and
127 | * shortening the sum to 32 bits). Otherwise
128 | * I suggest performing MD-5 as if every character
129 | * was two bytes--e.g., 0040 0025 = @%--but then
130 | * how will an ordinary MD-5 sum be matched?
131 | * There is no way to standardize text to something
132 | * like UTF-8 before transformation; speed cost is
133 | * utterly prohibitive. The JavaScript standard
134 | * itself needs to look at this: it should start
135 | * providing access to strings as preformed UTF-8
136 | * 8-bit unsigned value arrays.
137 | */
138 | function md5blk(s) { /* I figured global was faster. */
139 | var md5blks = [], i; /* Andy King said do it this way. */
140 | for (i=0; i<64; i+=4) {
141 | md5blks[i>>2] = s.charCodeAt(i)
142 | + (s.charCodeAt(i+1) << 8)
143 | + (s.charCodeAt(i+2) << 16)
144 | + (s.charCodeAt(i+3) << 24);
145 | }
146 | return md5blks;
147 | }
148 |
149 | var hex_chr = '0123456789abcdef'.split('');
150 |
151 | function rhex(n){
152 | var s='', j=0;
153 | for(; j<4; j++)
154 | s += hex_chr[(n >> (j * 8 + 4)) & 0x0F]
155 | + hex_chr[(n >> (j * 8)) & 0x0F];
156 | return s;
157 | }
158 |
159 | function hex(x) {
160 | for (var i=0; i> 16) + (y >> 16) + (lsw >> 16);
183 | return (msw << 16) | (lsw & 0xFFFF);
184 | }
185 | }
186 |
187 |
188 | export {md5};
189 |
--------------------------------------------------------------------------------
/src/Services/ChannelManager.js:
--------------------------------------------------------------------------------
1 | import MessageRoom from '../Components/MessageRoom/MessageRoom.jsx';
2 | import * as Helpers from '../Helpers/Helpers.js';
3 |
4 | class ChannelManager {
5 | constructor(opts) {
6 | this.bus = opts.bus;
7 | this.transport = opts.transport;
8 | this.state = opts.state;
9 | this.rooms = [];
10 | this.active = m.prop(null);
11 |
12 | // When we reconnect, we need to re-join all of our rooms
13 | this.bus.on('transport.open', (event) => {
14 | if (!event.was_reconnection) {
15 | return;
16 | }
17 |
18 | var groups = _.map(this.rooms, (room) => {
19 | return room.instance.transportSafeRoomName();
20 | });
21 |
22 | if (groups.length) {
23 | this.transport.join(groups);
24 | }
25 | }, this);
26 |
27 | // If we get a channel invite for one we don't already have, open it
28 | this.bus.on('message.channel.invite', (event) => {
29 | var label = event.channel_label;
30 | if (event.invite_size === 2 && event.channel_type === 3) {
31 | // Only us and 1 other person in this channel, and it's a private channel? it's a PM
32 | label = event.user;
33 | }
34 |
35 | // Add an '[irc]' marker infront of IRC private messages so they dont
36 | // get mixed up with reddit usernames
37 | if (event.channel_name.indexOf('irc_') === 0) {
38 | label = '[irc] ' + label;
39 | }
40 |
41 | var channel = this.getRoom(event.channel_name);
42 | if (channel) {
43 | channel.instance.label(label);
44 | } else {
45 | this.createRoom(event.channel_name, {
46 | label: label
47 | });
48 | }
49 | });
50 |
51 | this.bus.on('action.ocbutton.click', (event, channel_name) => {
52 | if (!channel_name) {
53 | return;
54 | }
55 |
56 | event.preventDefault();
57 | var channel = this.createRoom(channel_name);
58 | this.setActive(channel.instance.name());
59 | });
60 | }
61 |
62 | transportSafeRoomName(name) {
63 | return name.toLowerCase().replace('/r/', 'reddit_sub_');
64 | }
65 |
66 | createRoom(room_name, args) {
67 | args = args || {};
68 | var room = this.getRoom(room_name);
69 |
70 | if (!room) {
71 | room = Helpers.subModule(MessageRoom, {
72 | name: room_name,
73 | label: args.label,
74 | read_upto: args.read_upto,
75 | access: args.access,
76 | linked_channels: args.linked_channels,
77 | flags: args.flags,
78 | bus: this.bus,
79 | room_manager: this
80 | });
81 |
82 | this.rooms.push(room);
83 | this.transport.join(room.instance.transportSafeRoomName());
84 |
85 | this.bus.trigger('channel.created', room.instance);
86 | }
87 |
88 | // If we don't currently have an active room, make this the active room
89 | if (!this.active()) {
90 | this.setActive(room.instance.name);
91 | }
92 |
93 | return room;
94 | }
95 |
96 | closeRoom(room_name) {
97 | var room = this.getRoom(room_name);
98 | if (!room) {
99 | return;
100 | }
101 |
102 | var room_idx = this.rooms.indexOf(room);
103 |
104 | this.rooms = _.without(this.rooms, room);
105 | this.transport.leave(room.instance.transportSafeRoomName());
106 | this.bus.trigger('channel.close', room);
107 |
108 | // The room after the one that was just removed should now be selected. If there
109 | // is none, then the one before it.
110 | if (this.rooms[room_idx]) {
111 | this.setActive(this.rooms[room_idx].instance.name());
112 | } else if (this.rooms[room_idx - 1]){
113 | this.setActive(this.rooms[room_idx - 1].instance.name());
114 | } else {
115 | this.setActive(null);
116 | }
117 | }
118 |
119 | getRoom(room_name) {
120 | if (typeof room_name !== 'string') {
121 | return;
122 | }
123 |
124 | var normalised_name = this.transportSafeRoomName(room_name).toLowerCase();
125 | return _.find(this.rooms, function(room) {
126 | return normalised_name === room.instance.transportSafeRoomName().toLowerCase();
127 | });
128 | }
129 |
130 | setActive(room_name) {
131 | var current_room = this.active();
132 | var selected_room = this.getRoom(room_name);
133 |
134 | if (!selected_room) {
135 | return false;
136 | }
137 |
138 | this.active(selected_room || null);
139 |
140 | if (current_room) {
141 | current_room.instance.is_active = false;
142 | }
143 | if (selected_room) {
144 | selected_room.instance.is_active = true;
145 | }
146 |
147 | this.bus.trigger(
148 | 'channel.active',
149 | selected_room ? selected_room.instance : null,
150 | current_room ? current_room.instance : null
151 | );
152 | }
153 |
154 | setIndexActive(room_idx) {
155 | var room = this.rooms[0];
156 | this.setActive(room ? room.name() : null);
157 | }
158 | }
159 |
160 | export default ChannelManager;
161 |
--------------------------------------------------------------------------------
/src/Services/EventBus.js:
--------------------------------------------------------------------------------
1 | class EventBus {
2 |
3 | constructor() {
4 | this.findandAssignEventEmitter();
5 | this.overloadAddEvent();
6 | }
7 |
8 | findandAssignEventEmitter() {
9 | // reddit.com has backbone...
10 | if (Backbone && Backbone.Events) {
11 | _.extend(this, Backbone.Events);
12 | } else {
13 | // TODO: Include a very simple event emitter to use outside of reddit.com
14 | console.log('[error] No event emitter found!');
15 | }
16 | }
17 |
18 | overloadAddEvent() {
19 | var self = this;
20 | var original_on = this.on;
21 | var original_once = this.once;
22 |
23 | this.on = function on(event, fn, context) {
24 | original_on.call(this, event, fn, context);
25 | return {
26 | off: function off() {
27 | self.off(event, fn, context);
28 | }
29 | };
30 | };
31 |
32 | this.once = function once(event, fn, context) {
33 | original_once.call(this, event, fn, context);
34 | return {
35 | off: function off() {
36 | self.off(event, fn, context);
37 | }
38 | };
39 | };
40 | }
41 | }
42 |
43 | export default EventBus;
44 |
--------------------------------------------------------------------------------
/src/Services/ExtensionSync.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Syncing between tabs/windows with the Kango extension framework
3 | * @param {Object} values Object that stores the key/val settings
4 | */
5 | class ExtensionSync {
6 | constructor(state, bus, body) {
7 | this.state = state;
8 | this.bus = bus;
9 | this.body = body;
10 | this.is_syncing = false;
11 | }
12 |
13 |
14 | isExtensionAvailable() {
15 | return !!(this.ext || window.__oc6789);
16 | }
17 |
18 |
19 | init(cb) {
20 | var cb_after_sync = null;
21 |
22 | this.bus.on('state.change', (changed, new_value, old_value, values) => {
23 | if (!this.is_syncing && changed === 'channel_list') {
24 | this.extCall('setChannels', new_value);
25 | }
26 | });
27 |
28 | this.body.addEventListener('__ocext', (event) => {
29 | var payload = JSON.parse(event.detail);
30 | if (payload[0] !== 'ext') {
31 | return;
32 | }
33 |
34 | if (payload[1] === 'channelsStateUpdated') {
35 | this.is_syncing = true;
36 | this.state.set('channel_list', payload[2]);
37 | this.is_syncing = false;
38 |
39 | if (cb_after_sync) {
40 | cb_after_sync();
41 | cb_after_sync = null;
42 | }
43 | }
44 | });
45 |
46 | cb_after_sync = cb;
47 | this.extCall('sendMeChannels');
48 | }
49 |
50 |
51 | extCall(function_name, args) {
52 | var payload = ['app', function_name, args];
53 | var event = new CustomEvent('__ocext', {
54 | detail: JSON.stringify(payload)
55 | });
56 | this.body.dispatchEvent(event);
57 | }
58 | }
59 |
60 |
61 | export default ExtensionSync;
62 |
--------------------------------------------------------------------------------
/src/Services/ModeratorToolbox.js:
--------------------------------------------------------------------------------
1 | var ModeratorToolbox = {};
2 |
3 | ModeratorToolbox.isActive = function() {
4 | return $('#tb-bottombar').length;
5 | };
6 |
7 | export default ModeratorToolbox;
8 |
--------------------------------------------------------------------------------
/src/Services/Notifications.js:
--------------------------------------------------------------------------------
1 | var notification_requesting_permission = false;
2 |
3 | // Click handlers on mobile app notifications are tracked by an ID. Keep track
4 | // of id=>clickhandlerFn callbacks
5 | var mobile_click_handlers = Object.create(null);
6 | var next_mobile_click_id = 0;
7 |
8 |
9 | // Mobile app injects a nsWebViewInterface event emitter into the window
10 | function isInMobileApp() {
11 | return ('nsWebViewInterface' in window);
12 | }
13 |
14 |
15 | // If we're in the mobile app, listen out for the click callback events
16 | if (isInMobileApp()) {
17 | window.nsWebViewInterface.on('notification_clicked', (event) => {
18 | var notification_id = event.id;
19 | var handler = mobile_click_handlers[notification_id];
20 |
21 | if (!handler) {
22 | return;
23 | }
24 |
25 | delete mobile_click_handlers[notification_id];
26 | _.each(handler._click_handlers, (fn) => {
27 | fn();
28 | });
29 | });
30 | }
31 |
32 |
33 | export function notificationState() {
34 | // Mobile app local notifications
35 | if (isInMobileApp()) {
36 | return 'ok';
37 | }
38 |
39 | if (notification_requesting_permission) {
40 | return 'requesting';
41 | }
42 |
43 | if (!('Notification' in window)) {
44 | return 'not_supported';
45 | }
46 |
47 | if (Notification.permission === 'granted') {
48 | return 'ok';
49 | }
50 |
51 | if (Notification.permission === 'denied') {
52 | return 'denied';
53 | }
54 |
55 | return 'needs_request';
56 | }
57 |
58 |
59 | export function requestPermission(cb) {
60 | notification_requesting_permission = true;
61 |
62 | Notification.requestPermission((permission) => {
63 | notification_requesting_permission = false;
64 | cb(permission);
65 | });
66 | }
67 |
68 |
69 | export function notify(title, body, icon) {
70 | if (notificationState() !== 'ok') {
71 | return false;
72 | }
73 |
74 | if (isInMobileApp()) {
75 | var click_id = next_mobile_click_id++;
76 | nsWebViewInterface.emit('notification', {
77 | id: click_id,
78 | title: title,
79 | body: body
80 | });
81 |
82 | // Luckily all mobile browsers supper defineProperty, so use it to
83 | // set click handlers to be consistent with desktop
84 | // notificaiton.onclick = function(){}
85 | var ret = {_click_handlers: []};
86 | Object.defineProperty(ret, 'onclick', {
87 | get: () => {
88 | return undefined;
89 | },
90 | set: (fn) => {
91 | // Lazily adding the click handlers reference so it doesn't
92 | // build up when not required
93 | mobile_click_handlers[click_id] = ret;
94 | ret._click_handlers.push(fn);
95 | }
96 | });
97 |
98 | return ret;
99 |
100 | } else {
101 | var options = {
102 | body: body,
103 | icon: icon || 'https://app.orangechat.io/assets/logo-color.png'
104 | }
105 | return new Notification(title, options);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Services/Orangechat.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Integration to the orangechat.io backend
3 | *
4 | * TODO: Should Services/Transport be in here?
5 | */
6 |
7 |
8 | function Orangechat(state) {
9 | this.state = state;
10 | this.sid = state('sid') || '';
11 | this.username = m.prop(state('username') || '');
12 | this.uid = m.prop(state('uid') || '');
13 | this.api_url = 'https://app.orangechat.io/app';
14 | this.subreddits = m.prop([]);
15 | }
16 |
17 |
18 | /**
19 | * Generate a singleton instance
20 | * @return {Orangechat}
21 | */
22 | Orangechat.instance = (function() {
23 | var instance = null;
24 |
25 | return function(state) {
26 | instance = instance || new Orangechat(state);
27 | return instance;
28 | };
29 | })();
30 |
31 |
32 | // TODO: Not a fan of this setter. Find a way to detect changes on m.prop() and use that
33 | Orangechat.prototype.setSid = function(sid) {
34 | this.sid = sid;
35 | this.state.set('sid', sid);
36 | };
37 | // TODO: Not a fan of this setter. Find a way to detect changes on m.prop() and use that
38 | Orangechat.prototype.setUsername = function(username, uid) {
39 | if (!username) {
40 | this.username('');
41 | this.uid('');
42 | this.state.set('username', '');
43 | this.state.set('uid', '');
44 | } else {
45 | this.username(username);
46 | this.uid(uid);
47 | this.state.set('username', username);
48 | this.state.set('uid', uid);
49 | }
50 | };
51 |
52 |
53 | /**
54 | * Build a URL to call the backend
55 | * @param {String} path The API path, ie. API method to be called
56 | * @param {Object} _args Object of querystring parameters
57 | * @return {String} A complete API URL
58 | */
59 | Orangechat.prototype.apiUrl = function(path, _args) {
60 | var args = _args || {};
61 | var querystring_params = [];
62 |
63 | args.sid = this.sid || '';
64 |
65 | for (var prop in args) {
66 | if (!args.hasOwnProperty(prop)) {
67 | continue;
68 | }
69 |
70 | querystring_params.push(prop + '=' + encodeURIComponent(args[prop]));
71 | }
72 |
73 | return this.api_url + path + '?' + querystring_params.join('&');
74 | };
75 |
76 | Orangechat.prototype.ping = function() {
77 | var deferred = m.deferred();
78 |
79 | m.request({method: 'GET', url: this.apiUrl('/ping')})
80 | .then((resp) => {
81 | var just_logged_in = false;
82 |
83 | if (resp && resp.sid) {
84 | this.setSid(resp.sid);
85 | }
86 |
87 | if (!resp.username) {
88 | this.setUsername('');
89 | deferred.resolve({});
90 |
91 | } else {
92 | // If we don't current have a username but have one now, then we must have
93 | // just logged in.
94 | just_logged_in = !this.username();
95 |
96 | // Just make sure we know our correct username
97 | this.setUsername(resp.username, resp.uid);
98 |
99 | deferred.resolve({
100 | just_logged_in: just_logged_in,
101 | username: resp.username,
102 | user_channel: resp.user_channel || '',
103 | channels: resp.channels
104 | });
105 | }
106 | });
107 |
108 | return deferred.promise;
109 | };
110 |
111 | Orangechat.prototype.pingLoop = function() {
112 | this.ping().then(() => {
113 | setTimeout(this.pingLoop.bind(this), 1000 * 60 * 3);
114 | });
115 | };
116 |
117 |
118 | /**
119 | * Get a list fo trending channels
120 | */
121 | Orangechat.prototype.trendingChannels = function() {
122 | var url = this.apiUrl('/channels/trending');
123 | return m.request({
124 | method: 'GET',
125 | url: url,
126 | background: true,
127 | initialValue: []
128 | });
129 | };
130 |
131 |
132 | /**
133 | * Create a private channel and return the channel name
134 | */
135 | Orangechat.prototype.createChannel = function(_invite_users) {
136 | var invite_users = [].concat(_invite_users);
137 | var url = this.apiUrl('/channels/create', {
138 | invite: invite_users.join(',')
139 | });
140 |
141 | return m.request({method: 'GET', url: url})
142 | .then((resp) => {
143 | return resp;
144 | });
145 | };
146 |
147 |
148 | /**
149 | * get the banlist for a channel
150 | */
151 | Orangechat.prototype.getBanlist = function(channel_name) {
152 | var url = this.apiUrl('/channels/banlist', {
153 | channel: channel_name
154 | });
155 | return m.request({
156 | method: 'GET',
157 | url: url,
158 | background: true,
159 | initialValue: []
160 | });
161 | };
162 |
163 |
164 | /**
165 | * Ban users from a channel
166 | */
167 | Orangechat.prototype.banFromChannel = function(channel_name, _ban_users) {
168 | var users = [].concat(_ban_users);
169 | var api_params = {
170 | users: users.join(','),
171 | channel: channel_name
172 | };
173 |
174 | var url = this.apiUrl('/channels/ban', api_params);
175 |
176 | return m.request({method: 'GET', url: url, background: true})
177 | .then((resp) => {
178 | return resp;
179 | });
180 | };
181 |
182 |
183 | /**
184 | * Unban users from a channel
185 | */
186 | Orangechat.prototype.unbanFromChannel = function(channel_name, _ban_users) {
187 | var users = [].concat(_ban_users);
188 | var api_params = {
189 | users: users.join(','),
190 | channel: channel_name
191 | };
192 |
193 | var url = this.apiUrl('/channels/unban', api_params);
194 |
195 | return m.request({method: 'GET', url: url, background: true})
196 | .then((resp) => {
197 | return resp;
198 | });
199 | };
200 |
201 |
202 | /**
203 | * Create a private channel and return the channel name
204 | */
205 | Orangechat.prototype.inviteToChannel = function(channel_name, _invite_users, _opts) {
206 | var invite_users = [].concat(_invite_users);
207 | var opts = _opts || {};
208 | var api_params = {
209 | invite: invite_users.join(','),
210 | channel: channel_name
211 | };
212 |
213 | if (opts.label) {
214 | api_params.label = opts.label;
215 | }
216 |
217 | var url = this.apiUrl('/channels/invite', api_params);
218 |
219 | return m.request({method: 'GET', url: url})
220 | .then((resp) => {
221 | return resp;
222 | });
223 | };
224 |
225 | Orangechat.prototype.updateChannel = function(channel_name, updates) {
226 | updates.channel = channel_name;
227 | var url = this.apiUrl('/channels/update', updates);
228 |
229 | return m.request({method: 'GET', url: url})
230 | .then((resp) => {
231 | return resp;
232 | });
233 | };
234 |
235 | Orangechat.prototype.channelUserlist = function(channel_name) {
236 | var url = this.apiUrl('/channels/users', {
237 | channel: channel_name
238 | });
239 |
240 | return m.request({method: 'GET', url: url})
241 | .then((resp) => {
242 | return resp;
243 | });
244 | };
245 |
246 | Orangechat.prototype.loadSubreddits = function(force_reddit_update) {
247 | var subreddits = this.subreddits;
248 | var url = this.apiUrl('/subreddits', force_reddit_update ? {refresh:1} : undefined);
249 |
250 | return $.getJSON(url, (response) => {
251 | var new_subreddits = [];
252 | if (!response) {
253 | return;
254 | }
255 |
256 | _.each(response, (item) => {
257 | new_subreddits.push({
258 | name: '/r/' + item.subreddit,
259 | short_name: item.subreddit
260 | });
261 | });
262 |
263 | new_subreddits = _.sortBy(new_subreddits, (sub) => {
264 | return sub.name.toLowerCase();
265 | });
266 |
267 | subreddits(new_subreddits);
268 |
269 | // TODO: This redraw shouldn't be here.
270 | m.redraw();
271 |
272 | }).fail(function() {
273 | });
274 | };
275 |
276 |
277 | /**
278 | * Auth into the API backend
279 | * This will either return the username if already auth'd or process the reddit OAuth
280 | * and handle any redirects until fully auth'd, and then returns the username.
281 | */
282 | Orangechat.prototype.auth = function() {
283 | var deferred = m.deferred();
284 |
285 | m.startComputation();
286 |
287 | function resolveAuth(result) {
288 | m.endComputation();
289 | deferred.resolve(result);
290 | }
291 | function rejectAuth(err) {
292 | m.endComputation();
293 | deferred.reject(err);
294 | }
295 |
296 | m.request({method: 'GET', url: this.apiUrl('/auth')})
297 | .then((resp) => {
298 |
299 | // Check if we have a session ID set (can't use cookies.. IE9< doesn't
300 | // support cookies over CORS)
301 | if (resp && resp.sid) {
302 | this.setSid(resp.sid);
303 | }
304 |
305 | if (!resp || resp.status === 'bad') {
306 | rejectAuth(resp.error || 'unknown_error');
307 | return;
308 | }
309 |
310 | // If instructed to redirect somewhere, do that now
311 | if (resp.redirect) {
312 | window.location = resp.redirect + '&return_url=' + encodeURIComponent(window.location.href);
313 | return;
314 | }
315 |
316 | if (resp.username) {
317 | this.setUsername(resp.username, resp.uid);
318 | resolveAuth({username: this.username()});
319 | }
320 | });
321 |
322 | return deferred.promise;
323 | };
324 |
325 |
326 | /**
327 | *
328 | */
329 | Orangechat.prototype.logout = function() {
330 | return m.request({method: 'GET', url: this.apiUrl('/logout')})
331 | .then((resp) => {
332 | this.setUsername('');
333 | });
334 | };
335 |
336 |
337 | /**
338 | * Convert 'type_string:{JSON structure}' into an object
339 | * @param {String} message Raw string, typically recieved from a window message
340 | * @return {Object} {type: 'string', args: {args}
341 | */
342 | function parsePostedMessage(message) {
343 | var result = {
344 | type: null,
345 | args: {}
346 | };
347 |
348 | var split_pos = message.indexOf(':');
349 | if (split_pos === -1) {
350 | return result;
351 | }
352 |
353 | result.type = message.substring(0, split_pos);
354 | try {
355 | result.args = JSON.parse(message.substring(split_pos + 1));
356 | } catch (err) {
357 | }
358 |
359 | return result;
360 | }
361 |
362 | export default Orangechat;
363 |
--------------------------------------------------------------------------------
/src/Services/Reddit.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Hacky API to get reddit.com info while embedded into reddit.com
3 | * TODO: This should be moved into the backend via oauth
4 | */
5 | var Reddit = {};
6 |
7 | Reddit.isLoggedIn = function() {
8 | return $('.user > a').attr('class') !== 'login-required';
9 | };
10 |
11 | Reddit.username = (function() {
12 | var username = $('.user > a').text();
13 | return function() {
14 | return username;
15 | }
16 | })();
17 |
18 | Reddit.currentSubreddit = function() {
19 | return $('.redditname:first a').text();
20 | };
21 |
22 | Reddit.subreddits = function() {
23 | var subreddits = m.prop([]);
24 |
25 | $.getJSON('/subreddits/mine.json', (response) => {
26 | var new_subreddits = [];
27 | if (!response || !response.data) {
28 | return;
29 | }
30 |
31 | $.each(response.data.children, (idx, item) => {
32 | new_subreddits.push({
33 | name: '/r/' + item.data.display_name,
34 | short_name: item.data.display_name
35 | });
36 | });
37 |
38 | new_subreddits = _.sortBy(new_subreddits, (sub) => {
39 | return sub.name.toLowerCase();
40 | });
41 |
42 | subreddits(new_subreddits);
43 | m.redraw();
44 |
45 | }).fail(function() {
46 | });
47 |
48 | return subreddits;
49 | };
50 |
51 | Reddit.injectUserbarIcon = function(app, bus) {
52 | // Get the preferences link as we want to inject just before that
53 | var $preferences = $('#header-bottom-right a.pref-lang').parents('.flat-list:first');
54 | var $sep = $('| ');
55 | var $icon = $('im ');
56 |
57 | $sep.insertBefore($preferences);
58 | $icon.insertBefore($sep);
59 |
60 | $icon.on('click', (event) => {
61 | event.preventDefault();
62 | app.toggle();
63 | // Mithril won't detect the redraw as the icon is outside of the app
64 | m.redraw();
65 | });
66 |
67 | var alert_level = 0;
68 | function setAlertLevel(level) {
69 | if (level > alert_level) {
70 | alert_level = level;
71 | setIconStyles();
72 | }
73 | }
74 | function resetAlerts() {
75 | alert_level = 0;
76 | setIconStyles();
77 | }
78 | function setIconStyles() {
79 | if (alert_level === 0) {
80 | $icon.css({
81 | 'font-weight': 'normal',
82 | 'color': ''
83 | });
84 | } else if (alert_level === 1) {
85 | $icon.css({
86 | 'font-weight': 'bold',
87 | 'color': ''
88 | });
89 | } else if (alert_level === 2) {
90 | $icon.css({
91 | 'font-weight': 'bold',
92 | 'color': 'orangered'
93 | });
94 | }
95 | }
96 |
97 | bus.on('im.message', (message) => {
98 | if (window.document.hasFocus && window.document.hasFocus() && app.state('is_open')) {
99 | return;
100 | }
101 |
102 | setAlertLevel(1);
103 | if (message.content.toLowerCase().indexOf(Reddit.username().toLowerCase()) > -1) {
104 | setAlertLevel(2);
105 | }
106 | });
107 |
108 | bus.on('app.toggle', (is_open) => {
109 | if (is_open) {
110 | resetAlerts();
111 | }
112 | });
113 |
114 | bus.on('channel.active', (channel) => {
115 | // Consider this as the user being active and doing stuff, so they've seen the alert
116 | resetAlerts();
117 | });
118 | };
119 |
120 | Reddit.hookOcButtons = function(bus) {
121 | var $buttons = $('a[href^="https://app.orangechat.io"]');
122 | $buttons.on('click', function(event) {
123 | var url = $(this).attr('href');
124 | if (url && !url.match(/#./)) {
125 | return;
126 | }
127 |
128 | var channel = url.split('#')[1].split(',')[0];
129 | bus.trigger('action.ocbutton.click', event, channel);
130 | });
131 | };
132 |
133 | export default Reddit;
134 |
--------------------------------------------------------------------------------
/src/Services/Settings.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple settings object
3 | * @param {Object} values Object that stores the key/val settings
4 | */
5 | function Settings(values) {
6 | var instance;
7 |
8 | var get = function(key, default_val) {
9 | var val = values[key];
10 | return (val === null || val === undefined) ?
11 | default_val :
12 | val;
13 | };
14 |
15 | var set = function(key, val) {
16 | var old_val = values[key];
17 | values[key] = val;
18 | if (typeof instance.onChange === 'function') {
19 | instance.onChange(key, val, old_val, values);
20 | }
21 | };
22 |
23 | // Support:
24 | // instance(getter)
25 | // instance.get(getter)
26 | // instance.set(setter)
27 |
28 | instance = get;
29 | instance.get = get;
30 | instance.set = set;
31 | instance.values = values;
32 |
33 | // This .onChange as a property is bad mmkay. Add it as an event listener or something
34 | instance.onChange = null;
35 |
36 | return instance;
37 | }
38 |
39 |
40 | export default Settings;
41 |
--------------------------------------------------------------------------------
/src/Services/Storage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple wrapper around localStorage
3 | */
4 | var Storage = {};
5 |
6 | var store;
7 |
8 | try {
9 | if (typeof window.localStorage == 'undefined' || window.localStorage === null) {
10 | store = CookieStorage();
11 | } else {
12 | store = window.localStorage;
13 | }
14 | } catch(err) {
15 | store = CookieStorage();
16 | }
17 |
18 | Storage.get = function(key, default_val) {
19 | var val = null;
20 |
21 | try {
22 | val = store.getItem(key);
23 | val = JSON.parse(val);
24 | } catch (err) {}
25 |
26 | return (val === null) ?
27 | default_val :
28 | val;
29 | };
30 |
31 | Storage.set = function(key, val) {
32 | try {
33 | store.setItem(key, JSON.stringify(val));
34 | } catch (err) {
35 | console.error(err);
36 | }
37 | };
38 |
39 | export default Storage;
40 |
41 |
42 |
43 |
44 |
45 |
46 | function CookieStorage(type) {
47 | function createCookie(name, value, days) {
48 | var date, expires;
49 |
50 | if (days) {
51 | date = new Date();
52 | date.setTime(date.getTime()+(days*24*60*60*1000));
53 | expires = "; expires="+date.toGMTString();
54 | } else {
55 | expires = "";
56 | }
57 | document.cookie = name+"="+value+expires+"; path=/";
58 | }
59 |
60 | function readCookie(name) {
61 | var nameEQ = name + "=",
62 | ca = document.cookie.split(';'),
63 | i, c;
64 |
65 | for (i=0; i < ca.length; i++) {
66 | c = ca[i];
67 | while (c.charAt(0)==' ') {
68 | c = c.substring(1,c.length);
69 | }
70 |
71 | if (c.indexOf(nameEQ) == 0) {
72 | return c.substring(nameEQ.length,c.length);
73 | }
74 | }
75 | return null;
76 | }
77 |
78 | function setData(data) {
79 | data = JSON.stringify(data);
80 | if (type == 'session') {
81 | window.name = data;
82 | } else {
83 | createCookie('localStorage', data, 365);
84 | }
85 | }
86 |
87 | function clearData() {
88 | if (type == 'session') {
89 | window.name = '';
90 | } else {
91 | createCookie('localStorage', '', 365);
92 | }
93 | }
94 |
95 | function getData() {
96 | var data = type == 'session' ? window.name : readCookie('localStorage');
97 | return data ? JSON.parse(data) : {};
98 | }
99 |
100 |
101 | // initialise if there's already data
102 | var data = getData();
103 |
104 | return {
105 | length: 0,
106 | clear: function () {
107 | data = {};
108 | this.length = 0;
109 | clearData();
110 | },
111 | getItem: function (key) {
112 | return data[key] === undefined ? null : data[key];
113 | },
114 | key: function (i) {
115 | // not perfect, but works
116 | var ctr = 0;
117 | for (var k in data) {
118 | if (ctr == i) return k;
119 | else ctr++;
120 | }
121 | return null;
122 | },
123 | removeItem: function (key) {
124 | delete data[key];
125 | this.length--;
126 | setData(data);
127 | },
128 | setItem: function (key, value) {
129 | data[key] = value+''; // forces the value to a string
130 | this.length++;
131 | setData(data);
132 | }
133 | };
134 | }
135 |
--------------------------------------------------------------------------------
/src/Services/Transport.js:
--------------------------------------------------------------------------------
1 | import SockJS from 'sockjs-client';
2 |
3 | var MESSAGE_TYPE_JOIN = '01';
4 | var MESSAGE_TYPE_LEAVE = '02';
5 | var MESSAGE_TYPE_MESSAGE = '03';
6 | var MESSAGE_TYPE_GROUPMETA = '04';
7 | var MESSAGE_TYPE_TAGCOUNTS = '08';
8 |
9 | class Transport {
10 |
11 | constructor(sid, bus) {
12 | this.sid = null;
13 | this.bus = bus;
14 | this.connected = false;
15 | this.queue = [];
16 |
17 | this.setSessionId(sid);
18 | this.updateGroupMetaLoop();
19 | }
20 |
21 | setSessionId(sid) {
22 | this.sid = sid;
23 | if (sid) {
24 | this.initSocket();
25 | }
26 | }
27 |
28 | initSocket() {
29 | var self = this;
30 | var reconnect_attempts = 0;
31 |
32 | connectSocket();
33 |
34 | function connectSocket() {
35 | self.bus.trigger('transport.connecting', {
36 | reconnect_attempts: reconnect_attempts
37 | });
38 | self.sock = new SockJS('https://app.orangechat.io/transport2?sid=' + self.sid);
39 | self.sock.onopen = onOpen;
40 | self.sock.onclose = onClose;
41 | self.sock.onmessage = onMessage;
42 | }
43 |
44 | function onOpen() {
45 | self.bus.trigger('transport.open', {
46 | was_reconnection: reconnect_attempts > 0,
47 | reconnect_attempts: reconnect_attempts
48 | });
49 |
50 | reconnect_attempts = 0;
51 | self.connected = true;
52 | self.flushSocket();
53 | }
54 |
55 | function onMessage(event) {
56 | var message_type = event.data.substring(0, 2);
57 | var raw_message = event.data.substring(2);
58 | var message;
59 |
60 | if (message_type === MESSAGE_TYPE_MESSAGE) {
61 | try {
62 | message = JSON.parse(raw_message);
63 | self.bus.trigger('transport.message', message);
64 | } catch (err) {
65 | console.log(err);
66 | }
67 |
68 | } else if (message_type === MESSAGE_TYPE_GROUPMETA) {
69 | var groups = {};
70 | _.map(raw_message.split(' '), function(group_meta) {
71 | var parts = group_meta.split(':');
72 | groups[parts[0]] = {
73 | name: parts[0],
74 | num_users: parseInt(parts[1], 10)
75 | };
76 | });
77 |
78 | m.startComputation();
79 | self.bus.trigger('transport.groupmeta', groups);
80 | m.endComputation();
81 | }
82 | }
83 |
84 | function onClose() {
85 | self.connected = false;
86 | self.bus.trigger('transport.close');
87 |
88 | setTimeout(function() {
89 | reconnect_attempts++;
90 | connectSocket();
91 | }, reconnectInterval(reconnect_attempts));
92 | }
93 |
94 | // Exponential backoff upto 1min for reconnections
95 | function reconnectInterval(attempt_num) {
96 | var interval = Math.pow(2, attempt_num) - 1;
97 | interval = Math.min(60, interval);
98 | return interval * 1000;
99 | }
100 | }
101 |
102 | join(group) {
103 | var group_str = [].concat(group).join(',');
104 | this.queue.push(MESSAGE_TYPE_JOIN + group_str);
105 | this.flushSocket();
106 | }
107 |
108 | leave(group) {
109 | var group_str = [].concat(group).join(',');
110 | this.queue.push(MESSAGE_TYPE_LEAVE + group_str);
111 | this.flushSocket();
112 | }
113 |
114 | updateGroupMeta() {
115 | this.queue.push(MESSAGE_TYPE_TAGCOUNTS);
116 | this.flushSocket();
117 | }
118 |
119 | updateGroupMetaLoop() {
120 | if (this.connected) {
121 | this.updateGroupMeta();
122 | }
123 | setTimeout(_.bind(this.updateGroupMetaLoop, this), 10000);
124 | }
125 |
126 | flushSocket() {
127 | if (!this.sock || this.sock.readyState !== 1) {
128 | return;
129 | }
130 |
131 | _.each(this.queue, (data) => {
132 | this.sock.send(data);
133 | });
134 |
135 | this.queue = [];
136 | }
137 |
138 | }
139 |
140 | export default Transport;
141 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import * as Helpers from './Helpers/Helpers.js';
2 | import ChatApp from './Components/ChatApp/ChatApp.jsx';
3 | import Storage from './Services/Storage.js';
4 | import Settings from './Services/Settings.js';
5 | import EventBus from './Services/EventBus.js';
6 | import ExtensionSync from './Services/ExtensionSync.js';
7 |
8 | (function loadApp() {
9 | // Don't run on reddit API pages such as the oauth login page
10 | if (window.location.href.indexOf('reddit.com/api/') > -1 || window.location.href.indexOf('reddit.com/login') > -1) {
11 | return;
12 | }
13 |
14 | var body = document.querySelector('body');
15 | var extension_detected = false;
16 | var extension = null;
17 |
18 | // The extension adds an __ocext to the body, remove it as it's only needed
19 | // for us to detect the precense of the extension.
20 | if (body.getAttribute('__ocext')) {
21 | body.removeAttribute('__ocext');
22 | extension_detected = true;
23 | }
24 |
25 |
26 | // Lets not swallow all errors. This causes headaches
27 | m.deferred.onerror = function(err) {
28 | console.error(err.stack);
29 | };
30 |
31 |
32 | var event_bus = initEventBus();
33 | var app_state = null;
34 |
35 | initState(event_bus)
36 | .then((state) => {
37 | app_state = state;
38 | })
39 | .then(initUi);
40 |
41 |
42 |
43 |
44 | function initUi() {
45 | var $app = $('').appendTo($('#chat'));
46 | m.mount($app[0], Helpers.subModule(ChatApp, {
47 | bus: event_bus,
48 | state: app_state,
49 | has_extension: extension_detected
50 | }));
51 | }
52 |
53 |
54 | function initEventBus() {
55 | var event_bus = new EventBus();
56 | event_bus.on('all', function(event_name) {
57 | //console.log('[event bus] ' + event_name);
58 | });
59 |
60 | return event_bus;
61 | }
62 |
63 |
64 | function initState(bus) {
65 | var deferred = m.deferred();
66 |
67 | // Keep application state in one place so that page refreshing keeps
68 | // the most important things consistent (minimised, active channel, etc)
69 | var state_obj = Storage.get('oc-state');
70 | if (typeof state_obj !== 'object') {
71 | state_obj = {
72 | // is_open = render the whole app. false = minimised
73 | is_open: true,
74 |
75 | // Current subscribed channels
76 | channel_list: [],
77 |
78 | // Active channel name
79 | active_channel: '',
80 |
81 | // state of the sidebar
82 | is_sidebar_open: true
83 | };
84 | }
85 |
86 | var state = new Settings(state_obj);
87 | var state_fully_loaded = false;
88 | state.onChange = function(changed, new_value, old_value, values) {
89 | //console.log('state.onChange()', changed, new_value, old_value);
90 | // We only want to deal with state changes once it has been fully loaded as
91 | // the extension may modify/sync it's state first
92 | if (!state_fully_loaded) {
93 | return;
94 | }
95 |
96 | // If we're syncing state from the extension, don't save it to storage yet.
97 | // It will be stored when the page is closed or refreshed
98 | if (changed === 'channel_list' && extension && extension.is_syncing) {
99 | } else {
100 | Storage.set('oc-state', values);
101 | }
102 |
103 | bus.trigger('state.change', changed, new_value, old_value, values);
104 | };
105 |
106 | if (extension_detected) {
107 | extension = new ExtensionSync(state, bus, body);
108 | extension.init(() => {
109 | state_fully_loaded = true;
110 | deferred.resolve(state);
111 | });
112 |
113 | } else {
114 | state_fully_loaded = true;
115 | deferred.resolve(state);
116 | }
117 |
118 | return deferred.promise;
119 | }
120 | })();
121 |
--------------------------------------------------------------------------------
/src/stylus/variables.styl:
--------------------------------------------------------------------------------
1 | // To allow for easy styling and to prevent class clashes
2 | prefix = 'OC'
3 |
4 | // Brand colors
5 | brand-primary = #E64A19
6 | brand-hover = #F4511E
7 | brand-dividers = #FF5722
8 | brand-active = #FF5722
9 | brand-light = #FF8A65
10 |
11 | // Typography
12 | font-family = "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif
13 | base-font-size = 14px
14 | base-line-height = 20px
15 |
16 | // Material design based blacks and whites
17 | white-text = alpha(#FFFFFF, 100%)
18 | white-secondary = alpha(#FFFFFF, 70%)
19 | white-hint = alpha(#FFFFFF, 50%)
20 | white-underlays = alpha(#FFFFFF, 25%)
21 | white-dividers = alpha(#FFFFFF, 18%)
22 | black-text = alpha(#000000, 87%)
23 | black-secondary = alpha(#000000, 54%)
24 | black-hint = alpha(#000000, 38%)
25 | black-dividers = alpha(#000000, 13%)
26 | black-underlays = alpha(#000000, 4%)
27 |
28 | // Layout options
29 | topbar-height = 38px
30 | sidebar-width = 220px
31 | base-vs = 10px // Vertical spacing
32 | base-hs = 16px // Horizontal spacing
33 | base-cs = base-vs base-hs // Combined spacing
34 |
35 | // Convert pixel values to em values
36 | em(val, base = 14px)
37 | return unit(val / base, 'em')
38 |
39 | // Simple clearfix from http://nicolasgallagher.com/micro-clearfix-hack/
40 | clearfix()
41 | zoom: 1
42 |
43 | &:after
44 | &:before
45 | content: ""
46 | display: table
47 |
48 | &:after
49 | clear: both
50 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var poststylus = require('poststylus');
3 | var path = require('path')
4 |
5 | module.exports = {
6 | devServer: {
7 | contentBase: 'public',
8 | host: process.env.HOST || '127.0.0.1',
9 | port: process.env.PORT || 8000,
10 | stats: {
11 | colors: true
12 | },
13 | noInfo: true,
14 | inline: true
15 | },
16 | devtool: 'source-map',
17 | entry: path.resolve(__dirname, 'src'),
18 | output: {
19 | path: 'public/builds/',
20 | publicPath: '/builds/',
21 | filename: 'bundle.js'
22 | },
23 | resolve: {
24 | extensions: ['', '.js', '.jsx'],
25 | root: [
26 | path.resolve(__dirname, 'src')
27 | ]
28 | },
29 | module: {
30 | loaders: [
31 | {
32 | test: /\.jsx?$/,
33 | loader: 'babel',
34 | include: path.resolve(__dirname, 'src'),
35 | query: {
36 | presets: ['es2015'],
37 | plugins: ['mjsx']
38 | },
39 | },
40 | {
41 | test: /\.styl/,
42 | loader: 'style!css!stylus',
43 | },
44 | {
45 | test: /\.html/,
46 | loader: 'html'
47 | }
48 | ],
49 | },
50 | stylus: {
51 | use: [
52 | function (stylus) {
53 | stylus.import(path.resolve(__dirname, 'src/stylus/variables'));
54 | },
55 | poststylus([ 'autoprefixer' ])
56 | ]
57 | },
58 | plugins: [
59 | new webpack.ProvidePlugin({
60 | m: 'mithril'
61 | })
62 | ]
63 | }
64 |
--------------------------------------------------------------------------------