RealtimePlugin.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <?php
  2. /**
  3. * StatusNet, the distributed open-source microblogging tool
  4. *
  5. * Superclass for plugins that do "real time" updates of timelines using Ajax
  6. *
  7. * PHP version 5
  8. *
  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.
  13. *
  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.
  18. *
  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/>.
  21. *
  22. * @category Plugin
  23. * @package StatusNet
  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/
  30. */
  31. if (!defined('GNUSOCIAL')) { exit(1); }
  32. /**
  33. * Superclass for plugin to do realtime updates
  34. *
  35. * Based on experience with the Comet and Meteor plugins,
  36. * this superclass extracts out some of the common functionality
  37. *
  38. * Currently depends on Favorite plugin.
  39. *
  40. * @category Plugin
  41. * @package StatusNet
  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/
  45. */
  46. class RealtimePlugin extends Plugin
  47. {
  48. protected $showurl = null;
  49. /**
  50. * When it's time to initialize the plugin, calculate and
  51. * pass the URLs we need.
  52. */
  53. function onInitializePlugin()
  54. {
  55. // FIXME: need to find a better way to pass this pattern in
  56. $this->showurl = common_local_url('shownotice',
  57. array('notice' => '0000000000'));
  58. return true;
  59. }
  60. function onCheckSchema()
  61. {
  62. $schema = Schema::get();
  63. $schema->ensureTable('realtime_channel', Realtime_channel::schemaDef());
  64. return true;
  65. }
  66. /**
  67. * Hook for RouterInitialized event.
  68. *
  69. * @param URLMapper $m path-to-action mapper
  70. * @return boolean hook return
  71. */
  72. public function onRouterInitialized(URLMapper $m)
  73. {
  74. $m->connect('main/channel/:channelkey/keepalive',
  75. array('action' => 'keepalivechannel'),
  76. array('channelkey' => '[a-z0-9]{32}'));
  77. $m->connect('main/channel/:channelkey/close',
  78. array('action' => 'closechannel'),
  79. array('channelkey' => '[a-z0-9]{32}'));
  80. return true;
  81. }
  82. function onEndShowScripts($action)
  83. {
  84. $channel = $this->_getChannel($action);
  85. if (empty($channel)) {
  86. return true;
  87. }
  88. $timeline = $this->_pathToChannel(array($channel->channel_key));
  89. // If there's not a timeline on this page,
  90. // just return true
  91. if (empty($timeline)) {
  92. return true;
  93. }
  94. $base = $action->selfUrl();
  95. if (mb_strstr($base, '?')) {
  96. $url = $base . '&realtime=1';
  97. } else {
  98. $url = $base . '?realtime=1';
  99. }
  100. $scripts = $this->_getScripts();
  101. foreach ($scripts as $script) {
  102. $action->script($script);
  103. }
  104. $user = common_current_user();
  105. if (!empty($user->id)) {
  106. $user_id = $user->id;
  107. } else {
  108. $user_id = 0;
  109. }
  110. if ($action->boolean('realtime')) {
  111. $realtimeUI = ' RealtimeUpdate.initPopupWindow();';
  112. }
  113. else {
  114. $pluginPath = common_path('plugins/Realtime/');
  115. $keepalive = common_local_url('keepalivechannel', array('channelkey' => $channel->channel_key));
  116. $close = common_local_url('closechannel', array('channelkey' => $channel->channel_key));
  117. $realtimeUI = ' RealtimeUpdate.initActions('.json_encode($url).', '.json_encode($timeline).', '.json_encode($pluginPath).', '.json_encode($keepalive).', '.json_encode($close).'); ';
  118. }
  119. $script = ' $(document).ready(function() { '.
  120. $realtimeUI.
  121. $this->_updateInitialize($timeline, $user_id).
  122. '}); ';
  123. $action->inlineScript($script);
  124. return true;
  125. }
  126. public function onEndShowStylesheets(Action $action)
  127. {
  128. $urlpath = self::staticPath(str_replace('Plugin','',__CLASS__),
  129. 'css/realtimeupdate.css');
  130. $action->cssLink($urlpath, null, 'screen, projection, tv');
  131. return true;
  132. }
  133. public function onHandleQueuedNotice(Notice $notice)
  134. {
  135. $paths = array();
  136. // Add to the author's timeline
  137. try {
  138. $profile = $notice->getProfile();
  139. } catch (Exception $e) {
  140. $this->log(LOG_ERR, $e->getMessage());
  141. return true;
  142. }
  143. try {
  144. $user = $profile->getUser();
  145. $paths[] = array('showstream', $user->nickname, null);
  146. } catch (NoSuchUserException $e) {
  147. // We really should handle the remote profile views too
  148. $user = null;
  149. }
  150. // Add to the public timeline
  151. if ($notice->is_local == Notice::LOCAL_PUBLIC ||
  152. ($notice->is_local == Notice::REMOTE && !common_config('public', 'localonly'))) {
  153. $paths[] = array('public', null, null);
  154. }
  155. // Add to the tags timeline
  156. $tags = $this->getNoticeTags($notice);
  157. if (!empty($tags)) {
  158. foreach ($tags as $tag) {
  159. $paths[] = array('tag', $tag, null);
  160. }
  161. }
  162. // Add to inbox timelines
  163. // XXX: do a join
  164. $ni = $notice->whoGets();
  165. foreach (array_keys($ni) as $user_id) {
  166. $user = User::getKV('id', $user_id);
  167. $paths[] = array('all', $user->nickname, null);
  168. }
  169. // Add to the replies timeline
  170. $reply = new Reply();
  171. $reply->notice_id = $notice->id;
  172. if ($reply->find()) {
  173. while ($reply->fetch()) {
  174. $user = User::getKV('id', $reply->profile_id);
  175. if (!empty($user)) {
  176. $paths[] = array('replies', $user->nickname, null);
  177. }
  178. }
  179. }
  180. // Add to the group timeline
  181. // XXX: join
  182. $gi = new Group_inbox();
  183. $gi->notice_id = $notice->id;
  184. if ($gi->find()) {
  185. while ($gi->fetch()) {
  186. $ug = User_group::getKV('id', $gi->group_id);
  187. $paths[] = array('showgroup', $ug->nickname, null);
  188. }
  189. }
  190. if (count($paths) > 0) {
  191. $json = $this->noticeAsJson($notice);
  192. $this->_connect();
  193. // XXX: We should probably fan-out here and do a
  194. // new queue item for each path
  195. foreach ($paths as $path) {
  196. list($action, $arg1, $arg2) = $path;
  197. $channels = Realtime_channel::getAllChannels($action, $arg1, $arg2);
  198. $this->log(LOG_INFO, sprintf(_("%d candidate channels for notice %d"),
  199. count($channels),
  200. $notice->id));
  201. foreach ($channels as $channel) {
  202. // XXX: We should probably fan-out here and do a
  203. // new queue item for each user/path combo
  204. if (is_null($channel->user_id)) {
  205. $profile = null;
  206. } else {
  207. $profile = Profile::getKV('id', $channel->user_id);
  208. }
  209. if ($notice->inScope($profile)) {
  210. $this->log(LOG_INFO,
  211. sprintf(_("Delivering notice %d to channel (%s, %s, %s) for user '%s'"),
  212. $notice->id,
  213. $channel->action,
  214. $channel->arg1,
  215. $channel->arg2,
  216. ($profile) ? ($profile->nickname) : "<public>"));
  217. $timeline = $this->_pathToChannel(array($channel->channel_key));
  218. $this->_publish($timeline, $json);
  219. }
  220. }
  221. }
  222. $this->_disconnect();
  223. }
  224. return true;
  225. }
  226. function onStartShowBody($action)
  227. {
  228. $realtime = $action->boolean('realtime');
  229. if (!$realtime) {
  230. return true;
  231. }
  232. $action->elementStart('body',
  233. (common_current_user()) ? array('id' => $action->trimmed('action'),
  234. 'class' => 'user_in realtime-popup')
  235. : array('id' => $action->trimmed('action'),
  236. 'class'=> 'realtime-popup'));
  237. // XXX hack to deal with JS that tries to get the
  238. // root url from page output
  239. $action->elementStart('address');
  240. if (common_config('singleuser', 'enabled')) {
  241. $user = User::singleUser();
  242. $url = common_local_url('showstream', array('nickname' => $user->nickname));
  243. } else {
  244. $url = common_local_url('public');
  245. }
  246. $action->element('a', array('class' => 'url',
  247. 'href' => $url),
  248. '');
  249. $action->elementEnd('address');
  250. $action->showContentBlock();
  251. $action->showScripts();
  252. $action->elementEnd('body');
  253. return false; // No default processing
  254. }
  255. function noticeAsJson($notice)
  256. {
  257. // FIXME: this code should be abstracted to a neutral third
  258. // party, like Notice::asJson(). I'm not sure of the ethics
  259. // of refactoring from within a plugin, so I'm just abusing
  260. // the ApiAction method. Don't do this unless you're me!
  261. $act = new ApiAction('/dev/null');
  262. $arr = $act->twitterStatusArray($notice, true);
  263. $arr['url'] = $notice->getUrl(true);
  264. $arr['html'] = htmlspecialchars($notice->rendered);
  265. $arr['source'] = htmlspecialchars($arr['source']);
  266. $arr['conversation_url'] = $notice->getConversationUrl();
  267. $profile = $notice->getProfile();
  268. $arr['user']['profile_url'] = $profile->profileurl;
  269. // Add needed repeat data
  270. if (!empty($notice->repeat_of)) {
  271. $original = Notice::getKV('id', $notice->repeat_of);
  272. if ($original instanceof Notice) {
  273. $arr['retweeted_status']['url'] = $original->getUrl(true);
  274. $arr['retweeted_status']['html'] = htmlspecialchars($original->rendered);
  275. $arr['retweeted_status']['source'] = htmlspecialchars($original->source);
  276. $originalProfile = $original->getProfile();
  277. $arr['retweeted_status']['user']['profile_url'] = $originalProfile->profileurl;
  278. $arr['retweeted_status']['conversation_url'] = $original->getConversationUrl();
  279. }
  280. unset($original);
  281. }
  282. return $arr;
  283. }
  284. function getNoticeTags($notice)
  285. {
  286. $tags = null;
  287. $nt = new Notice_tag();
  288. $nt->notice_id = $notice->id;
  289. if ($nt->find()) {
  290. $tags = array();
  291. while ($nt->fetch()) {
  292. $tags[] = $nt->tag;
  293. }
  294. }
  295. $nt->free();
  296. $nt = null;
  297. return $tags;
  298. }
  299. function _getScripts()
  300. {
  301. $urlpath = self::staticPath(str_replace('Plugin','',__CLASS__),
  302. 'js/realtimeupdate.js');
  303. return array($urlpath);
  304. }
  305. /**
  306. * Export any i18n messages that need to be loaded at runtime...
  307. *
  308. * @param Action $action
  309. * @param array $messages
  310. *
  311. * @return boolean hook return value
  312. */
  313. function onEndScriptMessages($action, &$messages)
  314. {
  315. // TRANS: Text label for realtime view "play" button, usually replaced by an icon.
  316. $messages['realtime_play'] = _m('BUTTON', 'Play');
  317. // TRANS: Tooltip for realtime view "play" button.
  318. $messages['realtime_play_tooltip'] = _m('TOOLTIP', 'Play');
  319. // TRANS: Text label for realtime view "pause" button
  320. $messages['realtime_pause'] = _m('BUTTON', 'Pause');
  321. // TRANS: Tooltip for realtime view "pause" button
  322. $messages['realtime_pause_tooltip'] = _m('TOOLTIP', 'Pause');
  323. // TRANS: Text label for realtime view "popup" button, usually replaced by an icon.
  324. $messages['realtime_popup'] = _m('BUTTON', 'Pop up');
  325. // TRANS: Tooltip for realtime view "popup" button.
  326. $messages['realtime_popup_tooltip'] = _m('TOOLTIP', 'Pop up in a window');
  327. return true;
  328. }
  329. function _updateInitialize($timeline, $user_id)
  330. {
  331. return "RealtimeUpdate.init($user_id, \"$this->showurl\"); ";
  332. }
  333. function _connect()
  334. {
  335. }
  336. function _publish($timeline, $json)
  337. {
  338. }
  339. function _disconnect()
  340. {
  341. }
  342. function _pathToChannel($path)
  343. {
  344. return '';
  345. }
  346. function _getTimeline($action)
  347. {
  348. $channel = $this->_getChannel($action);
  349. if (empty($channel)) {
  350. return null;
  351. }
  352. return $this->_pathToChannel(array($channel->channel_key));
  353. }
  354. function _getChannel($action)
  355. {
  356. $timeline = null;
  357. $arg1 = null;
  358. $arg2 = null;
  359. $action_name = $action->trimmed('action');
  360. // FIXME: lists
  361. // FIXME: search (!)
  362. // FIXME: profile + tag
  363. switch ($action_name) {
  364. case 'public':
  365. // no arguments
  366. break;
  367. case 'tag':
  368. $tag = $action->trimmed('tag');
  369. if (!empty($tag)) {
  370. $arg1 = $tag;
  371. } else {
  372. $this->log(LOG_NOTICE, "Unexpected 'tag' action without tag argument");
  373. return null;
  374. }
  375. break;
  376. case 'showstream':
  377. case 'all':
  378. case 'replies':
  379. case 'showgroup':
  380. $nickname = common_canonical_nickname($action->trimmed('nickname'));
  381. if (!empty($nickname)) {
  382. $arg1 = $nickname;
  383. } else {
  384. $this->log(LOG_NOTICE, "Unexpected $action_name action without nickname argument.");
  385. return null;
  386. }
  387. break;
  388. default:
  389. return null;
  390. }
  391. $user = common_current_user();
  392. $user_id = (!empty($user)) ? $user->id : null;
  393. $channel = Realtime_channel::getChannel($user_id,
  394. $action_name,
  395. $arg1,
  396. $arg2);
  397. return $channel;
  398. }
  399. function onStartReadWriteTables(&$alwaysRW, &$rwdb)
  400. {
  401. $alwaysRW[] = 'realtime_channel';
  402. return true;
  403. }
  404. }