├── .gitignore ├── README.md ├── app.yaml ├── appengine_config.py ├── chat.tpl ├── chat_admin.tpl ├── generateAllowedEmoji.js ├── gulpfile.js ├── index.yaml ├── js ├── admin │ └── index.js ├── arrayFind.js ├── chatStore.js ├── page │ ├── index.js │ └── views │ │ ├── Chat.js │ │ ├── GlobalWarning.js │ │ ├── MessageInput.js │ │ ├── conf │ │ └── emoji.js │ │ └── templates │ │ ├── chatItem.hbs │ │ └── keyboard.hbs ├── sw │ └── index.js ├── toArray.js └── toMessageObj.js ├── jsconfig.json ├── main.py ├── package.json ├── requirements.txt ├── scss ├── _global.scss ├── _layout.scss ├── admin.scss ├── app.scss └── components │ ├── _global-warning.scss │ ├── _keyboard.scss │ ├── _message-form.scss │ ├── _messages.scss │ └── _toolbar.scss ├── setup.tpl ├── static ├── fonts │ └── roboto.woff └── imgs │ └── hangouts.png └── vendor.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | # don't include third-party dependencies. 3 | lib/ 4 | !lib/README.md 5 | .DS_Store 6 | node_modules 7 | static/css 8 | static/js 9 | myapp.datastore -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Push API demo on App Engine 2 | 3 | A demo App Engine server using https://w3c.github.io/push-api/ to send push 4 | messages to web browsers. 5 | 6 | Live version: https://jakearchibald-gcm.appspot.com/ 7 | 8 | Currently requires Chrome 42+. Other browsers will be added as the implement the 9 | Push and Notifications APIs (Firefox Nightly already has partial support). 10 | 11 | See [requirements.txt][1] for instructions on installing dependencies, if you 12 | want to run your own copy on App Engine. 13 | 14 | [1]: https://github.com/jakearchibald/push-api-appengine-demo/blob/master/requirements.txt 15 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # This file specifies your Python application's runtime configuration 2 | # including URL routing, versions, static file uploads, etc. See 3 | # https://developers.google.com/appengine/docs/python/config/appconfig 4 | # for details. 5 | 6 | application: jakearchibald-gcm 7 | version: 1 8 | runtime: python27 9 | api_version: 1 10 | threadsafe: true 11 | 12 | builtins: 13 | - admin_redirect: on 14 | - appstats: on 15 | 16 | # Handlers define how to route requests to your application. 17 | handlers: 18 | 19 | # Interative python console for admins only. 20 | - url: /admin/.* 21 | script: google.appengine.ext.admin.application 22 | login: admin 23 | secure: always 24 | 25 | # Only admins can access setup. 26 | - url: /setup 27 | script: main.app 28 | login: admin 29 | secure: always 30 | 31 | # App Engine serves and caches static files contained in the listed directories 32 | # (and subdirectories). Uncomment and set the directory as needed. 33 | - url: /static 34 | static_dir: static 35 | secure: always 36 | expiration: 0s 37 | 38 | - url: /sw.js 39 | static_files: static/js/sw.js 40 | upload: static/js/.* 41 | secure: always 42 | expiration: 0s 43 | 44 | - url: /sw.js.map 45 | static_files: static/js/sw.js.map 46 | upload: static/js/.* 47 | secure: always 48 | expiration: 0s 49 | 50 | # This handler tells app engine how to route requests to a WSGI application. 51 | # The script value is in the format . 52 | # where is a WSGI application object. 53 | 54 | # exceptions to the login rule 55 | - url: /messages.json 56 | script: main.app 57 | secure: always 58 | 59 | - url: /send 60 | script: main.app 61 | secure: always 62 | 63 | - url: /manifest.json 64 | script: main.app 65 | secure: always 66 | 67 | - url: .* # This regex directs all routes to main.bottle 68 | script: main.app 69 | secure: always 70 | login: required 71 | 72 | # Third party libraries that are included in the App Engine SDK must be listed 73 | # here if you want to use them. See 74 | # https://developers.google.com/appengine/docs/python/tools/libraries27 for 75 | # a list of libraries included in the SDK. Third party libs that are *not* part 76 | # of the App Engine SDK don't need to be listed here, instead add them to your 77 | # project directory, either as a git submodule or as a plain subdirectory. 78 | # TODO: List any other App Engine SDK libs you may need here. 79 | #libraries: 80 | #- name: jinja2 81 | # version: latest 82 | 83 | skip_files: 84 | - ^(.*/)?.*/node_modules/.*$ 85 | - ^(node_modules/.*) -------------------------------------------------------------------------------- /appengine_config.py: -------------------------------------------------------------------------------- 1 | """`appengine_config` gets loaded when starting a new application instance.""" 2 | import vendor 3 | # insert `lib` as a site directory so our `main` module can load 4 | # third-party libraries, and override built-ins with newer 5 | # versions. 6 | vendor.add('lib') 7 | 8 | appstats_CALC_RPC_COSTS = True 9 | appstats_SHELL_OK = True 10 | 11 | def webapp_add_wsgi_middleware(app): 12 | from google.appengine.ext.appstats import recording 13 | app = recording.appstats_wsgi_middleware(app) 14 | return app 15 | -------------------------------------------------------------------------------- /chat.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Emojoy! 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

Emojoy!

13 | Logout 14 |
15 |
16 |
17 |
    18 |
    19 |
    20 |
    21 | 22 | 25 |
    26 |
    27 |
    28 |
    29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /chat_admin.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Chat App Admin 4 | 5 | 6 | 7 | 8 |

    Chat App Admin

    9 | 10 | 15 | -------------------------------------------------------------------------------- /generateAllowedEmoji.js: -------------------------------------------------------------------------------- 1 | var emoji = require('./js/page/views/conf/emoji.js'); 2 | console.log( 3 | Object.keys(emoji).map(function(key) { 4 | return emoji[key]; 5 | }).join('').replace(/,/g, '') 6 | ); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var plugins = require('gulp-load-plugins')(); 3 | var runSequence = require('run-sequence'); 4 | var watchify = require('watchify'); 5 | var browserify = require('browserify'); 6 | var uglifyify = require('uglifyify'); 7 | var mergeStream = require('merge-stream'); 8 | var source = require('vinyl-source-stream'); 9 | var buffer = require('vinyl-buffer'); 10 | var babelify = require('babelify'); 11 | var hbsfy = require('hbsfy'); 12 | var configurify = require('configurify'); 13 | 14 | gulp.task('clean', function (done) { 15 | require('del')(['static/js', 'static/css'], done); 16 | }); 17 | 18 | gulp.task('css', function () { 19 | return gulp.src('scss/*.scss') 20 | .pipe(plugins.sourcemaps.init()) 21 | .pipe(plugins.sass({ outputStyle: 'compressed' })) 22 | .pipe(plugins.sourcemaps.write('./')) 23 | .pipe(gulp.dest('static/css')); 24 | }); 25 | 26 | function createBundler(src) { 27 | var b; 28 | 29 | if (plugins.util.env.production) { 30 | b = browserify(); 31 | } 32 | else { 33 | b = browserify({ 34 | cache: {}, packageCache: {}, fullPaths: true, 35 | debug: true 36 | }); 37 | } 38 | 39 | b.transform(configurify); 40 | b.transform(babelify.configure({ 41 | stage: 1 42 | })); 43 | 44 | b.transform(hbsfy); 45 | 46 | if (plugins.util.env.production) { 47 | b.transform({ 48 | global: true 49 | }, 'uglifyify'); 50 | } 51 | 52 | b.add(src); 53 | return b; 54 | } 55 | 56 | var bundlers = { 57 | 'page.js': createBundler('./js/page/index.js'), 58 | 'sw.js': createBundler('./js/sw/index.js'), 59 | 'admin.js': createBundler('./js/admin/index.js') 60 | }; 61 | 62 | function bundle(bundler, outputPath) { 63 | var splitPath = outputPath.split('/'); 64 | var outputFile = splitPath[splitPath.length - 1]; 65 | var outputDir = splitPath.slice(0, -1).join('/'); 66 | 67 | return bundler.bundle() 68 | // log errors if they happen 69 | .on('error', plugins.util.log.bind(plugins.util, 'Browserify Error')) 70 | .pipe(source(outputFile)) 71 | .pipe(buffer()) 72 | .pipe(plugins.sourcemaps.init({ loadMaps: true })) // loads map from browserify file 73 | .pipe(plugins.sourcemaps.write('./')) // writes .map file 74 | .pipe(gulp.dest('static/js/' + outputDir)); 75 | } 76 | 77 | gulp.task('js', function () { 78 | return mergeStream.apply(null, 79 | Object.keys(bundlers).map(function(key) { 80 | return bundle(bundlers[key], key); 81 | }) 82 | ); 83 | }); 84 | 85 | gulp.task('watch', ['build'], function () { 86 | gulp.watch(['scss/**/*.scss'], ['css']); 87 | 88 | Object.keys(bundlers).forEach(function(key) { 89 | var watchifyBundler = watchify(bundlers[key]); 90 | watchifyBundler.on('update', function() { 91 | return bundle(watchifyBundler, key); 92 | }); 93 | bundle(watchifyBundler, key); 94 | }); 95 | }); 96 | 97 | gulp.task('build', ['js', 'css']); 98 | 99 | gulp.task('serve', ['watch'], plugins.shell.task([ 100 | 'dev_appserver.py --datastore_path=myapp.datastore --port=9999 --admin_port=9998 app.yaml' 101 | ])); 102 | 103 | gulp.task('deploy', ['build'], plugins.shell.task([ 104 | 'appcfg.py update app.yaml' 105 | ])); 106 | 107 | gulp.task('default', ['build']); 108 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | - kind: Message 3 | ancestor: yes 4 | properties: 5 | - name: creation_date 6 | direction: desc 7 | 8 | - kind: Message 9 | ancestor: yes 10 | properties: 11 | - name: user 12 | - name: creation_date 13 | direction: desc -------------------------------------------------------------------------------- /js/admin/index.js: -------------------------------------------------------------------------------- 1 | window.$ = document.querySelector.bind(document); 2 | 3 | function setStatus(buttonName, className, text) { 4 | var result = $('#' + buttonName + '-result'); 5 | result.textContent = " " + text; 6 | if (!text) 7 | return; 8 | result.className = className; 9 | console.log(buttonName + " " + className + ": " + text); 10 | } 11 | 12 | function clearRegistrations(type) { 13 | console.log("Sending clear " + type + " registrations to " + location.hostname + "..."); 14 | var statusId = 'clear-' + type; 15 | setStatus(statusId, '', ""); 16 | 17 | var xhr = new XMLHttpRequest(); 18 | xhr.onload = function() { 19 | if (('' + xhr.status)[0] != '2') { 20 | setStatus(statusId, "Server error " + xhr.status 21 | + ": " + xhr.statusText); 22 | } else { 23 | setStatus(statusId, 'success', "Cleared."); 24 | } 25 | }; 26 | xhr.onerror = xhr.onabort = function() { 27 | setStatus(statusId, 'fail', "Failed to send!"); 28 | }; 29 | xhr.open('POST', '/' + type + '/clear-registrations'); 30 | xhr.send(); 31 | } -------------------------------------------------------------------------------- /js/arrayFind.js: -------------------------------------------------------------------------------- 1 | if (!Array.prototype.find) { 2 | Array.prototype.find = function(predicate) { 3 | if (this == null) { 4 | throw new TypeError('Array.prototype.find called on null or undefined'); 5 | } 6 | if (typeof predicate !== 'function') { 7 | throw new TypeError('predicate must be a function'); 8 | } 9 | var list = Object(this); 10 | var length = list.length >>> 0; 11 | var thisArg = arguments[1]; 12 | var value; 13 | 14 | for (var i = 0; i < length; i++) { 15 | value = list[i]; 16 | if (predicate.call(thisArg, value, i, list)) { 17 | return value; 18 | } 19 | } 20 | return undefined; 21 | }; 22 | } -------------------------------------------------------------------------------- /js/chatStore.js: -------------------------------------------------------------------------------- 1 | let dbPromise; 2 | 3 | function getDb() { 4 | if (!dbPromise) { 5 | dbPromise = openDb(); 6 | } 7 | return dbPromise; 8 | } 9 | 10 | function tx(stores, mode, callback) { 11 | return getDb().then(db => { 12 | return new Promise((resolve, reject) => { 13 | const transaction = db.transaction(stores, mode); 14 | const request = callback(transaction); 15 | 16 | if (request instanceof IDBRequest) { 17 | request.onsuccess = _ => resolve(request.result); 18 | } 19 | else if (request) { 20 | resolve(request); 21 | } 22 | 23 | transaction.onerror = _ => reject(transaction.error); 24 | transaction.oncomplete = _ => resolve(transaction.result); 25 | }); 26 | }); 27 | } 28 | 29 | function iterate(cursorRequest, callback) { 30 | return new Promise((resolve, reject) => { 31 | cursorRequest.onerror = _ => reject(request.error); 32 | cursorRequest.onsuccess = _ => { 33 | if (!cursorRequest.result) { 34 | resolve(); 35 | return; 36 | } 37 | callback(cursorRequest.result, resolve); 38 | }; 39 | }); 40 | } 41 | 42 | function getAll(cursorable) { 43 | if ('getAll' in cursorable) { 44 | return cursorable.getAll(); 45 | } 46 | 47 | var items = []; 48 | 49 | return iterate(cursorable.openCursor(), (cursor) => { 50 | items.push(cursor.value); 51 | cursor.continue(); 52 | }).then(_ => items); 53 | } 54 | 55 | function openDb() { 56 | return new Promise((resolve, reject) => { 57 | const request = indexedDB.open("push-chat", 1); 58 | request.onupgradeneeded = _ => { 59 | const db = request.result; 60 | var outbox = db.createObjectStore('outbox', {keyPath: 'id'}); 61 | outbox.createIndex('by-date', 'date'); 62 | var chat = db.createObjectStore('chat', {keyPath: 'id'}); 63 | chat.createIndex('by-date', 'date'); 64 | db.createObjectStore('keyval'); 65 | }; 66 | request.onerror = _ => reject(request.error); 67 | request.onsuccess = _ => resolve(request.result); 68 | }); 69 | } 70 | 71 | export function addToOutbox(message) { 72 | return tx('outbox', 'readwrite', transaction => { 73 | transaction.objectStore('outbox').add(message); 74 | }); 75 | } 76 | 77 | export function removeFromOutbox(id) { 78 | return tx('outbox', 'readwrite', transaction => { 79 | transaction.objectStore('outbox').delete(id); 80 | }); 81 | } 82 | 83 | export function getFirstOutboxItem() { 84 | return tx('outbox', 'readonly', transaction => { 85 | return transaction.objectStore('outbox').index('by-date').get(IDBKeyRange.lowerBound(new Date(0))); 86 | }); 87 | } 88 | 89 | export function getOutbox() { 90 | return tx('outbox', 'readonly', transaction => { 91 | return getAll(transaction.objectStore('outbox').index('by-date')); 92 | }); 93 | } 94 | 95 | 96 | export function setChatMessages(messages) { 97 | return tx('chat', 'readwrite', transaction => { 98 | const chat = transaction.objectStore('chat'); 99 | chat.clear(); 100 | messages.forEach(m => chat.add(m)); 101 | }); 102 | } 103 | 104 | export function addChatMessage(message) { 105 | return addChatMessages([message]); 106 | } 107 | 108 | export function addChatMessages(messages) { 109 | return tx('chat', 'readwrite', transaction => { 110 | const chat = transaction.objectStore('chat'); 111 | messages.forEach(m => chat.put(m)); 112 | }); 113 | } 114 | 115 | export function getChatMessages() { 116 | return tx('chat', 'readonly', transaction => { 117 | return getAll(transaction.objectStore('chat').index('by-date')); 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /js/page/index.js: -------------------------------------------------------------------------------- 1 | import "regenerator/runtime"; 2 | import ChatView from "./views/Chat"; 3 | import GlobalWarningView from "./views/GlobalWarning"; 4 | import MessageInputView from "./views/MessageInput"; 5 | import * as chatStore from "../chatStore"; 6 | import toMessageObj from "../toMessageObj"; 7 | 8 | const $ = document.querySelector.bind(document); 9 | 10 | class MainController { 11 | constructor() { 12 | this.chatView = new ChatView($('.chat-content'), userId); 13 | this.globalWarningView = new GlobalWarningView($('.global-warning')); 14 | this.messageInputView = new MessageInputView($('.message-input')); 15 | this.logoutEl = $('.logout'); 16 | this.serviceWorkerReg = this.registerServiceWorker(); 17 | this.pushSubscription = this.registerPush(); 18 | this.reloading = false; 19 | 20 | // events 21 | this.logoutEl.addEventListener('click', event => { 22 | event.preventDefault(); 23 | this.logout(); 24 | }); 25 | 26 | if (navigator.serviceWorker) { 27 | window.addEventListener('message', event => { // non-standard Chrome behaviour 28 | if (event.origin && event.origin != location.origin) return; 29 | this.onServiceWorkerMessage(event.data); 30 | }); 31 | navigator.serviceWorker.addEventListener("message", event => this.onServiceWorkerMessage(event.data)); 32 | navigator.serviceWorker.addEventListener('controllerchange', _ => this.onServiceWorkerControllerChange()); 33 | } 34 | 35 | this.messageInputView.on('sendmessage', ({message}) => this.onSend(message)); 36 | this.messageInputView.on('keyboardopen', _ => this.onKeyboardOpen()); 37 | window.addEventListener('resize', _ => this.onResize()); 38 | 39 | // init 40 | if (navigator.serviceWorker) { 41 | this.displayMessages(); 42 | } 43 | else { 44 | this.fallbackPollMessages(); 45 | } 46 | } 47 | 48 | onKeyboardOpen() { 49 | this.chatView.performScroll({instant: true}); 50 | } 51 | 52 | onResize() { 53 | // Scroll to bottom when keyboard opens 54 | // Somewhat of a hack. 55 | if (this.messageInputView.inputFocused()) { 56 | this.chatView.performScroll({instant: true}); 57 | } 58 | } 59 | 60 | onServiceWorkerControllerChange() { 61 | if (this.messageInputView.inputIsEmpty()) { 62 | if (this.reloading) return; 63 | this.reloading = true; 64 | window.location.reload(); 65 | } 66 | // TODO: I should show a toast if the input isn't empty 67 | } 68 | 69 | async onServiceWorkerMessage(message) { 70 | if (message == 'updateMessages') { 71 | await this.mergeCachedMessages(); 72 | } 73 | else if ('loginUrl' in message) { 74 | window.location.href = message.loginUrl; 75 | } 76 | else if ('messageSent' in message) { 77 | this.chatView.markSent(message.messageSent, { 78 | newId: message.message.id, 79 | newDate: message.message.date 80 | }); 81 | } 82 | else if ('sendFailed' in message) { 83 | this.chatView.markFailed(message.sendFailed.id, message.sendFailed.reason); 84 | } 85 | } 86 | 87 | async mergeCachedMessages() { 88 | this.chatView.mergeMessages(await chatStore.getChatMessages()); 89 | } 90 | 91 | async onSend(message) { 92 | this.messageInputView.resetInput(); 93 | const tempId = Date.now() + Math.random(); 94 | const newMessage = { 95 | userId, 96 | text: message, 97 | date: new Date(), 98 | sending: true, 99 | id: tempId, 100 | }; 101 | 102 | // No point doing idb storage if there's no SW 103 | // Also means we don't have to deal with Safari's mad IDB 104 | if (navigator.serviceWorker) { 105 | await chatStore.addToOutbox(newMessage); 106 | } 107 | 108 | this.chatView.addMessage(newMessage); 109 | 110 | if (navigator.serviceWorker) { 111 | const reg = await this.serviceWorkerReg; 112 | 113 | if (reg.sync && reg.sync.getTags) { 114 | await reg.sync.register('postOutbox'); 115 | } 116 | else { 117 | reg.active.postMessage('postOutbox'); 118 | } 119 | } 120 | else { 121 | // Booooo 122 | const data = new FormData(); 123 | data.append('message', message); 124 | 125 | const xhr = new XMLHttpRequest(); 126 | xhr.withCredentials = true; 127 | xhr.responseType = 'json'; 128 | xhr.onload = () => { 129 | if (xhr.status != 201) { 130 | this.onServiceWorkerMessage({ 131 | sendFailed: { 132 | id: tempId, 133 | reason: xhr.response.err 134 | } 135 | }); 136 | return; 137 | } 138 | 139 | if (xhr.response.loginUrl) { 140 | this.onServiceWorkerMessage(xhr.response); 141 | return; 142 | } 143 | 144 | this.onServiceWorkerMessage({ 145 | messageSent: tempId, 146 | message: toMessageObj(xhr.response) 147 | }); 148 | }; 149 | xhr.onerror = () => { 150 | this.onServiceWorkerMessage({ 151 | sendFailed: { 152 | id: tempId, 153 | reason: 'Failed to send' 154 | } 155 | }); 156 | }; 157 | xhr.open('POST', '/send') 158 | xhr.send(data); 159 | } 160 | } 161 | 162 | // this is only run in service-worker supporting browsers 163 | async displayMessages() { 164 | const dataPromise = fetch('/messages.json', { 165 | credentials: 'include' 166 | }).then(r => r.json()); 167 | 168 | await this.mergeCachedMessages(); 169 | this.chatView.mergeMessages(await chatStore.getOutbox()); 170 | 171 | const data = await dataPromise; 172 | 173 | if (data.loginUrl) { 174 | window.location.href = data.loginUrl; 175 | return; 176 | } 177 | 178 | const messages = data.messages.map(m => toMessageObj(m)); 179 | 180 | chatStore.setChatMessages(messages); 181 | this.chatView.mergeMessages(messages); 182 | } 183 | 184 | async fallbackPollMessages() { 185 | let data; 186 | 187 | try { 188 | // ew XHR 189 | data = await new Promise((resolve, reject) => { 190 | const xhr = new XMLHttpRequest(); 191 | xhr.withCredentials = true; 192 | xhr.responseType = 'json'; 193 | xhr.onload = () => resolve(xhr.response); 194 | xhr.onerror = () => reject(Error(xhr.statusText)); 195 | xhr.open('GET', '/messages.json') 196 | xhr.send(); 197 | }); 198 | } 199 | catch(e) { 200 | console.log('Message get failed', e); 201 | } 202 | 203 | const messages = data.messages.map(m => toMessageObj(m)); 204 | this.chatView.mergeMessages(messages); 205 | setTimeout(() => this.fallbackPollMessages(), 10000); 206 | } 207 | 208 | registerServiceWorker() { 209 | if (!navigator.serviceWorker) { 210 | return Promise.reject(Error("Service worker not supported")); 211 | } 212 | return navigator.serviceWorker.register('/sw.js'); 213 | } 214 | 215 | async registerPush() { 216 | if (!navigator.serviceWorker) { 217 | throw Error("Service worker not supported"); 218 | } 219 | 220 | const reg = await navigator.serviceWorker.ready; 221 | 222 | if (!reg.pushManager) { 223 | this.globalWarningView.warn("Your browser doesn't support service workers, so this isn't going to work."); 224 | throw Error("Push messaging not supported"); 225 | } 226 | 227 | let pushSub; 228 | 229 | try { 230 | pushSub = await reg.pushManager.subscribe({userVisibleOnly: true}); 231 | } 232 | catch (err) { 233 | console.warn("Push subscription failed."); 234 | throw err; 235 | } 236 | 237 | // The API was updated to only return an |endpoint|, but Chrome 238 | // 42 and 43 implemented the older API which provides a separate 239 | // subscriptionId. Concatenate them for backwards compatibility. 240 | let endpoint = pushSub.endpoint; 241 | if ('subscriptionId' in pushSub && !endpoint.includes(pushSub.subscriptionId)) { 242 | endpoint += "/" + pushSub.subscriptionId; 243 | } 244 | 245 | const data = new FormData(); 246 | data.append('endpoint', endpoint); 247 | 248 | await fetch('/subscribe', { 249 | body: data, 250 | credentials: 'include', 251 | method: 'POST' 252 | }); 253 | } 254 | 255 | async logout() { 256 | const reg = await navigator.serviceWorker.getRegistration('/'); 257 | if (reg) await reg.unregister(); 258 | // TODO: clear data 259 | window.location.href = this.logoutEl.href; 260 | } 261 | } 262 | 263 | new MainController(); 264 | -------------------------------------------------------------------------------- /js/page/views/Chat.js: -------------------------------------------------------------------------------- 1 | import chatItem from "./templates/chatItem.hbs"; 2 | import dateFormat from "dateformat"; 3 | import toArray from "../../toArray"; 4 | 5 | export default class Chat { 6 | constructor(container, currentUserId) { 7 | this.container = container; 8 | this.timeline = container.querySelector('.chat-timeline'); 9 | this.currentUserId = currentUserId; 10 | this.range = document.createRange(); 11 | this.range.setStart(this.timeline, 0); 12 | } 13 | 14 | async performScroll({ 15 | instant = false 16 | }={}) { 17 | if (document.fonts) await document.fonts.ready; 18 | const topPos = this.container.scrollHeight; 19 | if (instant) { 20 | this.container.style.scrollBehavior = 'auto'; 21 | } 22 | this.container.scrollTop = topPos; 23 | 24 | // ughhhh 25 | setTimeout(_ => this.container.style.scrollBehavior = '', 50); 26 | } 27 | 28 | _createElement(message) { 29 | const data = Object.create(message); 30 | data.readableDate = dateFormat(message.date, 'mmm d HH:MM'); 31 | data.date = data.date.toISOString(); 32 | data.fromCurrentUser = (data.userId === this.currentUserId); 33 | return this.range.createContextualFragment(chatItem(data)); 34 | } 35 | 36 | addMessages(messages) { 37 | const shouldScroll = (this.container.scrollTop + this.container.offsetHeight == this.container.scrollHeight); 38 | const shouldScrollInstantly = this.timeline.children.length === 0; 39 | messages.forEach(message => { 40 | this.timeline.appendChild(this._createElement(message)); 41 | }); 42 | 43 | if (shouldScroll) { 44 | this.performScroll({instant: shouldScrollInstantly}); 45 | } 46 | } 47 | 48 | addMessage(message) { 49 | return this.addMessages([message]); 50 | } 51 | 52 | mergeMessages(messages) { 53 | const times = toArray(this.timeline.querySelectorAll('time')).map(t => new Date(t.getAttribute('datetime'))); 54 | let messageIndex = 0; 55 | let message = messages[messageIndex]; 56 | if (!message) return; 57 | 58 | for (let i = 0; i < times.length; i++) { 59 | let time = times[i]; 60 | let el = this.timeline.children[i]; 61 | 62 | while (message.date.valueOf() <= time.valueOf()) { 63 | if (message.id !== Number(el.getAttribute('data-id'))) { 64 | this.timeline.insertBefore(this._createElement(message), el); 65 | } 66 | message = messages[++messageIndex]; 67 | if (!message) return; 68 | } 69 | } 70 | 71 | this.addMessages(messages.slice(messageIndex)); 72 | } 73 | 74 | markSent(id, {newId, newDate}) { 75 | const item = this.timeline.querySelector(`.chat-item[data-id='${id}']`); 76 | if (!item) throw Error('Message not found'); 77 | 78 | item.classList.remove('sending'); 79 | 80 | if (newId) item.setAttribute('data-id', newId); 81 | if (newDate) { 82 | let time = item.querySelector('time'); 83 | time.setAttribute('datetime', newDate.toISOString()); 84 | time.textContent = dateFormat(newDate, 'mmm d HH:MM'); 85 | } 86 | } 87 | 88 | markFailed(id, reason) { 89 | const item = this.timeline.querySelector(`.chat-item[data-id='${id}']`); 90 | item.querySelector('.state').textContent = 'Sending failed: ' + reason; 91 | } 92 | } -------------------------------------------------------------------------------- /js/page/views/GlobalWarning.js: -------------------------------------------------------------------------------- 1 | export default class GlobalWarning { 2 | constructor(el) { 3 | this.container = el; 4 | } 5 | 6 | warn(msg) { 7 | this.container.classList.add('active'); 8 | this.container.textContent = msg; 9 | } 10 | } -------------------------------------------------------------------------------- /js/page/views/MessageInput.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import keyboardTemplate from "./templates/keyboard.hbs"; 3 | import emoji from "./conf/emoji"; 4 | 5 | export default class MessageInput extends EventEmitter { 6 | constructor(el) { 7 | super(); 8 | this.container = el; 9 | this.form = el.querySelector('.message-form'); 10 | 11 | this.container.addEventListener('submit', event => { 12 | event.preventDefault(); 13 | this._onSubmit(); 14 | }); 15 | 16 | this.container.addEventListener('click', e => this._onContainerClick(e)); 17 | this._initKeyboard(); 18 | } 19 | 20 | _onContainerClick(event) { 21 | if (this.container.classList.contains('active')) return; 22 | this.container.classList.add('active'); 23 | this.emit('keyboardopen'); 24 | 25 | let outCheck = event => { 26 | if (event.target.closest('.message-input')) return; 27 | 28 | if (this.container.animate) { 29 | this.container.classList.add('exiting'); 30 | this.container.animate([ 31 | {transform: 'translateY(' + (-this.keyboard.offsetHeight) + 'px)'}, 32 | {transform: 'none'} 33 | ], { 34 | duration: 200, 35 | easing: 'ease-out' 36 | }).onfinish = _ => { 37 | this.container.classList.remove('active'); 38 | this.container.classList.remove('exiting'); 39 | }; 40 | } 41 | else { 42 | this.container.classList.remove('active'); 43 | } 44 | 45 | document.removeEventListener('click', outCheck, true); 46 | }; 47 | 48 | // TODO: can this be added so it doesn't pick up this event? 49 | document.addEventListener('click', outCheck); 50 | 51 | if (this.container.animate) { 52 | this.container.animate([ 53 | {transform: 'translateY(' + this.keyboard.offsetHeight + 'px)'}, 54 | {transform: 'none'} 55 | ], { 56 | duration: 200, 57 | easing: 'ease-out' 58 | }).onfinish = _ => { 59 | this.keys.classList.add('render-all'); 60 | }; 61 | } 62 | else { 63 | requestAnimationFrame(() => { 64 | this.keys.classList.add('render-all'); 65 | }); 66 | } 67 | } 68 | 69 | _initKeyboard() { 70 | // build the keyboard 71 | this.keyboard = this.container.querySelector('.keyboard'); 72 | this.keyboard.innerHTML = keyboardTemplate({emoji}); 73 | this.keys = this.container.querySelector('.keys'); 74 | 75 | // events 76 | this.container.querySelector('.categories').addEventListener('click', e => this._onCategoryClick(e)); 77 | this.keys.addEventListener('click', e => this._onEmojiKeyClick(e)); 78 | this.container.querySelector('.space button').addEventListener('click', e => this._onSpaceClick(e)); 79 | this.container.querySelector('.del').addEventListener('click', e => this._onDelClick(e)); 80 | document.addEventListener('keydown', e => this._onKeyDown(e)); 81 | 82 | // events for mouse/touchstart effect 83 | this._initButtonActiveStyle(this.keyboard); 84 | } 85 | 86 | _initButtonActiveStyle(el) { 87 | let activeEl; 88 | 89 | let end = event => { 90 | if (!activeEl) return; 91 | activeEl.classList.remove('active'); 92 | document.removeEventListener('mouseup', end); 93 | activeEl = undefined; 94 | }; 95 | 96 | let start = event => { 97 | let button = event.target.closest('button'); 98 | if (!button) return; 99 | activeEl = button; 100 | activeEl.classList.add('active'); 101 | document.addEventListener('mouseup', end); 102 | }; 103 | 104 | el.addEventListener('touchstart', start); 105 | el.addEventListener('mousedown', start); 106 | el.addEventListener('touchend', end); 107 | } 108 | 109 | _addToInput(val) { 110 | // limit to 200 chars 111 | let newMessage = this.form.message.value + val; 112 | let msg = ''; 113 | let i = 0; 114 | for (let codepoint of newMessage) { 115 | if (i > 200) break; 116 | msg += codepoint; 117 | } 118 | this.form.message.value = msg; 119 | this.form.message.scrollLeft = this.form.message.scrollWidth; 120 | } 121 | 122 | _del() { 123 | let codePoints = []; 124 | for (let codePoint of this.form.message.value) codePoints.push(codePoint); 125 | this.form.message.value = codePoints.slice(0, -1).join(''); 126 | } 127 | 128 | _onKeyDown(event) { 129 | if (!this.container.classList.contains('active')) return; 130 | if (event.keyCode == 8) { // backspace 131 | event.preventDefault(); 132 | this._del(); 133 | } 134 | else if (event.keyCode == 13) { // enter 135 | event.preventDefault(); 136 | this._onSubmit(); 137 | } 138 | else if (event.keyCode == 32) { // space 139 | event.preventDefault(); 140 | this._addToInput(' '); 141 | } 142 | else if (event.keyCode >= 48 && event.keyCode <= 90) { 143 | let allKeys = this.keys.querySelectorAll('button'); 144 | this._addToInput(allKeys[Math.floor(Math.random() * allKeys.length)].textContent); 145 | } 146 | } 147 | 148 | _onDelClick(event) { 149 | let button = event.currentTarget; 150 | this._del(); 151 | button.blur(); 152 | event.preventDefault(); 153 | } 154 | 155 | _onSpaceClick(event) { 156 | let button = event.currentTarget; 157 | this._addToInput(' '); 158 | button.blur(); 159 | event.preventDefault(); 160 | } 161 | 162 | _onEmojiKeyClick(event) { 163 | let button = event.target.closest('button'); 164 | if (!button) return; 165 | this._addToInput(button.textContent); 166 | button.blur(); 167 | event.preventDefault(); 168 | } 169 | 170 | _onCategoryClick(event) { 171 | let button = event.target.closest('button'); 172 | if (!button) return; 173 | 174 | let firstInCategory = this.keys.querySelector('.' + button.getAttribute('data-target')); 175 | this.keys.scrollLeft = firstInCategory.offsetLeft; 176 | button.blur(); 177 | event.preventDefault(); 178 | } 179 | 180 | _onSubmit() { 181 | let message = this.form.message.value.trim(); 182 | if (!message) return; 183 | this.emit('sendmessage', {message}); 184 | } 185 | 186 | resetInput() { 187 | this.form.message.value = ''; 188 | } 189 | 190 | inputFocused() { 191 | return this.form.message.matches(':focus'); 192 | } 193 | 194 | inputIsEmpty() { 195 | return !this.form.message.value; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /js/page/views/conf/emoji.js: -------------------------------------------------------------------------------- 1 | // This runs in node, the export of 2 | // this is stringified & browserified 3 | 4 | var emoji = require('emojilib'); 5 | var output = {}; 6 | 7 | // These emoji don't appear to work 8 | var omitList = [ 9 | 'relaxed', 10 | 'point_up', 11 | 'v' 12 | ]; 13 | 14 | emoji.keys.forEach(function(emojiKey) { 15 | var emojiDetails = emoji[emojiKey]; 16 | if (!emojiDetails.char) return; 17 | 18 | var iterator = emojiDetails.char[Symbol.iterator](); 19 | iterator.next(); 20 | 21 | // this looks like more than one char, skipping 22 | if (!iterator.next().done) return; 23 | 24 | // there aren't enough flags to make it worth while 25 | if (emojiDetails.category == 'flags') return; 26 | 27 | if (!output[emojiDetails.category]) { 28 | output[emojiDetails.category] = []; 29 | } 30 | 31 | output[emojiDetails.category].push(emojiDetails.char); 32 | }); 33 | 34 | module.exports = output; -------------------------------------------------------------------------------- /js/page/views/templates/chatItem.hbs: -------------------------------------------------------------------------------- 1 |
  • 2 | User 3 |
    4 |

    {{text}}

    5 |
    6 |
    Sending
    7 | 8 |
    9 |
    10 |
  • -------------------------------------------------------------------------------- /js/page/views/templates/keyboard.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 9 | 15 | 21 | 27 | 33 | 39 | 45 |
    46 | 52 |
    53 |
    54 | {{#each emoji as |value category|}} 55 | {{#each value}}
    {{this}}
    {{/each}} 56 | {{/each}} 57 |
    58 |
    59 | 60 |
    -------------------------------------------------------------------------------- /js/sw/index.js: -------------------------------------------------------------------------------- 1 | import "regenerator/runtime"; 2 | import "serviceworker-cache-polyfill"; 3 | import "../arrayFind"; 4 | import * as chatStore from "../chatStore"; 5 | import toMessageObj from "../toMessageObj"; 6 | 7 | const staticVersion = '30'; 8 | const cachesToKeep = ['chat-static-v' + staticVersion, 'chat-avatars']; 9 | 10 | self.addEventListener("install", event => { 11 | self.skipWaiting(); 12 | 13 | event.waitUntil( 14 | fetch('/messages.json', {credentials: 'include'}).then(r => r.json()).then(data => { 15 | if (data.loginUrl) { 16 | // needs login 17 | registration.unregister(); 18 | throw Error("Needs login"); 19 | } 20 | return caches.open('chat-static-v' + staticVersion); 21 | }).then(cache => { 22 | return Promise.all([ 23 | '/', 24 | '/static/css/app.css', 25 | '/static/fonts/roboto.woff', 26 | '/static/js/page.js', 27 | '/static/imgs/hangouts.png' 28 | ].map(url => { 29 | let request = new Request(url, {credentials: 'include'}); 30 | return fetch(request).then(response => { 31 | if (!response.ok) throw Error("NOT OK"); 32 | return cache.put(request, response); 33 | }); 34 | })); 35 | }) 36 | ); 37 | }); 38 | 39 | 40 | self.addEventListener('activate', event => { 41 | clients.claim(); 42 | event.waitUntil( 43 | caches.keys().then(cacheNames => { 44 | return Promise.all( 45 | cacheNames 46 | .filter(n => cachesToKeep.indexOf(n) === -1) 47 | .map(name => caches.delete(name)) 48 | ); 49 | }) 50 | ); 51 | }); 52 | 53 | async function avatarFetch(request) { 54 | // some hackery because Chrome doesn't support ignoreSearch in cache matching 55 | let noSearchUrl = new URL(request.url); 56 | noSearchUrl.search = ''; 57 | noSearchUrl = noSearchUrl.href; 58 | 59 | const responsePromise = fetch(request); 60 | const cache = await caches.open('chat-avatars'); 61 | const matchingRequest = (await cache.keys()).find(r => r.url.startsWith(noSearchUrl)); 62 | 63 | const networkResponse = responsePromise.then(response => { 64 | cache.put(request, response.clone()); 65 | return response; 66 | }); 67 | 68 | return (matchingRequest ? cache.match(matchingRequest) : networkResponse); 69 | } 70 | 71 | function messagesFetch(request) { 72 | return fetch(request).then(response => { 73 | const clonedResponse = response.clone(); 74 | 75 | (async _ => { 76 | const cachePromise = caches.open('chat-avatars'); 77 | const cachedRequestsPromise = cachePromise.then(c => c.keys()); 78 | const userIdsPromise = clonedResponse.json().then(data => { 79 | if (data.loginUrl) return []; 80 | return data.messages.map(m => m.user); 81 | }); 82 | 83 | const cache = await cachePromise; 84 | const cachedRequests = await cachedRequestsPromise; 85 | const userIds = await userIdsPromise; 86 | 87 | // Find cached avatars that don't appear in messages.json 88 | // and delete them - prevents avatars cache getting too big 89 | cachedRequests.filter( 90 | request => !userIds.some(id => request.url.includes(id)) 91 | ).map(request => cache.delete(request)); 92 | }()); 93 | 94 | return response; 95 | }); 96 | } 97 | 98 | self.addEventListener('fetch', event => { 99 | const request = event.request; 100 | const url = new URL(request.url); 101 | 102 | if (request.method != 'GET') return; 103 | 104 | if (url.origin == 'https://www.gravatar.com' && url.pathname.startsWith('/avatar/')) { 105 | event.respondWith(avatarFetch(request)); 106 | return; 107 | } 108 | 109 | if (url.origin == location.origin) { 110 | if (url.pathname.startsWith('/_')) { // login urls 111 | return; 112 | } 113 | if (url.pathname == '/messages.json') { 114 | event.respondWith(messagesFetch(request)); 115 | return; 116 | } 117 | } 118 | 119 | event.respondWith( 120 | caches.match(request).then(function(response) { 121 | return response || fetch(request); 122 | }) 123 | ); 124 | }); 125 | 126 | self.addEventListener('push', event => { 127 | event.waitUntil( 128 | fetch("/messages.json", { 129 | credentials: "include" 130 | }).then(r => r.json()).then(async data => { 131 | if (data.loginUrl) { 132 | self.registration.showNotification("New Chat!", { 133 | body: 'Requires login to view…', 134 | tag: 'chat', 135 | icon: `/static/imgs/hangouts.png` 136 | }); 137 | return; 138 | } 139 | 140 | const messages = data.messages.map(m => toMessageObj(m)); 141 | await chatStore.setChatMessages(messages); 142 | broadcast('updateMessages'); 143 | 144 | for (var client of (await clients.matchAll())) { 145 | if (client.visibilityState == 'visible' && new URL(client.url).pathname == '/') { 146 | return; 147 | } 148 | } 149 | 150 | const notificationMessage = messages[messages.length - 1]; 151 | 152 | self.registration.showNotification("New Chat!", { 153 | body: notificationMessage.text, 154 | tag: 'chat', 155 | icon: `https://www.gravatar.com/avatar/${notificationMessage.userId}?d=retro&s=192` 156 | }); 157 | }) 158 | ); 159 | }); 160 | 161 | function broadcast(message) { 162 | return clients.matchAll().then(function(clients) { 163 | for (var client of clients) { 164 | client.postMessage(message); 165 | } 166 | }); 167 | } 168 | 169 | self.addEventListener('notificationclick', event => { 170 | const rootUrl = new URL('/', location).href; 171 | event.notification.close(); 172 | // Enumerate windows, and call window.focus(), or open a new one. 173 | event.waitUntil( 174 | clients.matchAll().then(matchedClients => { 175 | for (let client of matchedClients) { 176 | if (client.url === rootUrl) { 177 | return client.focus(); 178 | } 179 | } 180 | return clients.openWindow("/"); 181 | }) 182 | ); 183 | }); 184 | 185 | async function postOutbox() { 186 | let message; 187 | while (message = await chatStore.getFirstOutboxItem()) { 188 | let data = new FormData(); 189 | data.append('message', message.text); 190 | const pushSub = await self.registration.pushManager.getSubscription(); 191 | 192 | if (pushSub) { 193 | let endpoint = pushSub.endpoint; 194 | if ('subscriptionId' in pushSub && !endpoint.includes(pushSub.subscriptionId)) { 195 | endpoint += "/" + pushSub.subscriptionId; 196 | } 197 | data.append('push_endpoint', endpoint); 198 | } 199 | 200 | let response = await fetch('/send', { 201 | method: 'POST', 202 | body: data, 203 | credentials: 'include' 204 | }); 205 | 206 | if (!response.ok) { 207 | // remove the bad message 208 | // (assuming the message is bad isn't a great assumption) 209 | chatStore.removeFromOutbox(message.id); 210 | let errReason = "Unknown error"; 211 | 212 | try { 213 | errReason = (await response.json()).err; 214 | } catch(e) {} 215 | 216 | broadcast({ 217 | sendFailed: { 218 | id: message.id, 219 | reason: errReason 220 | } 221 | }); 222 | continue; 223 | } 224 | 225 | let responseJson = await response.json(); 226 | 227 | if (responseJson.loginUrl) { 228 | broadcast(responseJson); 229 | return; 230 | } 231 | 232 | await chatStore.removeFromOutbox(message.id); 233 | let sentMessage = toMessageObj(responseJson); 234 | chatStore.addChatMessage(sentMessage); 235 | 236 | broadcast({ 237 | messageSent: message.id, 238 | message: sentMessage 239 | }); 240 | } 241 | } 242 | 243 | self.addEventListener('message', event => { 244 | if (event.data == 'postOutbox') { 245 | postOutbox(); 246 | } 247 | }); 248 | 249 | self.addEventListener('sync', event => { 250 | if (event.tag == 'postOutbox') { 251 | event.waitUntil(postOutbox()); 252 | } 253 | }); 254 | -------------------------------------------------------------------------------- /js/toArray.js: -------------------------------------------------------------------------------- 1 | export default function toArray(thing) { 2 | if (Array.from) return Array.from(thing); 3 | return Array.prototype.slice.call(thing); 4 | } -------------------------------------------------------------------------------- /js/toMessageObj.js: -------------------------------------------------------------------------------- 1 | export default function toMessageObj(serverMessageObj) { 2 | return { 3 | text: serverMessageObj.text, 4 | date: new Date(serverMessageObj.date), 5 | userId: serverMessageObj.user, 6 | id: serverMessageObj.id 7 | }; 8 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "target": "es6" 6 | }, 7 | "exclude": [ 8 | "node_modules", 9 | "bower_components", 10 | "jspm_packages", 11 | "tmp", 12 | "temp" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """`main` is the top level module for your Bottle application.""" 5 | 6 | import bottle 7 | from bottle import get, post, route, abort, redirect, template, request, response 8 | import cgi 9 | from google.appengine.api import app_identity, urlfetch, users 10 | from google.appengine.ext import ndb 11 | from google.appengine.ext.ndb import msgprop 12 | import json 13 | import logging 14 | import re 15 | import os 16 | from protorpc import messages 17 | import urllib 18 | import hashlib 19 | from datetime import timedelta, datetime 20 | 21 | DEFAULT_GCM_ENDPOINT = 'https://android.googleapis.com/gcm/send' 22 | 23 | # Hand-picked from 24 | # https://developer.android.com/google/gcm/server-ref.html#error-codes 25 | PERMANENT_GCM_ERRORS = {'InvalidRegistration', 'NotRegistered', 26 | 'InvalidPackageName', 'MismatchSenderId'} 27 | 28 | ALLOWED_CHARS = u' 😀😁😂😃😄😅😆😇😈👿😉😊😋😌😍😎😏😐😑😒😓😔😕😖😗😘😙😚😛😜😝😞😟😠😡😢😣😤😥😦😧😨😩😪😫😬😭😮😯😰😱😲😳😴😵😶😷😸😹😺😻😼😽😾😿🙀👣👤👥👶👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸💂👼🎅👻👹👺💩💀👽👾🙇💁🙅🙆🙋🙎🙍💆💇💑💏🙌👏👂👀👃👄💋👅💅👋👍👎👆👇👈👉👌👊✊✋💪👐🙏🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼💐🌾🌿🍀🍁🍂🍃🍄🌰🐀🐁🐭🐹🐂🐃🐄🐮🐅🐆🐯🐇🐰🐈🐱🐎🐴🐏🐑🐐🐓🐔🐤🐣🐥🐦🐧🐘🐪🐫🐗🐖🐷🐽🐕🐩🐶🐺🐻🐨🐼🐵🙈🙉🙊🐒🐉🐲🐊🐍🐢🐸🐋🐳🐬🐙🐟🐠🐡🐚🐌🐛🐜🐝🐞🐾⚡🔥🌙⛅💧💦☔💨🌟⭐🌠🌄🌅🌈🌊🌋🌌🗻🗾🌐🌍🌎🌏🌑🌒🌓🌔🌕🌖🌗🌘🌚🌝🌛🌜🌞🍅🍆🌽🍠🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵☕🍶🍷🍸🍹🍺🍻🍼🎀🎁🎂🎃🎄🎋🎍🎑🎆🎇🎉🎊🎈💫✨💥🎓👑🎎🎏🎐🎌🏮💍💔💌💕💞💓💗💖💘💝💟💜💛💚💙🏃🚶💃🚣🏊🏄🛀🏂🎿⛄🚴🚵🏇⛺🎣⚽🏀🏈🎾🏉⛳🏆🎽🏁🎹🎸🎻🎷🎺🎵🎶🎼🎧🎤🎭🎫🎩🎪🎬🎨🎯🎱🎳🎰🎲🎮🎴🃏🀄🎠🎡🎢🚃🚞🚂🚋🚝🚄🚅🚆🚇🚈🚉🚊🚌🚍🚎🚐🚑🚒🚓🚔🚨🚕🚖🚗🚘🚙🚚🚛🚜🚲🚏⛽🚧🚦🚥🚀🚁💺⚓🚢🚤⛵🚡🚠🚟🛂🛃🛄🛅💴💶💷💵🗽🗿🌁🗼⛲🏰🏯🌇🌆🌃🌉🏠🏡🏢🏬🏭🏣🏤🏥🏦🏨🏩💒⛪🏪🏫⌚📱📲💻⏰⏳⌛📷📹🎥📺📻📟📞📠💽💾💿📀📼🔋🔌💡🔦📡💳💸💰💎🌂👝👛👜💼🎒💄👓👒👡👠👢👞👟👙👗👘👚👕👔👖🚪🚿🛁🚽💈💉💊🔬🔭🔮🔧🔪🔩🔨💣🚬🔫🔖📰🔑📩📨📧📥📤📦📯📮📪📫📬📭📄📃📑📈📉📊📅📆🔅🔆📜📋📖📓📔📒📕📗📘📙📚📇🔗📎📌📐📍📏🚩📁📂📝🔏🔐🔒🔓📣📢🔈🔉🔊🔇💤🔔🔕💭💬🚸🔍🔎🚫⛔📛🚷🚯🚳🚱📵🔞🉑🉐💮🈴🈵🈲🈶🈚🈸🈺🈹🈳🈁🈯💹❎✅📳📴🆚🆎🆑🆘🆔🚾🆒🆓🆕🆖🆗🆙🏧♈♉♊♋♌♍♎♏♐♑♒♓🚻🚹🚺🚼♿🚰🚭🚮🔼🔽⏩⏪⏫⏬🔄🔀🔁🔂🔟🔢🔤🔡🔠📶🎦🔣➕➖➗🔃💱💲➰➿❗❓❕❔❌⭕💯🔚🔙🔛🔝🔜🌀⛎🔯🔰🔱💢💠⚪⚫🔘🔴🔵🔺🔻🔸🔹🔶🔷⬛⬜◾◽🔲🔳🕐🕜🕑🕝🕒🕞🕓🕟🕔🕠🕕🕡🕖🕢🕗🕣🕘🕤🕙🕥🕚🕦🕛🕧'; 29 | 30 | invoke = lambda f: f() # trick taken from AJAX frameworks 31 | 32 | 33 | @invoke 34 | def codepoint_count(): 35 | testlength = len(u'\U00010000') # pre-compute once 36 | assert (testlength == 1) or (testlength == 2) 37 | if testlength == 1: 38 | def closure(data): # count function for "wide" interpreter 39 | u'returns the number of Unicode code points in a unicode string' 40 | return len(data.encode('UTF-16BE').decode('UTF-16BE')) 41 | else: 42 | def is_surrogate(c): 43 | ordc = ord(c) 44 | return (ordc >= 55296) and (ordc < 56320) 45 | def closure(data): # count function for "narrow" interpreter 46 | u'returns the number of Unicode code points in a unicode string' 47 | return len(data) - len(filter(is_surrogate, data)) 48 | return closure 49 | 50 | 51 | class RegistrationType(messages.Enum): 52 | LEGACY = 1 53 | CHAT = 2 54 | CHAT_STALE = 3 # GCM told us the registration was no longer valid. 55 | 56 | class PushService(messages.Enum): 57 | GCM = 1 58 | FIREFOX = 2 # SimplePush 59 | 60 | class GcmSettings(ndb.Model): 61 | SINGLETON_DATASTORE_KEY = 'SINGLETON' 62 | 63 | @classmethod 64 | def singleton(cls): 65 | return cls.get_or_insert(cls.SINGLETON_DATASTORE_KEY) 66 | 67 | endpoint = ndb.StringProperty( 68 | default=DEFAULT_GCM_ENDPOINT, 69 | indexed=False) 70 | sender_id = ndb.StringProperty(default="", indexed=False) 71 | api_key = ndb.StringProperty(default="", indexed=False) 72 | spam_regex = ndb.StringProperty(default="", indexed=False) 73 | 74 | # The key of a GCM Registration entity is the push subscription ID; 75 | # the key of a Firefox Registration entity is the push endpoint URL. 76 | # If more push services are added, consider namespacing keys to avoid collision. 77 | class Registration(ndb.Model): 78 | username = ndb.StringProperty() 79 | type = msgprop.EnumProperty(RegistrationType, required=True, indexed=True) 80 | service = msgprop.EnumProperty(PushService, required=True, indexed=True) 81 | creation_date = ndb.DateTimeProperty(auto_now_add=True) 82 | 83 | class Message(ndb.Model): 84 | creation_date = ndb.DateTimeProperty(auto_now_add=True) 85 | text = ndb.StringProperty(indexed=False) 86 | user = ndb.StringProperty(indexed=True) 87 | 88 | def thread_key(thread_name='default_thread'): 89 | return ndb.Key('Thread', thread_name) 90 | 91 | 92 | def get_user_id(user): 93 | return hashlib.md5(user.email()).hexdigest(); 94 | 95 | 96 | @route('/setup', method=['GET', 'POST']) 97 | def setup(): 98 | # app.yaml should already have ensured that the user is logged in as admin. 99 | if not users.is_current_user_admin(): 100 | abort(401, "Sorry, only administrators can access this page.") 101 | 102 | is_dev = os.environ.get('SERVER_SOFTWARE', '').startswith('Development') 103 | setup_scheme = 'http' if is_dev else 'https' 104 | setup_url = '%s://%s/setup' % (setup_scheme, 105 | app_identity.get_default_version_hostname()) 106 | if request.url != setup_url: 107 | redirect(setup_url) 108 | 109 | result = "" 110 | settings = GcmSettings.singleton() 111 | if (request.forms.sender_id and request.forms.api_key and 112 | request.forms.endpoint): 113 | # Basic CSRF protection (will block some valid requests, like 114 | # https://1-dot-johnme-gcm.appspot.com/setup but ohwell). 115 | if request.get_header('Referer') != setup_url: 116 | abort(403, "Invalid Referer.") 117 | settings.endpoint = request.forms.endpoint 118 | settings.sender_id = request.forms.sender_id 119 | settings.api_key = request.forms.api_key 120 | settings.spam_regex = request.forms.spam_regex 121 | settings.put() 122 | result = 'Updated successfully' 123 | return template('setup', result=result, 124 | endpoint=settings.endpoint, 125 | sender_id=settings.sender_id, 126 | api_key=settings.api_key, 127 | spam_regex=settings.spam_regex) 128 | 129 | 130 | @get('/manifest.json') 131 | def manifest(): 132 | return { 133 | "short_name": "Emojoy", 134 | "name": "Emojoy", 135 | "scope": "./", 136 | "icons": [ 137 | { 138 | "src": "/static/imgs/hangouts.png", 139 | "sizes": "500x500", 140 | "type": "image/png" 141 | } 142 | ], 143 | "display": "standalone", 144 | "start_url": "/", 145 | "theme_color": "#9C27B0", 146 | "background_color": "#eee", 147 | "gcm_sender_id": GcmSettings.singleton().sender_id, 148 | "gcm_user_visible_only": True 149 | } 150 | 151 | 152 | @get('/') 153 | def root(): 154 | """Single page chat app.""" 155 | return template_with_sender_id( 156 | 'chat', 157 | user_id=get_user_id(users.get_current_user()), 158 | logout_url=users.create_logout_url('/') 159 | ) 160 | 161 | 162 | @get('/messages.json') 163 | def chat_messages(): 164 | """XHR to fetch the most recent chat messages.""" 165 | if not users.get_current_user(): 166 | return { 167 | "err": "Not logged in", 168 | "loginUrl": users.create_login_url('/') 169 | } 170 | 171 | messages = reversed(Message.query(ancestor=thread_key()) 172 | .order(-Message.creation_date).fetch(20)) 173 | return { 174 | "messages": [{ 175 | "text": message.text, 176 | "user": message.user, 177 | "date": message.creation_date.isoformat(), 178 | "id": message.key.id() 179 | } for message in messages] 180 | } 181 | return response 182 | 183 | 184 | @get('/admin') 185 | def chat_admin(): 186 | """Lets "admins" clear chat registrations.""" 187 | if not users.is_current_user_admin(): 188 | abort(401, "Sorry, only administrators can access this page.") 189 | # Despite the name, this route has no credential checks - don't put anything 190 | # sensitive here! 191 | # This template doesn't actually use the sender_id, but we want the warning. 192 | return template_with_sender_id('chat_admin') 193 | 194 | 195 | def template_with_sender_id(*args, **kwargs): 196 | settings = GcmSettings.singleton() 197 | if not settings.sender_id or not settings.api_key: 198 | abort(500, "You need to visit /setup to provide a GCM sender ID and " 199 | "corresponding API key") 200 | kwargs['sender_id'] = settings.sender_id 201 | return template(*args, **kwargs) 202 | 203 | 204 | @post('/subscribe') 205 | def register_chat(): 206 | return register(RegistrationType.CHAT) 207 | 208 | 209 | def register(type): 210 | """XHR adding a registration ID to our list.""" 211 | if not request.forms.endpoint: 212 | abort(400, "Missing endpoint") 213 | 214 | if request.forms.endpoint.startswith(DEFAULT_GCM_ENDPOINT): 215 | prefix_len = len(DEFAULT_GCM_ENDPOINT + '/') 216 | gcm_subscription_id = request.forms.endpoint[prefix_len:] 217 | if not gcm_subscription_id: 218 | abort(400, "Could not parse subscription ID from endpoint") 219 | registration = Registration.get_or_insert(gcm_subscription_id, 220 | type=type, 221 | service=PushService.GCM) 222 | else: 223 | # Assume unknown endpoints are Firefox Simple Push. 224 | # TODO: Find a better way of distinguishing these. 225 | registration = Registration.get_or_insert(request.forms.endpoint, 226 | type=type, 227 | service=PushService.FIREFOX) 228 | 229 | registration.username = get_user_id(users.get_current_user()) 230 | registration.put() 231 | response.status = 201 232 | return "" 233 | 234 | 235 | @post('/clear-registrations') 236 | def clear_chat_registrations(): 237 | if not users.is_current_user_admin(): 238 | abort(401, "Sorry, only administrators can access this page.") 239 | ndb.delete_multi( 240 | Registration.query(Registration.type == RegistrationType.CHAT) 241 | .fetch(keys_only=True)) 242 | ndb.delete_multi( 243 | Registration.query(Registration.type == RegistrationType.CHAT_STALE) 244 | .fetch(keys_only=True)) 245 | return "" 246 | 247 | 248 | @post('/send') 249 | def send_chat(): 250 | if not users.get_current_user(): 251 | return { 252 | "err": "Not logged in", 253 | "loginUrl": users.create_login_url('/') 254 | } 255 | 256 | message_text = unicode(request.forms.message).strip() 257 | user_endpoint = request.forms.push_endpoint 258 | 259 | user = users.get_current_user() 260 | sender = get_user_id(user) 261 | 262 | if message_text == '': 263 | response.status = 400 264 | return {"err": "Empty message"} 265 | 266 | if user.email() != 'jaffathecake@gmail.com': # I am special 267 | if codepoint_count(message_text) > 200: 268 | response.status = 413 269 | return {"err": "Message too long"} 270 | 271 | for code_point in message_text: 272 | if code_point not in ALLOWED_CHARS: 273 | response.status = 400 274 | return {"err": "Only emoji allowed"} 275 | 276 | settings = GcmSettings.singleton() 277 | if (settings.spam_regex 278 | and re.search(settings.spam_regex, message_text)): 279 | response.status = 400 280 | return {"err": "Detected as spam"} 281 | else: 282 | num_recent_messages_from_user = Message.query(ancestor=thread_key()) \ 283 | .filter(Message.creation_date > datetime.now() - timedelta(seconds=10), Message.user == sender) \ 284 | .count(1) 285 | if num_recent_messages_from_user > 10: 286 | response.status = 429 287 | return {"err": "Only allowed 10 messages within 10 seconds"} 288 | 289 | # Store message 290 | message = Message(parent=thread_key()) 291 | message.text = message_text 292 | message.user = sender 293 | message.put() 294 | 295 | push_send_message = send(RegistrationType.CHAT, message, user_endpoint) 296 | 297 | return { 298 | "text": message.text, 299 | "user": message.user, 300 | "date": message.creation_date.isoformat(), 301 | "id": message.key.id() 302 | } 303 | 304 | 305 | def send(type, data, user_endpoint): 306 | """XHR requesting that we send a push message to all users""" 307 | 308 | gcm_stats = sendGCM(type, data, user_endpoint) 309 | firefox_stats = sendFirefox(type, data, user_endpoint) 310 | 311 | if gcm_stats.total_count + firefox_stats.total_count \ 312 | != Registration.query(Registration.type == type).count(): 313 | # Migrate old registrations that don't yet have a service property; 314 | # they'll miss this message, but at least they'll work next time. 315 | # TODO: Remove this after a while. 316 | registrations = Registration.query(Registration.type == type).fetch() 317 | registrations = [r for r in registrations if r.service == None] 318 | for r in registrations: 319 | r.service = PushService.GCM 320 | ndb.put_multi(registrations) 321 | 322 | if gcm_stats.success_count + firefox_stats.success_count == 0: 323 | if not gcm_stats.total_count + firefox_stats.total_count == 0: 324 | abort(500, "Failed to send message to any of the %d registered " 325 | "devices%s%s" 326 | % (gcm_stats.total_count + firefox_stats.total_count, 327 | gcm_stats.text, firefox_stats.text)) 328 | 329 | response.status = 201 330 | return "Message sent successfully to %d/%d GCM devices and %d/%d Firefox " \ 331 | "devices%s%s" % (gcm_stats.success_count, gcm_stats.total_count, 332 | firefox_stats.success_count, 333 | firefox_stats.total_count, 334 | gcm_stats.text, firefox_stats.text) 335 | 336 | class SendStats: 337 | success_count = 0 338 | total_count = 0 339 | text = "" 340 | 341 | 342 | def sendFirefox(type, data, user_endpoint): 343 | ndb_query = Registration.query( 344 | Registration.type == type, 345 | Registration.service == PushService.FIREFOX) 346 | firefox_registration_keys = ndb_query.fetch(keys_only=True) 347 | push_endpoints = [key.string_id() for key in firefox_registration_keys] 348 | 349 | stats = SendStats() 350 | stats.total_count = len(push_endpoints) 351 | if not push_endpoints: 352 | return stats 353 | 354 | for endpoint in push_endpoints: 355 | if user_endpoint == endpoint: 356 | continue 357 | 358 | result = urlfetch.fetch(url=endpoint, 359 | payload="", 360 | method=urlfetch.PUT) 361 | if result.status_code == 200: 362 | stats.success_count += 1 363 | else: 364 | logging.error("Firefox send failed %d:\n%s" % (result.status_code, 365 | result.content)) 366 | # TODO: Deal with stale connections. 367 | return stats 368 | 369 | 370 | def sendGCM(type, data, user_endpoint): 371 | 372 | ndb_query = Registration.query(Registration.type == type, 373 | Registration.service == PushService.GCM) 374 | gcm_registration_keys = ndb_query.fetch(keys_only=True) 375 | registration_ids = [key.string_id() for key in gcm_registration_keys] 376 | 377 | stats = SendStats() 378 | stats.total_count = len(registration_ids) 379 | if not registration_ids: 380 | return stats 381 | 382 | # filter out user_endpoint 383 | registration_ids = [reg_id for reg_id in registration_ids if user_endpoint.rfind(reg_id) + len(reg_id) != len(user_endpoint)] 384 | stats.total_count = len(registration_ids) 385 | 386 | # TODO: Should limit batches to 1000 registration_ids at a time. 387 | post_data = json.dumps({ 388 | 'registration_ids': registration_ids, 389 | # Chrome doesn't yet support receiving data https://crbug.com/434808 390 | # (this is blocked on standardizing an encryption format). 391 | # Hence it's optimal to use collapse_key so device only gets woken up 392 | # once if multiple messages are sent whilst the device is offline (when 393 | # the Service Worker asks us what has changed since it last synced, by 394 | # fetching /chat/messages, it'll get all the new messages). 395 | #'data': { 396 | # 'data': data, #request.forms.msg, 397 | #}, 398 | 'collapse_key': str(type), 399 | #'time_to_live': 108, 400 | #'delay_while_idle': true, 401 | }) 402 | settings = GcmSettings.singleton() 403 | result = urlfetch.fetch(url=settings.endpoint, 404 | payload=post_data, 405 | method=urlfetch.POST, 406 | headers={ 407 | 'Content-Type': 'application/json', 408 | 'Authorization': 'key=' + settings.api_key, 409 | }, 410 | validate_certificate=True, 411 | allow_truncated=True) 412 | if result.status_code != 200: 413 | logging.error("GCM send failed %d:\n%s" % (result.status_code, 414 | result.content)) 415 | return stats 416 | 417 | try: 418 | result_json = json.loads(result.content) 419 | stats.success_count = result_json['success'] 420 | if users.is_current_user_admin(): 421 | stats.text = '\n\n' + result.content 422 | except: 423 | logging.exception("Failed to decode GCM JSON response") 424 | return stats 425 | 426 | # Stop sending messages to registrations that GCM tells us are stale. 427 | stale_keys = [] 428 | for i, res in enumerate(result_json['results']): 429 | if 'error' in res and res['error'] in PERMANENT_GCM_ERRORS: 430 | stale_keys.append(gcm_registration_keys[i]) 431 | stale_registrations = ndb.get_multi(stale_keys) 432 | for registration in stale_registrations: 433 | registration.type = RegistrationType.CHAT_STALE 434 | ndb.put_multi(stale_registrations) 435 | 436 | return stats 437 | 438 | 439 | bottle.run(server='gae', debug=True) 440 | app = bottle.app() 441 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push-api-appengine-demo", 3 | "version": "1.0.0", 4 | "description": "A demo App Engine server using https://w3c.github.io/push-api/ to send push messages to web browsers.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "serve": "gulp serve", 9 | "build": "gulp build --production", 10 | "deploy": "gulp deploy --production" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/jakearchibald/push-api-appengine-demo.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/jakearchibald/push-api-appengine-demo/issues" 20 | }, 21 | "homepage": "https://github.com/jakearchibald/push-api-appengine-demo#readme", 22 | "private": true, 23 | "devDependencies": { 24 | "babelify": "^6.1.3", 25 | "browserify": "^10.2.6", 26 | "configurify": "^1.0.0", 27 | "dateformat": "^1.0.11", 28 | "del": "^1.2.0", 29 | "emojilib": "^1.0.0", 30 | "gulp": "^3.9.0", 31 | "gulp-load-plugins": "^0.10.0", 32 | "gulp-sass": "^2.0.3", 33 | "gulp-shell": "^0.4.2", 34 | "gulp-sourcemaps": "^1.5.2", 35 | "gulp-util": "^3.0.6", 36 | "handlebars": "^3.0.3", 37 | "hbsfy": "^2.2.1", 38 | "merge-stream": "^0.1.8", 39 | "run-sequence": "^1.1.1", 40 | "serviceworker-cache-polyfill": "^3.0.0", 41 | "uglifyify": "^3.0.1", 42 | "vinyl-buffer": "^1.0.0", 43 | "vinyl-source-stream": "^1.1.0", 44 | "watchify": "^3.2.3" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This requirements file lists all dependecies for this project. 2 | # 3 | # Run 'pip install -r requirements.txt -t lib/' to install these dependencies 4 | # in this project's lib/ directory. The `lib` directory is added to `sys.path` 5 | # by `appengine_config.py`. 6 | bottle>=0.12.8 7 | -------------------------------------------------------------------------------- /scss/_global.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Roboto Regular'), local('Roboto-Regular'), url('/static/fonts/roboto.woff') format('woff'); 6 | } 7 | 8 | html { 9 | background: #eeeeee; 10 | } 11 | 12 | html, body { 13 | height: 100%; 14 | margin: 0; 15 | padding: 0; 16 | font-family: roboto, sans-serif; 17 | overflow: hidden; 18 | -webkit-tap-highlight-color: rgba(0,0,0,0); 19 | } 20 | 21 | pre, 22 | code, 23 | kbd, 24 | samp, 25 | tt{ 26 | font-family:monospace,monospace; 27 | font-size:1em; 28 | } 29 | 30 | button:-moz-focus-inner { 31 | border: 0; 32 | padding: 0; 33 | } 34 | 35 | button { 36 | cursor: pointer; 37 | } -------------------------------------------------------------------------------- /scss/_layout.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | height: 100%; 4 | flex-flow: column; 5 | } 6 | 7 | .chat-content { 8 | flex: 1; 9 | overflow-y: scroll; 10 | overflow-x: hidden; 11 | scroll-behavior: smooth; 12 | -webkit-overflow-scrolling: touch; 13 | } -------------------------------------------------------------------------------- /scss/admin.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 5vmin; 3 | font-family: sans-serif; 4 | } 5 | .success { 6 | color: green; 7 | font-style: italic; 8 | } 9 | .fail { 10 | color: red; 11 | font-style: italic; 12 | font-weight: bold; 13 | } -------------------------------------------------------------------------------- /scss/app.scss: -------------------------------------------------------------------------------- 1 | @import 'global'; 2 | @import 'layout'; 3 | @import 'components/toolbar'; 4 | @import 'components/message-form'; 5 | @import 'components/messages'; 6 | @import 'components/global-warning'; 7 | @import 'components/keyboard'; 8 | -------------------------------------------------------------------------------- /scss/components/_global-warning.scss: -------------------------------------------------------------------------------- 1 | .global-warning { 2 | display: none; 3 | padding: 10px 16px; 4 | background: #F9F99C; 5 | 6 | &.active { 7 | display: block; 8 | } 9 | } -------------------------------------------------------------------------------- /scss/components/_keyboard.scss: -------------------------------------------------------------------------------- 1 | .keyboard { 2 | display: none; 3 | flex-flow: column; 4 | height: 283px; 5 | background: #eceff1; 6 | max-height: 50vh; 7 | 8 | @media (min-height: 700px) { 9 | height: 311px; 10 | } 11 | 12 | & .keys { 13 | flex: 1; 14 | display: flex; 15 | flex-flow: column wrap; 16 | justify-content: center; 17 | align-content: flex-start; 18 | overflow: hidden; 19 | overflow-x: auto; 20 | scroll-behavior: smooth; 21 | border-top: 1px solid #f6f7f8; 22 | -webkit-overflow-scrolling: touch; 23 | 24 | &.render-all button { 25 | display: block; 26 | } 27 | } 28 | 29 | & .tools { 30 | display: flex; 31 | flex-flow: row; 32 | } 33 | 34 | & .categories { 35 | flex: 1; 36 | display: flex; 37 | flex-flow: row; 38 | justify-content: space-around; 39 | background: #e4e7e9; 40 | } 41 | 42 | & .tools button, 43 | & .keys button { 44 | border: none; 45 | background: none; 46 | padding: 0; 47 | margin: 0; 48 | box-sizing: content-box; 49 | fill: #b0b6bb; 50 | 51 | &:focus, 52 | &.active { 53 | outline: none; 54 | fill: #37474f; 55 | } 56 | 57 | & svg { 58 | width: 100%; 59 | height: 100%; 60 | } 61 | } 62 | 63 | & .keys button { 64 | display: none; 65 | width: 57px; 66 | height: 57px; 67 | 68 | // do a small initial layout for quick animation 69 | &:nth-child(-n+42) { 70 | display: block; 71 | } 72 | 73 | @media (min-width: 800px) { 74 | display: block; 75 | } 76 | 77 | &:focus, 78 | &.active { 79 | outline: none; 80 | background: rgba(0,0,0,0.1); 81 | } 82 | } 83 | 84 | & .keys .char { 85 | display: flex; 86 | width: 57px; 87 | height: 57px; 88 | font-size: 40px; 89 | justify-content: center; 90 | align-items: center; 91 | } 92 | 93 | & .tools button { 94 | width: 29px; 95 | height: 29px; 96 | padding: 9px; 97 | } 98 | 99 | & button.del { 100 | background: #eceff1; 101 | fill: #7f8b8f; 102 | } 103 | 104 | & .space { 105 | display: flex; 106 | border-top: 2px solid #e4e7e9; 107 | justify-content: center; 108 | 109 | & button { 110 | width: 70%; 111 | margin: 13px 0; 112 | border: none; 113 | border-radius: 5px; 114 | background: #d1d6d9; 115 | color: transparent; 116 | height: 24px; 117 | 118 | &:focus, 119 | &.active { 120 | outline: none; 121 | background: #7f8b8f; 122 | } 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /scss/components/_message-form.scss: -------------------------------------------------------------------------------- 1 | .message-input { 2 | position: relative; 3 | 4 | &.active { 5 | & .keyboard { 6 | display: flex; 7 | } 8 | } 9 | 10 | &.exiting { 11 | & .keyboard { 12 | position: absolute; 13 | top: 100%; 14 | left: 0; 15 | right: 0; 16 | } 17 | } 18 | } 19 | 20 | .message-form { 21 | background: #fff; 22 | border-top: 1px solid #c1c1c1; 23 | display: flex; 24 | 25 | & input { 26 | flex: 1; 27 | background: #fff; 28 | font-size: 1rem; 29 | font-family: roboto; 30 | padding: 13px; 31 | border: none; 32 | margin: 0; 33 | cursor: text; 34 | 35 | &:focus { 36 | outline: none; 37 | } 38 | } 39 | 40 | & button { 41 | fill: #adadad; 42 | border: none; 43 | background: none; 44 | padding: 0 6px 0 0; 45 | margin: 0 0 0 auto; 46 | width: 34px; 47 | box-sizing: content-box; 48 | 49 | &:focus { 50 | outline: none; 51 | fill: #000; 52 | } 53 | 54 | & svg { 55 | width: 100%; 56 | height: 100%; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /scss/components/_messages.scss: -------------------------------------------------------------------------------- 1 | .chat-timeline { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .chat-item { 7 | display: flex; 8 | margin: 9px 8px; 9 | 10 | .avatar { 11 | border-radius: 500px; 12 | margin-right: 6px; 13 | min-width: 48px; 14 | } 15 | 16 | p { 17 | margin: 0; 18 | word-wrap: break-word; 19 | font-size: 28px; 20 | } 21 | 22 | .bubble { 23 | padding: 7px 10px; 24 | color: #333; 25 | background: #fff; 26 | box-shadow: 0 3px 2px rgba(0,0,0,0.1); 27 | position: relative; 28 | max-width: 420px; 29 | min-width: 80px; 30 | 31 | &::before { 32 | content: ''; 33 | border-style: solid; 34 | border-width: 0 10px 10px 0; 35 | border-color: transparent #fff transparent transparent; 36 | position: absolute; 37 | top: 0; 38 | left: -10px; 39 | } 40 | 41 | .meta { 42 | font-size: 0.8rem; 43 | color: #999; 44 | margin-top: 3px; 45 | } 46 | } 47 | 48 | &.from-me { 49 | justify-content: flex-end; 50 | 51 | & .avatar { 52 | order: 1; 53 | margin: 0; 54 | margin-left: 6px; 55 | } 56 | 57 | & .bubble { 58 | background: #F9D7FF; 59 | 60 | &::before { 61 | left: 100%; 62 | border-width: 10px 10px 0 0; 63 | border-color: #F9D7FF transparent transparent transparent; 64 | } 65 | } 66 | } 67 | 68 | & .state { 69 | display: none; 70 | } 71 | 72 | &.sending { 73 | & .state { 74 | display: block; 75 | } 76 | & time { 77 | display: none; 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /scss/components/_toolbar.scss: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | position: relative; 3 | z-index: 1; 4 | background: #9C27B0; 5 | color: #fff; 6 | display: flex; 7 | align-items: center; 8 | height: 56px; 9 | box-shadow: 0 0px 11px rgba(0,0,0,0.4); 10 | 11 | & h1 { 12 | font-size: 1.2rem; 13 | font-weight: normal; 14 | margin: 0; 15 | padding: 0; 16 | margin-left: 16px; 17 | color: #fff; 18 | } 19 | 20 | & .logout { 21 | font-family: roboto; 22 | font-size: 1rem; 23 | color: #fff; 24 | border: none; 25 | background: none; 26 | padding: 10px 0; 27 | margin: 0 16px 0 auto; 28 | text-transform: uppercase; 29 | text-decoration: none; 30 | // disabling this until logout does the right thing 31 | display: none; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /setup.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Setup 4 | 5 | 22 | 23 |

    Setup

    24 | %if result: 25 |

    {{result}}

    26 | %end 27 |
    28 | 31 | 34 | 37 | 40 | 41 |
    42 | -------------------------------------------------------------------------------- /static/fonts/roboto.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/emojoy/fe0bbefd3bf25afc061224e5ce552d15714c16d6/static/fonts/roboto.woff -------------------------------------------------------------------------------- /static/imgs/hangouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/emojoy/fe0bbefd3bf25afc061224e5ce552d15714c16d6/static/imgs/hangouts.png -------------------------------------------------------------------------------- /vendor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2014 Jon Wayne Parrott, [proppy], Michael R. Bernstein 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | # Notes: 18 | # - Imported from https://github.com/jonparrott/Darth-Vendor/. 19 | # - Added license header. 20 | # - Renamed `darth.vendor` to `vendor.add` to match upcoming SDK interface. 21 | # - Renamed `position` param to `index` to match upcoming SDK interface. 22 | # - Removed funny arworks docstring. 23 | 24 | import site 25 | import os.path 26 | import sys 27 | 28 | 29 | def add(folder, index=1): 30 | """ 31 | Adds the given folder to the python path. Supports namespaced packages. 32 | By default, packages in the given folder take precedence over site-packages 33 | and any previous path manipulations. 34 | 35 | Args: 36 | folder: Path to the folder containing packages, relative to ``os.getcwd()`` 37 | position: Where in ``sys.path`` to insert the vendor packages. By default 38 | this is set to 1. It is inadvisable to set it to 0 as it will override 39 | any modules in the current working directory. 40 | """ 41 | 42 | # Check if the path contains a virtualenv. 43 | site_dir = os.path.join(folder, 'lib', 'python' + sys.version[:3], 'site-packages') 44 | if os.path.exists(site_dir): 45 | folder = site_dir 46 | # Otherwise it's just a normal path, make it absolute. 47 | else: 48 | folder = os.path.join(os.path.dirname(__file__), folder) 49 | 50 | # Use site.addsitedir() because it appropriately reads .pth 51 | # files for namespaced packages. Unfortunately, there's not an 52 | # option to choose where addsitedir() puts its paths in sys.path 53 | # so we have to do a little bit of magic to make it play along. 54 | 55 | # We're going to grab the current sys.path and split it up into 56 | # the first entry and then the rest. Essentially turning 57 | # ['.', '/site-packages/x', 'site-packages/y'] 58 | # into 59 | # ['.'] and ['/site-packages/x', 'site-packages/y'] 60 | # The reason for this is we want '.' to remain at the top of the 61 | # list but we want our vendor files to override everything else. 62 | sys.path, remainder = sys.path[:1], sys.path[1:] 63 | 64 | # Now we call addsitedir which will append our vendor directories 65 | # to sys.path (which was truncated by the last step.) 66 | site.addsitedir(folder) 67 | 68 | # Finally, we'll add the paths we removed back. 69 | # The final product is something like this: 70 | # ['.', '/vendor-folder', /site-packages/x', 'site-packages/y'] 71 | sys.path.extend(remainder) 72 | --------------------------------------------------------------------------------