From bdf669e2ed3c6f92ae5e2f3b1d776663a4c01a9a Mon Sep 17 00:00:00 2001 From: Hannes Mannerheim Date: Tue, 7 Jul 2015 00:52:26 +0200 Subject: [PATCH] adds feature requested in #138 --- QvitterPlugin.php | 14 +- actions/apitimelinefriendshiddenreplies.php | 474 ++++++++++++++++++++ actions/qvittersettings.php | 65 ++- 3 files changed, 529 insertions(+), 24 deletions(-) create mode 100644 actions/apitimelinefriendshiddenreplies.php diff --git a/QvitterPlugin.php b/QvitterPlugin.php index a5a061a..1557bf8 100644 --- a/QvitterPlugin.php +++ b/QvitterPlugin.php @@ -39,6 +39,7 @@ const QVITTERDIR = __DIR__; class QvitterPlugin extends Plugin { protected $hijack_ui = true; + protected $qvitter_hide_replies = false; static function settings($setting) { @@ -118,13 +119,14 @@ class QvitterPlugin extends Plugin { public function initialize() { - // check if we should reroute UI to qvitter + // check if we should reroute UI to qvitter, and which home-stream the user wants (hide-replies or normal) $scoped = Profile::current(); $qvitter_enabled_by_user = false; $qvitter_disabled_by_user = false; if ($scoped instanceof Profile) { $qvitter_enabled_by_user = $scoped->getPref('qvitter', 'enable_qvitter', false); $qvitter_disabled_by_user = $scoped->getPref('qvitter', 'disable_qvitter', false); + $this->qvitter_hide_replies = $scoped->getPref('qvitter', 'hide_replies', false); } $this->hijack_ui = (self::settings('enabledbydefault') && !$scoped) @@ -186,11 +188,19 @@ class QvitterPlugin extends Plugin { $m->connect('main/qlogin', array('action' => 'qvitterlogin')); + if ($this->hijack_ui) { $m->connect('', array('action' => 'qvitter')); $m->connect('main/all', array('action' => 'qvitter')); $m->connect('search/notice', array('action' => 'qvitter')); + // if the user wants the twitter style home stream with hidden replies to non-friends + if ($this->qvitter_hide_replies) { + URLMapperOverwrite::overwrite_variable($m, 'api/statuses/friends_timeline.:format', + array('action' => 'ApiTimelineFriends'), + array('format' => '(xml|json|rss|atom|as)'), + 'ApiTimelineFriendsHiddenReplies'); + } URLMapperOverwrite::overwrite_variable($m, ':nickname', array('action' => 'showstream'), @@ -353,7 +363,7 @@ class QvitterPlugin extends Plugin { // TRANS: Poll plugin menu item on user settings page. _m('MENU', 'Qvitter'), // TRANS: Poll plugin tooltip for user settings menu item. - _m('Enable/Disable Qvitter UI'), + _m('Qvitter Settings'), $action_name === 'qvittersettings'); return true; diff --git a/actions/apitimelinefriendshiddenreplies.php b/actions/apitimelinefriendshiddenreplies.php new file mode 100644 index 0000000..c47fdab --- /dev/null +++ b/actions/apitimelinefriendshiddenreplies.php @@ -0,0 +1,474 @@ +. + * + * @category API + * @package StatusNet + * @author Craig Andrews + * @author Evan Prodromou + * @author Jeffery To + * @author mac65 + * @author Mike Cochrane + * @author Robin Millette + * @author Zach Copley + * @author Hannes Mannerheim + * @copyright 2009-2010 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +/* External API usage documentation. Please update when you change how this method works. */ + +/*! @page friendstimeline statuses/friends_timeline + + @section Description + Returns the 20 most recent statuses posted by the authenticating + user and that user's friends. This is the equivalent of "You and + friends" page in the web interface. + + @par URL patterns + @li /api/statuses/friends_timeline.:format + @li /api/statuses/friends_timeline/:id.:format + + @par Formats (:format) + xml, json, rss, atom + + @par ID (:id) + username, user id + + @par HTTP Method(s) + GET + + @par Requires Authentication + Sometimes (see: @ref authentication) + + @param user_id (Optional) Specifies a user by ID + @param screen_name (Optional) Specifies a user by screename (nickname) + @param since_id (Optional) Returns only statuses with an ID greater + than (that is, more recent than) the specified ID. + @param max_id (Optional) Returns only statuses with an ID less than + (that is, older than) or equal to the specified ID. + @param count (Optional) Specifies the number of statuses to retrieve. + @param page (Optional) Specifies the page of results to retrieve. + + @sa @ref authentication + @sa @ref apiroot + + @subsection usagenotes Usage notes + @li The URL pattern is relative to the @ref apiroot. + @li The XML response uses GeoRSS + to encode the latitude and longitude (see example response below ). + + @subsection exampleusage Example usage + + @verbatim + curl http://identi.ca/api/statuses/friends_timeline/evan.xml?count=1&page=2 + @endverbatim + + @subsection exampleresponse Example response + + @verbatim + + + + back from the !yul !drupal meet with Evolving Web folk, @anarcat, @webchick and others, and an interesting refresher on SQL indexing + false + Wed Mar 31 01:33:02 +0000 2010 + + <a href="http://code.google.com/p/microblog-purple/">mbpidgin</a> + 26674201 + + + + false + + 246 + Mark + lambic + Montreal, Canada + Geek + http://avatar.identi.ca/246-48-20080702141545.png + http://lambic.co.uk + false + 73 + #F0F2F5 + + #002E6E + #CEE1E9 + + 58 + Wed Jul 02 14:12:15 +0000 2008 + 2 + -14400 + US/Eastern + + false + 933 + false + false + + + + @endverbatim +*/ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Returns the most recent notices (default 20) posted by the target user. + * This is the equivalent of 'You and friends' page accessed via Web. + * + * @category API + * @package StatusNet + * @author Craig Andrews + * @author Evan Prodromou + * @author Jeffery To + * @author mac65 + * @author Mike Cochrane + * @author Robin Millette + * @author Zach Copley + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ +class ApiTimelineFriendsHiddenRepliesAction extends ApiBareAuthAction +{ + var $notices = null; + + /** + * Take arguments for running + * + * @param array $args $_REQUEST args + * + * @return boolean success flag + * + */ + protected function prepare(array $args=array()) + { + parent::prepare($args); + $this->target = $this->getTargetProfile($this->arg('id')); + + if (!($this->target instanceof Profile)) { + // TRANS: Client error displayed when requesting dents of a user and friends for a user that does not exist. + $this->clientError(_('No such user.'), 404); + } + + $this->notices = $this->getNotices(); + + return true; + } + + /** + * Handle the request + * + * Just show the notices + * + * @return void + */ + protected function handle() + { + parent::handle(); + $this->showTimeline(); + } + + /** + * Show the timeline of notices + * + * @return void + */ + function showTimeline() + { + $sitename = common_config('site', 'name'); + // TRANS: Title of API timeline for a user and friends. + // TRANS: %s is a username. + $title = sprintf(_("%s and friends"), $this->target->nickname); + $taguribase = TagURI::base(); + $id = "tag:$taguribase:FriendsTimelineHiddenReplies:" . $this->target->id; + + $subtitle = sprintf( + // TRANS: Message is used as a subtitle. %1$s is a user nickname, %2$s is a site name. + _('Updates from %1$s and friends on %2$s! (with replies to non-friends hidden)'), + $this->target->nickname, + $sitename + ); + + $logo = $this->target->avatarUrl(AVATAR_PROFILE_SIZE); + $link = common_local_url('all', + array('nickname' => $this->target->nickname)); + $self = $this->getSelfUri(); + + switch($this->format) { + case 'xml': + $this->showXmlTimeline($this->notices); + break; + case 'rss': + + $this->showRssTimeline( + $this->notices, + $title, + $link, + $subtitle, + null, + $logo, + $self + ); + break; + case 'atom': + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed($this->auth_user); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + $atom->addLink($link); + $atom->setSelfLink($self); + + $atom->addEntryFromNotices($this->notices); + + $this->raw($atom->getString()); + + break; + case 'json': + $this->showJsonTimeline($this->notices); + break; + case 'as': + header('Content-Type: ' . ActivityStreamJSONDocument::CONTENT_TYPE); + $doc = new ActivityStreamJSONDocument($this->auth_user, $title); + $doc->addLink($link, 'alternate', 'text/html'); + $doc->addItemsFromNotices($this->notices); + $this->raw($doc->asString()); + break; + default: + // TRANS: Client error displayed when coming across a non-supported API method. + $this->clientError(_('API method not found.'), 404); + } + } + + /** + * Get notices + * + * @return array notices + */ + function getNotices() + { + $notices = array(); + + $stream = new InboxNoticeStreamHiddenReplies($this->target, $this->scoped); + + $notice = $stream->getNotices(($this->page-1) * $this->count, + $this->count, + $this->since_id, + $this->max_id); + + while ($notice->fetch()) { + $notices[] = clone($notice); + } + + return $notices; + } + + /** + * Is this action read only? + * + * @param array $args other arguments + * + * @return boolean true + */ + function isReadOnly($args) + { + return true; + } + + /** + * When was this feed last modified? + * + * @return string datestamp of the latest notice in the stream + */ + function lastModified() + { + if (!empty($this->notices) && (count($this->notices) > 0)) { + return strtotime($this->notices[0]->created); + } + + return null; + } + + /** + * An entity tag for this stream + * + * Returns an Etag based on the action name, language, user ID, and + * timestamps of the first and last notice in the timeline + * + * @return string etag + */ + function etag() + { + if (!empty($this->notices) && (count($this->notices) > 0)) { + $last = count($this->notices) - 1; + + return '"' . implode( + ':', + array($this->arg('action'), + common_user_cache_hash($this->auth_user), + common_language(), + $this->target->id, + strtotime($this->notices[0]->created), + strtotime($this->notices[$last]->created)) + ) + . '"'; + } + + return null; + } +} + + +/** + * Stream of notices for a profile's "all" feed, with hidden replies to non-friends + * + * @category General + * @package StatusNet + * @author Evan Prodromou + * @author Mikael Nordfeldth + * @copyright 2011 StatusNet, Inc. + * @copyright 2014 Free Software Foundation, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ +class InboxNoticeStreamHiddenReplies extends ScopingNoticeStream +{ + /** + * Constructor + * + * @param Profile $target Profile to get a stream for + * @param Profile $scoped Currently scoped profile (if null, it is fetched) + */ + function __construct(Profile $target, Profile $scoped=null) + { + if ($scoped === null) { + $scoped = Profile::current(); + } + // FIXME: we don't use CachingNoticeStream - but maybe we should? + parent::__construct(new CachingNoticeStream(new RawInboxNoticeStreamHiddenReplies($target), 'profileallhiddenreplies'), $scoped); + } +} + +/** + * Raw stream of notices for the target's inbox, with hidden replies to non-friends + * + * @category General + * @package StatusNet + * @author Evan Prodromou + * @copyright 2011 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ +class RawInboxNoticeStreamHiddenReplies extends NoticeStream +{ + protected $target = null; + protected $inbox = null; + + /** + * Constructor + * + * @param Profile $target Profile to get a stream for + */ + function __construct(Profile $target) + { + $this->target = $target; + } + + /** + * Get IDs in a range + * + * @param int $offset Offset from start + * @param int $limit Limit of number to get + * @param int $since_id Since this notice + * @param int $max_id Before this notice + * + * @return Array IDs found + */ + function getNoticeIds($offset, $limit, $since_id, $max_id) + { + $notice = new Notice(); + $notice->selectAdd(); + $notice->selectAdd('id'); + $notice->whereAdd(sprintf('notice.created > "%s"', $notice->escape($this->target->created))); + // Reply:: is a table of mentions + // Subscription:: is a table of subscriptions (every user is subscribed to themselves) + $notice->whereAdd( + + // notices from profiles we subscribe to + sprintf('( notice.profile_id IN (SELECT subscribed FROM subscription WHERE subscriber=%1$d) ' . + + // and in groups we're members of + 'OR notice.id IN (SELECT notice_id FROM group_inbox WHERE group_id IN (SELECT group_id FROM group_member WHERE profile_id=%1$d))' . + + // and from attention table (i, hannes, don't know whats in that though...) + 'OR notice.id IN (SELECT notice_id FROM attention WHERE profile_id=%1$d) ) ' . + + // all of the notices matching the above must also be either + // 1) a non-reply + 'AND (notice.reply_to IS NULL ' . + + // 2) OR a reply to myself + 'OR notice.profile_id=%1$d ' . + + // 3) OR a reply to someone i'm subscibing to + 'OR notice.reply_to IN (SELECT id FROM notice as noticereplies WHERE noticereplies.profile_id IN (SELECT subscribed FROM subscription WHERE subscriber=%1$d))) '. + + // lastly: include all notices mentioning me + 'OR (notice.id IN (SELECT notice_id FROM reply WHERE profile_id=%1$d) ' . + + // but not if they are from someone i don't subscribe to + 'AND notice.profile_id IN (SELECT subscribed FROM subscription WHERE subscriber=%1$d))', + + $this->target->id) + ); + if (!empty($since_id)) { + $notice->whereAdd(sprintf('notice.id > %d', $since_id)); + } + if (!empty($max_id)) { + $notice->whereAdd(sprintf('notice.id <= %d', $max_id)); + } + if (!empty($this->selectVerbs)) { + $notice->whereAddIn('verb', $this->selectVerbs, $notice->columnType('verb')); + } + $notice->limit($offset, $limit); + // notice.id will give us even really old posts, which were + // recently imported. For example if a remote instance had + // problems and just managed to post here. Another solution + // would be to have a 'notice.imported' field and order by it. + $notice->orderBy('notice.id DESC'); + + if (!$notice->find()) { + return array(); + } + + $ids = $notice->fetchAll('id'); + + return $ids; + } +} diff --git a/actions/qvittersettings.php b/actions/qvittersettings.php index 19b4621..7a5c27b 100644 --- a/actions/qvittersettings.php +++ b/actions/qvittersettings.php @@ -1,6 +1,6 @@ \\\\_\ · · \\) \____) · · · - · · + · · · · · Qvitter is free software: you can redistribute it and / or modify it · · under the terms of the GNU Affero General Public License as published by · @@ -31,7 +31,7 @@ · along with Qvitter. If not, see . · · · · Contact h@nnesmannerhe.im if you have any questions. · - · · + · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · */ if (!defined('STATUSNET') && !defined('LACONICA')) { @@ -60,7 +60,7 @@ class QvitterSettingsAction extends SettingsAction function getInstructions() { // TRANS: Page instructions. - return _m('Enable or disable Qvitter UI'); + return _m('Qvitter Settings'); } /** @@ -74,19 +74,26 @@ class QvitterSettingsAction extends SettingsAction if(QvitterPlugin::settings('enabledbydefault')) { try { - $prefs = Profile_prefs::getData($user->getProfile(), 'qvitter', 'disable_qvitter'); + $disable_enable_prefs = Profile_prefs::getData($user->getProfile(), 'qvitter', 'disable_qvitter'); } catch (NoResultException $e) { - $prefs = false; + $disable_enable_prefs = false; } } else { try { - $prefs = Profile_prefs::getData($user->getProfile(), 'qvitter', 'enable_qvitter'); + $disable_enable_prefs = Profile_prefs::getData($user->getProfile(), 'qvitter', 'enable_qvitter'); } catch (NoResultException $e) { - $prefs = false; - } - } + $disable_enable_prefs = false; + } + } - $form = new QvitterPrefsForm($this, $prefs); + + try { + $hide_replies_prefs = Profile_prefs::getData($user->getProfile(), 'qvitter', 'hide_replies'); + } catch (NoResultException $e) { + $hide_replies_prefs = false; + } + + $form = new QvitterPrefsForm($this, $disable_enable_prefs, $hide_replies_prefs); $form->show(); } @@ -104,12 +111,14 @@ class QvitterSettingsAction extends SettingsAction $user = common_current_user(); if(QvitterPlugin::settings('enabledbydefault')) { - Profile_prefs::setData($user->getProfile(), 'qvitter', 'disable_qvitter', $this->boolean('disable_qvitter')); + Profile_prefs::setData($user->getProfile(), 'qvitter', 'disable_qvitter', $this->boolean('disable_qvitter')); } else { - Profile_prefs::setData($user->getProfile(), 'qvitter', 'enable_qvitter', $this->boolean('enable_qvitter')); + Profile_prefs::setData($user->getProfile(), 'qvitter', 'enable_qvitter', $this->boolean('enable_qvitter')); } + Profile_prefs::setData($user->getProfile(), 'qvitter', 'hide_replies', $this->boolean('hide_replies')); + // TRANS: Confirmation shown when user profile settings are saved. $this->showForm(_('Settings saved.'), true); @@ -119,12 +128,14 @@ class QvitterSettingsAction extends SettingsAction class QvitterPrefsForm extends Form { - var $prefs; + var $disable_enable_prefs; + var $hide_replies_prefs; - function __construct($out, $prefs) + function __construct($out, $disable_enable_prefs, $hide_replies_prefs) { parent::__construct($out); - $this->prefs = $prefs; + $this->disable_enable_prefs = $disable_enable_prefs; + $this->hide_replies_prefs = $hide_replies_prefs; } /** @@ -138,21 +149,31 @@ class QvitterPrefsForm extends Form function formData() { - + if(QvitterPlugin::settings('enabledbydefault')) { $enabledisable = 'disable_qvitter'; $enabledisablelabel = _('Disable Qvitter'); } else { $enabledisable = 'enable_qvitter'; - $enabledisablelabel = _('Enable Qvitter'); - } - + $enabledisablelabel = _('Enable Qvitter'); + } + $this->elementStart('fieldset'); $this->elementStart('ul', 'form_data'); $this->elementStart('li'); $this->checkbox($enabledisable, $enabledisablelabel, - (!empty($this->prefs))); + (!empty($this->disable_enable_prefs))); + $this->elementEnd('li'); + $this->elementEnd('ul'); + $this->elementEnd('fieldset'); + + $this->elementStart('fieldset'); + $this->elementStart('ul', 'form_data'); + $this->elementStart('li'); + $this->checkbox('hide_replies', + _('Hide replies to people I\'m not following'), + (!empty($this->hide_replies_prefs))); $this->elementEnd('li'); $this->elementEnd('ul'); $this->elementEnd('fieldset');