RealtimePlugin.php 15 KB

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