<name>com.woltlab.wcf.moderation.queue</name>
<definitionname>com.woltlab.wcf.visitTracker.objectType</definitionname>
</type>
+ <type>
+ <name>com.woltlab.wcf.article</name>
+ <definitionname>com.woltlab.wcf.visitTracker.objectType</definitionname>
+ </type>
<!-- /Visit Tracker -->
<!-- activity points -->
<defaultvalue>1</defaultvalue>
<options>module_article</options>
</option>
+ <option name="article_enable_visit_tracking">
+ <categoryname>cms.article</categoryname>
+ <optiontype>boolean</optiontype>
+ <defaultvalue>1</defaultvalue>
+ <options>module_article</options>
+ </option>
<option name="articles_per_page">
<categoryname>cms.article</categoryname>
<optiontype>integer</optiontype>
<page identifier="com.woltlab.wcf.ArticleList">
<pageType>system</pageType>
<controller>wcf\page\ArticleListPage</controller>
+ <handler>wcf\system\page\handler\ArticleListPageHandler</handler>
<name language="de">Artikel-Liste</name>
<name language="en">Article List</name>
<options>module_article</options>
{lang}wcf.article.articleViews{/lang}
</li>
+ {if ARTICLE_ENABLE_VISIT_TRACKING && $article->isNew()}<li><span class="badge label newMessageBadge">{lang}wcf.message.new{/lang}</span></li>{/if}
+
<li class="articleLikesBadge"></li>
</ul>
{capture assign='headerNavigation'}
<li><a rel="alternate" href="{if $__wcf->getUser()->userID}{link controller='ArticleFeed'}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed'}{/link}{/if}" title="{lang}wcf.global.button.rss{/lang}" class="jsTooltip"><span class="icon icon16 fa-rss"></span> <span class="invisible">{lang}wcf.global.button.rss{/lang}</span></a></li>
+ {if ARTICLE_ENABLE_VISIT_TRACKING}
+ <li class="jsOnly"><a href="#" title="{lang}wcf.article.markAllAsRead{/lang}" class="markAllAsReadButton jsTooltip"><span class="icon icon16 fa-check"></span> <span class="invisible">{lang}wcf.article.markAllAsRead{/lang}</span></a></li>
+ {/if}
{/capture}
{if $__wcf->getSession()->getPermission('admin.content.article.canManageArticle')}
{/hascontent}
</footer>
+{if ARTICLE_ENABLE_VISIT_TRACKING}
+ <script data-relocate="true">
+ require(['WoltLabSuite/Core/Ui/Article/MarkAllAsRead'], function(UiArticleMarkAllAsRead) {
+ UiArticleMarkAllAsRead.init();
+ });
+ </script>
+{/if}
+
{include file='footer'}
{/if}
</li>
{/if}
+
+ {if ARTICLE_ENABLE_VISIT_TRACKING && $article->isNew()}<li><span class="badge label newMessageBadge">{lang}wcf.message.new{/lang}</span></li>{/if}
</ul>
</div>
{capture assign='headerNavigation'}
<li><a rel="alternate" href="{if $__wcf->getUser()->userID}{link controller='ArticleFeed' id=$categoryID}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed' id=$categoryID}{/link}{/if}" title="{lang}wcf.global.button.rss{/lang}" class="jsTooltip"><span class="icon icon16 fa-rss"></span> <span class="invisible">{lang}wcf.global.button.rss{/lang}</span></a></li>
+ {if ARTICLE_ENABLE_VISIT_TRACKING}
+ <li class="jsOnly"><a href="#" title="{lang}wcf.article.markAllAsRead{/lang}" class="markAllAsReadButton jsTooltip"><span class="icon icon16 fa-check"></span> <span class="invisible">{lang}wcf.article.markAllAsRead{/lang}</span></a></li>
+ {/if}
{/capture}
{if $__wcf->getSession()->getPermission('admin.content.article.canManageArticle')}
{/hascontent}
</footer>
+{if ARTICLE_ENABLE_VISIT_TRACKING}
+ <script data-relocate="true">
+ require(['WoltLabSuite/Core/Ui/Article/MarkAllAsRead'], function(UiArticleMarkAllAsRead) {
+ UiArticleMarkAllAsRead.init();
+ });
+ </script>
+{/if}
+
{include file='footer'}
define('OG_IMAGE', '');
define('HEAD_CODE', '');
define('AVATAR_DEFAULT_TYPE', 'initials');
+define('ARTICLE_ENABLE_VISIT_TRACKING', 1);
--- /dev/null
+/**
+ * Handles the 'mark as read' action for articles.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2017 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Article/MarkAllAsRead
+ */
+define(['Ajax'], function(Ajax) {
+ "use strict";
+
+ return {
+ init: function() {
+ elBySelAll('.markAllAsReadButton', undefined, (function(button) {
+ button.addEventListener(WCF_CLICK_EVENT, this._click.bind(this));
+ }).bind(this));
+ },
+
+ _click: function(event) {
+ event.preventDefault();
+
+ Ajax.api(this);
+ },
+
+ _ajaxSuccess: function() {
+ /* remove obsolete badges */
+ // main menu
+ var badge = elBySel('.mainMenu .active .badge');
+ if (badge) elRemove(badge);
+
+ // article list
+ elBySelAll('.articleList .newMessageBadge', undefined, elRemove);
+ },
+
+ _ajaxSetup: function() {
+ return {
+ data: {
+ actionName: 'markAllAsRead',
+ className: 'wcf\\data\\article\\ArticleAction'
+ }
+ };
+ }
+ };
+});
use wcf\system\request\LinkHandler;
use wcf\system\search\SearchIndexManager;
use wcf\system\tagging\TagEngine;
+use wcf\system\user\storage\UserStorageHandler;
use wcf\system\version\VersionTracker;
+use wcf\system\visitTracker\VisitTracker;
use wcf\system\WCF;
/**
*/
protected $requireACP = ['create', 'delete', 'restore', 'trash', 'update'];
+ /**
+ * @inheritDoc
+ */
+ protected $allowGuestAccess = ['markAllAsRead'];
+
/**
* @inheritDoc
* @return Article
}
}
+ // reset storage
+ if (ARTICLE_ENABLE_VISIT_TRACKING) {
+ UserStorageHandler::getInstance()->resetAll('unreadArticles');
+ }
+
return $article;
}
}
}
}
+
+ // reset storage
+ if (ARTICLE_ENABLE_VISIT_TRACKING) {
+ UserStorageHandler::getInstance()->resetAll('unreadArticles');
+ }
}
/**
public function restore() {
$this->articleEditor->update(['isDeleted' => 0]);
}
+
+ /**
+ * Marks articles as read.
+ */
+ public function markAsRead() {
+ if (empty($this->parameters['visitTime'])) {
+ $this->parameters['visitTime'] = TIME_NOW;
+ }
+
+ if (empty($this->objects)) {
+ $this->readObjects();
+ }
+
+ $articleIDs = [];
+ foreach ($this->getObjects() as $article) {
+ $articleIDs[] = $article->articleID;
+ VisitTracker::getInstance()->trackObjectVisit('com.woltlab.wcf.article', $article->articleID, $this->parameters['visitTime']);
+ }
+
+ // reset storage
+ if (WCF::getUser()->userID) {
+ UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
+ }
+ }
+
+ /**
+ * Marks all articles as read.
+ */
+ public function markAllAsRead() {
+ VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
+
+ // reset storage
+ if (WCF::getUser()->userID) {
+ UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
+ }
+ }
+
+ /**
+ * Validates the mark all as read action.
+ */
+ public function validateMarkAllAsRead() {
+ // does nothing
+ }
}
<?php
namespace wcf\data\article;
+use wcf\data\article\category\ArticleCategory;
use wcf\data\article\content\ArticleContent;
use wcf\data\article\content\ViewableArticleContent;
use wcf\data\media\ViewableMedia;
use wcf\data\user\UserProfile;
use wcf\data\DatabaseObjectDecorator;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\user\storage\UserStorageHandler;
+use wcf\system\visitTracker\VisitTracker;
+use wcf\system\WCF;
/**
* Represents a viewable article.
* @package WoltLabSuite\Core\Data\Article
* @since 3.0
*
- * @method Article getDecoratedObject()
- * @method ArticleContent|ViewableArticleContent getArticleContent()
- * @mixin Article
+ * @method Article getDecoratedObject()
+ * @method ArticleContent|ViewableArticleContent getArticleContent()
+ * @mixin Article
+ * @property-read integer|null $visitTime last time the active user has visited the time or `null` if object has not been fetched via `ViewableArticleList` or if the active user is a guest
*/
class ViewableArticle extends DatabaseObjectDecorator {
/**
*/
protected $userProfile = null;
+ /**
+ * effective visit time
+ * @var integer
+ */
+ protected $effectiveVisitTime;
+
+ /**
+ * number of unread articles
+ * @var integer
+ */
+ protected static $unreadArticles;
+
/**
* Returns a specific article decorated as viewable article or `null` if it does not exist.
*
return null;
}
+
+ /**
+ * Returns the effective visit time.
+ *
+ * @return integer
+ */
+ public function getVisitTime() {
+ if ($this->effectiveVisitTime === null) {
+ if (WCF::getUser()->userID) {
+ $this->effectiveVisitTime = max($this->visitTime, VisitTracker::getInstance()->getVisitTime('com.woltlab.wcf.article'));
+ }
+ else {
+ $this->effectiveVisitTime = max(VisitTracker::getInstance()->getObjectVisitTime('com.woltlab.wcf.article', $this->articleID), VisitTracker::getInstance()->getVisitTime('com.woltlab.wcf.article'));
+ }
+ if ($this->effectiveVisitTime === null) {
+ $this->effectiveVisitTime = 0;
+ }
+ }
+
+ return $this->effectiveVisitTime;
+ }
+
+ /**
+ * Returns true if this article is new for the active user.
+ *
+ * @return boolean
+ */
+ public function isNew() {
+ return $this->time > $this->getVisitTime();
+ }
+
+ /**
+ * Returns the number of unread articles.
+ *
+ * @return integer
+ */
+ public static function getUnreadArticles() {
+ if (self::$unreadArticles === null) {
+ self::$unreadArticles = 0;
+
+ if (WCF::getUser()->userID) {
+ $unreadArticles = UserStorageHandler::getInstance()->getField('unreadArticles');
+
+ // cache does not exist or is outdated
+ if ($unreadArticles === null) {
+ $categoryIDs = ArticleCategory::getAccessibleCategoryIDs();
+ if (!empty($categoryIDs)) {
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('article.categoryID IN (?)', [$categoryIDs]);
+ $conditionBuilder->add('article.time > ?', [VisitTracker::getInstance()->getVisitTime('com.woltlab.wcf.article')]);
+ $conditionBuilder->add('article.isDeleted = ?', [0]);
+ $conditionBuilder->add('article.publicationStatus = ?', [Article::PUBLISHED]);
+ $conditionBuilder->add('(article.time > tracked_visit.visitTime OR tracked_visit.visitTime IS NULL)');
+
+ $sql = "SELECT COUNT(*)
+ FROM wcf".WCF_N."_article article
+ LEFT JOIN wcf".WCF_N."_tracked_visit tracked_visit
+ ON (tracked_visit.objectTypeID = ".VisitTracker::getInstance()->getObjectTypeID('com.woltlab.wcf.article')."
+ AND tracked_visit.objectID = article.articleID
+ AND tracked_visit.userID = ".WCF::getUser()->userID.")
+ ".$conditionBuilder;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ self::$unreadArticles = $statement->fetchSingleColumn();
+ }
+
+ // update storage unreadEntries
+ UserStorageHandler::getInstance()->update(WCF::getUser()->userID, 'unreadArticles', self::$unreadArticles);
+ }
+ else {
+ self::$unreadArticles = $unreadArticles;
+ }
+ }
+ }
+
+ return self::$unreadArticles;
+ }
}
use wcf\data\article\content\ViewableArticleContentList;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
use wcf\system\like\LikeHandler;
+use wcf\system\visitTracker\VisitTracker;
use wcf\system\WCF;
/**
public function __construct() {
parent::__construct();
+ if (WCF::getUser()->userID != 0) {
+ // last visit time
+ if (!empty($this->sqlSelects)) $this->sqlSelects .= ',';
+ $this->sqlSelects .= 'tracked_visit.visitTime';
+ $this->sqlJoins .= " LEFT JOIN wcf".WCF_N."_tracked_visit tracked_visit ON (tracked_visit.objectTypeID = ".VisitTracker::getInstance()->getObjectTypeID('com.woltlab.wcf.article')." AND tracked_visit.objectID = article.articleID AND tracked_visit.userID = ".WCF::getUser()->userID.")";
+ }
+
// get like status
if (!empty($this->sqlSelects)) $this->sqlSelects .= ',';
$this->sqlSelects .= "like_object.likes, like_object.dislikes";
<?php
namespace wcf\page;
+use wcf\data\article\ArticleAction;
use wcf\data\article\category\ArticleCategory;
use wcf\data\article\content\ViewableArticleContent;
use wcf\data\article\AccessibleArticleList;
'views' => 1
]);
+ // update article visit
+ if (ARTICLE_ENABLE_VISIT_TRACKING && $this->article->isNew()) {
+ $articleAction = new ArticleAction([$this->article->getDecoratedObject()], 'markAsRead', [
+ 'viewableArticle' => $this->article
+ ]);
+ $articleAction->executeAction();
+ }
+
// get tags
if (MODULE_TAGGING && WCF::getSession()->getPermission('user.tag.canViewTag')) {
$this->tags = TagEngine::getInstance()->getObjectTags(
--- /dev/null
+<?php
+namespace wcf\system\page\handler;
+use wcf\data\article\ViewableArticle;
+
+/**
+ * Page handler implementation for the page showing the list of articles.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2017 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Page\Handler
+ * @since 3.1
+ */
+class ArticleListPageHandler extends AbstractMenuPageHandler {
+ /** @noinspection PhpMissingParentCallCommonInspection */
+ /**
+ * @inheritDoc
+ */
+ public function getOutstandingItemCount(/** @noinspection PhpUnusedParameterInspection */$objectID = null) {
+ return ARTICLE_ENABLE_VISIT_TRACKING ? ViewableArticle::getUnreadArticles() : 0;
+ }
+}
<item name="wcf.acp.option.avatar_default_type"><![CDATA[Standard Avatar-Typ]]></item>
<item name="wcf.acp.option.avatar_default_type.initials"><![CDATA[Initialen]]></item>
<item name="wcf.acp.option.avatar_default_type.silhouette"><![CDATA[Silhouette]]></item>
+ <item name="wcf.acp.option.article_enable_visit_tracking"><![CDATA[Gelesen-Markierung für Artikel aktivieren]]></item>
</category>
<category name="wcf.acp.package">
<item name="wcf.article.sortField.cumulativeLikes"><![CDATA[Likes]]></item>
<item name="wcf.article.sortField.time"><![CDATA[Datum]]></item>
<item name="wcf.article.sortField.views"><![CDATA[Zugriffe]]></item>
+ <item name="wcf.article.markAllAsRead"><![CDATA[Alle Artikel als gelesen markieren]]></item>
</category>
<category name="wcf.attachment">
<item name="wcf.acp.option.avatar_default_type"><![CDATA[Default Avatar Type]]></item>
<item name="wcf.acp.option.avatar_default_type.initials"><![CDATA[Initials]]></item>
<item name="wcf.acp.option.avatar_default_type.silhouette"><![CDATA[Silhouette]]></item>
+ <item name="wcf.acp.option.article_enable_visit_tracking"><![CDATA[Enable “mark as read” for articles]]></item>
</category>
<category name="wcf.acp.package">
<item name="wcf.article.sortField.cumulativeLikes"><![CDATA[Likes]]></item>
<item name="wcf.article.sortField.time"><![CDATA[Date]]></item>
<item name="wcf.article.sortField.views"><![CDATA[Views]]></item>
+ <item name="wcf.article.markAllAsRead"><![CDATA[Mark All Articles as Read]]></item>
</category>
<category name="wcf.attachment">