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 * @author Mikael Nordfeldth <mmn@hethane.se>
26 * @copyright 2009 StatusNet, Inc.
27 * @copyright 2014 Free Software Foundation, Inc.
28 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
29 * @link http://status.net/
32 if (!defined('GNUSOCIAL')) { exit(1); }
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
40 * Currently depends on Favorite plugin.
44 * @author Evan Prodromou <evan@status.net>
45 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
46 * @link http://status.net/
48 class RealtimePlugin extends Plugin
50 protected $showurl = null;
53 * When it's time to initialize the plugin, calculate and
54 * pass the URLs we need.
56 function onInitializePlugin()
58 // FIXME: need to find a better way to pass this pattern in
59 $this->showurl = common_local_url('shownotice',
60 array('notice' => '0000000000'));
64 function onCheckSchema()
66 $schema = Schema::get();
67 $schema->ensureTable('realtime_channel', Realtime_channel::schemaDef());
72 * Hook for RouterInitialized event.
74 * @param URLMapper $m path-to-action mapper
75 * @return boolean hook return
77 public function onRouterInitialized(URLMapper $m)
79 $m->connect('main/channel/:channelkey/keepalive',
80 array('action' => 'keepalivechannel'),
81 array('channelkey' => '[a-z0-9]{32}'));
82 $m->connect('main/channel/:channelkey/close',
83 array('action' => 'closechannel'),
84 array('channelkey' => '[a-z0-9]{32}'));
88 function onEndShowScripts($action)
90 $channel = $this->_getChannel($action);
92 if (empty($channel)) {
96 $timeline = $this->_pathToChannel(array($channel->channel_key));
98 // If there's not a timeline on this page,
101 if (empty($timeline)) {
105 $base = $action->selfUrl();
106 if (mb_strstr($base, '?')) {
107 $url = $base . '&realtime=1';
109 $url = $base . '?realtime=1';
112 $scripts = $this->_getScripts();
114 foreach ($scripts as $script) {
115 $action->script($script);
118 $user = common_current_user();
120 if (!empty($user->id)) {
121 $user_id = $user->id;
126 if ($action->boolean('realtime')) {
127 $realtimeUI = ' RealtimeUpdate.initPopupWindow();';
130 $pluginPath = common_path('plugins/Realtime/');
131 $keepalive = common_local_url('keepalivechannel', array('channelkey' => $channel->channel_key));
132 $close = common_local_url('closechannel', array('channelkey' => $channel->channel_key));
133 $realtimeUI = ' RealtimeUpdate.initActions('.json_encode($url).', '.json_encode($timeline).', '.json_encode($pluginPath).', '.json_encode($keepalive).', '.json_encode($close).'); ';
136 $script = ' $(document).ready(function() { '.
138 $this->_updateInitialize($timeline, $user_id).
140 $action->inlineScript($script);
145 public function onEndShowStylesheets(Action $action)
147 $urlpath = self::staticPath(str_replace('Plugin','',__CLASS__),
148 'css/realtimeupdate.css');
149 $action->cssLink($urlpath, null, 'screen, projection, tv');
153 public function onHandleQueuedNotice(Notice $notice)
157 // Add to the author's timeline
160 $profile = $notice->getProfile();
161 } catch (Exception $e) {
162 $this->log(LOG_ERR, $e->getMessage());
167 $user = $profile->getUser();
168 $paths[] = array('showstream', $user->nickname, null);
169 } catch (NoSuchUserException $e) {
170 // We really should handle the remote profile views too
174 // Add to the public timeline
176 $is_local = intval($notice->is_local);
177 if ($is_local === Notice::LOCAL_PUBLIC ||
178 ($is_local === Notice::REMOTE && !common_config('public', 'localonly'))) {
179 $paths[] = array('public', null, null);
182 // Add to the tags timeline
184 $tags = $this->getNoticeTags($notice);
187 foreach ($tags as $tag) {
188 $paths[] = array('tag', $tag, null);
192 // Add to inbox timelines
195 $ni = $notice->whoGets();
197 foreach (array_keys($ni) as $user_id) {
198 $user = User::getKV('id', $user_id);
199 $paths[] = array('all', $user->nickname, null);
202 // Add to the replies timeline
204 $reply = new Reply();
205 $reply->notice_id = $notice->id;
207 if ($reply->find()) {
208 while ($reply->fetch()) {
209 $user = User::getKV('id', $reply->profile_id);
211 $paths[] = array('replies', $user->nickname, null);
216 // Add to the group timeline
219 $gi = new Group_inbox();
220 $gi->notice_id = $notice->id;
223 while ($gi->fetch()) {
224 $ug = User_group::getKV('id', $gi->group_id);
225 $paths[] = array('showgroup', $ug->nickname, null);
229 if (count($paths) > 0) {
231 $json = $this->noticeAsJson($notice);
235 // XXX: We should probably fan-out here and do a
236 // new queue item for each path
238 foreach ($paths as $path) {
240 list($action, $arg1, $arg2) = $path;
242 $channels = Realtime_channel::getAllChannels($action, $arg1, $arg2);
243 $this->log(LOG_INFO, sprintf(_("%d candidate channels for notice %d"),
247 foreach ($channels as $channel) {
249 // XXX: We should probably fan-out here and do a
250 // new queue item for each user/path combo
252 if (is_null($channel->user_id)) {
255 $profile = Profile::getKV('id', $channel->user_id);
257 if ($notice->inScope($profile)) {
259 sprintf(_("Delivering notice %d to channel (%s, %s, %s) for user '%s'"),
264 ($profile) ? ($profile->nickname) : "<public>"));
265 $timeline = $this->_pathToChannel(array($channel->channel_key));
266 $this->_publish($timeline, $json);
271 $this->_disconnect();
277 function onStartShowBody($action)
279 $realtime = $action->boolean('realtime');
284 $action->elementStart('body',
285 (common_current_user()) ? array('id' => $action->trimmed('action'),
286 'class' => 'user_in realtime-popup')
287 : array('id' => $action->trimmed('action'),
288 'class'=> 'realtime-popup'));
290 // XXX hack to deal with JS that tries to get the
291 // root url from page output
293 $action->elementStart('address');
295 if (common_config('singleuser', 'enabled')) {
296 $user = User::singleUser();
297 $url = common_local_url('showstream', array('nickname' => $user->nickname));
299 $url = common_local_url('public');
302 $action->element('a', array('class' => 'url',
306 $action->elementEnd('address');
308 $action->showContentBlock();
309 $action->showScripts();
310 $action->elementEnd('body');
311 return false; // No default processing
314 function noticeAsJson(Notice $notice)
316 // FIXME: this code should be abstracted to a neutral third
317 // party, like Notice::asJson(). I'm not sure of the ethics
318 // of refactoring from within a plugin, so I'm just abusing
319 // the ApiAction method. Don't do this unless you're me!
321 $act = new ApiAction('/dev/null');
323 $arr = $act->twitterStatusArray($notice, true);
324 $arr['url'] = $notice->getUrl(true);
325 $arr['html'] = htmlspecialchars($notice->getRendered());
326 $arr['source'] = htmlspecialchars($arr['source']);
327 $arr['conversation_url'] = $notice->getConversationUrl();
329 $profile = $notice->getProfile();
330 $arr['user']['profile_url'] = $profile->profileurl;
332 // Add needed repeat data
334 if (!empty($notice->repeat_of)) {
335 $original = Notice::getKV('id', $notice->repeat_of);
336 if ($original instanceof Notice) {
337 $arr['retweeted_status']['url'] = $original->getUrl(true);
338 $arr['retweeted_status']['html'] = htmlspecialchars($original->getRendered());
339 $arr['retweeted_status']['source'] = htmlspecialchars($original->source);
340 $originalProfile = $original->getProfile();
341 $arr['retweeted_status']['user']['profile_url'] = $originalProfile->profileurl;
342 $arr['retweeted_status']['conversation_url'] = $original->getConversationUrl();
350 function getNoticeTags(Notice $notice)
354 $nt = new Notice_tag();
355 $nt->notice_id = $notice->id;
359 while ($nt->fetch()) {
370 function _getScripts()
372 $urlpath = self::staticPath(str_replace('Plugin','',__CLASS__),
373 'js/realtimeupdate.js');
374 return array($urlpath);
378 * Export any i18n messages that need to be loaded at runtime...
380 * @param Action $action
381 * @param array $messages
383 * @return boolean hook return value
385 function onEndScriptMessages($action, &$messages)
387 // TRANS: Text label for realtime view "play" button, usually replaced by an icon.
388 $messages['realtime_play'] = _m('BUTTON', 'Play');
389 // TRANS: Tooltip for realtime view "play" button.
390 $messages['realtime_play_tooltip'] = _m('TOOLTIP', 'Play');
391 // TRANS: Text label for realtime view "pause" button
392 $messages['realtime_pause'] = _m('BUTTON', 'Pause');
393 // TRANS: Tooltip for realtime view "pause" button
394 $messages['realtime_pause_tooltip'] = _m('TOOLTIP', 'Pause');
395 // TRANS: Text label for realtime view "popup" button, usually replaced by an icon.
396 $messages['realtime_popup'] = _m('BUTTON', 'Pop up');
397 // TRANS: Tooltip for realtime view "popup" button.
398 $messages['realtime_popup_tooltip'] = _m('TOOLTIP', 'Pop up in a window');
403 function _updateInitialize($timeline, $user_id)
405 return "RealtimeUpdate.init($user_id, \"$this->showurl\"); ";
412 function _publish($timeline, $json)
416 function _disconnect()
420 function _pathToChannel($path)
426 function _getTimeline($action)
428 $channel = $this->_getChannel($action);
429 if (empty($channel)) {
433 return $this->_pathToChannel(array($channel->channel_key));
436 function _getChannel($action)
442 $action_name = $action->trimmed('action');
446 // FIXME: profile + tag
448 switch ($action_name) {
453 $tag = $action->trimmed('tag');
457 $this->log(LOG_NOTICE, "Unexpected 'tag' action without tag argument");
465 $nickname = common_canonical_nickname($action->trimmed('nickname'));
466 if (!empty($nickname)) {
469 $this->log(LOG_NOTICE, "Unexpected $action_name action without nickname argument.");
477 $user = common_current_user();
479 $user_id = (!empty($user)) ? $user->id : null;
481 $channel = Realtime_channel::getChannel($user_id,
489 function onStartReadWriteTables(&$alwaysRW, &$rwdb)
491 $alwaysRW[] = 'realtime_channel';