3 * StatusNet, the distributed open-source microblogging tool
5 * Superclass for plugins that do "real time" updates of timelines using Ajax
9 * LICENCE: This program is free software: you can redistribute it and/or modify
10 * it under the terms of the GNU Affero General Public License as published by
11 * the Free Software Foundation, either version 3 of the License, or
12 * (at your option) any later version.
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU Affero General Public License for more details.
19 * You should have received a copy of the GNU Affero General Public License
20 * along with this program. If not, see <http://www.gnu.org/licenses/>.
24 * @author Evan Prodromou <evan@status.net>
25 * @copyright 2009 StatusNet, Inc.
26 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27 * @link http://status.net/
30 if (!defined('STATUSNET') && !defined('LACONICA')) {
35 * Superclass for plugin to do realtime updates
37 * Based on experience with the Comet and Meteor plugins,
38 * this superclass extracts out some of the common functionality
42 * @author Evan Prodromou <evan@status.net>
43 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
44 * @link http://status.net/
46 class RealtimePlugin extends Plugin
48 protected $showurl = null;
51 * When it's time to initialize the plugin, calculate and
52 * pass the URLs we need.
54 function onInitializePlugin()
56 // FIXME: need to find a better way to pass this pattern in
57 $this->showurl = common_local_url('shownotice',
58 array('notice' => '0000000000'));
62 function onCheckSchema()
64 $schema = Schema::get();
65 $schema->ensureTable('realtime_channel', Realtime_channel::schemaDef());
69 function onAutoload($cls)
71 $dir = dirname(__FILE__);
75 case 'KeepalivechannelAction':
76 case 'ClosechannelAction':
77 include_once $dir . '/' . strtolower(mb_substr($cls, 0, -6)) . '.php';
79 case 'Realtime_channel':
80 include_once $dir.'/'.$cls.'.php';
88 * Hook for RouterInitialized event.
90 * @param Net_URL_Mapper $m path-to-action mapper
91 * @return boolean hook return
93 function onRouterInitialized($m)
95 $m->connect('main/channel/:channelkey/keepalive',
96 array('action' => 'keepalivechannel'),
97 array('channelkey' => '[a-z0-9]{32}'));
98 $m->connect('main/channel/:channelkey/close',
99 array('action' => 'closechannel'),
100 array('channelkey' => '[a-z0-9]{32}'));
104 function onEndShowScripts($action)
106 $channel = $this->_getChannel($action);
108 if (empty($channel)) {
112 $timeline = $this->_pathToChannel(array($channel->channel_key));
114 // If there's not a timeline on this page,
117 if (empty($timeline)) {
121 $base = $action->selfUrl();
122 if (mb_strstr($base, '?')) {
123 $url = $base . '&realtime=1';
125 $url = $base . '?realtime=1';
128 $scripts = $this->_getScripts();
130 foreach ($scripts as $script) {
131 $action->script($script);
134 $user = common_current_user();
136 if (!empty($user->id)) {
137 $user_id = $user->id;
142 if ($action->boolean('realtime')) {
143 $realtimeUI = ' RealtimeUpdate.initPopupWindow();';
146 $pluginPath = common_path('plugins/Realtime/');
147 $keepalive = common_local_url('keepalivechannel', array('channelkey' => $channel->channel_key));
148 $close = common_local_url('closechannel', array('channelkey' => $channel->channel_key));
149 $realtimeUI = ' RealtimeUpdate.initActions('.json_encode($url).', '.json_encode($timeline).', '.json_encode($pluginPath).', '.json_encode($keepalive).', '.json_encode($close).'); ';
152 $script = ' $(document).ready(function() { '.
154 $this->_updateInitialize($timeline, $user_id).
156 $action->inlineScript($script);
161 function onEndShowStatusNetStyles($action)
163 $action->cssLink(Plugin::staticPath('Realtime', 'realtimeupdate.css'),
165 'screen, projection, tv');
169 function onHandleQueuedNotice($notice)
173 // Add to the author's timeline
176 $profile = $notice->getProfile();
177 } catch (Exception $e) {
178 $this->log(LOG_ERR, $e->getMessage());
182 $user = User::staticGet('id', $notice->profile_id);
185 $paths[] = array('showstream', $user->nickname, null);
188 // Add to the public timeline
190 if ($notice->is_local == Notice::LOCAL_PUBLIC ||
191 ($notice->is_local == Notice::REMOTE && !common_config('public', 'localonly'))) {
192 $paths[] = array('public', null, null);
195 // Add to the tags timeline
197 $tags = $this->getNoticeTags($notice);
200 foreach ($tags as $tag) {
201 $paths[] = array('tag', $tag, null);
205 // Add to inbox timelines
208 $ni = $notice->whoGets();
210 foreach (array_keys($ni) as $user_id) {
211 $user = User::staticGet('id', $user_id);
212 $paths[] = array('all', $user->nickname, null);
215 // Add to the replies timeline
217 $reply = new Reply();
218 $reply->notice_id = $notice->id;
220 if ($reply->find()) {
221 while ($reply->fetch()) {
222 $user = User::staticGet('id', $reply->profile_id);
224 $paths[] = array('replies', $user->nickname, null);
229 // Add to the group timeline
232 $gi = new Group_inbox();
233 $gi->notice_id = $notice->id;
236 while ($gi->fetch()) {
237 $ug = User_group::staticGet('id', $gi->group_id);
238 $paths[] = array('showgroup', $ug->nickname, null);
242 if (count($paths) > 0) {
244 $json = $this->noticeAsJson($notice);
248 // XXX: We should probably fan-out here and do a
249 // new queue item for each path
251 foreach ($paths as $path) {
253 list($action, $arg1, $arg2) = $path;
255 $channels = Realtime_channel::getAllChannels($action, $arg1, $arg2);
257 foreach ($channels as $channel) {
259 // XXX: We should probably fan-out here and do a
260 // new queue item for each user/path combo
262 if (is_null($channel->user_id)) {
265 $profile = Profile::staticGet('id', $channel->user_id);
267 if ($notice->inScope($profile)) {
269 sprintf(_("Delivering notice %d to channel (%s, %s, %s) for user '%s'"),
274 ($profile) ? ($profile->nickname) : "<public>"));
275 $timeline = $this->_pathToChannel(array($channel->channel_key));
276 $this->_publish($timeline, $json);
281 $this->_disconnect();
287 function onStartShowBody($action)
289 $realtime = $action->boolean('realtime');
294 $action->elementStart('body',
295 (common_current_user()) ? array('id' => $action->trimmed('action'),
296 'class' => 'user_in realtime-popup')
297 : array('id' => $action->trimmed('action'),
298 'class'=> 'realtime-popup'));
300 // XXX hack to deal with JS that tries to get the
301 // root url from page output
303 $action->elementStart('address');
305 if (common_config('singleuser', 'enabled')) {
306 $user = User::singleUser();
307 $url = common_local_url('showstream', array('nickname' => $user->nickname));
309 $url = common_local_url('public');
312 $action->element('a', array('class' => 'url',
316 $action->elementEnd('address');
318 $action->showContentBlock();
319 $action->showScripts();
320 $action->elementEnd('body');
321 return false; // No default processing
324 function noticeAsJson($notice)
326 // FIXME: this code should be abstracted to a neutral third
327 // party, like Notice::asJson(). I'm not sure of the ethics
328 // of refactoring from within a plugin, so I'm just abusing
329 // the ApiAction method. Don't do this unless you're me!
331 $act = new ApiAction('/dev/null');
333 $arr = $act->twitterStatusArray($notice, true);
334 $arr['url'] = $notice->bestUrl();
335 $arr['html'] = htmlspecialchars($notice->rendered);
336 $arr['source'] = htmlspecialchars($arr['source']);
337 $arr['conversation_url'] = $this->getConversationUrl($notice);
339 $profile = $notice->getProfile();
340 $arr['user']['profile_url'] = $profile->profileurl;
342 // Add needed repeat data
344 if (!empty($notice->repeat_of)) {
345 $original = Notice::staticGet('id', $notice->repeat_of);
346 if (!empty($original)) {
347 $arr['retweeted_status']['url'] = $original->bestUrl();
348 $arr['retweeted_status']['html'] = htmlspecialchars($original->rendered);
349 $arr['retweeted_status']['source'] = htmlspecialchars($original->source);
350 $originalProfile = $original->getProfile();
351 $arr['retweeted_status']['user']['profile_url'] = $originalProfile->profileurl;
352 $arr['retweeted_status']['conversation_url'] = $this->getConversationUrl($original);
360 function getNoticeTags($notice)
364 $nt = new Notice_tag();
365 $nt->notice_id = $notice->id;
369 while ($nt->fetch()) {
380 function getConversationUrl($notice)
384 if ($notice->hasConversation()) {
385 $conv = Conversation::staticGet(
387 $notice->conversation
389 $convurl = $conv->uri;
391 if(empty($convurl)) {
392 $msg = sprintf( "Could not find Conversation ID %d to make 'in context'"
393 . "link for Notice ID %d.",
394 $notice->conversation,
398 common_log(LOG_WARNING, $msg);
400 $convurl .= '#notice-' . $notice->id;
407 function _getScripts()
409 if (common_config('site', 'minify')) {
410 $js = 'realtimeupdate.min.js';
412 $js = 'realtimeupdate.js';
414 return array(Plugin::staticPath('Realtime', $js));
418 * Export any i18n messages that need to be loaded at runtime...
420 * @param Action $action
421 * @param array $messages
423 * @return boolean hook return value
425 function onEndScriptMessages($action, &$messages)
427 // TRANS: Text label for realtime view "play" button, usually replaced by an icon.
428 $messages['realtime_play'] = _m('BUTTON', 'Play');
429 // TRANS: Tooltip for realtime view "play" button.
430 $messages['realtime_play_tooltip'] = _m('TOOLTIP', 'Play');
431 // TRANS: Text label for realtime view "pause" button
432 $messages['realtime_pause'] = _m('BUTTON', 'Pause');
433 // TRANS: Tooltip for realtime view "pause" button
434 $messages['realtime_pause_tooltip'] = _m('TOOLTIP', 'Pause');
435 // TRANS: Text label for realtime view "popup" button, usually replaced by an icon.
436 $messages['realtime_popup'] = _m('BUTTON', 'Pop up');
437 // TRANS: Tooltip for realtime view "popup" button.
438 $messages['realtime_popup_tooltip'] = _m('TOOLTIP', 'Pop up in a window');
443 function _updateInitialize($timeline, $user_id)
445 return "RealtimeUpdate.init($user_id, \"$this->showurl\"); ";
452 function _publish($timeline, $json)
456 function _disconnect()
460 function _pathToChannel($path)
466 function _getTimeline($action)
468 $channel = $this->_getChannel($action);
469 if (empty($channel)) {
473 return $this->_pathToChannel(array($channel->channel_key));
476 function _getChannel($action)
482 $action_name = $action->trimmed('action');
486 // FIXME: profile + tag
488 switch ($action_name) {
493 $tag = $action->trimmed('tag');
497 $this->log(LOG_NOTICE, "Unexpected 'tag' action without tag argument");
505 $nickname = common_canonical_nickname($action->trimmed('nickname'));
506 if (!empty($nickname)) {
509 $this->log(LOG_NOTICE, "Unexpected $action_name action without nickname argument.");
517 $user = common_current_user();
519 $user_id = (!empty($user)) ? $user->id : null;
521 $channel = Realtime_channel::getChannel($user_id,
529 function onStartReadWriteTables(&$alwaysRW, &$rwdb)
531 $alwaysRW[] = 'realtime_channel';