RealtimePlugin.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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. ['action' => 'keepalivechannel'],
  76. ['channelkey' => '[a-z0-9]{32}']);
  77. $m->connect('main/channel/:channelkey/close',
  78. ['action' => 'closechannel'],
  79. ['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. $is_local = intval($notice->is_local);
  152. if ($is_local === Notice::LOCAL_PUBLIC ||
  153. ($is_local === Notice::REMOTE && !common_config('public', 'localonly'))) {
  154. $paths[] = array('public', null, null);
  155. }
  156. // Add to the tags timeline
  157. $tags = $this->getNoticeTags($notice);
  158. if (!empty($tags)) {
  159. foreach ($tags as $tag) {
  160. $paths[] = array('tag', $tag, null);
  161. }
  162. }
  163. // Add to inbox timelines
  164. // XXX: do a join
  165. $ni = $notice->whoGets();
  166. foreach (array_keys($ni) as $user_id) {
  167. $user = User::getKV('id', $user_id);
  168. $paths[] = array('all', $user->nickname, null);
  169. }
  170. // Add to the replies timeline
  171. $reply = new Reply();
  172. $reply->notice_id = $notice->id;
  173. if ($reply->find()) {
  174. while ($reply->fetch()) {
  175. $user = User::getKV('id', $reply->profile_id);
  176. if (!empty($user)) {
  177. $paths[] = array('replies', $user->nickname, null);
  178. }
  179. }
  180. }
  181. // Add to the group timeline
  182. // XXX: join
  183. $gi = new Group_inbox();
  184. $gi->notice_id = $notice->id;
  185. if ($gi->find()) {
  186. while ($gi->fetch()) {
  187. $ug = User_group::getKV('id', $gi->group_id);
  188. $paths[] = array('showgroup', $ug->nickname, null);
  189. }
  190. }
  191. if (count($paths) > 0) {
  192. $json = $this->noticeAsJson($notice);
  193. $this->_connect();
  194. // XXX: We should probably fan-out here and do a
  195. // new queue item for each path
  196. foreach ($paths as $path) {
  197. list($action, $arg1, $arg2) = $path;
  198. $channels = Realtime_channel::getAllChannels($action, $arg1, $arg2);
  199. $this->log(LOG_INFO, sprintf(_("%d candidate channels for notice %d"),
  200. count($channels),
  201. $notice->id));
  202. foreach ($channels as $channel) {
  203. // XXX: We should probably fan-out here and do a
  204. // new queue item for each user/path combo
  205. if (is_null($channel->user_id)) {
  206. $profile = null;
  207. } else {
  208. $profile = Profile::getKV('id', $channel->user_id);
  209. }
  210. if ($notice->inScope($profile)) {
  211. $this->log(LOG_INFO,
  212. sprintf(_("Delivering notice %d to channel (%s, %s, %s) for user '%s'"),
  213. $notice->id,
  214. $channel->action,
  215. $channel->arg1,
  216. $channel->arg2,
  217. ($profile) ? ($profile->nickname) : "<public>"));
  218. $timeline = $this->_pathToChannel(array($channel->channel_key));
  219. $this->_publish($timeline, $json);
  220. }
  221. }
  222. }
  223. $this->_disconnect();
  224. }
  225. return true;
  226. }
  227. function onStartShowBody($action)
  228. {
  229. $realtime = $action->boolean('realtime');
  230. if (!$realtime) {
  231. return true;
  232. }
  233. $action->elementStart('body',
  234. (common_current_user()) ? array('id' => $action->trimmed('action'),
  235. 'class' => 'user_in realtime-popup')
  236. : array('id' => $action->trimmed('action'),
  237. 'class'=> 'realtime-popup'));
  238. // XXX hack to deal with JS that tries to get the
  239. // root url from page output
  240. $action->elementStart('address');
  241. if (common_config('singleuser', 'enabled')) {
  242. $user = User::singleUser();
  243. $url = common_local_url('showstream', array('nickname' => $user->nickname));
  244. } else {
  245. $url = common_local_url('public');
  246. }
  247. $action->element('a', array('class' => 'url',
  248. 'href' => $url),
  249. '');
  250. $action->elementEnd('address');
  251. $action->showContentBlock();
  252. $action->showScripts();
  253. $action->elementEnd('body');
  254. return false; // No default processing
  255. }
  256. function noticeAsJson(Notice $notice)
  257. {
  258. // FIXME: this code should be abstracted to a neutral third
  259. // party, like Notice::asJson(). I'm not sure of the ethics
  260. // of refactoring from within a plugin, so I'm just abusing
  261. // the ApiAction method. Don't do this unless you're me!
  262. $act = new ApiAction('/dev/null');
  263. $arr = $act->twitterStatusArray($notice, true);
  264. $arr['url'] = $notice->getUrl(true);
  265. $arr['html'] = htmlspecialchars($notice->getRendered());
  266. $arr['source'] = htmlspecialchars($arr['source']);
  267. $arr['conversation_url'] = $notice->getConversationUrl();
  268. $profile = $notice->getProfile();
  269. $arr['user']['profile_url'] = $profile->profileurl;
  270. // Add needed repeat data
  271. if (!empty($notice->repeat_of)) {
  272. $original = Notice::getKV('id', $notice->repeat_of);
  273. if ($original instanceof Notice) {
  274. $arr['retweeted_status']['url'] = $original->getUrl(true);
  275. $arr['retweeted_status']['html'] = htmlspecialchars($original->getRendered());
  276. $arr['retweeted_status']['source'] = htmlspecialchars($original->source);
  277. $originalProfile = $original->getProfile();
  278. $arr['retweeted_status']['user']['profile_url'] = $originalProfile->profileurl;
  279. $arr['retweeted_status']['conversation_url'] = $original->getConversationUrl();
  280. }
  281. unset($original);
  282. }
  283. return $arr;
  284. }
  285. function getNoticeTags(Notice $notice)
  286. {
  287. $tags = null;
  288. $nt = new Notice_tag();
  289. $nt->notice_id = $notice->id;
  290. if ($nt->find()) {
  291. $tags = array();
  292. while ($nt->fetch()) {
  293. $tags[] = $nt->tag;
  294. }
  295. }
  296. $nt->free();
  297. $nt = null;
  298. return $tags;
  299. }
  300. function _getScripts()
  301. {
  302. $urlpath = self::staticPath(str_replace('Plugin','',__CLASS__),
  303. 'js/realtimeupdate.js');
  304. return array($urlpath);
  305. }
  306. /**
  307. * Export any i18n messages that need to be loaded at runtime...
  308. *
  309. * @param Action $action
  310. * @param array $messages
  311. *
  312. * @return boolean hook return value
  313. */
  314. function onEndScriptMessages($action, &$messages)
  315. {
  316. // TRANS: Text label for realtime view "play" button, usually replaced by an icon.
  317. $messages['realtime_play'] = _m('BUTTON', 'Play');
  318. // TRANS: Tooltip for realtime view "play" button.
  319. $messages['realtime_play_tooltip'] = _m('TOOLTIP', 'Play');
  320. // TRANS: Text label for realtime view "pause" button
  321. $messages['realtime_pause'] = _m('BUTTON', 'Pause');
  322. // TRANS: Tooltip for realtime view "pause" button
  323. $messages['realtime_pause_tooltip'] = _m('TOOLTIP', 'Pause');
  324. // TRANS: Text label for realtime view "popup" button, usually replaced by an icon.
  325. $messages['realtime_popup'] = _m('BUTTON', 'Pop up');
  326. // TRANS: Tooltip for realtime view "popup" button.
  327. $messages['realtime_popup_tooltip'] = _m('TOOLTIP', 'Pop up in a window');
  328. return true;
  329. }
  330. function _updateInitialize($timeline, $user_id)
  331. {
  332. return "RealtimeUpdate.init($user_id, \"$this->showurl\"); ";
  333. }
  334. function _connect()
  335. {
  336. }
  337. function _publish($timeline, $json)
  338. {
  339. }
  340. function _disconnect()
  341. {
  342. }
  343. function _pathToChannel($path)
  344. {
  345. return '';
  346. }
  347. function _getTimeline($action)
  348. {
  349. $channel = $this->_getChannel($action);
  350. if (empty($channel)) {
  351. return null;
  352. }
  353. return $this->_pathToChannel(array($channel->channel_key));
  354. }
  355. function _getChannel($action)
  356. {
  357. $timeline = null;
  358. $arg1 = null;
  359. $arg2 = null;
  360. $action_name = $action->trimmed('action');
  361. // FIXME: lists
  362. // FIXME: search (!)
  363. // FIXME: profile + tag
  364. switch ($action_name) {
  365. case 'public':
  366. // no arguments
  367. break;
  368. case 'tag':
  369. $tag = $action->trimmed('tag');
  370. if (!empty($tag)) {
  371. $arg1 = $tag;
  372. } else {
  373. $this->log(LOG_NOTICE, "Unexpected 'tag' action without tag argument");
  374. return null;
  375. }
  376. break;
  377. case 'showstream':
  378. case 'all':
  379. case 'replies':
  380. case 'showgroup':
  381. $nickname = common_canonical_nickname($action->trimmed('nickname'));
  382. if (!empty($nickname)) {
  383. $arg1 = $nickname;
  384. } else {
  385. $this->log(LOG_NOTICE, "Unexpected $action_name action without nickname argument.");
  386. return null;
  387. }
  388. break;
  389. default:
  390. return null;
  391. }
  392. $user = common_current_user();
  393. $user_id = (!empty($user)) ? $user->id : null;
  394. $channel = Realtime_channel::getChannel($user_id,
  395. $action_name,
  396. $arg1,
  397. $arg2);
  398. return $channel;
  399. }
  400. function onStartReadWriteTables(&$alwaysRW, &$rwdb)
  401. {
  402. $alwaysRW[] = 'realtime_channel';
  403. return true;
  404. }
  405. }