RealtimePlugin.php 15 KB

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