├── .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 | 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 |
75 |
{title}
76 | {controller.active_menu.view()} 77 |
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 |
32 | Open moderators channel 33 | Channel settings 34 |
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 | 210 | 211 | 212 | 213 | 214 | 220 | Subreddit history 221 | 222 | 223 | 227 | 233 | Notes 234 | 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 | , 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 |
  • 699 | 700 | 701 | 702 |
  • 703 | ); 704 | } 705 | 706 | if(controller.access.is_reddit_mod) { 707 | tabs.push( 708 |
  • 709 | 710 | 711 | 712 |
  • 713 | ); 714 | } 715 | 716 | tabs.push( 717 |
  • 721 | 722 | 723 | 724 |
  • 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 |
    737 |
    738 | 739 | 740 | 741 |
    742 |
    743 |

    {title}

    744 |
    745 |
    746 | 749 | 754 |
    755 |
    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 |
    74 | Access via IRC! 75 |
    76 | Prefer IRC? You can access this channel via irc.snoonet.org/{args.irc_channel} 77 |
    78 | 81 |
    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 |
    92 | Close 93 |
    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 |
    Ban list
    160 |

    Users banned from this channel.

    161 | 162 | {list} 163 |
    164 |
    165 | ); 166 | }; 167 | 168 | ModSettings.viewOperators = function(controller) { 169 | return ( 170 |
    171 |
    Channel operators
    172 |

    Promote non-moderators to help operate the orangechat channel.

    173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 |
    prawnsaladremove
    someone elseremove
    other_personremove
    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 |
    IRC link
    204 |

    Link this channel to an IRC channel on irc.snoonet.org.

    205 | 210 | {status} 211 |
    212 | ); 213 | }; 214 | 215 | ModSettings.viewFloodControl = function(controller) { 216 | return ( 217 |
    218 |
    Flood control
    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 |
    50 | {'/u/' + controller.orangechat.username()} 51 |
    52 | ); 53 | 54 | items.push( 55 | Preferences 59 | ); 60 | 61 | var is_logged_in = !!controller.orangechat.username(); 62 | if (is_logged_in) { 63 | items.push( 64 | Logout 68 | ); 69 | } 70 | 71 | items.push(
    ); 72 | 73 | if (!Helpers.hasExtension() && !Helpers.isInReddit()) { 74 | items.push( 75 | Download browser extension 80 | ); 81 | } 82 | 83 | items.push( 84 | orangechat.io 89 | ); 90 | items.push( 91 | Twitter 96 | ); 97 | 98 | return ( 99 |
    100 | {items} 101 |
    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 |
    121 | 122 | Find more channels 123 | 124 |
    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 | --------------------------------------------------------------------------------