Implemented visit tracking for articles
authorMarcel Werk <burntime@woltlab.com>
Tue, 28 Mar 2017 17:35:05 +0000 (19:35 +0200)
committerMarcel Werk <burntime@woltlab.com>
Tue, 28 Mar 2017 17:35:13 +0000 (19:35 +0200)
Closes #2182

16 files changed:
com.woltlab.wcf/objectType.xml
com.woltlab.wcf/option.xml
com.woltlab.wcf/page.xml
com.woltlab.wcf/templates/article.tpl
com.woltlab.wcf/templates/articleList.tpl
com.woltlab.wcf/templates/articleListItems.tpl
com.woltlab.wcf/templates/categoryArticleList.tpl
constants.php
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.js [new file with mode: 0644]
wcfsetup/install/files/lib/data/article/ArticleAction.class.php
wcfsetup/install/files/lib/data/article/ViewableArticle.class.php
wcfsetup/install/files/lib/data/article/ViewableArticleList.class.php
wcfsetup/install/files/lib/page/AbstractArticlePage.class.php
wcfsetup/install/files/lib/system/page/handler/ArticleListPageHandler.class.php [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

index b90073176cc46afa285c4f7c06b06b042fe8bea4..32e238a882295d574b82100405ddad786cfc527f 100644 (file)
                        <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 -->
index 956d83f72f4b7d9ff5549ef54a40e90ab5318d3d..1ac8dc67161acb224117e348df0cdf494437d596 100644 (file)
@@ -1527,6 +1527,12 @@ DESC:wcf.global.sortOrder.descending</selectoptions>
                                <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>
index 26a1028389d3c8c4f6f5e66eabdd89c4c55e4dfe..02442110507cff3437d565b21d89b5b47a44c9c5 100644 (file)
                <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>
index 69b440d34cab312a7be7e0d21a93ca1274f87436..43b6b41f25a36db85e6b43c0e1c66ea253fcf8fe 100644 (file)
@@ -39,6 +39,8 @@
                                        {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>
                        
index 92eee88ea479e044a3f32a98873ead06a3ca3e9e..52a2fa22c2604238a62d206e8ec5871f6aadc2e3 100644 (file)
@@ -15,6 +15,9 @@
 
 {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'}
index 56ce16e6521770535918edf2b6313115bab0c6a5..503e09c690cc83055c1b724f2b05f6a94835ddd4 100644 (file)
@@ -30,6 +30,8 @@
                                                                                {/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>
                                                
index dbcbeba40606b9a28ac15ee6b99d60d5653b5f03..12abb84fd559085b376dddf341062a9c346e439b 100644 (file)
@@ -19,6 +19,9 @@
 
 {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'}
index 40e64a874e90ee44b1abbf79f7b9565dd73462d1..396eb740c708deb44467367b18f09a072fa5b1a0 100644 (file)
@@ -219,3 +219,4 @@ define('USE_PAGE_TITLE_ON_LANDING_PAGE', 1);
 define('OG_IMAGE', '');
 define('HEAD_CODE', '');
 define('AVATAR_DEFAULT_TYPE', 'initials');
+define('ARTICLE_ENABLE_VISIT_TRACKING', 1);
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.js
new file mode 100644 (file)
index 0000000..52682f8
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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'
+                               }
+                       };
+               }
+       };
+});
index b99faf709499c78dc8d4f7cf49f9dc404a936439..985db0bee85ab2a32e2b2685cf22d3a2d872046b 100644 (file)
@@ -11,7 +11,9 @@ use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
 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;
 
 /**
@@ -58,6 +60,11 @@ class ArticleAction extends AbstractDatabaseObjectAction {
         */
        protected $requireACP = ['create', 'delete', 'restore', 'trash', 'update'];
        
+       /**
+        * @inheritDoc
+        */
+       protected $allowGuestAccess = ['markAllAsRead'];
+       
        /**
         * @inheritDoc
         * @return      Article
@@ -115,6 +122,11 @@ class ArticleAction extends AbstractDatabaseObjectAction {
                        }
                }
                
+               // reset storage
+               if (ARTICLE_ENABLE_VISIT_TRACKING) {
+                       UserStorageHandler::getInstance()->resetAll('unreadArticles');
+               }
+               
                return $article;
        }
        
@@ -213,6 +225,11 @@ class ArticleAction extends AbstractDatabaseObjectAction {
                                }
                        }
                }
+               
+               // reset storage
+               if (ARTICLE_ENABLE_VISIT_TRACKING) {
+                       UserStorageHandler::getInstance()->resetAll('unreadArticles');
+               }
        }
        
        /**
@@ -287,4 +304,47 @@ class ArticleAction extends AbstractDatabaseObjectAction {
        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
+       }
 }
index 38ed5098a447955f80e2e6935d76be75b14b1bff..a03231f9efce878e91f464bdf4d72616fe72fcde 100644 (file)
@@ -1,5 +1,6 @@
 <?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;
@@ -7,6 +8,10 @@ use wcf\data\user\User;
 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.
@@ -17,9 +22,10 @@ use wcf\system\cache\runtime\UserProfileRuntimeCache;
  * @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 {
        /**
@@ -33,6 +39,18 @@ 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.
         *
@@ -108,4 +126,81 @@ class ViewableArticle extends DatabaseObjectDecorator {
                
                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;
+       }
 }
index c734d1be4924ed84da1a698c174790a58dbb2988..cf1b3bf134391effeb3eb4226be986bad2b3b483 100644 (file)
@@ -3,6 +3,7 @@ namespace wcf\data\article;
 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;
 
 /**
@@ -37,6 +38,13 @@ class ViewableArticleList extends ArticleList {
        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";
index 625acab921343fd35a645b1c26df96fd8a9a3c1b..2440cd6419b7c9693eb2a402239ab15f04b79e58 100644 (file)
@@ -1,5 +1,6 @@
 <?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;
@@ -108,6 +109,14 @@ abstract class AbstractArticlePage extends AbstractPage {
                        '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(
diff --git a/wcfsetup/install/files/lib/system/page/handler/ArticleListPageHandler.class.php b/wcfsetup/install/files/lib/system/page/handler/ArticleListPageHandler.class.php
new file mode 100644 (file)
index 0000000..d7d2ddd
--- /dev/null
@@ -0,0 +1,22 @@
+<?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;
+       }
+}
index 9344d127aa3eb4d1c6a86710160344eb8905b185..5e2c45c3db8bdcee8af68becd626588f99ebbe5d 100644 (file)
@@ -1262,6 +1262,7 @@ GmbH=Gesellschaft mit beschränkter Haftung]]></item>
                <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">
@@ -2023,6 +2024,7 @@ Benutzerkontos nun in vollem Umfang nutzen.]]></item>
                <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">
index d56dbd16b8c0d784afcba7bc49c91cf98178bfe8..49914c59c62c12c03c51d542839632f7956b38fe 100644 (file)
@@ -1264,6 +1264,7 @@ Examples for medium ID detection:
                <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">
@@ -1978,6 +1979,7 @@ full extend.]]></item>
                <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">