From f8f195b329f92900b29bac8c9098c328dd3daa74 Mon Sep 17 00:00:00 2001 From: David Fisher Date: Mon, 11 Jun 2012 17:49:32 -0700 Subject: [PATCH] Make Notifications Realtime Summary: Adds the node.js Aphlict server, the flash Aphlict client, and some supporting javascript. Built on top of - and requires - D2703 (which is still in progress). Will likely work with no modification on top of the final version, though. The node server is currently run with sudo node support/aphlict/server/aphlict_server.js Test Plan: tested locally Reviewers: epriestley Reviewed By: epriestley CC: allenjohnashton, keebuhm, aran, Korvin Differential Revision: https://secure.phabricator.com/D2704 --- src/__celerity_resource_map__.php | 3 +- src/__phutil_library_map__.php | 2 + ...AphrontDefaultApplicationConfiguration.php | 2 + .../feed/PhabricatorFeedStoryPublisher.php | 12 ++ ...icatorNotificationIndividualController.php | 43 ++++++ ...PhabricatorNotificationPanelController.php | 8 ++ src/view/page/PhabricatorStandardPageView.php | 22 +-- support/aphlict/client/src/Aphlict.as | 79 +---------- .../src/com/phabricator/AphlictReceiver.as | 25 ---- support/aphlict/server/aphlict_server.js | 125 +++++++++++++++--- .../aphlict/behavior-aphlict-dropdown.js | 26 +++- .../aphlict/behavior-aphlict-listen.js | 26 +++- webroot/rsrc/swf/aphlict.swf | Bin 8423 -> 7217 bytes 13 files changed, 235 insertions(+), 138 deletions(-) create mode 100644 src/applications/notification/controller/PhabricatorNotificationIndividualController.php delete mode 100644 support/aphlict/client/src/com/phabricator/AphlictReceiver.as diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php index 731e5596c0..ec237c388c 100644 --- a/src/__celerity_resource_map__.php +++ b/src/__celerity_resource_map__.php @@ -772,7 +772,7 @@ celerity_register_resource_map(array( ), 'javelin-behavior-aphlict-listen' => array( - 'uri' => '/res/6388e057/rsrc/js/application/aphlict/behavior-aphlict-listen.js', + 'uri' => '/res/7f4bc63b/rsrc/js/application/aphlict/behavior-aphlict-listen.js', 'type' => 'js', 'requires' => array( @@ -780,6 +780,7 @@ celerity_register_resource_map(array( 1 => 'javelin-aphlict', 2 => 'javelin-util', 3 => 'javelin-stratcom', + 4 => 'javelin-behavior-aphlict-dropdown', ), 'disk' => '/rsrc/js/application/aphlict/behavior-aphlict-listen.js', ), diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 380481bb8b..278fb7e335 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -736,6 +736,7 @@ phutil_register_library_map(array( 'PhabricatorMySQLFileStorageEngine' => 'applications/files/engine/PhabricatorMySQLFileStorageEngine.php', 'PhabricatorNotificationBuilder' => 'applications/notification/builder/PhabricatorNotificationBuilder.php', 'PhabricatorNotificationController' => 'applications/notification/controller/PhabricatorNotificationController.php', + 'PhabricatorNotificationIndividualController' => 'applications/notification/controller/PhabricatorNotificationIndividualController.php', 'PhabricatorNotificationPanelController' => 'applications/notification/controller/PhabricatorNotificationPanelController.php', 'PhabricatorNotificationQuery' => 'applications/notification/PhabricatorNotificationQuery.php', 'PhabricatorNotificationStoryView' => 'applications/notification/view/PhabricatorNotificationStoryView.php', @@ -1698,6 +1699,7 @@ phutil_register_library_map(array( 'PhabricatorMustVerifyEmailController' => 'PhabricatorAuthController', 'PhabricatorMySQLFileStorageEngine' => 'PhabricatorFileStorageEngine', 'PhabricatorNotificationController' => 'PhabricatorController', + 'PhabricatorNotificationIndividualController' => 'PhabricatorNotificationController', 'PhabricatorNotificationPanelController' => 'PhabricatorNotificationController', 'PhabricatorNotificationStoryView' => 'PhabricatorNotificationView', 'PhabricatorNotificationTestController' => 'PhabricatorNotificationController', diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php index ebabff25b5..b59b130f09 100644 --- a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php @@ -423,6 +423,8 @@ class AphrontDefaultApplicationConfiguration '/notification/test/' => 'PhabricatorNotificationTestController', '/notification/panel/' => 'PhabricatorNotificationPanelController', + '/notification/individual/' + => 'PhabricatorNotificationIndividualController', '/flag/' => array( '' => 'PhabricatorFlagListController', 'view/(?P[^/]+)/' => 'PhabricatorFlagListController', diff --git a/src/applications/feed/PhabricatorFeedStoryPublisher.php b/src/applications/feed/PhabricatorFeedStoryPublisher.php index d80073980b..74828c5237 100644 --- a/src/applications/feed/PhabricatorFeedStoryPublisher.php +++ b/src/applications/feed/PhabricatorFeedStoryPublisher.php @@ -98,6 +98,7 @@ final class PhabricatorFeedStoryPublisher { if (PhabricatorEnv::getEnvConfig('notification.enabled')) { $this->insertNotifications($chrono_key); + $this->sendNotification($chrono_key); } return $story; } @@ -136,6 +137,17 @@ final class PhabricatorFeedStoryPublisher { implode(', ', $sql)); } + private function sendNotification($chrono_key) { + $aphlict_url = 'http://127.0.0.1:22281/push?'; //TODO: make configurable + $future = new HTTPFuture($aphlict_url, array( + "key" => (string)$chrono_key, + // TODO: fix. \r\n appears to be appended to the final value here. + // this is a temporary workaround + "nothing" => "", + )); + $future->setMethod('POST'); + $future->resolve(); + } /** * We generate a unique chronological key for each story type because we want diff --git a/src/applications/notification/controller/PhabricatorNotificationIndividualController.php b/src/applications/notification/controller/PhabricatorNotificationIndividualController.php new file mode 100644 index 0000000000..916f84d6da --- /dev/null +++ b/src/applications/notification/controller/PhabricatorNotificationIndividualController.php @@ -0,0 +1,43 @@ +getRequest(); + $user = $request->getUser(); + + $chron_key = $request->getStr('key'); + $story = id(new PhabricatorFeedStoryNotification()) + ->loadOneWhere('userPHID = %s AND chronologicalKey = %s', + $user->getPHID(), + $chron_key); + + if ($story == null) { + $json = array( "pertinent" => false ); + } else { + $json = array( + "pertinent" => true, + "primaryObjectPHID" => $story->getPrimaryObjectPHID(), + ); + } + + return id(new AphrontAjaxResponse())->setContent($json); + } +} diff --git a/src/applications/notification/controller/PhabricatorNotificationPanelController.php b/src/applications/notification/controller/PhabricatorNotificationPanelController.php index 8cbcb606ea..cb80063899 100644 --- a/src/applications/notification/controller/PhabricatorNotificationPanelController.php +++ b/src/applications/notification/controller/PhabricatorNotificationPanelController.php @@ -33,10 +33,18 @@ final class PhabricatorNotificationPanelController $builder = new PhabricatorNotificationBuilder($stories); $notifications_view = $builder->buildView(); + $num_unconsumed = 0; + foreach ($stories as $story) { + if (!$story->getHasViewed()) { + $num_unconsumed++; + } + } + $json = array( "content" => $stories ? $notifications_view->render() : "You currently have no notifications", + "number" => $num_unconsumed, ); return id(new AphrontAjaxResponse())->setContent($json); diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php index f190f5fbf9..a2e9ae8b78 100644 --- a/src/view/page/PhabricatorStandardPageView.php +++ b/src/view/page/PhabricatorStandardPageView.php @@ -376,18 +376,19 @@ final class PhabricatorStandardPageView extends AphrontPageView { if (PhabricatorEnv::getEnvConfig('notification.enabled') && $user->isLoggedIn()) { + $aphlict_object_id = 'aphlictswfobject'; - $aphlict_content = phutil_render_tag( - 'object', + $server_uri = new PhutilURI(PhabricatorEnv::getURI('')); + $server_domain = $server_uri->getDomain(); + + Javelin::initBehavior( + 'aphlict-listen', array( - 'classid' => 'clsid:d27cdb6e-ae6d-11cf-96b8-444553540000', - ), - ''. - ''. - ''. - ''); + 'id' => $aphlict_object_id, + 'server' => $server_domain, + 'port' => 2600, + )); Javelin::initBehavior('aphlict-dropdown', array()); @@ -405,8 +406,7 @@ final class PhabricatorStandardPageView extends AphrontPageView { $notification_header = $notification_indicator. ''. - '
'. - $aphlict_content. + '
'. '
'. ''; $notification_dropdown = diff --git a/support/aphlict/client/src/Aphlict.as b/support/aphlict/client/src/Aphlict.as index 9ae36954e2..13172cced1 100644 --- a/support/aphlict/client/src/Aphlict.as +++ b/support/aphlict/client/src/Aphlict.as @@ -7,23 +7,12 @@ package { import flash.events.*; import flash.external.ExternalInterface; - import com.phabricator.*; - import vegas.strings.JSON; public class Aphlict extends Sprite { private var client:String; - private var master:LocalConnection; - private var recv:LocalConnection; - private var send:LocalConnection; - - private var receiver:AphlictReceiver; - private var loyalUntil:Number = 0; - private var subjects:Array; - private var frequency:Number = 100; - private var socket:Socket; private var readBuffer:ByteArray; @@ -47,60 +36,10 @@ package { this.remoteServer = server; this.remotePort = port; - this.master = null; - this.receiver = new AphlictReceiver(this); - this.subjects = []; - - this.send = new LocalConnection(); - - this.recv = new LocalConnection(); - this.recv.client = this.receiver; - for (var ii:Number = 0; ii < 32; ii++) { - try { - this.recv.connect('aphlict_subject_' + ii); - this.client = 'aphlict_subject_' + ii; - } catch (x:Error) { - // Some other Aphlict client is holding that ID. - } - } - - if (!this.client) { - // Too many clients open already, just exit. - return; - } - - this.usurp(); + this.connectToServer(); + return; } - private function usurp():void { - if (this.master) { - for (var ii:Number = 0; ii < this.subjects.length; ii++) { - if (this.subjects[ii] == this.client) { - continue; - } - this.send.send(this.subjects[ii], 'remainLoyal'); - } - } else if (this.loyalUntil < new Date().getTime()) { - var recv:LocalConnection = new LocalConnection(); - recv.client = this.receiver; - try { - recv.connect('aphlict_master'); - this.master = recv; - this.subjects = [this.client]; - - this.connectToServer(); - - } catch (x:Error) { - // Can't become the master. - } - - if (!this.master) { - this.send.send('aphlict_master', 'becomeLoyal', this.client); - this.remainLoyal(); - } - } - setTimeout(this.usurp, this.frequency); - } public function connectToServer():void { var socket:Socket = new Socket(); @@ -156,9 +95,7 @@ package { t.writeBytes(b, msg_len + 8); this.readBuffer = t; - for (var ii:Number = 0; ii < this.subjects.length; ii++) { - this.send.send(this.subjects[ii], 'receiveMessage', data); - } + this.receiveMessage(data); } else { break; } @@ -166,14 +103,6 @@ package { } - public function remainLoyal():void { - this.loyalUntil = new Date().getTime() + (2 * this.frequency); - } - - public function becomeLoyal(subject:String):void { - this.subjects.push(subject); - } - public function receiveMessage(msg:Object):void { this.externalInvoke('receive', msg); } @@ -188,4 +117,4 @@ package { } -} \ No newline at end of file +} diff --git a/support/aphlict/client/src/com/phabricator/AphlictReceiver.as b/support/aphlict/client/src/com/phabricator/AphlictReceiver.as deleted file mode 100644 index a7eae20a58..0000000000 --- a/support/aphlict/client/src/com/phabricator/AphlictReceiver.as +++ /dev/null @@ -1,25 +0,0 @@ -package com.phabricator { - - public class AphlictReceiver { - - private var core:Object; - - public function AphlictReceiver(core:Object) { - this.core = core; - } - - public function remainLoyal():void { - this.core.remainLoyal(); - } - - public function becomeLoyal(subject:String):void { - this.core.becomeLoyal(subject); - } - - public function receiveMessage(msg:Object):void { - this.core.receiveMessage(msg); - } - - } - -} \ No newline at end of file diff --git a/support/aphlict/server/aphlict_server.js b/support/aphlict/server/aphlict_server.js index f50fa855ba..d00f04711a 100755 --- a/support/aphlict/server/aphlict_server.js +++ b/support/aphlict/server/aphlict_server.js @@ -1,4 +1,21 @@ var net = require('net'); +var http = require('http'); +var url = require('url'); +var querystring = require('querystring'); +var fs = require('fs'); + +// set up log file +logfile = fs.createWriteStream('/var/log/aphlict.log', + { flags: 'a', + encoding: null, + mode: 0666 }); +logfile.write('----- ' + (new Date()).toLocaleString() + ' -----\n'); + +function log(str) { + console.log(str); + logfile.write(str + '\n'); +} + function getFlashPolicy() { return [ @@ -8,35 +25,113 @@ function getFlashPolicy() { '', '', '' - ].join("\n"); + ].join('\n'); } net.createServer(function(socket) { socket.on('data', function() { socket.write(getFlashPolicy() + '\0'); }); + + socket.on('error', function (e) { + log('Error in policy server: ' + e); + }); }).listen(843); -var sp_server = net.createServer(function(socket) { - function xwrite() { - var data = {hi: "hello"}; - var serial = JSON.stringify(data); - var length = Buffer.byteLength(serial, 'utf8'); - length = length.toString(); - while (length.length < 8) { - length = "0" + length; - } - socket.write(length + serial); - - console.log('write : ' + length + serial); +function write_json(socket, data) { + var serial = JSON.stringify(data); + var length = Buffer.byteLength(serial, 'utf8'); + length = length.toString(); + while (length.length < 8) { + length = '0' + length; } + socket.write(length + serial); +} + + +var clients = {}; +var current_connections = 0; +// According to the internet up to 2^53 can +// be stored in javascript, this is less than that +var MAX_ID = 9007199254740991;//2^53 -1 + +// If we get one connections per millisecond this will +// be fine as long as someone doesn't maintain a +// connection for longer than 6854793 years. If +// you want to write something pretty be my guest + +function generate_id() { + if (typeof generate_id.current_id == 'undefined' + || generate_id.current_id > MAX_ID) { + generate_id.current_id = 0; + } + return generate_id.current_id++; +} + +var send_server = net.createServer(function(socket) { + var client_id = generate_id(); socket.on('connect', function() { + clients[client_id] = socket; + current_connections++; + log(client_id + ': connected\t\t(' + + current_connections + ' current connections)'); + }); - xwrite(); - setInterval(xwrite, 1000); + socket.on('close', function() { + delete clients[client_id]; + current_connections--; + log(client_id + ': closed\t\t(' + + current_connections + ' current connections)'); + }); + socket.on('timeout', function() { + log(client_id + ': timed out!'); + }); + + socket.on('end', function() { + log(client_id + ': ended the connection'); + // node automatically closes half-open connections + }); + + socket.on('error', function (e) { + console.log('Uncaught error in send server: ' + e); }); }).listen(2600); + + + +var receive_server = http.createServer(function(request, response) { + response.writeHead(200, {'Content-Type' : 'text/plain'}); + + if (request.method == 'POST') { // Only pay attention to POST requests + var body = ''; + + request.on('data', function (data) { + body += data; + }); + + request.on('end', function () { + var data = querystring.parse(body); + log('notification: ' + JSON.stringify(data)); + broadcast(data); + response.end(); + }); + } +}).listen(22281, '127.0.0.1'); + +function broadcast(data) { + for(var client_id in clients) { + try { + write_json(clients[client_id], data); + log(' wrote to client ' + client_id); + } catch (error) { + delete clients[client_id]; + current_connections--; + log(' ERROR: could not write to client ' + client_id); + } + } +} + diff --git a/webroot/rsrc/js/application/aphlict/behavior-aphlict-dropdown.js b/webroot/rsrc/js/application/aphlict/behavior-aphlict-dropdown.js index 14637ff099..c128d8e584 100644 --- a/webroot/rsrc/js/application/aphlict/behavior-aphlict-dropdown.js +++ b/webroot/rsrc/js/application/aphlict/behavior-aphlict-dropdown.js @@ -11,13 +11,28 @@ JX.behavior('aphlict-dropdown', function(config) { var dropdown = JX.$('phabricator-notification-dropdown'); var indicator = JX.$('phabricator-notification-indicator'); var visible = false; + var request = null; + + function refresh() { + if (request) { //already fetching + return; + } + + request = new JX.Request('/notification/panel/', function(response) { + indicator.textContent = '' + response.number; + if (response.number == 0) { + indicator.style.fontWeight = ""; + } else { + indicator.style.fontWeight = "bold"; + } + JX.DOM.setContent(dropdown, JX.$H(response.content)); + request = null; + }); + request.send(); + } //populate panel - (new JX.Request('/notification/panel/', - function(response) { - JX.DOM.setContent(dropdown, JX.$H(response.content)); - })).send(); - + refresh(); JX.Stratcom.listen( 'click', @@ -48,4 +63,5 @@ JX.behavior('aphlict-dropdown', function(config) { } ) + JX.Stratcom.listen('notification-panel-update', null, refresh); }); diff --git a/webroot/rsrc/js/application/aphlict/behavior-aphlict-listen.js b/webroot/rsrc/js/application/aphlict/behavior-aphlict-listen.js index 9a6ab06f30..c53b1f89c8 100644 --- a/webroot/rsrc/js/application/aphlict/behavior-aphlict-listen.js +++ b/webroot/rsrc/js/application/aphlict/behavior-aphlict-listen.js @@ -4,19 +4,24 @@ * javelin-aphlict * javelin-util * javelin-stratcom + * javelin-behavior-aphlict-dropdown */ JX.behavior('aphlict-listen', function(config) { function onready() { - JX.log("The flash component is ready!"); - var client = new JX.Aphlict(config.id, config.server, config.port) .setHandler(function(type, message) { if (message) { - JX.log("Got aphlict event '" + type + "':"); - JX.log(message); - } else { - JX.log("Got aphlict event '" + type + "'."); + if (type == 'receive') { + var request = new JX.Request('/notification/individual/', + function(response) { + if (response.pertinent) { + JX.Stratcom.invoke('notification-panel-update', null, {}); + } + }); + request.addData({ "key": message.key }); + request.send(); + } } }) .start(); @@ -27,4 +32,13 @@ JX.behavior('aphlict-listen', function(config) { // If we just go crazy and start making calls to it before it loads, its // interfaces won't be registered yet. JX.Stratcom.listen('aphlict-component-ready', null, onready); + + // Add Flash object to page + JX.$("aphlictswf-container").innerHTML = + '' + + '' + + '' + + '' + + '' + + ''; //Evan sanctioned }); diff --git a/webroot/rsrc/swf/aphlict.swf b/webroot/rsrc/swf/aphlict.swf index 3bd89060899879cbef92f371114848be70e22a51..4ac315b9de8a6669fe373cfbd9e5449814371dc2 100644 GIT binary patch literal 7217 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 J{|DHQOD2HFa)