diff --git a/package.json b/package.json index 0dcb915ddd..1904ada244 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,44 @@ "jquery" ] }, + "eslintConfig": { + "extends": "xo", + "rules": { + "quotes": [ + "error", + "single" + ], + "comma-dangle": [ + "error", + "always-multiline" + ], + "block-spacing": "error", + "array-bracket-spacing": [ + "error", + "always" + ], + "multiline-comment-style": "off", + "computed-property-spacing": "off", + "space-in-parens": "off", + "capitalized-comments": "off", + "object-curly-spacing": [ + "error", + "always" + ], + "no-lonely-if": "off", + "unicorn/prefer-module": "off", + "space-before-function-paren": [ + "error", + "never" + ] + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "jquery": true + } + }, "browserslist": [ "> 1%", "not ie 11", diff --git a/phpBB/adm/style/acp_posting_buttons.html b/phpBB/adm/style/acp_posting_buttons.html index dfe09ae12e..e032b8e77b 100644 --- a/phpBB/adm/style/acp_posting_buttons.html +++ b/phpBB/adm/style/acp_posting_buttons.html @@ -8,10 +8,14 @@ // ]]> +{% include 'mentions_templates.html' %} + + + -
+
data-mention-url="{U_MENTION_URL}" data-mention-names-limit="{S_MENTION_NAMES_LIMIT}" data-topic-id="{S_TOPIC_ID}" data-user-id="{S_USER_ID}"> diff --git a/phpBB/adm/style/admin.css b/phpBB/adm/style/admin.css index 2a5eacfcd7..b0f9141d5b 100644 --- a/phpBB/adm/style/admin.css +++ b/phpBB/adm/style/admin.css @@ -1670,6 +1670,103 @@ fieldset.submit-buttons legend { } } +/* Mentions and mention dropdown +---------------------------------------- */ +.mention { + font-weight: bold; +} + +.mention-container { + text-align: left; + background-color: #ffffff; + border-radius: 2px; + box-shadow: + 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12); + position: absolute; + z-index: 999; + overflow: auto; /* placed here for list to scroll with arrow key press */ + max-height: 200px; + transition: all 0.2s ease; +} + +.rtl .mention-container { + text-align: right; +} + +.mention-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.mention-media { + color: #757575; + display: inline-flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + margin-right: 8px; + margin-left: 0; +} + +.rtl .mention-media { + margin-right: 0; + margin-left: 16px; +} + +.mention-media-avatar { + width: 40px; + height: 40px; +} + +.mention-item { + font-size: 16px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0.04em; + border-bottom: 1px solid #dddddd; + color: #212121; + position: relative; + display: flex; + overflow: hidden; + justify-content: flex-start; + align-items: center; + padding: 8px; + cursor: pointer; +} + +.mention-item:hover, +.mention-item.is-active { + text-decoration: none; + background-color: #eeeeee; + color: #2d80d2; +} + +.mention-item:hover .mention-media-avatar, +.mention-item.is-active .mention-media-avatar { + color: #2d80d2; +} + +.mention-name, +.mention-rank { + display: block; +} + +.mention-name { + line-height: 1.25; + margin-right: 20px; /* needed to account for scrollbar bug on Firefox for Windows */ +} + +.mention-rank { + font-size: 14px; + font-weight: 400; + line-height: 1.2871; + letter-spacing: 0.04em; + color: #757575; +} + /* Input field styles ---------------------------------------- */ input.radio, diff --git a/phpBB/assets/javascript/core.js b/phpBB/assets/javascript/core.js index 3c0bcf2243..d301cc8da8 100644 --- a/phpBB/assets/javascript/core.js +++ b/phpBB/assets/javascript/core.js @@ -1745,6 +1745,31 @@ phpbb.lazyLoadAvatars = function loadAvatars() { }); }; +/** + * Get editor text area element + * + * @param {string} formName Name of form + * @param {string} textareaName Textarea name + * + * @return {HTMLElement|null} Text area element or null if textarea couldn't be found + */ +phpbb.getEditorTextArea = function(formName, textareaName) { + let doc; + + // find textarea, make sure browser supports necessary functions + if (document.forms[formName]) { + doc = document; + } else { + doc = opener.document; + } + + if (!doc.forms[formName]) { + return; + } + + return doc.forms[formName].elements[textareaName]; +} + phpbb.recaptcha = { button: null, ready: false, diff --git a/phpBB/assets/javascript/editor.js b/phpBB/assets/javascript/editor.js index 94063c2766..3b86ca380d 100644 --- a/phpBB/assets/javascript/editor.js +++ b/phpBB/assets/javascript/editor.js @@ -384,28 +384,22 @@ function getCaretPosition(txtarea) { return caretPos; } -/** -* Allow to use tab character when typing code -* Keep indentation of last line of code when typing code -*/ (function($) { - $(document).ready(function() { - var doc, textarea; + 'use strict'; - // find textarea, make sure browser supports necessary functions - if (document.forms[form_name]) { - doc = document; - } else { - doc = opener.document; - } + $(document).ready(() => { + const textarea = phpbb.getEditorTextArea(form_name, text_name); - if (!doc.forms[form_name]) { + if (typeof textarea === 'undefined') { return; } - textarea = doc.forms[form_name].elements[text_name]; - + /** + * Allow to use tab character when typing code + * Keep indentation of last line of code when typing code + */ phpbb.applyCodeEditor(textarea); + if ($('#attach-panel').length) { phpbb.showDragNDrop(textarea); } @@ -417,4 +411,3 @@ function getCaretPosition(txtarea) { }); }); })(jQuery); - diff --git a/phpBB/assets/javascript/mentions.js b/phpBB/assets/javascript/mentions.js new file mode 100644 index 0000000000..a0a63eb0a5 --- /dev/null +++ b/phpBB/assets/javascript/mentions.js @@ -0,0 +1,327 @@ +/* global phpbb */ +/* import Tribute from './tribute.min'; */ + +(function($) { + 'use strict'; + + /** + * Mentions data returned from ajax requests + * @typedef {Object} MentionsData + * @property {string} name User/group name + * @property {string} id User/group ID + * @property {{img: string, group: string}} avatar Avatar data + * @property {string} rank User rank or empty string for groups + * @property {number} priority Priority of data entry + */ + + /** + * Mentions class + * @constructor + */ + function Mentions() { + const $mentionDataContainer = $('[data-mention-url]:first'); + const mentionURL = $mentionDataContainer.data('mentionUrl'); + const mentionNamesLimit = $mentionDataContainer.data('mentionNamesLimit'); + const mentionTopicId = $mentionDataContainer.data('topicId'); + const mentionUserId = $mentionDataContainer.data('userId'); + let queryInProgress = null; + const cachedNames = []; + const cachedAll = []; + const cachedSearchKey = 'name'; + let tribute = null; + + /** + * Get default avatar + * @param {string} type Type of avatar; either 'g' for group or user on any other value + * @returns {string} Default avatar svg code + */ + function defaultAvatar(type) { + if (type === 'g') { + return $('[data-id="mention-default-avatar-group"]').html(); + } + + return $('[data-id="mention-default-avatar"]').html(); + } + + /** + * Get avatar HTML for data and type of avatar + * + * @param {object} data + * @param {string} type + * @return {string} Avatar HTML + */ + function getAvatar(data, type) { + if (data.html === '' && data.src === '') { + return defaultAvatar(type); + } + + if (data.html === '') { + const $avatarImg = $($('[data-id="mention-media-avatar-img"]').html()); + $avatarImg.attr({ + src: data.src, + width: data.width, + height: data.height, + alt: data.title, + }); + return $avatarImg.get(0).outerHTML; + } + + const $avatarImg = $(data.html); + $avatarImg.addClass('mention-media-avatar'); + return $avatarImg.get(0).outerHTML; + } + + /** + * Get cached keyword for query string + * @param {string} query Query string + * @returns {?string} Cached keyword if one fits query, else empty string if cached keywords exist, null if cached keywords do not exist + */ + function getCachedKeyword(query) { + if (!cachedNames) { + return null; + } + + let i; + + for (i = query.length; i > 0; i--) { + const startString = query.slice(0, i); + if (cachedNames[startString]) { + return startString; + } + } + + return ''; + } + + /** + * Get names matching query + * @param {string} query Query string + * @param {Object.} items List of {@link MentionsData} items + * @param {string} searchKey Key to use for matching items + * @returns {Object.} List of {@link MentionsData} items filtered with query and by searchKey + */ + function getMatchedNames(query, items, searchKey) { + let i; + let itemsLength; + const matchedNames = []; + for (i = 0, itemsLength = items.length; i < itemsLength; i++) { + const item = items[i]; + if (isItemMatched(query, item, searchKey)) { + matchedNames.push(item); + } + } + + return matchedNames; + } + + /** + * Return whether item is matched by query + * + * @param {string} query Search query string + * @param {MentionsData} item Mentions data item + * @param {string }searchKey Key to use for matching items + * @return {boolean} True if items is matched, false otherwise + */ + function isItemMatched(query, item, searchKey) { + return String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase()) === 0; + } + + /** + * Filter items by search query + * + * @param {string} query Search query string + * @param {Object.} items List of {@link MentionsData} items + * @return {Object.} List of {@link MentionsData} items filtered with query and by searchKey + */ + function itemFilter(query, items) { + let i; + let itemsLength; + const highestPriorities = { u: 1, g: 1 }; + const _unsorted = { u: {}, g: {} }; + const _exactMatch = []; + let _results = []; + + // Reduce the items array to the relevant ones + items = getMatchedNames(query, items, 'name'); + + // Group names by their types and calculate priorities + for (i = 0, itemsLength = items.length; i < itemsLength; i++) { + const item = items[i]; + + // Check for unsupported type - in general, this should never happen + if (!_unsorted[item.type]) { + continue; + } + + // Current user doesn't want to mention themselves with "@" in most cases - + // do not waste list space with their own name + if (item.type === 'u' && item.id === String(mentionUserId)) { + continue; + } + + // Exact matches should not be prioritised - they always come first + if (item.name === query) { + _exactMatch.push(items[i]); + continue; + } + + // If the item hasn't been added yet - add it + if (!_unsorted[item.type][item.id]) { + _unsorted[item.type][item.id] = item; + continue; + } + + // Priority is calculated as the sum of priorities from different sources + _unsorted[item.type][item.id].priority += Number.parseFloat(item.priority.toString()); + + // Calculate the highest priority - we'll give it to group names + highestPriorities[item.type] = Math.max(highestPriorities[item.type], _unsorted[item.type][item.id].priority); + } + + // All types of names should come at the same level of importance, + // otherwise they will be unlikely to be shown + // That's why we normalize priorities and push names to a single results array + $.each([ 'u', 'g' ], (key, type) => { + if (_unsorted[type]) { + $.each(_unsorted[type], (name, value) => { + // Normalize priority + value.priority /= highestPriorities[type]; + + // Add item to all results + _results.push(value); + }); + } + }); + + // Sort names by priorities - higher values come first + _results = _results.sort((a, b) => { + return b.priority - a.priority; + }); + + // Exact match is the most important - should come above anything else + $.each(_exactMatch, (name, value) => { + _results.unshift(value); + }); + + return _results; + } + + /** + * remoteFilter callback filter function + * @param {string} query Query string + * @param {function} callback Callback function for filtered items + */ + function remoteFilter(query, callback) { + /* + * Do not make a new request until the previous one for the same query is returned + * This fixes duplicate server queries e.g. when arrow keys are pressed + */ + if (queryInProgress === query) { + setTimeout(() => { + remoteFilter(query, callback); + }, 1000); + return; + } + + const cachedKeyword = getCachedKeyword(query); + const cachedNamesForQuery = cachedKeyword === null ? null : cachedNames[cachedKeyword]; + + /* + * Use cached values when we can: + * 1) There are some names in the cache relevant for the query + * (cache for the query with the same first characters contains some data) + * 2) We have enough names to display OR + * all relevant names have been fetched from the server + */ + if (cachedNamesForQuery && + (getMatchedNames(query, cachedNamesForQuery, cachedSearchKey).length >= mentionNamesLimit || + cachedAll[cachedKeyword])) { + callback(cachedNamesForQuery); + return; + } + + queryInProgress = query; + + // eslint-disable-next-line camelcase + const parameters = { keyword: query, topic_id: mentionTopicId, _referer: location.href }; + $.getJSON(mentionURL, parameters, data => { + cachedNames[query] = data.names; + cachedAll[query] = data.all; + callback(data.names); + }).always(() => { + queryInProgress = null; + }); + } + + /** + * Generate menu item HTML representation. Also ensures that mention-list + * class is set for unordered list in mention container + * + * @param {object} data Item data + * @returns {string} HTML representation of menu item + */ + function menuItemTemplate(data) { + const itemData = data; + const avatar = getAvatar(itemData.avatar, itemData.type); + const rank = (itemData.rank) ? $($('[data-id="mention-rank-span"]').html()).text(itemData.rank).get(0).outerHTML : ''; + const $mentionContainer = $('.' + tribute.current.collection.containerClass); + + if (typeof $mentionContainer !== 'undefined' && $mentionContainer.children('ul').hasClass('mention-list') === false) { + $mentionContainer.children('ul').addClass('mention-list'); + } + + const $avatarSpan = $($('[data-id="mention-media-span"]').html()); + $avatarSpan.html(avatar); + + const $nameSpan = $($('[data-id="mention-name-span"]').html()); + $nameSpan.html(itemData.name + rank); + + return $avatarSpan.get(0).outerHTML + $nameSpan.get(0).outerHTML; + } + + this.isEnabled = function() { + return $mentionDataContainer.length; + }; + + /* global Tribute */ + this.handle = function(textarea) { + tribute = new Tribute({ + trigger: '@', + allowSpaces: true, + containerClass: 'mention-container', + selectClass: 'is-active', + itemClass: 'mention-item', + menuItemTemplate, + selectTemplate(item) { + return '[mention=' + item.type + ':' + item.id + ']' + item.name + '[/mention]'; + }, + menuItemLimit: mentionNamesLimit, + values(text, cb) { + remoteFilter(text, users => cb(users)); + }, + lookup(element) { + return Object.prototype.hasOwnProperty.call(element, 'name') ? element.name : ''; + }, + }); + + tribute.search.filter = itemFilter; + + tribute.attach($(textarea)); + }; + } + + phpbb.mentions = new Mentions(); + + $(document).ready(() => { + /* global form_name, text_name */ + const textarea = phpbb.getEditorTextArea(form_name, text_name); + + if (typeof textarea === 'undefined') { + return; + } + + if (phpbb.mentions.isEnabled()) { + phpbb.mentions.handle(textarea); + } + }); +})(jQuery); diff --git a/phpBB/assets/javascript/tribute.min.js b/phpBB/assets/javascript/tribute.min.js new file mode 100644 index 0000000000..4d11a82932 --- /dev/null +++ b/phpBB/assets/javascript/tribute.min.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Tribute=t()}(this,(function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,i=new Array(t);n>>0,r=arguments[1],o=0;o container for the click");n.selectItemAtIndex(i.getAttribute("data-index"),t),n.hideMenu()}else n.current.element&&!n.current.externalTrigger&&(n.current.externalTrigger=!1,setTimeout((function(){return n.hideMenu()})))}},{key:"keyup",value:function(e,t){if(e.inputEvent&&(e.inputEvent=!1),e.updateSelection(this),27!==t.keyCode){if(!e.tribute.allowSpaces&&e.tribute.hasTrailingSpace)return e.tribute.hasTrailingSpace=!1,e.commandEvent=!0,void e.callbacks().space(t,this);if(!e.tribute.isActive)if(e.tribute.autocompleteMode)e.callbacks().triggerChar(t,this,"");else{var n=e.getKeyCode(e,this,t);if(isNaN(n)||!n)return;var i=e.tribute.triggers().find((function(e){return e.charCodeAt(0)===n}));void 0!==i&&e.callbacks().triggerChar(t,this,i)}e.tribute.current.mentionText.length=r.current.collection.menuShowMinLength&&r.inputEvent&&r.showMenuFor(n,!0)},enter:function(t,n){e.tribute.isActive&&e.tribute.current.filteredItems&&(t.preventDefault(),t.stopPropagation(),setTimeout((function(){e.tribute.selectItemAtIndex(e.tribute.menuSelected,t),e.tribute.hideMenu()}),0))},escape:function(t,n){e.tribute.isActive&&(t.preventDefault(),t.stopPropagation(),e.tribute.isActive=!1,e.tribute.hideMenu())},tab:function(t,n){e.callbacks().enter(t,n)},space:function(t,n){e.tribute.isActive&&(e.tribute.spaceSelectsMatch?e.callbacks().enter(t,n):e.tribute.allowSpaces||(t.stopPropagation(),setTimeout((function(){e.tribute.hideMenu(),e.tribute.isActive=!1}),0)))},up:function(t,n){if(e.tribute.isActive&&e.tribute.current.filteredItems){t.preventDefault(),t.stopPropagation();var i=e.tribute.current.filteredItems.length,r=e.tribute.menuSelected;i>r&&r>0?(e.tribute.menuSelected--,e.setActiveLi()):0===r&&(e.tribute.menuSelected=i-1,e.setActiveLi(),e.tribute.menu.scrollTop=e.tribute.menu.scrollHeight)}},down:function(t,n){if(e.tribute.isActive&&e.tribute.current.filteredItems){t.preventDefault(),t.stopPropagation();var i=e.tribute.current.filteredItems.length-1,r=e.tribute.menuSelected;i>r?(e.tribute.menuSelected++,e.setActiveLi()):i===r&&(e.tribute.menuSelected=0,e.setActiveLi(),e.tribute.menu.scrollTop=0)}},delete:function(t,n){e.tribute.isActive&&e.tribute.current.mentionText.length<1?e.tribute.hideMenu():e.tribute.isActive&&e.tribute.showMenuFor(n)}}}},{key:"setActiveLi",value:function(e){var t=this.tribute.menu.querySelectorAll("li"),n=t.length>>>0;e&&(this.tribute.menuSelected=parseInt(e));for(var i=0;iu.bottom){var l=o.bottom-u.bottom;this.tribute.menu.scrollTop+=l}else if(o.topi.width&&(r.left||r.right),u=window.innerHeight>i.height&&(r.top||r.bottom);(o||u)&&(n.tribute.menu.style.cssText="display: none",n.positionMenuAtCaret(e))}),0)}else this.tribute.menu.style.cssText="display: none"}},{key:"selectElement",value:function(e,t,n){var i,r=e;if(t)for(var o=0;o=0&&(t=i.substring(0,r))}}else{var o=this.tribute.current.element;if(o){var u=o.selectionStart;o.value&&u>=0&&(t=o.value.substring(0,u))}}return t}},{key:"getLastWordInText",value:function(e){var t;return e=e.replace(/\u00A0/g," "),(t=this.tribute.autocompleteSeparator?e.split(this.tribute.autocompleteSeparator):e.split(/\s+/))[t.length-1].trim()}},{key:"getTriggerInfo",value:function(e,t,n,i,r){var o,u,l,a=this,s=this.tribute.current;if(this.isContentEditable(s.element)){var c=this.getContentEditableSelectedPath(s);c&&(o=c.selected,u=c.path,l=c.offset)}else o=this.tribute.current.element;var h=this.getTextPrecedingCurrentSelection(),d=this.getLastWordInText(h);if(r)return{mentionPosition:h.length-d.length,mentionText:d,mentionSelectedElement:o,mentionSelectedPath:u,mentionSelectedOffset:l};if(null!=h){var f,m=-1;if(this.tribute.collection.forEach((function(e){var t=e.trigger,i=e.requireLeadingSpace?a.lastIndexWithLeadingSpace(h,t):h.lastIndexOf(t);i>m&&(m=i,f=t,n=e.requireLeadingSpace)})),m>=0&&(0===m||!n||/[\xA0\s]/g.test(h.substring(m-1,m)))){var p=h.substring(m+f.length,h.length);f=h.substring(m,m+f.length);var v=p.substring(0,1),g=p.length>0&&(" "===v||" "===v);t&&(p=p.trim());var b=i?/[^\S ]/g:/[\xA0\s]/g;if(this.tribute.hasTrailingSpace=b.test(p),!g&&(e||!b.test(p)))return{mentionPosition:m,mentionText:p,mentionSelectedElement:o,mentionSelectedPath:u,mentionSelectedOffset:l,mentionTriggerChar:f}}}}},{key:"lastIndexWithLeadingSpace",value:function(e,t){for(var n=e.split("").reverse().join(""),i=-1,r=0,o=e.length;r=0;s--)if(t[s]!==n[r-s]){a=!1;break}if(a&&(u||l)){i=e.length-1-r;break}}return i}},{key:"isContentEditable",value:function(e){return"INPUT"!==e.nodeName&&"TEXTAREA"!==e.nodeName}},{key:"isMenuOffScreen",value:function(e,t){var n=window.innerWidth,i=window.innerHeight,r=document.documentElement,o=(window.pageXOffset||r.scrollLeft)-(r.clientLeft||0),u=(window.pageYOffset||r.scrollTop)-(r.clientTop||0),l="number"==typeof e.top?e.top:u+i-e.bottom-t.height,a="number"==typeof e.right?e.right:e.left+t.width,s="number"==typeof e.bottom?e.bottom:e.top+t.height,c="number"==typeof e.left?e.left:o+n-e.right-t.width;return{top:lMath.ceil(o+n),bottom:s>Math.ceil(u+i),left:cparseInt(u.height)&&(o.overflowY="scroll")):o.overflow="hidden",r.textContent=e.value.substring(0,t),"INPUT"===e.nodeName&&(r.textContent=r.textContent.replace(/\s/g," "));var l=this.getDocument().createElement("span");l.textContent=e.value.substring(t)||".",r.appendChild(l);var a=e.getBoundingClientRect(),s=document.documentElement,c=(window.pageXOffset||s.scrollLeft)-(s.clientLeft||0),h=(window.pageYOffset||s.scrollTop)-(s.clientTop||0),d=0,f=0;this.menuContainerIsBody&&(d=a.top,f=a.left);var m={top:d+h+l.offsetTop+parseInt(u.borderTopWidth)+parseInt(u.fontSize)-e.scrollTop,left:f+c+l.offsetLeft+parseInt(u.borderLeftWidth)},p=window.innerWidth,v=window.innerHeight,g=this.getMenuDimensions(),b=this.isMenuOffScreen(m,g);b.right&&(m.right=p-m.left,m.left="auto");var y=this.tribute.menuContainer?this.tribute.menuContainer.offsetHeight:this.getDocument().body.offsetHeight;if(b.bottom){var w=y-(v-(this.tribute.menuContainer?this.tribute.menuContainer.getBoundingClientRect():this.getDocument().body.getBoundingClientRect()).top);m.bottom=w+(v-a.top-l.offsetTop),m.top="auto"}return(b=this.isMenuOffScreen(m,g)).left&&(m.left=p>g.width?c+p-g.width:c,delete m.right),b.top&&(m.top=v>g.height?h+v-g.height:h,delete m.bottom),this.getDocument().body.removeChild(r),m}},{key:"getContentEditableCaretPosition",value:function(e){var t,n=this.getWindowSelection();(t=this.getDocument().createRange()).setStart(n.anchorNode,e),t.setEnd(n.anchorNode,e),t.collapse(!1);var i=t.getBoundingClientRect(),r=document.documentElement,o=(window.pageXOffset||r.scrollLeft)-(r.clientLeft||0),u=(window.pageYOffset||r.scrollTop)-(r.clientTop||0),l={left:i.left+o,top:i.top+i.height+u},a=window.innerWidth,s=window.innerHeight,c=this.getMenuDimensions(),h=this.isMenuOffScreen(l,c);h.right&&(l.left="auto",l.right=a-i.left-o);var d=this.tribute.menuContainer?this.tribute.menuContainer.offsetHeight:this.getDocument().body.offsetHeight;if(h.bottom){var f=d-(s-(this.tribute.menuContainer?this.tribute.menuContainer.getBoundingClientRect():this.getDocument().body.getBoundingClientRect()).top);l.top="auto",l.bottom=f+(s-i.top)}return(h=this.isMenuOffScreen(l,c)).left&&(l.left=a>c.width?o+a-c.width:o,delete l.right),h.top&&(l.top=s>c.height?u+s-c.height:u,delete l.bottom),this.menuContainerIsBody||(l.left=l.left?l.left-this.tribute.menuContainer.offsetLeft:l.left,l.top=l.top?l.top-this.tribute.menuContainer.offsetTop:l.top),l}},{key:"scrollIntoView",value:function(e){var t,n=this.menu;if(void 0!==n){for(;void 0===t||0===t.height;)if(0===(t=n.getBoundingClientRect()).height&&(void 0===(n=n.childNodes[0])||!n.getBoundingClientRect))return;var i=t.top,r=i+t.height;if(i<0)window.scrollTo(0,window.pageYOffset+t.top-20);else if(r>window.innerHeight){var o=window.pageYOffset+t.top-20;o-window.pageYOffset>100&&(o=window.pageYOffset+100);var u=window.pageYOffset-(window.innerHeight-r);u>o&&(u=o),window.scrollTo(0,u)}}}},{key:"menuContainerIsBody",get:function(){return this.tribute.menuContainer===document.body||!this.tribute.menuContainer}}]),t}(),s=function(){function t(n){e(this,t),this.tribute=n,this.tribute.search=this}return n(t,[{key:"simpleFilter",value:function(e,t){var n=this;return t.filter((function(t){return n.test(e,t)}))}},{key:"test",value:function(e,t){return null!==this.match(e,t)}},{key:"match",value:function(e,t,n){n=n||{};t.length;var i=n.pre||"",r=n.post||"",o=n.caseSensitive&&t||t.toLowerCase();if(n.skip)return{rendered:t,score:0};e=n.caseSensitive&&e||e.toLowerCase();var u=this.traverse(o,e,0,0,[]);return u?{rendered:this.render(t,u.cache,i,r),score:u.score}:null}},{key:"traverse",value:function(e,t,n,i,r){if(this.tribute.autocompleteSeparator&&(t=t.split(this.tribute.autocompleteSeparator).splice(-1)[0]),t.length===i)return{score:this.calculateScore(r),cache:r.slice()};if(!(e.length===n||t.length-i>e.length-n)){for(var o,u,l=t[i],a=e.indexOf(l,n);a>-1;){if(r.push(a),u=this.traverse(e,t,a+1,i+1,r),r.pop(),!u)return o;(!o||o.score0&&(e[r-1]+1===i?n+=n+1:n=1),t+=n})),t}},{key:"render",value:function(e,t,n,i){var r=e.substring(0,t[0]);return t.forEach((function(o,u){r+=n+e[o]+i+e.substring(o+1,t[u+1]?t[u+1]:e.length)})),r}},{key:"filter",value:function(e,t,n){var i=this;return n=n||{},t.reduce((function(t,r,o,u){var l=r;n.extract&&((l=n.extract(r))||(l=""));var a=i.match(e,l,n);return null!=a&&(t[t.length]={string:a.rendered,score:a.score,index:o,original:r}),t}),[]).sort((function(e,t){var n=t.score-e.score;return n||e.index-t.index}))}}]),t}();return function(){function t(n){var i,r=this,o=n.values,c=void 0===o?null:o,h=n.loadingItemTemplate,d=void 0===h?null:h,f=n.iframe,m=void 0===f?null:f,p=n.selectClass,v=void 0===p?"highlight":p,g=n.containerClass,b=void 0===g?"tribute-container":g,y=n.itemClass,w=void 0===y?"":y,T=n.trigger,C=void 0===T?"@":T,S=n.autocompleteMode,E=void 0!==S&&S,k=n.autocompleteSeparator,x=void 0===k?null:k,M=n.selectTemplate,A=void 0===M?null:M,L=n.menuItemTemplate,I=void 0===L?null:L,N=n.lookup,O=void 0===N?"key":N,D=n.fillAttr,P=void 0===D?"value":D,R=n.collection,W=void 0===R?null:R,H=n.menuContainer,B=void 0===H?null:H,F=n.noMatchTemplate,_=void 0===F?null:F,j=n.requireLeadingSpace,Y=void 0===j||j,z=n.allowSpaces,K=void 0!==z&&z,q=n.replaceTextSuffix,U=void 0===q?null:q,X=n.positionMenu,Q=void 0===X||X,V=n.spaceSelectsMatch,$=void 0!==V&&V,G=n.searchOpts,J=void 0===G?{}:G,Z=n.menuItemLimit,ee=void 0===Z?null:Z,te=n.menuShowMinLength,ne=void 0===te?0:te;if(e(this,t),this.autocompleteMode=E,this.autocompleteSeparator=x,this.menuSelected=0,this.current={},this.inputEvent=!1,this.isActive=!1,this.menuContainer=B,this.allowSpaces=K,this.replaceTextSuffix=U,this.positionMenu=Q,this.hasTrailingSpace=!1,this.spaceSelectsMatch=$,this.autocompleteMode&&(C="",K=!1),c)this.collection=[{trigger:C,iframe:m,selectClass:v,containerClass:b,itemClass:w,selectTemplate:(A||t.defaultSelectTemplate).bind(this),menuItemTemplate:(I||t.defaultMenuItemTemplate).bind(this),noMatchTemplate:(i=_,"string"==typeof i?""===i.trim()?null:i:"function"==typeof i?i.bind(r):_||function(){return"
  • No Match Found!
  • "}.bind(r)),lookup:O,fillAttr:P,values:c,loadingItemTemplate:d,requireLeadingSpace:Y,searchOpts:J,menuItemLimit:ee,menuShowMinLength:ne}];else{if(!W)throw new Error("[Tribute] No collection specified.");this.autocompleteMode&&console.warn("Tribute in autocomplete mode does not work for collections"),this.collection=W.map((function(e){return{trigger:e.trigger||C,iframe:e.iframe||m,selectClass:e.selectClass||v,containerClass:e.containerClass||b,itemClass:e.itemClass||w,selectTemplate:(e.selectTemplate||t.defaultSelectTemplate).bind(r),menuItemTemplate:(e.menuItemTemplate||t.defaultMenuItemTemplate).bind(r),noMatchTemplate:function(e){return"string"==typeof e?""===e.trim()?null:e:"function"==typeof e?e.bind(r):_||function(){return"
  • No Match Found!
  • "}.bind(r)}(_),lookup:e.lookup||O,fillAttr:e.fillAttr||P,values:e.values,loadingItemTemplate:e.loadingItemTemplate,requireLeadingSpace:e.requireLeadingSpace,searchOpts:e.searchOpts||J,menuItemLimit:e.menuItemLimit||ee,menuShowMinLength:e.menuShowMinLength||ne}}))}new a(this),new u(this),new l(this),new s(this)}return n(t,[{key:"triggers",value:function(){return this.collection.map((function(e){return e.trigger}))}},{key:"attach",value:function(e){if(!e)throw new Error("[Tribute] Must pass in a DOM node or NodeList.");if("undefined"!=typeof jQuery&&e instanceof jQuery&&(e=e.get()),e.constructor===NodeList||e.constructor===HTMLCollection||e.constructor===Array)for(var t=e.length,n=0;n",post:n.current.collection.searchOpts.post||"",skip:n.current.collection.searchOpts.skip,extract:function(e){if("string"==typeof n.current.collection.lookup)return e[n.current.collection.lookup];if("function"==typeof n.current.collection.lookup)return n.current.collection.lookup(e,n.current.mentionText);throw new Error("Invalid lookup attribute, lookup must be string or function.")}});n.current.collection.menuItemLimit&&(r=r.slice(0,n.current.collection.menuItemLimit)),n.current.filteredItems=r;var o=n.menu.querySelector("ul");if(n.range.positionMenuAtCaret(t),!r.length){var u=new CustomEvent("tribute-no-match",{detail:n.menu});return n.current.element.dispatchEvent(u),void("function"==typeof n.current.collection.noMatchTemplate&&!n.current.collection.noMatchTemplate()||!n.current.collection.noMatchTemplate?n.hideMenu():"function"==typeof n.current.collection.noMatchTemplate?o.innerHTML=n.current.collection.noMatchTemplate():o.innerHTML=n.current.collection.noMatchTemplate)}o.innerHTML="";var l=n.range.getDocument().createDocumentFragment();r.forEach((function(e,t){var r=n.range.getDocument().createElement("li");r.setAttribute("data-index",t),r.className=n.current.collection.itemClass,r.addEventListener("mousemove",(function(e){var t=i(n._findLiTarget(e.target),2),r=(t[0],t[1]);0!==e.movementY&&n.events.setActiveLi(r)})),n.menuSelected===t&&r.classList.add(n.current.collection.selectClass),r.innerHTML=n.current.collection.menuItemTemplate(e),l.appendChild(r)})),o.appendChild(l)}};"function"==typeof this.current.collection.values?(this.current.collection.loadingItemTemplate&&(this.menu.querySelector("ul").innerHTML=this.current.collection.loadingItemTemplate,this.range.positionMenuAtCaret(t)),this.current.collection.values(this.current.mentionText,r)):r(this.current.collection.values)}}},{key:"_findLiTarget",value:function(e){if(!e)return[];var t=e.getAttribute("data-index");return t?[e,t]:this._findLiTarget(e.parentNode)}},{key:"showMenuForCollection",value:function(e,t){e!==document.activeElement&&this.placeCaretAtEnd(e),this.current.collection=this.collection[t||0],this.current.externalTrigger=!0,this.current.element=e,e.isContentEditable?this.insertTextAtCursor(this.current.collection.trigger):this.insertAtCaret(e,this.current.collection.trigger),this.showMenuFor(e)}},{key:"placeCaretAtEnd",value:function(e){if(e.focus(),void 0!==window.getSelection&&void 0!==document.createRange){var t=document.createRange();t.selectNodeContents(e),t.collapse(!1);var n=window.getSelection();n.removeAllRanges(),n.addRange(t)}else if(void 0!==document.body.createTextRange){var i=document.body.createTextRange();i.moveToElementText(e),i.collapse(!1),i.select()}}},{key:"insertTextAtCursor",value:function(e){var t,n;(n=(t=window.getSelection()).getRangeAt(0)).deleteContents();var i=document.createTextNode(e);n.insertNode(i),n.selectNodeContents(i),n.collapse(!1),t.removeAllRanges(),t.addRange(n)}},{key:"insertAtCaret",value:function(e,t){var n=e.scrollTop,i=e.selectionStart,r=e.value.substring(0,i),o=e.value.substring(e.selectionEnd,e.value.length);e.value=r+t+o,i+=t.length,e.selectionStart=i,e.selectionEnd=i,e.focus(),e.scrollTop=n}},{key:"hideMenu",value:function(){this.menu&&(this.menu.style.cssText="display: none;",this.isActive=!1,this.menuSelected=0,this.current={})}},{key:"selectItemAtIndex",value:function(e,t){if("number"==typeof(e=parseInt(e))&&!isNaN(e)){var n=this.current.filteredItems[e],i=this.current.collection.selectTemplate(n);null!==i&&this.replaceText(i,t,n)}}},{key:"replaceText",value:function(e,t,n){this.range.replaceTriggerText(e,!0,!0,t,n)}},{key:"_append",value:function(e,t,n){if("function"==typeof e.values)throw new Error("Unable to append to values, as it is a function.");e.values=n?t:e.values.concat(t)}},{key:"append",value:function(e,t,n){var i=parseInt(e);if("number"!=typeof i)throw new Error("please provide an index for the collection to update.");var r=this.collection[i];this._append(r,t,n)}},{key:"appendCurrent",value:function(e,t){if(!this.isActive)throw new Error("No active state. Please use append instead and pass an index.");this._append(this.current.collection,e,t)}},{key:"detach",value:function(e){if(!e)throw new Error("[Tribute] Must pass in a DOM node or NodeList.");if("undefined"!=typeof jQuery&&e instanceof jQuery&&(e=e.get()),e.constructor===NodeList||e.constructor===HTMLCollection||e.constructor===Array)for(var t=e.length,n=0;n'+(this.current.collection.trigger+e.original[this.current.collection.fillAttr])+"":this.current.collection.trigger+e.original[this.current.collection.fillAttr]}},{key:"defaultMenuItemTemplate",value:function(e){return e.string}},{key:"inputTypes",value:function(){return["TEXTAREA","INPUT"]}}]),t}()})); +//# sourceMappingURL=tribute.min.js.map diff --git a/phpBB/config/default/container/services.yml b/phpBB/config/default/container/services.yml index b8fc7fa755..1d48ead588 100644 --- a/phpBB/config/default/container/services.yml +++ b/phpBB/config/default/container/services.yml @@ -15,6 +15,7 @@ imports: - { resource: services_help.yml } - { resource: services_http.yml } - { resource: services_language.yml } + - { resource: services_mention.yml } - { resource: services_migrator.yml } - { resource: services_mimetype_guesser.yml } - { resource: services_module.yml } diff --git a/phpBB/config/default/container/services_mention.yml b/phpBB/config/default/container/services_mention.yml new file mode 100644 index 0000000000..e2861b984c --- /dev/null +++ b/phpBB/config/default/container/services_mention.yml @@ -0,0 +1,75 @@ +services: +# ----- Controller ----- + mention.controller: + class: phpbb\mention\controller\mention + arguments: + - '@mention.source_collection' + - '@request' + - '%core.root_path%' + - '%core.php_ext%' + +# ----- Sources for mention ----- + mention.source_collection: + class: phpbb\di\service_collection + arguments: + - '@service_container' + tags: + - { name: service_collection, tag: mention.source } + + mention.source.base_group: + abstract: true + arguments: + - '@dbal.conn' + - '@config' + - '@group_helper' + - '@user' + - '@auth' + - '%core.root_path%' + - '%core.php_ext%' + + mention.source.base_user: + abstract: true + arguments: + - '@dbal.conn' + - '@config' + - '@user_loader' + - '%core.root_path%' + - '%core.php_ext%' + + mention.source.friend: + class: phpbb\mention\source\friend + parent: mention.source.base_user + calls: + - [set_user, ['@user']] + tags: + - { name: mention.source } + + mention.source.group: + class: phpbb\mention\source\group + parent: mention.source.base_group + tags: + - { name: mention.source } + + mention.source.team: + class: phpbb\mention\source\team + parent: mention.source.base_user + tags: + - { name: mention.source } + + mention.source.topic: + class: phpbb\mention\source\topic + parent: mention.source.base_user + tags: + - { name: mention.source } + + mention.source.user: + class: phpbb\mention\source\user + parent: mention.source.base_user + tags: + - { name: mention.source } + + mention.source.usergroup: + class: phpbb\mention\source\usergroup + parent: mention.source.base_group + tags: + - { name: mention.source } diff --git a/phpBB/config/default/container/services_notification.yml b/phpBB/config/default/container/services_notification.yml index c18e358114..65552b1e13 100644 --- a/phpBB/config/default/container/services_notification.yml +++ b/phpBB/config/default/container/services_notification.yml @@ -95,6 +95,15 @@ services: tags: - { name: notification.type } + notification.type.mention: + class: phpbb\notification\type\mention + shared: false + parent: notification.type.post + calls: + - [set_helper, ['@text_formatter.s9e.mention_helper']] + tags: + - { name: notification.type } + notification.type.pm: class: phpbb\notification\type\pm shared: false diff --git a/phpBB/config/default/container/services_text_formatter.yml b/phpBB/config/default/container/services_text_formatter.yml index 4e4abf6564..3d44e87948 100644 --- a/phpBB/config/default/container/services_text_formatter.yml +++ b/phpBB/config/default/container/services_text_formatter.yml @@ -52,6 +52,15 @@ services: text_formatter.s9e.link_helper: class: phpbb\textformatter\s9e\link_helper + text_formatter.s9e.mention_helper: + class: phpbb\textformatter\s9e\mention_helper + arguments: + - '@dbal.conn' + - '@auth' + - '@user' + - '%core.root_path%' + - '%core.php_ext%' + text_formatter.s9e.parser: class: phpbb\textformatter\s9e\parser arguments: @@ -76,6 +85,7 @@ services: - '@text_formatter.s9e.factory' - '@dispatcher' calls: + - [configure_mention_helper, ['@text_formatter.s9e.mention_helper']] - [configure_quote_helper, ['@text_formatter.s9e.quote_helper']] - [configure_smilies_path, ['@config', '@path_helper']] - [configure_user, ['@user', '@config', '@auth']] diff --git a/phpBB/config/default/routing/routing.yml b/phpBB/config/default/routing/routing.yml index a5e9265dc3..441e544cbf 100644 --- a/phpBB/config/default/routing/routing.yml +++ b/phpBB/config/default/routing/routing.yml @@ -24,6 +24,11 @@ phpbb_help_routing: resource: help.yml prefix: /help +phpbb_mention_controller: + path: /mention + methods: [GET, POST] + defaults: { _controller: mention.controller:handle } + phpbb_report_routing: resource: report.yml diff --git a/phpBB/includes/acp/acp_bbcodes.php b/phpBB/includes/acp/acp_bbcodes.php index 5706367ee3..3c0371a3a7 100644 --- a/phpBB/includes/acp/acp_bbcodes.php +++ b/phpBB/includes/acp/acp_bbcodes.php @@ -195,7 +195,7 @@ class acp_bbcodes $data = $this->build_regexp($bbcode_match, $bbcode_tpl); // Make sure the user didn't pick a "bad" name for the BBCode tag. - $hard_coded = array('code', 'quote', 'quote=', 'attachment', 'attachment=', 'b', 'i', 'url', 'url=', 'img', 'size', 'size=', 'color', 'color=', 'u', 'list', 'list=', 'email', 'email=', 'flash', 'flash='); + $hard_coded = array('code', 'quote', 'quote=', 'attachment', 'attachment=', 'b', 'i', 'url', 'url=', 'img', 'size', 'size=', 'color', 'color=', 'u', 'list', 'list=', 'email', 'email=', 'flash', 'flash=', 'mention'); if (($action == 'modify' && strtolower($data['bbcode_tag']) !== strtolower($row['bbcode_tag'])) || ($action == 'create')) { diff --git a/phpBB/includes/acp/acp_board.php b/phpBB/includes/acp/acp_board.php index 8d8483d92a..1bda032db9 100644 --- a/phpBB/includes/acp/acp_board.php +++ b/phpBB/includes/acp/acp_board.php @@ -220,7 +220,12 @@ class acp_board 'max_post_img_width' => array('lang' => 'MAX_POST_IMG_WIDTH', 'validate' => 'int:0:9999', 'type' => 'number:0:9999', 'explain' => true, 'append' => ' ' . $user->lang['PIXEL']), 'max_post_img_height' => array('lang' => 'MAX_POST_IMG_HEIGHT', 'validate' => 'int:0:9999', 'type' => 'number:0:9999', 'explain' => true, 'append' => ' ' . $user->lang['PIXEL']), - 'legend3' => 'ACP_SUBMIT_CHANGES', + 'legend3' => 'MENTIONS', + 'allow_mentions' => array('lang' => 'ALLOW_MENTIONS', 'validate' => 'bool', 'type' => 'radio:yes_no', 'explain' => false), + 'mention_names_limit' => array('lang' => 'MENTION_NAMES_LIMIT', 'validate' => 'int:1:9999', 'type' => 'number:1:9999', 'explain' => false), + 'mention_batch_size' => array('lang' => 'MENTION_BATCH_SIZE', 'validate' => 'int:1:9999', 'type' => 'number:1:9999', 'explain' => true), + + 'legend4' => 'ACP_SUBMIT_CHANGES', ) ); break; diff --git a/phpBB/includes/functions.php b/phpBB/includes/functions.php index 337242bcd7..be8eecfe2a 100644 --- a/phpBB/includes/functions.php +++ b/phpBB/includes/functions.php @@ -575,6 +575,7 @@ function markread($mode, $forum_id = false, $topic_id = false, $post_time = 0, $ // Mark all topic notifications read for this user $phpbb_notifications->mark_notifications(array( 'notification.type.topic', + 'notification.type.mention', 'notification.type.quote', 'notification.type.bookmark', 'notification.type.post', @@ -660,6 +661,7 @@ function markread($mode, $forum_id = false, $topic_id = false, $post_time = 0, $ $db->sql_freeresult($result); $phpbb_notifications->mark_notifications_by_parent(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.bookmark', 'notification.type.post', @@ -771,6 +773,7 @@ function markread($mode, $forum_id = false, $topic_id = false, $post_time = 0, $ ), $topic_id, $user->data['user_id'], $post_time); $phpbb_notifications->mark_notifications_by_parent(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.bookmark', 'notification.type.post', @@ -3943,6 +3946,10 @@ function page_header($page_title = '', $display_online_list = false, $item_id = 'U_RESTORE_PERMISSIONS' => ($user->data['user_perm_from'] && $auth->acl_get('a_switchperm')) ? append_sid("{$phpbb_root_path}ucp.$phpEx", 'mode=restore_perm') : '', 'U_FEED' => $controller_helper->route('phpbb_feed_index'), + 'S_ALLOW_MENTIONS' => ($config['allow_mentions'] && $auth->acl_get('u_mention') && (empty($forum_id) || $auth->acl_get('f_mention', $forum_id))) ? true : false, + 'S_MENTION_NAMES_LIMIT' => $config['mention_names_limit'], + 'U_MENTION_URL' => $controller_helper->route('phpbb_mention_controller'), + 'S_USER_LOGGED_IN' => ($user->data['user_id'] != ANONYMOUS) ? true : false, 'S_AUTOLOGIN_ENABLED' => ($config['allow_autologin']) ? true : false, 'S_BOARD_DISABLED' => ($config['board_disable']) ? true : false, @@ -3964,6 +3971,7 @@ function page_header($page_title = '', $display_online_list = false, $item_id = 'S_REGISTER_ENABLED' => ($config['require_activation'] != USER_ACTIVATION_DISABLE) ? true : false, 'S_FORUM_ID' => $forum_id, 'S_TOPIC_ID' => $topic_id, + 'S_USER_ID' => $user->data['user_id'], 'S_LOGIN_ACTION' => ((!defined('ADMIN_START')) ? append_sid("{$phpbb_root_path}ucp.$phpEx", 'mode=login') : append_sid("{$phpbb_admin_path}index.$phpEx", false, true, $user->session_id)), 'S_LOGIN_REDIRECT' => $s_login_redirect, diff --git a/phpBB/includes/functions_acp.php b/phpBB/includes/functions_acp.php index ff2a8badb8..ba1584ab82 100644 --- a/phpBB/includes/functions_acp.php +++ b/phpBB/includes/functions_acp.php @@ -24,7 +24,7 @@ if (!defined('IN_PHPBB')) */ function adm_page_header($page_title) { - global $config, $user, $template; + global $config, $user, $template, $auth; global $phpbb_root_path, $phpbb_admin_path, $phpEx, $SID, $_SID; global $phpbb_dispatcher, $phpbb_container; @@ -66,6 +66,9 @@ function adm_page_header($page_title) } } + /** @var \phpbb\controller\helper $controller_helper */ + $controller_helper = $phpbb_container->get('controller.helper'); + $phpbb_version_parts = explode('.', PHPBB_VERSION, 3); $phpbb_major = $phpbb_version_parts[0] . '.' . $phpbb_version_parts[1]; @@ -86,6 +89,10 @@ function adm_page_header($page_title) 'U_ADM_INDEX' => append_sid("{$phpbb_admin_path}index.$phpEx"), 'U_INDEX' => append_sid("{$phpbb_root_path}index.$phpEx"), + 'S_ALLOW_MENTIONS' => ($config['allow_mentions'] && $auth->acl_get('u_mention')) ? true : false, + 'S_MENTION_NAMES_LIMIT' => $config['mention_names_limit'], + 'U_MENTION_URL' => $controller_helper->route('phpbb_mention_controller'), + 'T_IMAGES_PATH' => "{$phpbb_root_path}images/", 'T_SMILIES_PATH' => "{$phpbb_root_path}{$config['smilies_path']}/", 'T_AVATAR_GALLERY_PATH' => "{$phpbb_root_path}{$config['avatar_gallery_path']}/", @@ -108,6 +115,7 @@ function adm_page_header($page_title) 'ICON_SYNC' => '', 'ICON_SYNC_DISABLED' => '', + 'S_USER_ID' => $user->data['user_id'], 'S_USER_LANG' => $user->lang['USER_LANG'], 'S_CONTENT_DIRECTION' => $user->lang['DIRECTION'], 'S_CONTENT_ENCODING' => 'UTF-8', diff --git a/phpBB/includes/functions_admin.php b/phpBB/includes/functions_admin.php index 9c6de68b64..5f7089161b 100644 --- a/phpBB/includes/functions_admin.php +++ b/phpBB/includes/functions_admin.php @@ -908,6 +908,7 @@ function delete_posts($where_type, $where_ids, $auto_sync = true, $posted_sync = // Notifications types to delete $delete_notifications_types = array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.approve_post', 'notification.type.post_in_queue', diff --git a/phpBB/includes/functions_posting.php b/phpBB/includes/functions_posting.php index c01ff29d33..04ad81fc76 100644 --- a/phpBB/includes/functions_posting.php +++ b/phpBB/includes/functions_posting.php @@ -2443,6 +2443,7 @@ function submit_post($mode, $subject, $username, $topic_type, &$poll_ary, &$data { case 'post': $phpbb_notifications->add_notifications(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.topic', ), $notification_data); @@ -2451,6 +2452,7 @@ function submit_post($mode, $subject, $username, $topic_type, &$poll_ary, &$data case 'reply': case 'quote': $phpbb_notifications->add_notifications(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.bookmark', 'notification.type.post', @@ -2465,6 +2467,7 @@ function submit_post($mode, $subject, $username, $topic_type, &$poll_ary, &$data if ($user->data['user_id'] == $poster_id) { $phpbb_notifications->update_notifications(array( + 'notification.type.mention', 'notification.type.quote', ), $notification_data); } diff --git a/phpBB/includes/mcp/mcp_queue.php b/phpBB/includes/mcp/mcp_queue.php index fe54a01de7..eebb8e4fc4 100644 --- a/phpBB/includes/mcp/mcp_queue.php +++ b/phpBB/includes/mcp/mcp_queue.php @@ -810,10 +810,14 @@ class mcp_queue ), $post_data); } } - $phpbb_notifications->add_notifications(array('notification.type.quote'), $post_data); + $phpbb_notifications->add_notifications(array( + 'notification.type.mention', + 'notification.type.quote', + ), $post_data); $phpbb_notifications->delete_notifications('notification.type.post_in_queue', $post_id); $phpbb_notifications->mark_notifications(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.bookmark', 'notification.type.post', @@ -1045,12 +1049,13 @@ class mcp_queue if ($topic_data['topic_visibility'] == ITEM_UNAPPROVED) { $phpbb_notifications->add_notifications(array( + 'notification.type.mention', 'notification.type.quote', 'notification.type.topic', ), $topic_data); } - $phpbb_notifications->mark_notifications('quote', $topic_data['post_id'], $user->data['user_id']); + $phpbb_notifications->mark_notifications(array('mention', 'quote'), $topic_data['post_id'], $user->data['user_id']); $phpbb_notifications->mark_notifications('topic', $topic_id, $user->data['user_id']); if ($notify_poster) diff --git a/phpBB/install/schemas/schema_data.sql b/phpBB/install/schemas/schema_data.sql index 3a98eab482..e8294f0a8f 100644 --- a/phpBB/install/schemas/schema_data.sql +++ b/phpBB/install/schemas/schema_data.sql @@ -21,6 +21,7 @@ INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_emailreuse', INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_forum_notify', '1'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_live_searches', '1'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_mass_pm', '1'); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_mentions', '1'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_name_chars', 'USERNAME_CHARS_ANY'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_namechange', '0'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('allow_nocensors', '0'); @@ -234,6 +235,8 @@ INSERT INTO phpbb_config (config_name, config_value) VALUES ('max_sig_img_height INSERT INTO phpbb_config (config_name, config_value) VALUES ('max_sig_img_width', '0'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('max_sig_smilies', '0'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('max_sig_urls', '5'); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('mention_batch_size', '50'); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('mention_names_limit', '10'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('mime_triggers', 'body|head|html|img|plaintext|a href|pre|script|table|title'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('min_name_chars', '3'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('min_pass_chars', '6'); @@ -380,6 +383,7 @@ INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_ignoreflood', 1 INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_img', 1); INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_list', 1); INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_list_topics', 1); +INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_mention', 1); INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_noapprove', 1); INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_poll', 1); INSERT INTO phpbb_acl_options (auth_option, is_local) VALUES ('f_post', 1); @@ -478,6 +482,7 @@ INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_hideonline', 1 INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_ignoreflood', 1); INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_masspm', 1); INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_masspm_group', 1); +INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_mention', 1); INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_pm_attach', 1); INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_pm_bbcode', 1); INSERT INTO phpbb_acl_options (auth_option, is_global) VALUES ('u_pm_delete', 1); @@ -590,7 +595,7 @@ INSERT INTO phpbb_acl_roles_data (role_id, auth_option_id, auth_setting) SELECT INSERT INTO phpbb_acl_roles_data (role_id, auth_option_id, auth_setting) SELECT 7, auth_option_id, 1 FROM phpbb_acl_options WHERE auth_option LIKE 'u_%' AND auth_option NOT IN ('u_attach', 'u_viewonline', 'u_chggrp', 'u_chgname', 'u_ignoreflood', 'u_pm_attach', 'u_pm_emailpm', 'u_pm_flash', 'u_savedrafts', 'u_search', 'u_sendemail', 'u_sendim', 'u_masspm', 'u_masspm_group'); # No Private Messages (u_) -INSERT INTO phpbb_acl_roles_data (role_id, auth_option_id, auth_setting) SELECT 8, auth_option_id, 1 FROM phpbb_acl_options WHERE auth_option LIKE 'u_%' AND auth_option IN ('u_', 'u_chgavatar', 'u_chgcensors', 'u_chgemail', 'u_chgpasswd', 'u_download', 'u_hideonline', 'u_sig', 'u_viewprofile'); +INSERT INTO phpbb_acl_roles_data (role_id, auth_option_id, auth_setting) SELECT 8, auth_option_id, 1 FROM phpbb_acl_options WHERE auth_option LIKE 'u_%' AND auth_option IN ('u_', 'u_chgavatar', 'u_chgcensors', 'u_chgemail', 'u_chgpasswd', 'u_download', 'u_hideonline', 'u_mention', 'u_sig', 'u_viewprofile'); INSERT INTO phpbb_acl_roles_data (role_id, auth_option_id, auth_setting) SELECT 8, auth_option_id, 0 FROM phpbb_acl_options WHERE auth_option LIKE 'u_%' AND auth_option IN ('u_readpm', 'u_sendpm', 'u_masspm', 'u_masspm_group'); # No Avatar (u_) diff --git a/phpBB/language/en/acp/board.php b/phpBB/language/en/acp/board.php index d02c8b0141..8d85f898a0 100644 --- a/phpBB/language/en/acp/board.php +++ b/phpBB/language/en/acp/board.php @@ -157,6 +157,7 @@ $lang = array_merge($lang, array( // Post Settings $lang = array_merge($lang, array( 'ACP_POST_SETTINGS_EXPLAIN' => 'Here you can set all default settings for posting.', + 'ALLOW_MENTIONS' => 'Allow mentions of users and groups boardwide', 'ALLOW_POST_LINKS' => 'Allow links in posts/private messages', 'ALLOW_POST_LINKS_EXPLAIN' => 'If disallowed the [URL] BBCode tag and automatic/magic URLs are disabled.', 'ALLOWED_SCHEMES_LINKS' => 'Allowed schemes in links', @@ -187,6 +188,10 @@ $lang = array_merge($lang, array( 'MAX_POST_IMG_WIDTH_EXPLAIN' => 'Maximum width of a flash file in postings. Set to 0 for unlimited size.', 'MAX_POST_URLS' => 'Maximum links per post', 'MAX_POST_URLS_EXPLAIN' => 'Maximum number of URLs in a post. Set to 0 for unlimited links.', + 'MENTIONS' => 'Mentions', + 'MENTION_BATCH_SIZE' => 'Maximum number of names fetched from each source of names for a single request', + 'MENTION_BATCH_SIZE_EXPLAIN' => 'Examples of sources: friends, topic repliers, group members etc.', + 'MENTION_NAMES_LIMIT' => 'Maximum number of names in dropdown list', 'MIN_CHAR_LIMIT' => 'Minimum characters per post/message', 'MIN_CHAR_LIMIT_EXPLAIN' => 'The minimum number of characters the user need to enter within a post/private message. The minimum for this setting is 1.', 'POSTING' => 'Posting', diff --git a/phpBB/language/en/acp/permissions_phpbb.php b/phpBB/language/en/acp/permissions_phpbb.php index cdf4820475..475ac5aadd 100644 --- a/phpBB/language/en/acp/permissions_phpbb.php +++ b/phpBB/language/en/acp/permissions_phpbb.php @@ -76,6 +76,7 @@ $lang = array_merge($lang, array( 'ACL_U_ATTACH' => 'Can attach files', 'ACL_U_DOWNLOAD' => 'Can download files', + 'ACL_U_MENTION' => 'Can mention users and groups', 'ACL_U_SAVEDRAFTS' => 'Can save drafts', 'ACL_U_CHGCENSORS' => 'Can disable word censors', 'ACL_U_SIG' => 'Can use signature', @@ -123,6 +124,7 @@ $lang = array_merge($lang, array( 'ACL_F_STICKY' => 'Can post stickies', 'ACL_F_ANNOUNCE' => 'Can post announcements', 'ACL_F_ANNOUNCE_GLOBAL' => 'Can post global announcements', + 'ACL_F_MENTION' => 'Can mention users and groups', 'ACL_F_REPLY' => 'Can reply to topics', 'ACL_F_EDIT' => 'Can edit own posts', 'ACL_F_DELETE' => 'Can permanently delete own posts', diff --git a/phpBB/language/en/common.php b/phpBB/language/en/common.php index 24fd293326..f04a0e891b 100644 --- a/phpBB/language/en/common.php +++ b/phpBB/language/en/common.php @@ -475,6 +475,9 @@ $lang = array_merge($lang, array( 'NOTIFICATION_FORUM' => 'Forum: %1$s', 'NOTIFICATION_GROUP_REQUEST' => 'Group request from %1$s to join the group %2$s.', 'NOTIFICATION_GROUP_REQUEST_APPROVED' => 'Group request approved to join the group %1$s.', + 'NOTIFICATION_MENTION' => array( + 1 => 'Mentioned by %1$s in:', + ), 'NOTIFICATION_METHOD_INVALID' => 'The method "%s" does not refer to a valid notification method.', 'NOTIFICATION_PM' => 'Private Message from %1$s:', 'NOTIFICATION_POST' => array( diff --git a/phpBB/language/en/email/mention.txt b/phpBB/language/en/email/mention.txt new file mode 100644 index 0000000000..95bc4c8601 --- /dev/null +++ b/phpBB/language/en/email/mention.txt @@ -0,0 +1,20 @@ +Subject: Topic reply notification - "{{ TOPIC_TITLE }}" + +Hello {{ USERNAME }}, + +You are receiving this notification because "{{ AUTHOR_NAME }}" mentioned you in the topic "{{ TOPIC_TITLE }}" at "{{ SITENAME }}". You can use the following link to view the reply made. + +If you want to view the post where you have been mentioned, click the following link: +{{ U_VIEW_POST }} + +If you want to view the topic, click the following link: +{{ U_TOPIC }} + +If you want to view the forum, click the following link: +{{ U_FORUM }} + +If you no longer wish to receive updates about replies mentioning you, please update your notification settings here: + +{{ U_NOTIFICATION_SETTINGS }} + +{{ EMAIL_SIG }} diff --git a/phpBB/language/en/ucp.php b/phpBB/language/en/ucp.php index 446f357f5d..f42992ca0b 100644 --- a/phpBB/language/en/ucp.php +++ b/phpBB/language/en/ucp.php @@ -332,6 +332,7 @@ $lang = array_merge($lang, array( 'NOTIFICATION_TYPE_GROUP_REQUEST' => 'Someone requests to join a group you lead', 'NOTIFICATION_TYPE_FORUM' => 'Someone replies to a topic in a forum to which you are subscribed', 'NOTIFICATION_TYPE_IN_MODERATION_QUEUE' => 'A post or topic needs approval', + 'NOTIFICATION_TYPE_MENTION' => 'Someone mentions you in a post', 'NOTIFICATION_TYPE_MODERATION_QUEUE' => 'Your topics/posts are approved or disapproved by a moderator', 'NOTIFICATION_TYPE_PM' => 'Someone sends you a private message', 'NOTIFICATION_TYPE_POST' => 'Someone replies to a topic to which you are subscribed', diff --git a/phpBB/phpbb/db/migration/data/v330/add_mention_settings.php b/phpBB/phpbb/db/migration/data/v330/add_mention_settings.php new file mode 100644 index 0000000000..c0e9a8cf58 --- /dev/null +++ b/phpBB/phpbb/db/migration/data/v330/add_mention_settings.php @@ -0,0 +1,43 @@ + +* @license GNU General Public License, version 2 (GPL-2.0) +* +* For full copyright and license information, please see +* the docs/CREDITS.txt file. +* +*/ + +namespace phpbb\db\migration\data\v330; + +class add_mention_settings extends \phpbb\db\migration\migration +{ + public function update_data() + { + return array( + array('config.add', array('allow_mentions', true)), + array('config.add', array('mention_batch_size', 50)), + array('config.add', array('mention_names_limit', 10)), + + // Set up user permissions + array('permission.add', array('u_mention', true)), + array('permission.permission_set', array('ROLE_USER_FULL', 'u_mention')), + array('permission.permission_set', array('ROLE_USER_STANDARD', 'u_mention')), + array('permission.permission_set', array('ROLE_USER_LIMITED', 'u_mention')), + array('permission.permission_set', array('ROLE_USER_NOPM', 'u_mention')), + array('permission.permission_set', array('ROLE_USER_NOAVATAR', 'u_mention')), + + // Set up forum permissions + array('permission.add', array('f_mention', false)), + array('permission.permission_set', array('ROLE_FORUM_FULL', 'f_mention')), + array('permission.permission_set', array('ROLE_FORUM_STANDARD', 'f_mention')), + array('permission.permission_set', array('ROLE_FORUM_LIMITED', 'f_mention')), + array('permission.permission_set', array('ROLE_FORUM_ONQUEUE', 'f_mention')), + array('permission.permission_set', array('ROLE_FORUM_POLLS', 'f_mention')), + array('permission.permission_set', array('ROLE_FORUM_LIMITED_POLLS', 'f_mention')), + ); + } +} diff --git a/phpBB/phpbb/mention/controller/mention.php b/phpBB/phpbb/mention/controller/mention.php new file mode 100644 index 0000000000..37a5cfd323 --- /dev/null +++ b/phpBB/phpbb/mention/controller/mention.php @@ -0,0 +1,78 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\controller; + +use phpbb\di\service_collection; +use phpbb\request\request_interface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; + +class mention +{ + /** @var service_collection */ + protected $mention_sources; + + /** @var request_interface */ + protected $request; + + /** @var string */ + protected $phpbb_root_path; + + /** @var string */ + protected $php_ext; + + /** + * Constructor + * + * @param service_collection|array $mention_sources + * @param request_interface $request + * @param string $phpbb_root_path + * @param string $phpEx + */ + public function __construct($mention_sources, request_interface $request, string $phpbb_root_path, string $phpEx) + { + $this->mention_sources = $mention_sources; + $this->request = $request; + $this->phpbb_root_path = $phpbb_root_path; + $this->php_ext = $phpEx; + } + + /** + * Handle requests to mention controller + * + * @return JsonResponse|RedirectResponse + */ + public function handle() + { + if (!$this->request->is_ajax()) + { + return new RedirectResponse(append_sid($this->phpbb_root_path . 'index.' . $this->php_ext)); + } + + $keyword = $this->request->variable('keyword', '', true); + $topic_id = $this->request->variable('topic_id', 0); + $names = []; + $has_names_remaining = false; + + foreach ($this->mention_sources as $source) + { + $has_names_remaining = !$source->get($names, $keyword, $topic_id) || $has_names_remaining; + } + + return new JsonResponse([ + 'names' => array_values($names), + 'all' => !$has_names_remaining, + ]); + } +} diff --git a/phpBB/phpbb/mention/source/base_group.php b/phpBB/phpbb/mention/source/base_group.php new file mode 100644 index 0000000000..58fca4d054 --- /dev/null +++ b/phpBB/phpbb/mention/source/base_group.php @@ -0,0 +1,185 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +use phpbb\auth\auth; +use phpbb\config\config; +use phpbb\db\driver\driver_interface; +use phpbb\group\helper; + +abstract class base_group implements source_interface +{ + /** @var driver_interface */ + protected $db; + + /** @var config */ + protected $config; + + /** @var helper */ + protected $helper; + + /** @var \phpbb\user */ + protected $user; + + /** @var auth */ + protected $auth; + + /** @var string */ + protected $phpbb_root_path; + + /** @var string */ + protected $php_ext; + + /** @var string|false */ + protected $cache_ttl = false; + + /** @var array Fetched groups' data */ + protected $groups = null; + + /** + * base_group constructor. + * + * @param driver_interface $db + * @param config $config + * @param helper $helper + * @param \phpbb\user $user + * @param auth $auth + * @param string $phpbb_root_path + * @param string $phpEx + */ + public function __construct(driver_interface $db, config $config, helper $helper, \phpbb\user $user, auth $auth, string $phpbb_root_path, string $phpEx) + { + $this->db = $db; + $this->config = $config; + $this->helper = $helper; + $this->user = $user; + $this->auth = $auth; + $this->phpbb_root_path = $phpbb_root_path; + $this->php_ext = $phpEx; + + if (!function_exists('phpbb_get_user_rank')) + { + include($this->phpbb_root_path . 'includes/functions_display.' . $this->php_ext); + } + } + + /** + * Returns data for all board groups + * + * @return array Array of groups' data + */ + protected function get_groups(): array + { + if (is_null($this->groups)) + { + $query = $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'g.*, ug.user_id as ug_user_id', + 'FROM' => [ + GROUPS_TABLE => 'g', + ], + 'LEFT_JOIN' => [ + [ + 'FROM' => [USER_GROUP_TABLE => 'ug'], + 'ON' => 'ug.group_id = g.group_id AND ug.user_pending = 0 AND ug.user_id = ' . (int) $this->user->data['user_id'], + ], + ], + ]); + // Cache results for 5 minutes + $result = $this->db->sql_query($query, 600); + + $this->groups = []; + while ($row = $this->db->sql_fetchrow($result)) + { + if ($row['group_type'] == GROUP_SPECIAL && !in_array($row['group_name'], ['ADMINISTRATORS', 'GLOBAL_MODERATORS']) || $row['group_type'] == GROUP_HIDDEN && !$this->auth->acl_gets('a_group', 'a_groupadd', 'a_groupdel') && $row['ug_user_id'] != $this->user->data['user_id']) + { + // Skip the group that we should not be able to mention. + continue; + } + + $group_name = $this->helper->get_name($row['group_name']); + $this->groups['names'][$row['group_id']] = $group_name; + $this->groups[$row['group_id']] = $row; + $this->groups[$row['group_id']]['group_name'] = $group_name; + } + + $this->db->sql_freeresult($result); + } + return $this->groups; + } + + /** + * Builds a query for getting group IDs based on user input + * + * @param string $keyword Search string + * @param int $topic_id Current topic ID + * @return string Query ready for execution + */ + abstract protected function query(string $keyword, int $topic_id): string; + + /** + * {@inheritdoc} + */ + public function get_priority(array $row): int + { + // By default every result from the source increases the priority by a fixed value + return 1; + } + + /** + * {@inheritdoc} + */ + public function get(array &$names, string $keyword, int $topic_id): bool + { + // Grab all group IDs and cache them if needed + $result = $this->db->sql_query($this->query($keyword, $topic_id), $this->cache_ttl); + + $group_ids = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $group_ids[] = $row['group_id']; + } + + $this->db->sql_freeresult($result); + + // Grab group data + $groups = $this->get_groups(); + + $matches = preg_grep('/^' . preg_quote($keyword) . '.*/i', $groups['names']); + $group_ids = array_intersect($group_ids, array_flip($matches)); + + $i = 0; + foreach ($group_ids as $group_id) + { + if ($i >= $this->config['mention_batch_size']) + { + // Do not exceed the names limit + return false; + } + + $group_rank = phpbb_get_user_rank($groups[$group_id], false); + array_push($names, [ + 'name' => $groups[$group_id]['group_name'], + 'type' => 'g', + 'id' => $group_id, + 'avatar' => $this->helper->get_avatar($groups[$group_id]), + 'rank' => (isset($group_rank['title'])) ? $group_rank['title'] : '', + 'priority' => $this->get_priority($groups[$group_id]), + ]); + + $i++; + } + + return true; + } +} diff --git a/phpBB/phpbb/mention/source/base_user.php b/phpBB/phpbb/mention/source/base_user.php new file mode 100644 index 0000000000..7e9b41d67d --- /dev/null +++ b/phpBB/phpbb/mention/source/base_user.php @@ -0,0 +1,173 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +use phpbb\config\config; +use phpbb\db\driver\driver_interface; +use phpbb\user_loader; + +abstract class base_user implements source_interface +{ + /** @var driver_interface */ + protected $db; + + /** @var config */ + protected $config; + + /** @var user_loader */ + protected $user_loader; + + /** @var string */ + protected $phpbb_root_path; + + /** @var string */ + protected $php_ext; + + /** @var string|false */ + protected $cache_ttl = false; + + /** + * base_user constructor. + * + * @param driver_interface $db + * @param config $config + * @param user_loader $user_loader + * @param string $phpbb_root_path + * @param string $phpEx + */ + public function __construct(driver_interface $db, config $config, user_loader $user_loader, string $phpbb_root_path, string $phpEx) + { + $this->db = $db; + $this->config = $config; + $this->user_loader = $user_loader; + $this->phpbb_root_path = $phpbb_root_path; + $this->php_ext = $phpEx; + + if (!function_exists('phpbb_get_user_rank')) + { + include($this->phpbb_root_path . 'includes/functions_display.' . $this->php_ext); + } + } + + /** + * Builds a query based on user input + * + * @param string $keyword Search string + * @param int $topic_id Current topic ID + * @return string Query ready for execution + */ + abstract protected function query(string $keyword, int $topic_id): string; + + /** + * {@inheritdoc} + */ + public function get_priority(array $row): int + { + // By default every result from the source increases the priority by a fixed value + return 1; + } + + /** + * {@inheritdoc} + */ + public function get(array &$names, string $keyword, int $topic_id): bool + { + $fetched_all = false; + $keyword = utf8_clean_string($keyword); + + $i = 0; + $users = []; + $user_ids = []; + + // Grab all necessary user IDs and cache them if needed + if ($this->cache_ttl) + { + $result = $this->db->sql_query($this->query($keyword, $topic_id), $this->cache_ttl); + + while ($i < $this->config['mention_batch_size']) + { + $row = $this->db->sql_fetchrow($result); + + if (!$row) + { + $fetched_all = true; + break; + } + + if (!empty($keyword) && strpos($row['username_clean'], $keyword) !== 0) + { + continue; + } + + $i++; + $users[] = $row; + $user_ids[] = $row['user_id']; + } + + // Determine whether all usernames were fetched in current batch + if (!$fetched_all) + { + $fetched_all = true; + + while ($row = $this->db->sql_fetchrow($result)) + { + if (!empty($keyword) && strpos($row['username_clean'], $keyword) !== 0) + { + continue; + } + + // At least one username hasn't been fetched - exit loop + $fetched_all = false; + break; + } + } + } + else + { + $result = $this->db->sql_query_limit($this->query($keyword, $topic_id), $this->config['mention_batch_size'], 0); + + while ($row = $this->db->sql_fetchrow($result)) + { + $users[] = $row; + $user_ids[] = $row['user_id']; + } + + // Determine whether all usernames were fetched in current batch + if (count($user_ids) < $this->config['mention_batch_size']) + { + $fetched_all = true; + } + } + + $this->db->sql_freeresult($result); + + // Load all user data with a single SQL query, needed for ranks and avatars + $this->user_loader->load_users($user_ids); + + foreach ($users as $user) + { + $user_rank = $this->user_loader->get_rank($user['user_id']); + array_push($names, [ + 'name' => $this->user_loader->get_username($user['user_id'], 'username'), + 'type' => 'u', + 'id' => $user['user_id'], + 'avatar' => $this->user_loader->get_avatar($user['user_id']), + 'rank' => (isset($user_rank['rank_title'])) ? $user_rank['rank_title'] : '', + 'priority' => $this->get_priority($user), + ]); + } + + return $fetched_all; + } +} diff --git a/phpBB/phpbb/mention/source/friend.php b/phpBB/phpbb/mention/source/friend.php new file mode 100644 index 0000000000..5c7a3f91ef --- /dev/null +++ b/phpBB/phpbb/mention/source/friend.php @@ -0,0 +1,58 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class friend extends base_user +{ + /** @var \phpbb\user */ + protected $user; + + /** + * Set the user service used to retrieve current user ID + * + * @param \phpbb\user $user + */ + public function set_user(\phpbb\user $user): void + { + $this->user = $user; + } + + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + /* + * For optimization purposes all friends are returned regardless of the keyword + * Names filtering is done on the frontend + * Results will be cached on a per-user basis + */ + return $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'u.username_clean, u.user_id', + 'FROM' => [ + USERS_TABLE => 'u', + ], + 'LEFT_JOIN' => [ + [ + 'FROM' => [ZEBRA_TABLE => 'z'], + 'ON' => 'u.user_id = z.zebra_id' + ] + ], + 'WHERE' => 'z.friend = 1 AND z.user_id = ' . (int) $this->user->data['user_id'] . ' + AND ' . $this->db->sql_in_set('u.user_type', [USER_NORMAL, USER_FOUNDER]) . ' + AND u.username_clean ' . $this->db->sql_like_expression($keyword . $this->db->get_any_char()), + 'ORDER_BY' => 'u.user_lastvisit DESC' + ]); + } +} diff --git a/phpBB/phpbb/mention/source/group.php b/phpBB/phpbb/mention/source/group.php new file mode 100644 index 0000000000..11a8e02e94 --- /dev/null +++ b/phpBB/phpbb/mention/source/group.php @@ -0,0 +1,48 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class group extends base_group +{ + /** @var string|false */ + protected $cache_ttl = 300; + + /** + * {@inheritdoc} + */ + public function get_priority(array $row): int + { + /* + * Presence in array with all names for this type should not increase the priority + * Otherwise names will not be properly sorted because we fetch them in batches + * and the name from 'special' source can be absent from the array with all names + * and therefore it will appear lower than needed + */ + return 0; + } + + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + return $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'g.group_id', + 'FROM' => [ + GROUPS_TABLE => 'g', + ], + 'ORDER_BY' => 'g.group_name', + ]); + } +} diff --git a/phpBB/phpbb/mention/source/source_interface.php b/phpBB/phpbb/mention/source/source_interface.php new file mode 100644 index 0000000000..2fe45ef234 --- /dev/null +++ b/phpBB/phpbb/mention/source/source_interface.php @@ -0,0 +1,38 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +interface source_interface +{ + /** + * Searches database for names to mention + * and alters the passed array of found items + * + * @param array $names Array of already fetched data with names + * @param string $keyword Search string + * @param int $topic_id Current topic ID + * @return bool Whether there are no more satisfying names left + */ + public function get(array &$names, string $keyword, int $topic_id): bool; + + /** + * Returns the priority of the currently selected name + * Please note that simple inner priorities for a certain source + * can be set with ORDER BY SQL clause + * + * @param array $row Array of fetched data for the name type (e.g. user row) + * @return int Priority (defaults to 1) + */ + public function get_priority(array $row): int; +} diff --git a/phpBB/phpbb/mention/source/team.php b/phpBB/phpbb/mention/source/team.php new file mode 100644 index 0000000000..02fd8cefbb --- /dev/null +++ b/phpBB/phpbb/mention/source/team.php @@ -0,0 +1,46 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class team extends base_user +{ + /** @var string|false */ + protected $cache_ttl = 300; + + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + /* + * Select unique names of team members: each name should be selected only once + * regardless of the number of groups the certain user is a member of + * + * For optimization purposes all team members are returned regardless of the keyword + * Names filtering is done on the frontend + * Results will be cached in a single file + */ + return $this->db->sql_build_query('SELECT_DISTINCT', [ + 'SELECT' => 'u.username_clean, u.user_id', + 'FROM' => [ + USERS_TABLE => 'u', + USER_GROUP_TABLE => 'ug', + TEAMPAGE_TABLE => 't', + ], + 'WHERE' => 'ug.group_id = t.group_id AND ug.user_id = u.user_id AND ug.user_pending = 0 + AND ' . $this->db->sql_in_set('u.user_type', [USER_NORMAL, USER_FOUNDER]), + 'ORDER_BY' => 'u.username_clean' + ]); + } +} diff --git a/phpBB/phpbb/mention/source/topic.php b/phpBB/phpbb/mention/source/topic.php new file mode 100644 index 0000000000..842d38c4ef --- /dev/null +++ b/phpBB/phpbb/mention/source/topic.php @@ -0,0 +1,64 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class topic extends base_user +{ + /** + * {@inheritdoc} + */ + public function get_priority(array $row): int + { + /* + * Topic's open poster is probably the most mentionable user in the topic + * so we give him a significant priority + */ + return $row['user_id'] === $row['topic_poster'] ? 5 : 1; + } + + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + /* + * Select poster's username together with topic author's ID + * that will be later used for prioritisation + * + * For optimization purposes all users are returned regardless of the keyword + * Names filtering is done on the frontend + * Results will be cached on a per-topic basis + */ + return $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'u.username_clean, u.user_id, t.topic_poster', + 'FROM' => [ + USERS_TABLE => 'u', + ], + 'LEFT_JOIN' => [ + [ + 'FROM' => [POSTS_TABLE => 'p'], + 'ON' => 'u.user_id = p.poster_id' + ], + [ + 'FROM' => [TOPICS_TABLE => 't'], + 'ON' => 't.topic_id = p.topic_id' + ], + ], + 'WHERE' => 'p.topic_id = ' . (int) $topic_id . ' + AND ' . $this->db->sql_in_set('u.user_type', [USER_NORMAL, USER_FOUNDER]) . ' + AND u.username_clean ' . $this->db->sql_like_expression($keyword . $this->db->get_any_char()), + 'ORDER_BY' => 'p.post_time DESC' + ]); + } +} diff --git a/phpBB/phpbb/mention/source/user.php b/phpBB/phpbb/mention/source/user.php new file mode 100644 index 0000000000..3189f32b83 --- /dev/null +++ b/phpBB/phpbb/mention/source/user.php @@ -0,0 +1,47 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class user extends base_user +{ + /** + * {@inheritdoc} + */ + public function get_priority(array $row): int + { + /* + * Presence in array with all names for this type should not increase the priority + * Otherwise names will not be properly sorted because we fetch them in batches + * and the name from 'special' source can be absent from the array with all names + * and therefore it will appear lower than needed + */ + return 0; + } + + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + return $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'u.username_clean, u.user_id', + 'FROM' => [ + USERS_TABLE => 'u', + ], + 'WHERE' => $this->db->sql_in_set('u.user_type', [USER_NORMAL, USER_FOUNDER]) . ' + AND u.username_clean ' . $this->db->sql_like_expression($keyword . $this->db->get_any_char()), + 'ORDER_BY' => 'u.user_lastvisit DESC' + ]); + } +} diff --git a/phpBB/phpbb/mention/source/usergroup.php b/phpBB/phpbb/mention/source/usergroup.php new file mode 100644 index 0000000000..de02cd76d6 --- /dev/null +++ b/phpBB/phpbb/mention/source/usergroup.php @@ -0,0 +1,38 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\mention\source; + +class usergroup extends base_group +{ + /** + * {@inheritdoc} + */ + protected function query(string $keyword, int $topic_id): string + { + return $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'g.group_id', + 'FROM' => [ + GROUPS_TABLE => 'g', + ], + 'LEFT_JOIN' => [ + [ + 'FROM' => [USER_GROUP_TABLE => 'ug'], + 'ON' => 'g.group_id = ug.group_id' + ] + ], + 'WHERE' => 'ug.user_pending = 0 AND ug.user_id = ' . (int) $this->user->data['user_id'], + 'ORDER_BY' => 'g.group_name', + ]); + } +} diff --git a/phpBB/phpbb/notification/type/mention.php b/phpBB/phpbb/notification/type/mention.php new file mode 100644 index 0000000000..fad31b9912 --- /dev/null +++ b/phpBB/phpbb/notification/type/mention.php @@ -0,0 +1,157 @@ + +* @license GNU General Public License, version 2 (GPL-2.0) +* +* For full copyright and license information, please see +* the docs/CREDITS.txt file. +* +*/ + +namespace phpbb\notification\type; + +use phpbb\textformatter\s9e\mention_helper; + +/** +* Post mentioning notifications class +* This class handles notifying users when they have been mentioned in a post +*/ + +class mention extends post +{ + /** + * @var mention_helper + */ + protected $helper; + + /** + * {@inheritDoc} + */ + public function get_type() + { + return 'notification.type.mention'; + } + + /** + * {@inheritDoc} + */ + protected $language_key = 'NOTIFICATION_MENTION'; + + /** + * {@inheritDoc} + */ + public static $notification_option = [ + 'lang' => 'NOTIFICATION_TYPE_MENTION', + 'group' => 'NOTIFICATION_GROUP_POSTING', + ]; + + /** + * {@inheritDoc} + */ + public function is_available() + { + return $this->config['allow_mentions'] && $this->auth->acl_get('u_mention'); + } + + /** + * {@inheritDoc} + */ + public function find_users_for_notification($post, $options = array()) + { + $options = array_merge(array( + 'ignore_users' => array(), + ), $options); + + $user_ids = $this->helper->get_mentioned_user_ids($post['post_text']); + + $user_ids = array_unique($user_ids); + + $user_ids = array_diff($user_ids, [(int) $post['poster_id']]); + + if (empty($user_ids)) + { + return array(); + } + + return $this->get_authorised_recipients($user_ids, $post['forum_id'], $options, true); + } + + /** + * Update a notification + * + * @param array $post Data specific for this type that will be updated + * @return true + */ + public function update_notifications($post) + { + $old_notifications = $this->notification_manager->get_notified_users($this->get_type(), array( + 'item_id' => static::get_item_id($post), + )); + + // Find the new users to notify + $notifications = $this->find_users_for_notification($post); + + // Find the notifications we must delete + $remove_notifications = array_diff(array_keys($old_notifications), array_keys($notifications)); + + // Find the notifications we must add + $add_notifications = array(); + foreach (array_diff(array_keys($notifications), array_keys($old_notifications)) as $user_id) + { + $add_notifications[$user_id] = $notifications[$user_id]; + } + + // Add the necessary notifications + $this->notification_manager->add_notifications_for_users($this->get_type(), $post, $add_notifications); + + // Remove the necessary notifications + if (!empty($remove_notifications)) + { + $this->notification_manager->delete_notifications($this->get_type(), static::get_item_id($post), false, $remove_notifications); + } + + // return true to continue with the update code in the notifications service (this will update the rest of the notifications) + return true; + } + + /** + * {@inheritDoc} + */ + public function get_redirect_url() + { + return $this->get_url(); + } + + /** + * {@inheritDoc} + */ + public function get_email_template() + { + return 'mention'; + } + + /** + * {@inheritDoc} + */ + public function get_email_template_variables() + { + $user_data = $this->user_loader->get_user($this->get_data('poster_id')); + + return array_merge(parent::get_email_template_variables(), array( + 'AUTHOR_NAME' => htmlspecialchars_decode($user_data['username']), + )); + } + + /** + * Set the helper service used to retrieve mentioned used + * + * @param mention_helper $helper + */ + public function set_helper(mention_helper $helper): void + { + $this->helper = $helper; + } +} diff --git a/phpBB/phpbb/permissions.php b/phpBB/phpbb/permissions.php index bf3b33856e..857ae2a1ec 100644 --- a/phpBB/phpbb/permissions.php +++ b/phpBB/phpbb/permissions.php @@ -231,6 +231,7 @@ class permissions 'u_attach' => array('lang' => 'ACL_U_ATTACH', 'cat' => 'post'), 'u_download' => array('lang' => 'ACL_U_DOWNLOAD', 'cat' => 'post'), + 'u_mention' => array('lang' => 'ACL_U_MENTION', 'cat' => 'post'), 'u_savedrafts' => array('lang' => 'ACL_U_SAVEDRAFTS', 'cat' => 'post'), 'u_chgcensors' => array('lang' => 'ACL_U_CHGCENSORS', 'cat' => 'post'), 'u_sig' => array('lang' => 'ACL_U_SIG', 'cat' => 'post'), @@ -276,6 +277,7 @@ class permissions 'f_sticky' => array('lang' => 'ACL_F_STICKY', 'cat' => 'post'), 'f_announce' => array('lang' => 'ACL_F_ANNOUNCE', 'cat' => 'post'), 'f_announce_global' => array('lang' => 'ACL_F_ANNOUNCE_GLOBAL', 'cat' => 'post'), + 'f_mention' => array('lang' => 'ACL_F_MENTION', 'cat' => 'post'), 'f_reply' => array('lang' => 'ACL_F_REPLY', 'cat' => 'post'), 'f_edit' => array('lang' => 'ACL_F_EDIT', 'cat' => 'post'), 'f_delete' => array('lang' => 'ACL_F_DELETE', 'cat' => 'post'), diff --git a/phpBB/phpbb/textformatter/renderer_interface.php b/phpBB/phpbb/textformatter/renderer_interface.php index 609b0bb642..106dbdc25f 100644 --- a/phpBB/phpbb/textformatter/renderer_interface.php +++ b/phpBB/phpbb/textformatter/renderer_interface.php @@ -89,4 +89,12 @@ interface renderer_interface * @return null */ public function set_viewsmilies($value); + + /** + * Set the "usemention" option + * + * @param bool $value Option's value + * @return null + */ + public function set_usemention($value); } diff --git a/phpBB/phpbb/textformatter/s9e/factory.php b/phpBB/phpbb/textformatter/s9e/factory.php index 721549cf72..6ccd15ab96 100644 --- a/phpBB/phpbb/textformatter/s9e/factory.php +++ b/phpBB/phpbb/textformatter/s9e/factory.php @@ -84,6 +84,12 @@ class factory implements \phpbb\textformatter\cache_interface 'img' => '[IMG src={IMAGEURL;useContent}]', 'list' => '[LIST type={HASHMAP=1:decimal,a:lower-alpha,A:upper-alpha,i:lower-roman,I:upper-roman;optional;postFilter=#simpletext} #createChild=LI]{TEXT}[/LIST]', 'li' => '[* $tagName=LI]{TEXT}[/*]', + 'mention' => + "[MENTION={PARSE=/^g:(?'group_id'\d+)|u:(?'user_id'\d+)$/} + group_id={UINT;optional} + profile_url={URL;optional;postFilter=#false} + user_id={UINT;optional} + ]{TEXT}[/MENTION]", 'quote' => "[QUOTE author={TEXT1;optional} @@ -108,13 +114,13 @@ class factory implements \phpbb\textformatter\cache_interface * @var array Default templates, taken from bbcode::bbcode_tpl() */ protected $default_templates = array( - 'b' => '', - 'i' => '', - 'u' => '', - 'img' => '{L_IMAGE}', - 'size' => 'font-size: %; line-height: normal', - 'color' => '', - 'email' => ' + 'b' => '', + 'i' => '', + 'u' => '', + 'img' => '{L_IMAGE}', + 'size' => 'font-size: %; line-height: normal', + 'color' => '', + 'email' => ' mailto: @@ -126,6 +132,19 @@ class factory implements \phpbb\textformatter\cache_interface ', + 'mention' => '@ + + + + + + + + + + + + ', ); /** @@ -287,8 +306,8 @@ class factory implements \phpbb\textformatter\cache_interface $configurator->tags['QUOTE']->nestingLimit = PHP_INT_MAX; } - // Modify the template to disable images/flash depending on user's settings - foreach (array('FLASH', 'IMG') as $name) + // Modify the template to disable images/flash/mentions depending on user's settings + foreach (array('FLASH', 'IMG', 'MENTION') as $name) { $tag = $configurator->tags[$name]; $tag->template = '' . $tag->template . ''; diff --git a/phpBB/phpbb/textformatter/s9e/mention_helper.php b/phpBB/phpbb/textformatter/s9e/mention_helper.php new file mode 100644 index 0000000000..3094dabb4a --- /dev/null +++ b/phpBB/phpbb/textformatter/s9e/mention_helper.php @@ -0,0 +1,203 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\textformatter\s9e; + +use s9e\TextFormatter\Utils as TextFormatterUtils; + +class mention_helper +{ + /** + * @var \phpbb\db\driver\driver_interface + */ + protected $db; + + /** + * @var \phpbb\auth\auth + */ + protected $auth; + + /** + * @var \phpbb\user + */ + protected $user; + + /** + * @var string Base URL for a user profile link, uses {USER_ID} as placeholder + */ + protected $user_profile_url; + + /** + * @var string Base URL for a group profile link, uses {GROUP_ID} as placeholder + */ + protected $group_profile_url; + + /** + * @var array Array of group IDs allowed to be mentioned by current user + */ + protected $mentionable_groups = null; + + /** + * Constructor + * + * @param \phpbb\db\driver\driver_interface $db + * @param \phpbb\auth\auth $auth + * @param \phpbb\user $user + * @param string $root_path + * @param string $php_ext + */ + public function __construct($db, $auth, $user, $root_path, $php_ext) + { + $this->db = $db; + $this->auth = $auth; + $this->user = $user; + $this->user_profile_url = append_sid($root_path . 'memberlist.' . $php_ext, 'mode=viewprofile&u={USER_ID}', false); + $this->group_profile_url = append_sid($root_path . 'memberlist.' . $php_ext, 'mode=group&g={GROUP_ID}', false); + } + + /** + * Inject dynamic metadata into MENTION tags in given XML + * + * @param string $xml Original XML + * @return string Modified XML + */ + public function inject_metadata($xml) + { + $profile_urls = [ + 'u' => $this->user_profile_url, + 'g' => $this->group_profile_url, + ]; + + return TextFormatterUtils::replaceAttributes( + $xml, + 'MENTION', + function ($attributes) use ($profile_urls) + { + if (isset($attributes['user_id'])) + { + $attributes['profile_url'] = str_replace('{USER_ID}', $attributes['user_id'], $profile_urls['u']); + } + else if (isset($attributes['group_id'])) + { + $attributes['profile_url'] = str_replace('{GROUP_ID}', $attributes['group_id'], $profile_urls['g']); + } + + return $attributes; + } + ); + } + + /** + * Get group IDs allowed to be mentioned by current user + * + * @return array + */ + protected function get_mentionable_groups() + { + if (is_array($this->mentionable_groups)) + { + return $this->mentionable_groups; + } + + $hidden_restriction = (!$this->auth->acl_gets('a_group', 'a_groupadd', 'a_groupdel')) ? ' AND (g.group_type <> ' . GROUP_HIDDEN . ' OR (ug.user_pending = 0 AND ug.user_id = ' . (int) $this->user->data['user_id'] . '))' : ''; + + $query = $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'g.group_id', + 'FROM' => [ + GROUPS_TABLE => 'g', + ], + 'LEFT_JOIN' => [[ + 'FROM' => [ + USER_GROUP_TABLE => 'ug', + ], + 'ON' => 'g.group_id = ug.group_id', + ]], + 'WHERE' => '(g.group_type <> ' . GROUP_SPECIAL . ' OR ' . $this->db->sql_in_set('g.group_name', ['ADMINISTRATORS', 'GLOBAL_MODERATORS']) . ')' . $hidden_restriction, + ]); + $result = $this->db->sql_query($query); + + $this->mentionable_groups = []; + + while ($row = $this->db->sql_fetchrow($result)) + { + $this->mentionable_groups[] = $row['group_id']; + } + + $this->db->sql_freeresult($result); + + return $this->mentionable_groups; + } + + /** + * Selects IDs of user members of a certain group + * + * @param array $user_ids Array of already selected user IDs + * @param int $group_id ID of the group to search members in + */ + protected function get_user_ids_for_group(&$user_ids, $group_id) + { + if (!in_array($group_id, $this->get_mentionable_groups())) + { + return; + } + + $query = $this->db->sql_build_query('SELECT', [ + 'SELECT' => 'ug.user_id, ug.group_id', + 'FROM' => [ + USER_GROUP_TABLE => 'ug', + GROUPS_TABLE => 'g', + ], + 'WHERE' => 'g.group_id = ug.group_id', + ]); + // Cache results for 5 minutes + $result = $this->db->sql_query($query, 300); + + while ($row = $this->db->sql_fetchrow($result)) + { + if ($row['group_id'] == $group_id) + { + $user_ids[] = (int) $row['user_id']; + } + } + + $this->db->sql_freeresult($result); + } + + /** + * Get a list of mentioned user IDs + * + * @param string $xml Parsed text + * @return int[] List of user IDs + */ + public function get_mentioned_user_ids($xml) + { + $ids = array(); + if (strpos($xml, 'get_user_ids_for_group($ids, (int) $group_id); + } + + return $ids; + } +} diff --git a/phpBB/phpbb/textformatter/s9e/renderer.php b/phpBB/phpbb/textformatter/s9e/renderer.php index 6fcd2b0a98..64875d96fc 100644 --- a/phpBB/phpbb/textformatter/s9e/renderer.php +++ b/phpBB/phpbb/textformatter/s9e/renderer.php @@ -28,6 +28,11 @@ class renderer implements \phpbb\textformatter\renderer_interface */ protected $dispatcher; + /** + * @var mention_helper + */ + protected $mention_helper; + /** * @var quote_helper */ @@ -58,6 +63,11 @@ class renderer implements \phpbb\textformatter\renderer_interface */ protected $viewsmilies = false; + /** + * @var bool Whether the user is allowed to use mentions + */ + protected $usemention = false; + /** * Constructor * @@ -117,6 +127,16 @@ class renderer implements \phpbb\textformatter\renderer_interface extract($dispatcher->trigger_event('core.text_formatter_s9e_renderer_setup', compact($vars))); } + /** + * Configure the mention_helper object used to display extended information in mentions + * + * @param mention_helper $mention_helper + */ + public function configure_mention_helper(mention_helper $mention_helper) + { + $this->mention_helper = $mention_helper; + } + /** * Configure the quote_helper object used to display extended information in quotes * @@ -162,6 +182,7 @@ class renderer implements \phpbb\textformatter\renderer_interface $this->set_viewflash($user->optionget('viewflash')); $this->set_viewimg($user->optionget('viewimg')); $this->set_viewsmilies($user->optionget('viewsmilies')); + $this->set_usemention($config['allow_mentions'] && $auth->acl_get('u_mention')); // Set the stylesheet parameters foreach (array_keys($this->renderer->getParameters()) as $param_name) @@ -229,6 +250,11 @@ class renderer implements \phpbb\textformatter\renderer_interface */ public function render($xml) { + if (isset($this->mention_helper)) + { + $xml = $this->mention_helper->inject_metadata($xml); + } + if (isset($this->quote_helper)) { $xml = $this->quote_helper->inject_metadata($xml); @@ -310,4 +336,13 @@ class renderer implements \phpbb\textformatter\renderer_interface $this->viewsmilies = $value; $this->renderer->setParameter('S_VIEWSMILIES', $value); } + + /** + * {@inheritdoc} + */ + public function set_usemention($value) + { + $this->usemention = $value; + $this->renderer->setParameter('S_VIEWMENTION', $value); + } } diff --git a/phpBB/styles/prosilver/template/mentions_templates.html b/phpBB/styles/prosilver/template/mentions_templates.html new file mode 100644 index 0000000000..23015b03e0 --- /dev/null +++ b/phpBB/styles/prosilver/template/mentions_templates.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/phpBB/styles/prosilver/template/posting_buttons.html b/phpBB/styles/prosilver/template/posting_buttons.html index 27a7481ad8..049c81b07e 100644 --- a/phpBB/styles/prosilver/template/posting_buttons.html +++ b/phpBB/styles/prosilver/template/posting_buttons.html @@ -25,7 +25,12 @@ } } + +{% include 'mentions_templates.html' %} + + + -
    +
    data-mention-url="{U_MENTION_URL}" data-mention-names-limit="{S_MENTION_NAMES_LIMIT}" data-topic-id="{S_TOPIC_ID}" data-user-id="{S_USER_ID}"> diff --git a/phpBB/styles/prosilver/theme/bidi.css b/phpBB/styles/prosilver/theme/bidi.css index bcf271b0c8..29fc0aac7c 100644 --- a/phpBB/styles/prosilver/theme/bidi.css +++ b/phpBB/styles/prosilver/theme/bidi.css @@ -365,6 +365,24 @@ float: left; } +/** +* mentions.css +*/ + +/* Mention block +---------------------------------------- */ + +/* Mention dropdown +---------------------------------------- */ +.rtl .mention-container { /* mention-container */ + text-align: right; +} + +.rtl .mention-media { + margin-right: 0; + margin-left: 16px; +} + /** * content.css */ diff --git a/phpBB/styles/prosilver/theme/colours.css b/phpBB/styles/prosilver/theme/colours.css index 60d8b03c36..2499908b44 100644 --- a/phpBB/styles/prosilver/theme/colours.css +++ b/phpBB/styles/prosilver/theme/colours.css @@ -369,6 +369,41 @@ p.post-notice { background-image: none; } +/* colours and backgrounds for mentions.css */ + +/* mention dropdown */ +.mention-container { /* mention-container */ + background-color: #ffffff; + box-shadow: + 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12); +} + +.mention-media { + color: #757575; +} + +.mention-item { + border-bottom-color: #dddddd; + color: #212121; +} + +.mention-item:hover, +.mention-item.is-active { + background-color: #eeeeee; + color: #2d80d2; +} + +.mention-item:hover .mention-media-avatar, +.mention-item.is-active .mention-media-avatar { + color: #2d80d2; +} + +.mention-rank { + color: #757575; +} + /* colours and backgrounds for content.css */ ul.forums { background-color: #edf4f7; diff --git a/phpBB/styles/prosilver/theme/mentions.css b/phpBB/styles/prosilver/theme/mentions.css new file mode 100644 index 0000000000..a12ad8178c --- /dev/null +++ b/phpBB/styles/prosilver/theme/mentions.css @@ -0,0 +1,83 @@ +/* -------------------------------------------------------------- /* + $Mentions +/* -------------------------------------------------------------- */ + +/* stylelint-disable selector-max-compound-selectors */ +/* stylelint-disable selector-no-qualifying-type */ + +/* Mention block +---------------------------------------- */ +.mention { + font-weight: bold; +} + +/* Mention dropdown +---------------------------------------- */ +.mention-container { + text-align: left; + border-radius: 2px; + position: absolute; + z-index: 999; + overflow: auto; /* placed here for list to scroll with arrow key press */ + max-height: 200px; + transition: all 0.2s ease; +} + +.mention-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.mention-media { + display: inline-flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + margin-right: 8px; + margin-left: 0; +} + +.mention-media-avatar { + width: 40px; + height: 40px; +} + +.mention-item { + font-size: 16px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0.04em; + border-bottom: 1px solid transparent; + position: relative; + display: flex; + overflow: hidden; + justify-content: flex-start; + align-items: center; + padding: 8px; + cursor: pointer; +} + +.mention-item:hover { + text-decoration: none; +} + +.mention-name, +.mention-rank { + display: block; +} + +.mention-name { + line-height: 1.25; + margin-right: 20px; /* needed to account for scrollbar bug on Firefox for Windows */ +} + +.mention-rank { + font-size: 14px; + font-weight: 400; + line-height: 1.2871; + letter-spacing: 0.04em; +} + +/* stylelint-enable selector-max-compound-selectors */ +/* stylelint-enable selector-no-qualifying-type */ diff --git a/phpBB/styles/prosilver/theme/stylesheet.css b/phpBB/styles/prosilver/theme/stylesheet.css index 5a1ee0c46d..0a91f0a4fe 100644 --- a/phpBB/styles/prosilver/theme/stylesheet.css +++ b/phpBB/styles/prosilver/theme/stylesheet.css @@ -14,6 +14,7 @@ @import url("common.css?hash=658f990b"); @import url("buttons.css?hash=eb16911f"); @import url("links.css?hash=5fec3654"); +@import url("mentions.css?hash=a67fa183"); @import url("content.css?hash=f7bdea58"); @import url("cp.css?hash=73c6f37d"); @import url("forms.css?hash=5e06dbba"); diff --git a/tests/mention/controller_test.php b/tests/mention/controller_test.php new file mode 100644 index 0000000000..c473da9db6 --- /dev/null +++ b/tests/mention/controller_test.php @@ -0,0 +1,558 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; + +class phpbb_mention_controller_test extends phpbb_database_test_case +{ + protected $db, $container, $user, $config, $auth, $cache; + + /** + * @var \phpbb\mention\controller\mention + */ + protected $controller; + + /** + * @var PHPUnit_Framework_MockObject_MockObject + */ + protected $request; + + public function getDataSet() + { + return $this->createXMLDataSet(dirname(__FILE__) . '/fixtures/mention.xml'); + } + + public function setUp(): void + { + parent::setUp(); + + global $auth, $cache, $config, $db, $phpbb_container, $phpbb_dispatcher, $lang, $user, $request, $phpEx, $phpbb_root_path, $user_loader; + + // Database + $this->db = $this->new_dbal(); + $db = $this->db; + + // Auth + $auth = $this->createMock('\phpbb\auth\auth'); + $auth->expects($this->any()) + ->method('acl_gets') + ->with('a_group', 'a_groupadd', 'a_groupdel') + ->willReturn(false) + ; + + // Config + $config = new \phpbb\config\config(array( + 'allow_mentions' => true, + 'mention_batch_size' => 8, + 'mention_names_limit' => 3, + )); + + $cache_driver = new \phpbb\cache\driver\dummy(); + $cache = new \phpbb\cache\service( + $cache_driver, + $config, + $db, + $phpbb_root_path, + $phpEx + ); + + // Event dispatcher + $phpbb_dispatcher = new phpbb_mock_event_dispatcher(); + + // Language + $lang = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); + + // User + $user = $this->createMock('\phpbb\user', array(), array( + $lang, + '\phpbb\datetime' + )); + $user->ip = ''; + $user->data = array( + 'user_id' => 2, + 'username' => 'myself', + 'is_registered' => true, + 'user_colour' => '', + ); + + // Request + $this->request = $request = $this->createMock('\phpbb\request\request'); + + $request->expects($this->any()) + ->method('is_ajax') + ->willReturn(true); + $avatar_helper = $this->getMockBuilder('\phpbb\avatar\helper') + ->disableOriginalConstructor() + ->getMock(); + + $user_loader = new \phpbb\user_loader($avatar_helper, $db, $phpbb_root_path, $phpEx, USERS_TABLE); + + // Container + $phpbb_container = new ContainerBuilder(); + + $loader = new YamlFileLoader($phpbb_container, new FileLocator(__DIR__ . '/fixtures')); + $loader->load('services_mention.yml'); + $phpbb_container->set('user_loader', $user_loader); + $phpbb_container->set('user', $user); + $phpbb_container->set('language', $lang); + $phpbb_container->set('config', $config); + $phpbb_container->set('dbal.conn', $db); + $phpbb_container->set('auth', $auth); + $phpbb_container->set('cache.driver', $cache_driver); + $phpbb_container->set('cache', $cache); + $phpbb_container->set('request', $request); + $phpbb_container->set('group_helper', new \phpbb\group\helper( + $this->getMockBuilder('\phpbb\auth\auth')->disableOriginalConstructor()->getMock(), + $avatar_helper, + $cache, + $config, + new \phpbb\language\language( + new phpbb\language\language_file_loader($phpbb_root_path, $phpEx) + ), + new phpbb_mock_event_dispatcher(), + new \phpbb\path_helper( + new \phpbb\symfony_request( + new phpbb_mock_request() + ), + $this->getMockBuilder('\phpbb\request\request')->disableOriginalConstructor()->getMock(), + $phpbb_root_path, + $phpEx + ), + $user + )); + $phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils()); + $phpbb_container->set( + 'text_formatter.s9e.mention_helper', + new \phpbb\textformatter\s9e\mention_helper( + $this->db, + $auth, + $user, + $phpbb_root_path, + $phpEx + ) + ); + $phpbb_container->setParameter('core.root_path', $phpbb_root_path); + $phpbb_container->setParameter('core.php_ext', $phpEx); + $phpbb_container->addCompilerPass(new phpbb\di\pass\markpublic_pass()); + $phpbb_container->compile(); + + // Mention Sources + $mention_sources = array('friend', 'group', 'team', 'topic', 'user', 'usergroup'); + $mention_sources_array = array(); + foreach ($mention_sources as $source) + { + $class = $phpbb_container->get('mention.source.' . $source); + $mention_sources_array['mention.source.' . $source] = $class; + } + + $this->controller = new \phpbb\mention\controller\mention($mention_sources_array, $request, $phpbb_root_path, $phpEx); + } + + public function handle_data() + { + /** + * NOTE: + * 1) in production comparison with 'myself' is being done in JS + * 2) team members of hidden groups can also be mentioned (because they are shown on teampage) + */ + return [ + ['', 0, [ + 'names' => [ + [ + 'name' => 'friend', + 'type' => 'u', + 'id' => 7, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'Group we are a member of', + 'type' => 'g', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'Normal group', + 'type' => 'g', + 'id' => 1, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'myself', + 'type' => 'u', + 'id' => 2, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'poster', + 'type' => 'u', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'replier', + 'type' => 'u', + 'id' => 4, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'friend', + 'type' => 'u', + 'id' => 7, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test', + 'type' => 'u', + 'id' => 8, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test1', + 'type' => 'u', + 'id' => 9, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'Group we are a member of', + 'type' => 'g', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + ], + 'all' => false, + ]], + ['', 1, [ + 'names' => [ + [ + 'name' => 'friend', + 'type' => 'u', + 'id' => 7, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'Group we are a member of', + 'type' => 'g', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'Normal group', + 'type' => 'g', + 'id' => 1, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'replier', + 'type' => 'u', + 'id' => 4, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'poster', + 'type' => 'u', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 5, + ], + [ + 'name' => 'myself', + 'type' => 'u', + 'id' => 2, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'poster', + 'type' => 'u', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'replier', + 'type' => 'u', + 'id' => 4, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'friend', + 'type' => 'u', + 'id' => 7, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test', + 'type' => 'u', + 'id' => 8, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test1', + 'type' => 'u', + 'id' => 9, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'Group we are a member of', + 'type' => 'g', + 'id' => 3, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + ], + 'all' => false, + ]], + ['t', 1, [ + 'names' => [ + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 1, + ], + [ + 'name' => 'team_member_normal', + 'type' => 'u', + 'id' => 5, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'team_member_hidden', + 'type' => 'u', + 'id' => 6, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test', + 'type' => 'u', + 'id' => 8, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test1', + 'type' => 'u', + 'id' => 9, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test2', + 'type' => 'u', + 'id' => 10, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test3', + 'type' => 'u', + 'id' => 11, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + ], + 'all' => true, + ]], + ['test', 1, [ + 'names' => [ + [ + 'name' => 'test', + 'type' => 'u', + 'id' => 8, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test1', + 'type' => 'u', + 'id' => 9, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test2', + 'type' => 'u', + 'id' => 10, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + [ + 'name' => 'test3', + 'type' => 'u', + 'id' => 11, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ], + ], + 'all' => true, + ]], + ['test1', 1, [ + 'names' => [[ + 'name' => 'test1', + 'type' => 'u', + 'id' => 9, + 'avatar' => [], + 'rank' => '', + 'priority' => 0, + ]], + 'all' => true, + ]], + ]; + } + + /** + * @dataProvider handle_data + */ + public function test_handle($keyword, $topic_id, $expected_result) + { + $this->request->expects($this->atLeast(2)) + ->method('variable') + ->withConsecutive( + ['keyword', '', true], + ['topic_id', 0]) + ->willReturnOnConsecutiveCalls( + $keyword, + $topic_id + ); + $data = json_decode($this->controller->handle()->getContent(), true); + $this->assertEquals($expected_result, $data); + } +} diff --git a/tests/mention/fixtures/mention.xml b/tests/mention/fixtures/mention.xml new file mode 100644 index 0000000000..36d6bfab04 --- /dev/null +++ b/tests/mention/fixtures/mention.xml @@ -0,0 +1,204 @@ + + + + group_id + group_name + group_type + group_desc + + 1 + Normal group + 0 + + + + 2 + Hidden group + 2 + + + + 3 + Group we are a member of + 0 + + +
    + + post_id + topic_id + forum_id + poster_id + post_time + post_text + + 1 + 1 + 1 + 3 + 1 + Topic's initial post. + + + 2 + 1 + 1 + 4 + 2 + A reply. + +
    + + teampage_id + group_id + + 1 + 1 + + + 2 + 2 + +
    + + topic_id + forum_id + topic_poster + + 1 + 1 + 3 + +
    + + user_id + username + username_clean + user_type + user_lastvisit + user_permissions + user_sig + + 2 + myself + myself + 0 + 19 + + + + + 3 + poster + poster + 0 + 18 + + + + + 4 + replier + replier + 0 + 17 + + + + + 5 + team_member_normal + team_member_normal + 0 + 16 + + + + + 6 + team_member_hidden + team_member_hidden + 0 + 15 + + + + + 7 + friend + friend + 0 + 14 + + + + + 8 + test + test + 0 + 13 + + + + + 9 + test1 + test1 + 0 + 12 + + + + + 10 + test2 + test2 + 0 + 11 + + + + + 11 + test3 + test3 + 0 + 10 + + + +
    + + user_id + group_id + user_pending + + 2 + 3 + 0 + + + 5 + 1 + 0 + + + 6 + 2 + 0 + +
    + + user_id + zebra_id + friend + foe + + 2 + 7 + 1 + 0 + +
    +
    diff --git a/tests/mention/fixtures/services_mention.yml b/tests/mention/fixtures/services_mention.yml new file mode 100644 index 0000000000..3cf14918a3 --- /dev/null +++ b/tests/mention/fixtures/services_mention.yml @@ -0,0 +1,2 @@ +imports: + - { resource: ../../../phpBB/config/default/container/services_mention.yml } diff --git a/tests/notification/base.php b/tests/notification/base.php index 59c7956ee8..d1ceb9aabd 100644 --- a/tests/notification/base.php +++ b/tests/notification/base.php @@ -33,6 +33,7 @@ abstract class phpbb_tests_notification_base extends phpbb_database_test_case 'notification.type.disapprove_post', 'notification.type.disapprove_topic', 'notification.type.forum', + 'notification.type.mention', 'notification.type.pm', 'notification.type.post', 'notification.type.post_in_queue', @@ -73,6 +74,7 @@ abstract class phpbb_tests_notification_base extends phpbb_database_test_case 'allow_topic_notify' => true, 'allow_forum_notify' => true, 'allow_board_notifications' => true, + 'allow_mentions' => true, )); $lang_loader = new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx); $lang = new \phpbb\language\language($lang_loader); @@ -105,6 +107,16 @@ abstract class phpbb_tests_notification_base extends phpbb_database_test_case $phpbb_container->set('cache.driver', $cache_driver); $phpbb_container->set('cache', $cache); $phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils()); + $phpbb_container->set( + 'text_formatter.s9e.mention_helper', + new \phpbb\textformatter\s9e\mention_helper( + $this->db, + $auth, + $this->user, + $phpbb_root_path, + $phpEx + ) + ); $phpbb_container->set('dispatcher', $this->phpbb_dispatcher); $phpbb_container->setParameter('core.root_path', $phpbb_root_path); $phpbb_container->setParameter('core.php_ext', $phpEx); diff --git a/tests/notification/fixtures/services_notification.yml b/tests/notification/fixtures/services_notification.yml index 470768d986..69e6374f4c 100644 --- a/tests/notification/fixtures/services_notification.yml +++ b/tests/notification/fixtures/services_notification.yml @@ -44,6 +44,9 @@ services: text_formatter.s9e.quote_helper: synthetic: true + text_formatter.s9e.mention_helper: + synthetic: true + text_formatter.parser: synthetic: true diff --git a/tests/notification/fixtures/submit_post_notification.type.mention.xml b/tests/notification/fixtures/submit_post_notification.type.mention.xml new file mode 100644 index 0000000000..86ae1fd037 --- /dev/null +++ b/tests/notification/fixtures/submit_post_notification.type.mention.xml @@ -0,0 +1,187 @@ + + + + group_id + group_name + group_type + group_desc + + 1 + Normal group + 0 + + + + 2 + Hidden group + 2 + + +
    + + notification_id + notification_type_id + user_id + item_id + item_parent_id + notification_read + notification_data + + 1 + 1 + 5 + 1 + 1 + 0 + + +
    + + notification_type_id + notification_type_name + notification_type_enabled + + 1 + notification.type.mention + 1 + +
    + + post_id + topic_id + forum_id + post_text + + 1 + 1 + 1 + + +
    + + topic_id + forum_id + + 1 + 1 + +
    + + user_id + username_clean + user_permissions + user_sig + + 2 + poster + + + + + 3 + test + + + + + 4 + unauthorized + + + + + 5 + notified + + + + + 6 + disabled + + + + + 7 + default + + + + + 8 + member of normal group + + + + + 9 + member of hidden group + + + +
    + + user_id + group_id + user_pending + + 8 + 1 + 0 + + + 9 + 2 + 0 + +
    + + item_type + item_id + user_id + method + notify + + notification.type.mention + 0 + 2 + notification.method.board + 1 + + + notification.type.mention + 0 + 3 + notification.method.board + 1 + + + notification.type.mention + 0 + 4 + notification.method.board + 1 + + + notification.type.mention + 0 + 5 + notification.method.board + 1 + + + notification.type.mention + 0 + 6 + notification.method.board + 0 + + + notification.type.mention + 0 + 8 + notification.method.board + 1 + +
    +
    diff --git a/tests/notification/notification_method_email_test.php b/tests/notification/notification_method_email_test.php index 87db91aa1c..2c71b0eda9 100644 --- a/tests/notification/notification_method_email_test.php +++ b/tests/notification/notification_method_email_test.php @@ -91,6 +91,16 @@ class notification_method_email_test extends phpbb_tests_notification_base $phpbb_container->setParameter('tables.user_notifications', 'phpbb_user_notifications'); $phpbb_container->setParameter('tables.notification_types', 'phpbb_notification_types'); $phpbb_container->setParameter('tables.notification_emails', 'phpbb_notification_emails'); + $phpbb_container->set( + 'text_formatter.s9e.mention_helper', + new \phpbb\textformatter\s9e\mention_helper( + $this->db, + $auth, + $this->user, + $phpbb_root_path, + $phpEx + ) + ); $this->notification_method_email = $this->getMockBuilder('\phpbb\notification\method\email') ->setConstructorArgs([ diff --git a/tests/notification/notification_test.php b/tests/notification/notification_test.php index 08eabaa12a..4658f4c39a 100644 --- a/tests/notification/notification_test.php +++ b/tests/notification/notification_test.php @@ -59,6 +59,7 @@ class phpbb_notification_test extends phpbb_tests_notification_base self::assertArrayHasKey('NOTIFICATION_GROUP_POSTING', $subscription_types); self::assertArrayHasKey('notification.type.bookmark', $subscription_types['NOTIFICATION_GROUP_POSTING']); + self::assertArrayHasKey('notification.type.mention', $subscription_types['NOTIFICATION_GROUP_POSTING']); self::assertArrayHasKey('notification.type.post', $subscription_types['NOTIFICATION_GROUP_POSTING']); self::assertArrayHasKey('notification.type.quote', $subscription_types['NOTIFICATION_GROUP_POSTING']); self::assertArrayHasKey('notification.type.topic', $subscription_types['NOTIFICATION_GROUP_POSTING']); @@ -73,6 +74,7 @@ class phpbb_notification_test extends phpbb_tests_notification_base { $expected_subscriptions = array( 'notification.type.forum' => array('notification.method.board'), + 'notification.type.mention' => array('notification.method.board'), 'notification.type.post' => array('notification.method.board'), 'notification.type.topic' => array('notification.method.board'), 'notification.type.quote' => array('notification.method.board'), diff --git a/tests/notification/submit_post_base.php b/tests/notification/submit_post_base.php index 2d17b601a2..c0ef8f3caa 100644 --- a/tests/notification/submit_post_base.php +++ b/tests/notification/submit_post_base.php @@ -70,6 +70,8 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c array('f_noapprove', 1, true), array('f_postcount', 1, true), array('m_edit', 1, false), + array('f_mention', 1, true), + array('u_mention', 0, true), ))); // Config @@ -77,6 +79,7 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c 'num_topics' => 1, 'num_posts' => 1, 'allow_board_notifications' => true, + 'allow_mentions' => true, )); $cache_driver = new \phpbb\cache\driver\dummy(); @@ -132,6 +135,16 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c $phpbb_container->set('cache.driver', $cache_driver); $phpbb_container->set('cache', $cache); $phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils()); + $phpbb_container->set( + 'text_formatter.s9e.mention_helper', + new \phpbb\textformatter\s9e\mention_helper( + $this->db, + $auth, + $user, + $phpbb_root_path, + $phpEx + ) + ); $phpbb_container->set('dispatcher', $phpbb_dispatcher); $phpbb_container->set('storage.attachment', $storage); $phpbb_container->setParameter('core.root_path', $phpbb_root_path); @@ -145,7 +158,7 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c $phpbb_container->compile(); // Notification Types - $notification_types = array('quote', 'bookmark', 'post', 'post_in_queue', 'topic', 'topic_in_queue', 'approve_topic', 'approve_post', 'forum'); + $notification_types = array('quote', 'mention', 'bookmark', 'post', 'post_in_queue', 'topic', 'topic_in_queue', 'approve_topic', 'approve_post', 'forum'); $notification_types_array = array(); foreach ($notification_types as $type) { diff --git a/tests/notification/submit_post_type_mention_test.php b/tests/notification/submit_post_type_mention_test.php new file mode 100644 index 0000000000..f660a38ecc --- /dev/null +++ b/tests/notification/submit_post_type_mention_test.php @@ -0,0 +1,129 @@ + +* @license GNU General Public License, version 2 (GPL-2.0) +* +* For full copyright and license information, please see +* the docs/CREDITS.txt file. +* +*/ + +require_once dirname(__FILE__) . '/submit_post_base.php'; + +class phpbb_notification_submit_post_type_mention_test extends phpbb_notification_submit_post_base +{ + protected $item_type = 'notification.type.mention'; + + public function setUp(): void + { + parent::setUp(); + + global $auth; + + // Add additional permissions + $auth->expects($this->any()) + ->method('acl_get_list') + ->with($this->anything(), + $this->stringContains('_'), + $this->greaterThan(0)) + ->will($this->returnValueMap(array( + array( + array(3, 4, 5, 6, 7, 8, 10), + 'f_read', + 1, + array( + 1 => array( + 'f_read' => array(3, 5, 6, 7, 8), + ), + ), + ), + ))); + $auth->expects($this->any()) + ->method('acl_gets') + ->with('a_group', 'a_groupadd', 'a_groupdel') + ->will($this->returnValue(false)); + } + + /** + * submit_post() Notifications test + * + * submit_post() $mode = 'reply' + * Notification item_type = 'mention' + */ + public function submit_post_data() + { + // The new mock container is needed because the data providers may be executed before phpunit call setUp() + $parser = $this->get_test_case_helpers()->set_s9e_services(new phpbb_mock_container_builder())->get('text_formatter.parser'); + + return array( + /** + * Normal post + * + * User => State description + * 2 => Poster, should NOT receive a notification + * 3 => mentioned, should receive a notification + * 4 => mentioned, but unauthed to read, should NOT receive a notification + * 5 => mentioned, but already notified, should STILL receive a new notification + * 6 => mentioned, but option disabled, should NOT receive a notification + * 7 => mentioned, option set to default, should receive a notification + * 8 => mentioned as a member of group 1, should receive a notification + */ + array( + array( + 'message' => $parser->parse(implode(' ', array( + '[mention=u:2]poster[/mention] poster should not be notified', + '[mention=u:3]test[/mention] test should be notified', + '[mention=u:4]unauthorized[/mention] unauthorized to read, should not receive a notification', + '[mention=u:5]notified[/mention] already notified, should not receive a new notification', + '[mention=u:6]disabled[/mention] option disabled, should not receive a notification', + '[mention=u:7]default[/mention] option set to default, should receive a notification', + '[mention=g:1]normal group[/mention] group members of a normal group shoud receive a notification', + '[mention=g:2]hidden group[/mention] group members of a hidden group shoud not receive a notification from a non-member', + '[mention=u:10]doesn\'t exist[/mention] user does not exist, should not receive a notification', + ))), + 'bbcode_uid' => 'uid', + ), + array( + array('user_id' => 5, 'item_id' => 1, 'item_parent_id' => 1), + ), + array( + array('user_id' => 3, 'item_id' => 2, 'item_parent_id' => 1), + array('user_id' => 5, 'item_id' => 1, 'item_parent_id' => 1), + array('user_id' => 5, 'item_id' => 2, 'item_parent_id' => 1), + array('user_id' => 7, 'item_id' => 2, 'item_parent_id' => 1), + array('user_id' => 8, 'item_id' => 2, 'item_parent_id' => 1), + ), + ), + + /** + * Unapproved post + * + * No new notifications + */ + array( + array( + 'message' => $parser->parse(implode(' ', array( + '[mention=u:2]poster[/mention] poster should not be notified', + '[mention=u:3]test[/mention] test should be notified', + '[mention=u:4]unauthorized[/mention] unauthorized to read, should not receive a notification', + '[mention=u:5]notified[/mention] already notified, should not receive a new notification', + '[mention=u:6]disabled[/mention] option disabled, should not receive a notification', + '[mention=u:7]default[/mention] option set to default, should receive a notification', + '[mention=u:8]doesn\'t exist[/mention] user does not exist, should not receive a notification', + ))), + 'bbcode_uid' => 'uid', + 'force_approved_state' => false, + ), + array( + array('user_id' => 5, 'item_id' => 1, 'item_parent_id' => 1), + ), + array( + array('user_id' => 5, 'item_id' => 1, 'item_parent_id' => 1), + ), + ), + ); + } +} diff --git a/tests/test_framework/phpbb_test_case_helpers.php b/tests/test_framework/phpbb_test_case_helpers.php index 0c22b3c5a4..3e28a3271b 100644 --- a/tests/test_framework/phpbb_test_case_helpers.php +++ b/tests/test_framework/phpbb_test_case_helpers.php @@ -579,6 +579,9 @@ class phpbb_test_case_helpers } $user->add_lang('common'); + // Get an auth interface + $auth = ($container->has('auth')) ? $container->get('auth') : new \phpbb\auth\auth; + // Create and register a quote_helper $quote_helper = new \phpbb\textformatter\s9e\quote_helper( $container->get('user'), @@ -587,6 +590,16 @@ class phpbb_test_case_helpers ); $container->set('text_formatter.s9e.quote_helper', $quote_helper); + // Create and register a mention_helper + $mention_helper = new \phpbb\textformatter\s9e\mention_helper( + ($container->has('dbal.conn')) ? $container->get('dbal.conn') : $db_driver, + $auth, + $container->get('user'), + $phpbb_root_path, + $phpEx + ); + $container->set('text_formatter.s9e.mention_helper', $mention_helper); + // Create and register the text_formatter.s9e.parser service and its alias $parser = new \phpbb\textformatter\s9e\parser( $cache, @@ -607,8 +620,8 @@ class phpbb_test_case_helpers ); // Calls configured in services.yml - $auth = ($container->has('auth')) ? $container->get('auth') : new \phpbb\auth\auth; $renderer->configure_quote_helper($quote_helper); + $renderer->configure_mention_helper($mention_helper); $renderer->configure_smilies_path($config, $path_helper); $renderer->configure_user($user, $config, $auth); diff --git a/tests/text_formatter/s9e/fixtures/mention.xml b/tests/text_formatter/s9e/fixtures/mention.xml new file mode 100644 index 0000000000..13553b1081 --- /dev/null +++ b/tests/text_formatter/s9e/fixtures/mention.xml @@ -0,0 +1,116 @@ + + + + group_id + group_name + group_type + group_colour + group_desc + + 1 + Normal group + 0 + + + + + 2 + Hidden group + 2 + + + + + 3 + Hidden group we are a member of + 2 + + + +
    + + user_id + username + username_clean + user_type + user_lastvisit + user_colour + user_permissions + user_sig + + 2 + myself + myself + 0 + 0 + + + + + + 3 + test + test + 0 + 0 + + + + + + 4 + group_member_normal + group_member_normal + 0 + 0 + + + + + + 5 + group_member_hidden + group_member_hidden + 0 + 0 + + + + + + 6 + group_member_visible + group_member_visible + 0 + 0 + + + + +
    + + user_id + group_id + user_pending + + 2 + 3 + 0 + + + 4 + 1 + 0 + + + 5 + 2 + 0 + + + 6 + 3 + 0 + +
    +
    diff --git a/tests/text_formatter/s9e/mention_helper_test.php b/tests/text_formatter/s9e/mention_helper_test.php new file mode 100644 index 0000000000..ea53557c78 --- /dev/null +++ b/tests/text_formatter/s9e/mention_helper_test.php @@ -0,0 +1,123 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class mention_helper_test extends phpbb_database_test_case +{ + protected $db, $container, $user, $auth; + + /** + * @var \phpbb\textformatter\s9e\mention_helper + */ + protected $mention_helper; + + public function getDataSet() + { + return $this->createXMLDataSet(dirname(__FILE__) . '/fixtures/mention.xml'); + } + + public function setUp(): void + { + parent::setUp(); + + global $auth, $db, $cache, $phpbb_container, $phpEx, $phpbb_root_path; + + // Disable caching for this test class + $cache = null; + + // Database + $this->db = $this->new_dbal(); + $db = $this->db; + + // Auth + $auth = $this->createMock('\phpbb\auth\auth'); + $auth->expects($this->any()) + ->method('acl_gets') + ->with('a_group', 'a_groupadd', 'a_groupdel') + ->willReturn(false) + ; + + // Language + $lang = new \phpbb\language\language(new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx)); + + // User + $user = $this->createMock('\phpbb\user', array(), array( + $lang, + '\phpbb\datetime' + )); + $user->ip = ''; + $user->data = array( + 'user_id' => 2, + 'username' => 'myself', + 'is_registered' => true, + 'user_colour' => '', + ); + + // Container + $phpbb_container = new phpbb_mock_container_builder(); + + $phpbb_container->set('dbal.conn', $db); + $phpbb_container->set('auth', $auth); + $phpbb_container->set('user', $user); + + $this->get_test_case_helpers()->set_s9e_services($phpbb_container); + + $this->mention_helper = $phpbb_container->get('text_formatter.s9e.mention_helper'); + } + + public function inject_metadata_data() + { + return [ + [ + '[mention=u:3]test[/mention]', + 'mode=viewprofile&u=3', + ], + [ + '[mention=g:3]test[/mention]', + 'mode=group&g=3', + ], + ]; + } + + /** + * @dataProvider inject_metadata_data + */ + public function test_inject_metadata($incoming_xml, $expected_profile_substring) + { + $result = $this->mention_helper->inject_metadata($incoming_xml); + $this->assertStringContainsString($expected_profile_substring, $result); + } + + public function get_mentioned_user_ids_data() + { + return [ + [ + '[mention=u:3]test[/mention][mention=u:4]test[/mention][mention=u:5]test[/mention]', + [3, 4, 5], + ], + [ + '[mention=g:1]test[/mention][mention=g:2]test[/mention][mention=g:3]test[/mention]', + [4, 2, 6], + ], + ]; + } + + /** + * @dataProvider get_mentioned_user_ids_data + */ + public function test_get_mentioned_user_ids($incoming_xml, $expected_result) + { + $this->assertSame($expected_result, $this->mention_helper->get_mentioned_user_ids($incoming_xml)); + } +}