Support mentions of user groups
authorAlexander Ebert <ebert@woltlab.com>
Wed, 16 Jan 2019 22:00:11 +0000 (23:00 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Wed, 16 Jan 2019 22:00:11 +0000 (23:00 +0100)
See #2804

15 files changed:
com.woltlab.wcf/bbcode.xml
com.woltlab.wcf/templates/groupBBCodeTag.tpl [new file with mode: 0644]
wcfsetup/install/files/acp/templates/userGroupAdd.tpl
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Redactor/Mention.js
wcfsetup/install/files/lib/acp/form/UserGroupAddForm.class.php
wcfsetup/install/files/lib/acp/form/UserGroupEditForm.class.php
wcfsetup/install/files/lib/data/user/UserAction.class.php
wcfsetup/install/files/lib/data/user/group/UserGroup.class.php
wcfsetup/install/files/lib/system/bbcode/GroupBBCode.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeTextParser.class.php
wcfsetup/install/files/lib/util/MessageUtil.class.php
wcfsetup/install/files/style/bbcode/groupMention.scss [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml
wcfsetup/setup/db/install.sql

index a9ee4c8622ce68c3766357fb9b9e199fbca7e295..a74e5f21e38707504a543193874ca9e6da66e5da 100644 (file)
                                </attribute>
                        </attributes>
                </bbcode>
+               <bbcode name="group">
+                       <classname>wcf\system\bbcode\GroupBBCode</classname>
+                       <attributes>
+                               <attribute name="0">
+                                       <validationpattern><![CDATA[^\d+$]]></validationpattern>
+                                       <required>1</required>
+                               </attribute>
+                       </attributes>
+               </bbcode>
        </import>
 </data>
diff --git a/com.woltlab.wcf/templates/groupBBCodeTag.tpl b/com.woltlab.wcf/templates/groupBBCodeTag.tpl
new file mode 100644 (file)
index 0000000..74ae8bf
--- /dev/null
@@ -0,0 +1,5 @@
+{if $group}
+       <span class="groupMention">{$group->getName()}</span>
+{else}
+       @{$groupName}
+{/if}
index 055bc8d8baf1589db1358cda178049eb2f290d3d..bd03a7537cb97023fc167e85704480d1f212a690 100644 (file)
                                </dd>
                        </dl>
                {/if}
+
+               {if $action === 'add' || !$isUnmentionableGroup}
+                       <dl>
+                               <dt></dt>
+                               <dd>
+                                       <label><input type="checkbox" id="allowMention" name="allowMention" value="1"{if $allowMention} checked{/if}> {lang}wcf.acp.group.allowMention{/lang}</label>
+                               </dd>
+                       </dl>
+               {/if}
                
                {event name='dataFields'}
        </div>
index a1bf7505e2ddcd3e864e0bb330463768a49782cb..65ec70a03fe6489c2e185603bd84de186fc27822 100644 (file)
@@ -373,7 +373,8 @@ define(['Ajax', 'Environment', 'StringUtil', 'Ui/CloseOverlay'], function(Ajax,
                                        interfaceName: 'wcf\\data\\ISearchAction',
                                        parameters: {
                                                data: {
-                                                       includeUserGroups: false
+                                                       includeUserGroups: true,
+                                                       scope: 'mention'
                                                }
                                        }
                                },
index e35528e613729116867b5691bcb65e8ff4f3275c..0fff208d71a03f1f2bcd47fd7573480dc8387584 100755 (executable)
@@ -80,6 +80,11 @@ class UserGroupAddForm extends AbstractOptionListForm {
         */
        protected $showOnTeamPage = 0;
        
+       /**
+        * @var int
+        */
+       protected $allowMention = 0;
+       
        /**
         * @inheritDoc
         */
@@ -104,6 +109,7 @@ class UserGroupAddForm extends AbstractOptionListForm {
                if (isset($_POST['priority'])) $this->priority = intval($_POST['priority']);
                if (isset($_POST['userOnlineMarking'])) $this->userOnlineMarking = StringUtil::trim($_POST['userOnlineMarking']);
                if (isset($_POST['showOnTeamPage'])) $this->showOnTeamPage = intval($_POST['showOnTeamPage']);
+               if (isset($_POST['allowMention'])) $this->allowMention = intval($_POST['allowMention']);
        }
        
        /**
@@ -150,7 +156,8 @@ class UserGroupAddForm extends AbstractOptionListForm {
                                'groupDescription' => $this->groupDescription,
                                'priority' => $this->priority,
                                'userOnlineMarking' => $this->userOnlineMarking,
-                               'showOnTeamPage' => $this->showOnTeamPage
+                               'showOnTeamPage' => $this->showOnTeamPage,
+                               'allowMention' => $this->allowMention ? 1 : 0,
                        ]),
                        'options' => $optionValues
                ];
@@ -185,7 +192,7 @@ class UserGroupAddForm extends AbstractOptionListForm {
                
                // reset values
                $this->groupName = '';
-               $this->priority = 0;
+               $this->allowMention = $this->priority = $this->showOnTeamPage = 0;
                
                I18nHandler::getInstance()->reset();
        }
@@ -219,7 +226,8 @@ class UserGroupAddForm extends AbstractOptionListForm {
                        'userOnlineMarking' => $this->userOnlineMarking,
                        'showOnTeamPage' => $this->showOnTeamPage,
                        'groupIsGuest' => false,
-                       'isBlankForm' => empty($_POST)
+                       'isBlankForm' => empty($_POST),
+                       'allowMention' => $this->allowMention,
                ]);
        }
        
index 79f81dc5a64b2a6e98f8e97e09fff4ecc36ed02b..1582b7f52697a942b2e4cb5789d8e048799e0fa6 100755 (executable)
@@ -40,6 +40,11 @@ class UserGroupEditForm extends UserGroupAddForm {
         */
        public $group;
        
+       /**
+        * @var bool
+        */
+       public $isUnmentionableGroup = false;
+       
        /**
         * @inheritDoc
         */
@@ -62,6 +67,8 @@ class UserGroupEditForm extends UserGroupAddForm {
                $this->optionHandler->setUserGroup($group);
                /** @noinspection PhpUndefinedMethodInspection */
                $this->optionHandler->init();
+               
+               $this->isUnmentionableGroup = $this->group->isUnmentionableGroup();
        }
        
        /**
@@ -72,6 +79,17 @@ class UserGroupEditForm extends UserGroupAddForm {
                // user group
        }
        
+       /**
+        * @inheritDoc
+        */
+       public function validate() {
+               parent::validate();
+               
+               if ($this->allowMention && $this->isUnmentionableGroup) {
+                       $this->allowMention = false;
+               }
+       }
+       
        /**
         * @inheritDoc
         */
@@ -84,6 +102,7 @@ class UserGroupEditForm extends UserGroupAddForm {
                        $this->priority = $this->group->priority;
                        $this->userOnlineMarking = $this->group->userOnlineMarking;
                        $this->showOnTeamPage = $this->group->showOnTeamPage;
+                       $this->allowMention = $this->group->allowMention;
                }
                
                parent::readData();
@@ -104,7 +123,8 @@ class UserGroupEditForm extends UserGroupAddForm {
                        'availableUserGroups' => UserGroup::getAccessibleGroups(),
                        'groupIsEveryone' => $this->group->groupType == UserGroup::EVERYONE,
                        'groupIsGuest' => $this->group->groupType == UserGroup::GUESTS,
-                       'groupIsUsers' => $this->group->groupType == UserGroup::USERS
+                       'groupIsUsers' => $this->group->groupType == UserGroup::USERS,
+                       'isUnmentionableGroup' => $this->isUnmentionableGroup ? 1 : 0,
                ]);
                
                // add warning when the initiator is in the group
@@ -149,7 +169,8 @@ class UserGroupEditForm extends UserGroupAddForm {
                                'groupDescription' => $this->groupDescription,
                                'priority' => $this->priority,
                                'userOnlineMarking' => $this->userOnlineMarking,
-                               'showOnTeamPage' => $this->showOnTeamPage
+                               'showOnTeamPage' => $this->showOnTeamPage,
+                               'allowMention' => $this->allowMention,
                        ]),
                        'options' => $optionValues
                ]);
index c83b9d4f2a96b2bd2a4741ce2c3ccf1361989861..af4e7916ddf7dfc784154f2a78b6f9bf08b2f509 100644 (file)
@@ -480,10 +480,17 @@ class UserAction extends AbstractDatabaseObjectAction implements IClipboardActio
                $this->readBoolean('includeUserGroups', false, 'data');
                $this->readString('searchString', false, 'data');
                $this->readIntegerArray('restrictUserGroupIDs', true, 'data');
+               $this->readString('scope', true, 'data');
                
                if (isset($this->parameters['data']['excludedSearchValues']) && !is_array($this->parameters['data']['excludedSearchValues'])) {
                        throw new UserInputException('excludedSearchValues');
                }
+               
+               if ($this->parameters['data']['scope']) {
+                       if (!in_array($this->parameters['data']['scope'], ['mention'])) {
+                               throw new UserInputException('scope');
+                       }
+               }
        }
        
        /**
@@ -504,6 +511,10 @@ class UserAction extends AbstractDatabaseObjectAction implements IClipboardActio
                                        continue;
                                }
                                
+                               if ($this->parameters['data']['scope'] === 'mention' && !$group->canBeMentioned()) {
+                                       continue;
+                               }
+                               
                                $groupName = $group->getName();
                                if (!in_array($groupName, $excludedSearchValues)) {
                                        $pos = mb_strripos($groupName, $searchString);
index 6d6087baf9e69d1050d79e959c3e42ef7c4caeb6..9cfa83c24e809565c5dbf1762da9964cebb9d195 100644 (file)
@@ -17,12 +17,19 @@ use wcf\system\WCF;
  * @package    WoltLabSuite\Core\Data\User\Group
  *
  * @property-read      integer         $groupID                unique id of the user group
- * @property-read      string          $groupName              name of the user group or name of language item which contains the name
- * @property-read      string          $groupDescription       description of the user group or name of language item which contains the description
+ * @property-read      string          $groupName              name of the user group or name of language
+ *                item which contains the name
+ * @property-read      string          $groupDescription       description of the user group or name of
+ *                language item which contains the description
  * @property-read      integer         $groupType              identifier of the type of user group
- * @property-read      integer         $priority               priority of the user group used to determine member's user rank and online marking
- * @property-read      string          $userOnlineMarking      HTML code used to print the formatted name of a user group member
- * @property-read      integer         $showOnTeamPage         is `1` if the user group and its members should be shown on the team page, otherwise `0`
+ * @property-read      integer         $priority               priority of the user group used to determine
+ *                member's user rank and online marking
+ * @property-read      string          $userOnlineMarking      HTML code used to print the formatted name of
+ *                a user group member
+ * @property-read      integer         $showOnTeamPage         is `1` if the user group and its members
+ *                should be shown on the team page, otherwise `0`
+ * @property-read       int             $allowMention           is `1` if the user group can be mentioned in messages,
+ *                      otherwise `0`
  */
 class UserGroup extends DatabaseObject implements ITitledObject {
        /**
@@ -388,4 +395,47 @@ class UserGroup extends DatabaseObject implements ITitledObject {
        public function getTitle() {
                return WCF::getLanguage()->get($this->groupName);
        }
+       
+       /**
+        * The `Everyone`, `Guests` and `Users` group can never be mentioned.
+        * 
+        * @return bool
+        * @since 5.2
+        */
+       public function isUnmentionableGroup() {
+               return in_array($this->groupType, [self::EVERYONE, self::GUESTS, self::USERS]);
+       }
+       
+       /**
+        * Returns true if this group can be mentioned, is always false for the
+        * `Everyone`, `Guests` and `Users` group.
+        *
+        * @return bool
+        * @since 5.2
+        */
+       public function canBeMentioned() {
+               if ($this->isUnmentionableGroup()) {
+                       return false;
+               }
+               
+               return !!$this->allowMention;
+       }
+       
+       /**
+        * @return UserGroup[]
+        * @since 5.2
+        */
+       public static function getMentionableGroups() {
+               self::getCache();
+               
+               $groups = [];
+               /** @var UserGroup $group */
+               foreach (self::$cache['groups'] as $group) {
+                       if ($group->canBeMentioned()) {
+                               $groups[] = $group;
+                       }
+               }
+               
+               return $groups;
+       }
 }
diff --git a/wcfsetup/install/files/lib/system/bbcode/GroupBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/GroupBBCode.class.php
new file mode 100644 (file)
index 0000000..cbb82e1
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+namespace wcf\system\bbcode;
+use wcf\data\user\group\UserGroup;
+use wcf\system\WCF;
+
+/**
+ * Parses the [user] bbcode tag.
+ *
+ * @author      Alexander Ebert
+ * @copyright   2001-2019 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package     WoltLabSuite\Core\System\Bbcode
+ * @since       5.0
+ */
+class GroupBBCode extends AbstractBBCode {
+       /**
+        * @inheritDoc
+        */
+       public function getParsedTag(array $openingTag, $content, array $closingTag, BBCodeParser $parser) {
+               $groupID = (!empty($openingTag['attributes'][0])) ? intval($openingTag['attributes'][0]) : 0;
+               $group = UserGroup::getGroupByID($groupID);
+               if ($group === null || !$group->canBeMentioned()) {
+                       return "[group]{$content}[/group]";
+               }
+               
+               return WCF::getTPL()->fetch('groupBBCodeTag', 'wcf', [
+                       'group' => $group,
+                       'groupName' => $content,
+               ], true);
+       }
+}
index 6259bd2e94ab439ae66971f9f6fbcd642b9f112e..e815306a991bfe4e1f937ca431c9c648ddd45838 100644 (file)
@@ -3,6 +3,7 @@ namespace wcf\system\html\input\node;
 use wcf\data\bbcode\media\provider\BBCodeMediaProvider;
 use wcf\data\smiley\Smiley;
 use wcf\data\smiley\SmileyCache;
+use wcf\data\user\group\UserGroup;
 use wcf\system\bbcode\BBCodeHandler;
 use wcf\system\bbcode\HtmlBBCodeParser;
 use wcf\system\database\util\PreparedStatementConditionBuilder;
@@ -174,9 +175,11 @@ class HtmlInputNodeTextParser {
                        $this->detectMention($node, $value, $usernames);
                }
                
-               $users = [];
+               $groups = $users = [];
                if (!empty($usernames)) {
                        $users = $this->lookupUsernames($usernames);
+                       $groups = $this->lookupGroups($usernames);
+                       
                }
                
                $allowEmail = BBCodeHandler::getInstance()->isAvailableBBCode('email');
@@ -188,8 +191,8 @@ class HtmlInputNodeTextParser {
                        $node = $nodes[$i];
                        $oldValue = $value = $node->textContent;
                        
-                       if (!empty($users)) {
-                               $value = $this->parseMention($node, $value, $users);
+                       if (!empty($users) || !empty($groups)) {
+                               $value = $this->parseMention($node, $value, $users, $groups);
                        }
                        
                        if ($allowURL || $allowMedia) {
@@ -305,36 +308,72 @@ class HtmlInputNodeTextParser {
                return $users;
        }
        
+       /**
+        * @param string[] $usernames
+        * @return UserGroup[]
+        * @since 5.2
+        */
+       protected function lookupGroups(array $usernames) {
+               /** @var UserGroup[] $availableUserGroups */
+               $availableUserGroups = [];
+               foreach (UserGroup::getMentionableGroups() as $group) {
+                       $availableUserGroups[] = $group;
+               }
+               
+               if (empty($availableUserGroups)) {
+                       return [];
+               }
+               
+               // Sorting the usernames by length allows for more precise matches.
+               usort($usernames, function ($usernameA, $usernameB) {
+                       return mb_strlen($usernameA) - mb_strlen($usernameB);
+               });
+               
+               $groups = [];
+               foreach ($usernames as $username) {
+                       foreach ($availableUserGroups as $group) {
+                               if (strcasecmp($group->getName(), $username) === 0) {
+                                       $groups[$group->groupID] = $group->getName();
+                                       
+                                       continue 2;
+                               }
+                       }
+               }
+               
+               return$groups;
+       }
+       
        /**
         * Parses text nodes and searches for mentions.
         * 
         * @param       \DOMText        $text           text node
         * @param       string          $value          node value
         * @param       string[]        $users          list of usernames by user id
+        * @param       string[]        $groups         list of group names by group id
         * @return      string          modified node value with replacement placeholders
         */
-       protected function parseMention(\DOMText $text, $value, array $users) {
+       protected function parseMention(\DOMText $text, $value, array $users, array $groups) {
                if (mb_strpos($value, '@') === false) {
                        return $value;
                }
                
-               foreach ($users as $userID => $username) {
+               $replaceMatch = function($objectID, $objectTitle, $bbcodeTagName) use ($text, &$value) {
                        $offset = 0;
                        do {
-                               $needle = '@' . $username;
+                               $needle = '@' . $objectTitle;
                                $pos = mb_stripos($value, $needle, $offset);
                                
                                // username not found, maybe it is quoted
                                if ($pos === false) {
-                                       $needle = "@'" . str_ireplace("'", "''", $username) . "'";
+                                       $needle = "@'" . str_ireplace("'", "''", $objectTitle) . "'";
                                        $pos = mb_stripos($value, $needle, $offset);
                                }
                                
                                if ($pos !== false) {
                                        $element = $text->ownerDocument->createElement('woltlab-metacode');
-                                       $element->setAttribute('data-name', 'user');
-                                       $element->setAttribute('data-attributes', base64_encode(JSON::encode([$userID])));
-                                       $element->appendChild($text->ownerDocument->createTextNode($username));
+                                       $element->setAttribute('data-name', $bbcodeTagName);
+                                       $element->setAttribute('data-attributes', base64_encode(JSON::encode([$objectID])));
+                                       $element->appendChild($text->ownerDocument->createTextNode($objectTitle));
                                        
                                        $marker = $this->addReplacement($text, $element);
                                        
@@ -347,6 +386,14 @@ class HtmlInputNodeTextParser {
                                }
                        }
                        while ($pos);
+               };
+               
+               foreach ($users as $userID => $username) {
+                       $replaceMatch($userID, $username, 'user');
+               }
+               
+               foreach ($groups as $groupID => $name) {
+                       $replaceMatch($groupID, $name, 'group');
                }
                
                return $value;
index 589c06699399d7d386cce2c587c8f6fa80501fe9..f6357a0fcfac46459c6a5941255657121d73c700 100644 (file)
@@ -1,8 +1,11 @@
 <?php
 namespace wcf\util;
+use wcf\data\user\group\UserGroup;
 use wcf\system\application\ApplicationHandler;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
 use wcf\system\html\input\HtmlInputProcessor;
 use wcf\system\Regex;
+use wcf\system\WCF;
 
 /**
  * Contains message-related functions.
@@ -45,11 +48,13 @@ class MessageUtil {
         */
        public static function getMentionedUsers(HtmlInputProcessor $htmlInputProcessor) {
                $usernames = [];
+               $groups = [];
                
                $elements = $htmlInputProcessor->getHtmlInputNodeProcessor()->getDocument()->getElementsByTagName('woltlab-metacode');
                /** @var \DOMElement $element */
                foreach ($elements as $element) {
-                       if ($element->getAttribute('data-name') != 'user') {
+                       $type = $element->getAttribute('data-name');
+                       if ($type !== 'user' && $type !== 'group') {
                                continue;
                        }
                        
@@ -58,7 +63,38 @@ class MessageUtil {
                                continue;
                        }
                        
-                       $usernames[] = $element->textContent;
+                       if ($type === 'user') {
+                               $usernames[] = $element->textContent;
+                       }
+                       else if ($type === 'group') {
+                               $attributes = $htmlInputProcessor->getHtmlInputNodeProcessor()->parseAttributes(
+                                       $element->getAttribute('data-attributes')
+                               );
+                               
+                               if (!empty($attributes[0])) {
+                                       $group = UserGroup::getGroupByID($attributes[0]);
+                                       if ($group !== null && $group->canBeMentioned() && !isset($groups[$group->groupID])) {
+                                               $groups[$group->groupID] = $group;
+                                       }
+                               }
+                       }
+               }
+               
+               if (!empty($groups)) {
+                       $conditions = new PreparedStatementConditionBuilder();
+                       $conditions->add('user_to_group.groupID IN (?)', [array_keys($groups)]);
+                       if (!empty($usernames)) $conditions->add('user_table.username NOT IN (?)', [$usernames]);
+                       
+                       $sql = "SELECT          user_table.username
+                               FROM            wcf".WCF_N."_user_to_group user_to_group
+                               LEFT JOIN       wcf".WCF_N."_user user_table
+                               ON              (user_table.userID = user_to_group.userID)
+                               ".$conditions;
+                       $statement = WCF::getDB()->prepareStatement($sql);
+                       $statement->execute($conditions->getParameters());
+                       while ($username = $statement->fetchSingleColumn()) {
+                               $usernames[] = $username;
+                       }
                }
                
                return $usernames;
diff --git a/wcfsetup/install/files/style/bbcode/groupMention.scss b/wcfsetup/install/files/style/bbcode/groupMention.scss
new file mode 100644 (file)
index 0000000..caf946c
--- /dev/null
@@ -0,0 +1,17 @@
+.groupMention {
+       background-color: $wcfSidebarBackground;
+       border-radius: 2px;
+       color: $wcfSidebarLink;
+       padding: 1px 5px;
+       
+       &::before {
+               content: '@';
+               /* Avoids breaks between the '@' and the group name, but still allows
+                  wrapping inside the name itself */
+               display: inline-block;
+       }
+       
+       &:hover {
+               color: $wcfSidebarLinkActive;
+       }
+}
index 6c74ece735af499780e9caea091a794b86002698..a609611eac0ada95e958854ead687c3425feb828 100644 (file)
                <item name="wcf.acp.group.option.user.contactForm.attachment.allowedExtensions.description"><![CDATA[Eine Dateiendung pro Zeile]]></item>
                <item name="wcf.acp.group.option.user.contactForm.attachment.maxCount"><![CDATA[Maximale Dateianhänge pro Nachricht]]></item>
                <item name="wcf.acp.group.option.user.profile.canEditUserProfile"><![CDATA[Kann eigenes Profil bearbeiten]]></item>
+               <item name="wcf.acp.group.allowMention"><![CDATA[Benutzergruppe kann erwähnt werden]]></item>
        </category>
        <category name="wcf.acp.index">
                <item name="wcf.acp.index.credits"><![CDATA[Über WoltLab Suite&trade;]]></item>
index fc1fc97b9e4c9b5966cbdd8afc9b037911d6bdff..657bd02faeb3800b133800377c6d069ee60df883 100644 (file)
                <item name="wcf.acp.group.option.user.contactForm.attachment.allowedExtensions.description"><![CDATA[Enter one extension per line.]]></item>
                <item name="wcf.acp.group.option.user.contactForm.attachment.maxCount"><![CDATA[Maximum Attachments per Message]]></item>
                <item name="wcf.acp.group.option.user.profile.canEditUserProfile"><![CDATA[Can edit their profile]]></item>
+               <item name="wcf.acp.group.allowMention"><![CDATA[User group can be mentioned]]></item>
        </category>
        <category name="wcf.acp.index">
                <item name="wcf.acp.index.credits"><![CDATA[About WoltLab Suite&trade;]]></item>
index e482e00aeab05a8ca940641cd5fe08c5bbfe1602..7cb83622b5fa7a6cfcc9dcd441267b87f3059cde 100644 (file)
@@ -1558,7 +1558,8 @@ CREATE TABLE wcf1_user_group (
        groupType TINYINT(1) NOT NULL DEFAULT 4,
        priority MEDIUMINT(8) NOT NULL DEFAULT 0,
        userOnlineMarking VARCHAR(255) NOT NULL DEFAULT '%s',
-       showOnTeamPage TINYINT(1) NOT NULL DEFAULT 0
+       showOnTeamPage TINYINT(1) NOT NULL DEFAULT 0,
+       allowMention TINYINT(1) NOT NULL DEFAULT 0
 );
 
 DROP TABLE IF EXISTS wcf1_user_group_assignment;