diff --git a/app/assets/javascripts/pretty-text/addon/mentions.js b/app/assets/javascripts/pretty-text/addon/mentions.js
new file mode 100644
index 0000000000..a81fb2c501
--- /dev/null
+++ b/app/assets/javascripts/pretty-text/addon/mentions.js
@@ -0,0 +1,31 @@
+export function mentionRegex(unicodeUsernames) {
+ if (unicodeUsernames) {
+ try {
+ // Create the regex from a string, because Babel doesn't understand
+ // Unicode property escapes and completely mangles the regexp.
+ const alnum = "\\p{Alphabetic}\\p{Mark}\\p{Decimal_Number}";
+ return new RegExp(
+ `@([${alnum}_][${alnum}._-]{0,58}[${alnum}])|@([${alnum}_])`,
+ "u"
+ );
+ } catch (e) {
+ if (!(e instanceof SyntaxError)) {
+ throw e;
+ }
+
+ // Fallback for older browsers and MiniRacer.
+ // Created with regexpu-core@4.5.4 by executing the following in nodejs:
+ //
+ // const rewritePattern = require('regexpu-core')
+ // new RegExp(rewritePattern(/[\p{Alphabetic}\p{Mark}\p{Decimal_Number}]/u.source, 'u', { 'unicodePropertyEscape': true }))
+ const alnum =
+ /(?:[0-9A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0300-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u0483-\u052F\u0531-\u0556\u0559\u0560-\u0588\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u05D0-\u05EA\u05EF-\u05F2\u0610-\u061A\u0620-\u0669\u066E-\u06D3\u06D5-\u06DC\u06E1-\u06E8\u06DF-\u06E4\u06ED-\u06F9\u06EA-\u06FC\u06FF\u0710-\u074A\u074D-\u07B1\u07C0-\u07F5\u07FA\u07FD\u0800-\u082D\u0840-\u085B\u0860-\u086A\u08A0-\u08B4\u08B6-\u08BD\u08D3-\u08E1\u08E3-\u0963\u0966-\u096F\u0971-\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BC-\u09C4\u09C7\u09C8\u09CB-\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E3\u09E6-\u09F1\u09FC\u09FE\u0A01-\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A59-\u0A5C\u0A5E\u0A66-\u0A75\u0A81-\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABC-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AD0\u0AE0-\u0AE3\u0AE6-\u0AEF\u0AF9-\u0AFF\u0B01-\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3C-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B5C\u0B5D\u0B5F-\u0B63\u0B66-\u0B6F\u0B71\u0B82\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD0\u0BD7\u0BE6-\u0BEF\u0C00-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C58-\u0C5A\u0C60-\u0C63\u0C66-\u0C6F\u0C80-\u0C83\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBC-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CDE\u0CE0-\u0CE3\u0CE6-\u0CEF\u0CF1\u0CF2\u0D00-\u0D03\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D44\u0D46-\u0D48\u0D4A-\u0D4E\u0D54-\u0D57\u0D5F-\u0D63\u0D66-\u0D6F\u0D7A-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2\u0DF3\u0E01-\u0E3A\u0E40-\u0E4E\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EBD\u0EC0-\u0EC4\u0EC6\u0EC8-\u0ECD\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F18\u0F19\u0F20-\u0F29\u0F35\u0F37\u0F39\u0F3E-\u0F47\u0F49-\u0F6C\u0F71-\u0F84\u0F86-\u0F97\u0F99-\u0FBC\u0FC6\u1000-\u1049\u1050-\u109D\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u135D-\u135F\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u170C\u170E-\u1714\u1720-\u1734\u1740-\u1753\u1760-\u176C\u176E-\u1770\u1772\u1773\u1780-\u17D3\u17D7\u17DC\u17DD\u17E0-\u17E9\u180B-\u180D\u1810-\u1819\u1820-\u1878\u1880-\u18AA\u18B0-\u18F5\u1900-\u191E\u1920-\u192B\u1930-\u193B\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19D9\u1A00-\u1A1B\u1A20-\u1A5E\u1A60-\u1A7C\u1A7F-\u1A89\u1A90-\u1A99\u1AA7\u1AB0-\u1ABE\u1B00-\u1B4B\u1B50-\u1B59\u1B6B-\u1B73\u1B80-\u1BF3\u1C00-\u1C37\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CD0-\u1CD2\u1CD4-\u1CFA\u1D00-\u1DF9\u1DFB-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u20D0-\u20F0\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2160-\u2188\u24B6-\u24E9\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D7F-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2DE0-\u2DFF\u2E2F\u3005-\u3007\u3021-\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u3099\u309A\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FEF\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA672\uA674-\uA67D\uA67F-\uA6F1\uA717-\uA71F\uA722-\uA788\uA78B-\uA7BF\uA7C2-\uA7C6\uA7F7-\uA827\uA840-\uA873\uA880-\uA8C5\uA8D0-\uA8D9\uA8E0-\uA8F7\uA8FB\uA8FD-\uA92D\uA930-\uA953\uA960-\uA97C\uA980-\uA9C0\uA9CF-\uA9D9\uA9E0-\uA9FE\uAA00-\uAA36\uAA40-\uAA4D\uAA50-\uAA59\uAA60-\uAA76\uAA7A-\uAAC2\uAADB-\uAADD\uAAE0-\uAAEF\uAAF2-\uAAF6\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB67\uAB70-\uABEA\uABEC\uABED\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE00-\uFE0F\uFE20-\uFE2F\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD40-\uDD74\uDDFD\uDE80-\uDE9C\uDEA0-\uDED0\uDEE0\uDF00-\uDF1F\uDF2D-\uDF4A\uDF50-\uDF7A\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC60-\uDC76\uDC80-\uDC9E\uDCE0-\uDCF2\uDCF4\uDCF5\uDD00-\uDD15\uDD20-\uDD39\uDD80-\uDDB7\uDDBE\uDDBF\uDE00-\uDE03\uDE05\uDE06\uDE0C-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE38-\uDE3A\uDE3F\uDE60-\uDE7C\uDE80-\uDE9C\uDEC0-\uDEC7\uDEC9-\uDEE6\uDF00-\uDF35\uDF40-\uDF55\uDF60-\uDF72\uDF80-\uDF91]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDD00-\uDD27\uDD30-\uDD39\uDF00-\uDF1C\uDF27\uDF30-\uDF50\uDFE0-\uDFF6]|\uD804[\uDC00-\uDC46\uDC66-\uDC6F\uDC82-\uDCBA\uDC7F-\uDC82\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD00-\uDD34\uDD36-\uDD3F\uDD44-\uDD46\uDD50-\uDD73\uDD76\uDD80-\uDDC4\uDDC9-\uDDCC\uDDD0-\uDDDA\uDDDC\uDE00-\uDE11\uDE13-\uDE37\uDE3E\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEEA\uDEF0-\uDEF9\uDF00-\uDF03\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3B-\uDF44\uDF47\uDF48\uDF4B-\uDF4D\uDF50\uDF57\uDF5D-\uDF63\uDF66-\uDF6C\uDF70-\uDF74]|\uD805[\uDC00-\uDC4A\uDC50-\uDC59\uDC5E\uDC5F\uDC80-\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDB5\uDDB8-\uDDC0\uDDD8-\uDDDD\uDE00-\uDE40\uDE44\uDE50-\uDE59\uDE80-\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF1D-\uDF2B\uDF30-\uDF39]|\uD806[\uDC00-\uDC3A\uDCA0-\uDCE9\uDCFF\uDDA0-\uDDA7\uDDAA-\uDDD7\uDDDA-\uDDE1\uDDE3\uDDE4\uDE00-\uDE3E\uDE47\uDE50-\uDE99\uDE9D\uDEC0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC36\uDC38-\uDC40\uDC50-\uDC59\uDC72-\uDC8F\uDC92-\uDCA7\uDCA9-\uDCB6\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD36\uDD3A\uDD3C\uDD3D\uDD3F-\uDD47\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD8E\uDD90\uDD91\uDD93-\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF6]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|[\uD80C\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDED0-\uDEED\uDEF0-\uDEF4\uDF00-\uDF36\uDF40-\uDF43\uDF50-\uDF59\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE7F\uDF00-\uDF4A\uDF4F-\uDF87\uDF8F-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDC00-\uDD1E\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99\uDC9D\uDC9E]|\uD834[\uDD65-\uDD69\uDD6D-\uDD72\uDD7B-\uDD82\uDD85-\uDD8B\uDDAA-\uDDAD\uDE42-\uDE44]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD836[\uDE00-\uDE36\uDE3B-\uDE6C\uDE75\uDE84\uDE9B-\uDE9F\uDEA1-\uDEAF]|\uD838[\uDC00-\uDC06\uDC08-\uDC18\uDC1B-\uDC21\uDC23\uDC24\uDC26-\uDC2A\uDD00-\uDD2C\uDD30-\uDD3D\uDD40-\uDD49\uDD4E\uDEC0-\uDEF9]|\uD83A[\uDC00-\uDCC4\uDCD0-\uDCD6\uDD00-\uDD4B\uDD50-\uDD59]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD30-\uDD49\uDD50-\uDD69\uDD70-\uDD89]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uDB40[\uDD00-\uDDEF])/
+ .source;
+ return new RegExp(
+ `@((?:_|${alnum})(?:[._-]|${alnum}){0,58}${alnum})|@(?:(_|${alnum}))`
+ );
+ }
+ } else {
+ return /@(\w[\w.-]{0,58}[^\W_])|@(\w)/;
+ }
+}
diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js
index 02c48ebeca..2342c19dda 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js
@@ -1,3 +1,5 @@
+import { mentionRegex } from "pretty-text/mentions";
+
function addMention(buffer, matches, state) {
let username = matches[1] || matches[2];
let tag = "span";
@@ -32,35 +34,3 @@ export function setup(helper) {
md.core.textPostProcess.ruler.push("mentions", rule);
});
}
-
-export function mentionRegex(unicodeUsernames) {
- if (unicodeUsernames) {
- try {
- // Create the regex from a string, because Babel doesn't understand
- // Unicode property escapes and completely mangles the regexp.
- const alnum = "\\p{Alphabetic}\\p{Mark}\\p{Decimal_Number}";
- return new RegExp(
- `@([${alnum}_][${alnum}._-]{0,58}[${alnum}])|@([${alnum}_])`,
- "u"
- );
- } catch (e) {
- if (!(e instanceof SyntaxError)) {
- throw e;
- }
-
- // Fallback for older browsers and MiniRacer.
- // Created with regexpu-core@4.5.4 by executing the following in nodejs:
- //
- // const rewritePattern = require('regexpu-core')
- // new RegExp(rewritePattern(/[\p{Alphabetic}\p{Mark}\p{Decimal_Number}]/u.source, 'u', { 'unicodePropertyEscape': true }))
- const alnum =
- /(?:[0-9A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0300-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u0483-\u052F\u0531-\u0556\u0559\u0560-\u0588\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u05D0-\u05EA\u05EF-\u05F2\u0610-\u061A\u0620-\u0669\u066E-\u06D3\u06D5-\u06DC\u06E1-\u06E8\u06DF-\u06E4\u06ED-\u06F9\u06EA-\u06FC\u06FF\u0710-\u074A\u074D-\u07B1\u07C0-\u07F5\u07FA\u07FD\u0800-\u082D\u0840-\u085B\u0860-\u086A\u08A0-\u08B4\u08B6-\u08BD\u08D3-\u08E1\u08E3-\u0963\u0966-\u096F\u0971-\u0983\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BC-\u09C4\u09C7\u09C8\u09CB-\u09CE\u09D7\u09DC\u09DD\u09DF-\u09E3\u09E6-\u09F1\u09FC\u09FE\u0A01-\u0A03\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A59-\u0A5C\u0A5E\u0A66-\u0A75\u0A81-\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABC-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AD0\u0AE0-\u0AE3\u0AE6-\u0AEF\u0AF9-\u0AFF\u0B01-\u0B03\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3C-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B5C\u0B5D\u0B5F-\u0B63\u0B66-\u0B6F\u0B71\u0B82\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD0\u0BD7\u0BE6-\u0BEF\u0C00-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C58-\u0C5A\u0C60-\u0C63\u0C66-\u0C6F\u0C80-\u0C83\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBC-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CDE\u0CE0-\u0CE3\u0CE6-\u0CEF\u0CF1\u0CF2\u0D00-\u0D03\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D44\u0D46-\u0D48\u0D4A-\u0D4E\u0D54-\u0D57\u0D5F-\u0D63\u0D66-\u0D6F\u0D7A-\u0D7F\u0D82\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2\u0DF3\u0E01-\u0E3A\u0E40-\u0E4E\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EBD\u0EC0-\u0EC4\u0EC6\u0EC8-\u0ECD\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F18\u0F19\u0F20-\u0F29\u0F35\u0F37\u0F39\u0F3E-\u0F47\u0F49-\u0F6C\u0F71-\u0F84\u0F86-\u0F97\u0F99-\u0FBC\u0FC6\u1000-\u1049\u1050-\u109D\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u135D-\u135F\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u170C\u170E-\u1714\u1720-\u1734\u1740-\u1753\u1760-\u176C\u176E-\u1770\u1772\u1773\u1780-\u17D3\u17D7\u17DC\u17DD\u17E0-\u17E9\u180B-\u180D\u1810-\u1819\u1820-\u1878\u1880-\u18AA\u18B0-\u18F5\u1900-\u191E\u1920-\u192B\u1930-\u193B\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19D9\u1A00-\u1A1B\u1A20-\u1A5E\u1A60-\u1A7C\u1A7F-\u1A89\u1A90-\u1A99\u1AA7\u1AB0-\u1ABE\u1B00-\u1B4B\u1B50-\u1B59\u1B6B-\u1B73\u1B80-\u1BF3\u1C00-\u1C37\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CD0-\u1CD2\u1CD4-\u1CFA\u1D00-\u1DF9\u1DFB-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u20D0-\u20F0\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2160-\u2188\u24B6-\u24E9\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D7F-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2DE0-\u2DFF\u2E2F\u3005-\u3007\u3021-\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u3099\u309A\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FEF\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA672\uA674-\uA67D\uA67F-\uA6F1\uA717-\uA71F\uA722-\uA788\uA78B-\uA7BF\uA7C2-\uA7C6\uA7F7-\uA827\uA840-\uA873\uA880-\uA8C5\uA8D0-\uA8D9\uA8E0-\uA8F7\uA8FB\uA8FD-\uA92D\uA930-\uA953\uA960-\uA97C\uA980-\uA9C0\uA9CF-\uA9D9\uA9E0-\uA9FE\uAA00-\uAA36\uAA40-\uAA4D\uAA50-\uAA59\uAA60-\uAA76\uAA7A-\uAAC2\uAADB-\uAADD\uAAE0-\uAAEF\uAAF2-\uAAF6\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB67\uAB70-\uABEA\uABEC\uABED\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE00-\uFE0F\uFE20-\uFE2F\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD40-\uDD74\uDDFD\uDE80-\uDE9C\uDEA0-\uDED0\uDEE0\uDF00-\uDF1F\uDF2D-\uDF4A\uDF50-\uDF7A\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC60-\uDC76\uDC80-\uDC9E\uDCE0-\uDCF2\uDCF4\uDCF5\uDD00-\uDD15\uDD20-\uDD39\uDD80-\uDDB7\uDDBE\uDDBF\uDE00-\uDE03\uDE05\uDE06\uDE0C-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE38-\uDE3A\uDE3F\uDE60-\uDE7C\uDE80-\uDE9C\uDEC0-\uDEC7\uDEC9-\uDEE6\uDF00-\uDF35\uDF40-\uDF55\uDF60-\uDF72\uDF80-\uDF91]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDD00-\uDD27\uDD30-\uDD39\uDF00-\uDF1C\uDF27\uDF30-\uDF50\uDFE0-\uDFF6]|\uD804[\uDC00-\uDC46\uDC66-\uDC6F\uDC82-\uDCBA\uDC7F-\uDC82\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD00-\uDD34\uDD36-\uDD3F\uDD44-\uDD46\uDD50-\uDD73\uDD76\uDD80-\uDDC4\uDDC9-\uDDCC\uDDD0-\uDDDA\uDDDC\uDE00-\uDE11\uDE13-\uDE37\uDE3E\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEEA\uDEF0-\uDEF9\uDF00-\uDF03\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3B-\uDF44\uDF47\uDF48\uDF4B-\uDF4D\uDF50\uDF57\uDF5D-\uDF63\uDF66-\uDF6C\uDF70-\uDF74]|\uD805[\uDC00-\uDC4A\uDC50-\uDC59\uDC5E\uDC5F\uDC80-\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDB5\uDDB8-\uDDC0\uDDD8-\uDDDD\uDE00-\uDE40\uDE44\uDE50-\uDE59\uDE80-\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF1D-\uDF2B\uDF30-\uDF39]|\uD806[\uDC00-\uDC3A\uDCA0-\uDCE9\uDCFF\uDDA0-\uDDA7\uDDAA-\uDDD7\uDDDA-\uDDE1\uDDE3\uDDE4\uDE00-\uDE3E\uDE47\uDE50-\uDE99\uDE9D\uDEC0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC36\uDC38-\uDC40\uDC50-\uDC59\uDC72-\uDC8F\uDC92-\uDCA7\uDCA9-\uDCB6\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD36\uDD3A\uDD3C\uDD3D\uDD3F-\uDD47\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD8E\uDD90\uDD91\uDD93-\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF6]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|[\uD80C\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDED0-\uDEED\uDEF0-\uDEF4\uDF00-\uDF36\uDF40-\uDF43\uDF50-\uDF59\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE7F\uDF00-\uDF4A\uDF4F-\uDF87\uDF8F-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDC00-\uDD1E\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99\uDC9D\uDC9E]|\uD834[\uDD65-\uDD69\uDD6D-\uDD72\uDD7B-\uDD82\uDD85-\uDD8B\uDDAA-\uDDAD\uDE42-\uDE44]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD836[\uDE00-\uDE36\uDE3B-\uDE6C\uDE75\uDE84\uDE9B-\uDE9F\uDEA1-\uDEAF]|\uD838[\uDC00-\uDC06\uDC08-\uDC18\uDC1B-\uDC21\uDC23\uDC24\uDC26-\uDC2A\uDD00-\uDD2C\uDD30-\uDD3D\uDD40-\uDD49\uDD4E\uDEC0-\uDEF9]|\uD83A[\uDC00-\uDCC4\uDCD0-\uDCD6\uDD00-\uDD4B\uDD50-\uDD59]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD30-\uDD49\uDD50-\uDD69\uDD70-\uDD89]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uDB40[\uDD00-\uDDEF])/
- .source;
- return new RegExp(
- `@((?:_|${alnum})(?:[._-]|${alnum}){0,58}${alnum})|@(?:(_|${alnum}))`
- );
- }
- } else {
- return /@(\w[\w.-]{0,58}[^\W_])|@(\w)/;
- }
-}
diff --git a/config/site_settings.yml b/config/site_settings.yml
index c9de05c5e8..d9fd02a919 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -908,7 +908,10 @@ posting:
default: true
client: true
max_mentions_per_post: 10
- max_users_notified_per_group_mention: 100
+ max_users_notified_per_group_mention:
+ default: 100
+ max: 250
+ client: true
newuser_max_replies_per_topic: 3
newuser_max_mentions_per_post: 2
here_mention:
diff --git a/plugins/chat/app/controllers/api/hints_controller.rb b/plugins/chat/app/controllers/api/hints_controller.rb
new file mode 100644
index 0000000000..b06d325c53
--- /dev/null
+++ b/plugins/chat/app/controllers/api/hints_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class Chat::Api::HintsController < ApplicationController
+ before_action :ensure_logged_in
+
+ def check_group_mentions
+ RateLimiter.new(current_user, "group_mention_hints", 5, 10.seconds).performed!
+ group_names = params[:mentions]
+
+ raise Discourse::InvalidParameters.new(:mentions) if group_names.blank?
+
+ visible_groups = Group
+ .where("LOWER(name) IN (?)", group_names)
+ .visible_groups(current_user)
+ .pluck(:name)
+
+ mentionable_groups = filter_mentionable_groups(visible_groups)
+
+ result = {
+ unreachable: visible_groups - mentionable_groups.map(&:name),
+ over_members_limit: mentionable_groups.select { |g| g.user_count > SiteSetting.max_users_notified_per_group_mention }.map(&:name),
+ }
+
+ result[:invalid] = (group_names - result[:unreachable]) - result[:over_members_limit]
+
+ render json: result
+ end
+
+ private
+
+ def filter_mentionable_groups(group_names)
+ return [] if group_names.empty?
+
+ Group
+ .select(:name, :user_count)
+ .where(name: group_names)
+ .mentionable(current_user, include_public: false)
+ end
+end
diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb
index ab7c8bea28..638903da40 100644
--- a/plugins/chat/app/services/chat_publisher.rb
+++ b/plugins/chat/app/services/chat_publisher.rb
@@ -153,23 +153,19 @@ module ChatPublisher
user_id,
chat_message,
cannot_chat_users,
- without_membership
+ without_membership,
+ too_many_members,
+ mentions_disabled
)
MessageBus.publish(
"/chat/#{chat_message.chat_channel_id}",
{
type: :mention_warning,
chat_message_id: chat_message.id,
- cannot_see:
- ActiveModel::ArraySerializer.new(
- cannot_chat_users,
- each_serializer: BasicUserSerializer,
- ).as_json,
- without_membership:
- ActiveModel::ArraySerializer.new(
- without_membership,
- each_serializer: BasicUserSerializer,
- ).as_json,
+ cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json,
+ without_membership: without_membership.map { |u| { username: u.username, id: u.id } }.as_json,
+ groups_with_too_many_members: too_many_members.map(&:name).as_json,
+ group_mentions_disabled: mentions_disabled.map(&:name).as_json
},
user_ids: [user_id],
)
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
index 231c7b13ed..0edc60c760 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
@@ -21,12 +21,15 @@ import { Promise } from "rsvp";
import { translations } from "pretty-text/emoji/data";
import { channelStatusName } from "discourse/plugins/chat/discourse/models/chat-channel";
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
+import discourseDebounce from "discourse-common/lib/debounce";
import {
chatComposerButtons,
chatComposerButtonsDependentKeys,
} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
+import { mentionRegex } from "pretty-text/mentions";
const THROTTLE_MS = 150;
+const MENTION_DEBOUNCE_MS = 1000;
export default Component.extend(TextareaTextManipulation, {
chatChannel: null,
@@ -41,12 +44,14 @@ export default Component.extend(TextareaTextManipulation, {
editingMessage: null,
onValueChange: null,
timer: null,
+ mentionsTimer: null,
value: "",
inProgressUploads: null,
composerEventPrefix: "chat",
composerFocusSelector: ".chat-composer-input",
canAttachUploads: reads("siteSettings.chat_allow_uploads"),
isNetworkUnreliable: reads("chat.isNetworkUnreliable"),
+ typingMention: false,
@discourseComputed(...chatComposerButtonsDependentKeys())
inlineButtons() {
@@ -144,10 +149,8 @@ export default Component.extend(TextareaTextManipulation, {
"_inProgressUploadsChanged"
);
- if (this.timer) {
- cancel(this.timer);
- this.timer = null;
- }
+ cancel(this.timer);
+ cancel(this.mentionsTimer);
this.appEvents.off("chat:focus-composer", this, "_focusTextArea");
this.appEvents.off("chat:insert-text", this, "insertText");
@@ -230,6 +233,7 @@ export default Component.extend(TextareaTextManipulation, {
replyToMsg: this.draft.replyToMsg,
});
+ this._debouncedCaptureMentions();
this._syncUploads(this.draft.uploads);
this.setInReplyToMsg(this.draft.replyToMsg);
}
@@ -294,6 +298,13 @@ export default Component.extend(TextareaTextManipulation, {
this.set("value", value);
this.resizeTextarea();
+ this.typingMention = value.slice(-1) === "@";
+
+ if (this.typingMention && value.slice(-1) === " ") {
+ this.typingMention = false;
+ this._debouncedCaptureMentions();
+ }
+
// throttle, not debounce, because we do eventually want to react during the typing
this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS);
},
@@ -304,6 +315,44 @@ export default Component.extend(TextareaTextManipulation, {
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
},
+ @bind
+ _debouncedCaptureMentions() {
+ this.mentionsTimer = discourseDebounce(
+ this,
+ this._captureMentions,
+ MENTION_DEBOUNCE_MS
+ );
+ },
+
+ @bind
+ _captureMentions() {
+ if (this.siteSettings.enable_mentions) {
+ const mentions = this._extractMentions();
+ this.onMentionUpdates(mentions);
+ }
+ },
+
+ _extractMentions() {
+ let message = this.value;
+ const regex = mentionRegex(this.siteSettings.unicode_usernames);
+ const mentions = [];
+ let mentionsLeft = true;
+
+ while (mentionsLeft) {
+ const matches = message.match(regex);
+
+ if (matches) {
+ const mention = matches[1] || matches[2];
+ mentions.push(mention);
+ message = message.replaceAll(`${mention}`, "");
+ } else {
+ mentionsLeft = false;
+ }
+ }
+
+ return mentions;
+ },
+
@bind
_blurInput() {
document.activeElement?.blur();
@@ -350,6 +399,7 @@ export default Component.extend(TextareaTextManipulation, {
afterComplete: (text) => {
this.set("value", text);
this._focusTextArea();
+ this._debouncedCaptureMentions();
},
});
}
@@ -660,6 +710,7 @@ export default Component.extend(TextareaTextManipulation, {
value: "",
inReplyMsg: null,
});
+ this.onMentionUpdates([]);
this._syncUploads([]);
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs
index 875518b318..2e1696714d 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs
@@ -27,6 +27,13 @@
+
+
{{else}}
{{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}}
-
+
{{else}}
{{/if}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js
index db465aa10c..47db5ee387 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js
@@ -38,6 +38,12 @@ const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500;
const PAST = "past";
const FUTURE = "future";
+const MENTION_RESULT = {
+ invalid: -1,
+ unreachable: 0,
+ over_members_limit: 1,
+};
+
export default Component.extend({
classNameBindings: [":chat-live-pane", "sendingLoading", "loading"],
chatChannel: null,
@@ -68,6 +74,14 @@ export default Component.extend({
targetMessageId: null,
hasNewMessages: null,
+ // Track mention hints to display warnings
+ unreachableGroupMentions: null, // Array
+ overMembersLimitGroupMentions: null, // Array
+ tooManyMentions: false,
+ mentionsCount: null,
+ // Complimentary structure to avoid repeating mention checks.
+ _mentionWarningsSeen: null, // Hash
+
chat: service(),
router: service(),
chatEmojiPickerManager: service(),
@@ -82,6 +96,9 @@ export default Component.extend({
this._super(...arguments);
this.set("messages", []);
+ this.set("_mentionWarningsSeen", {});
+ this.set("unreachableGroupMentions", []);
+ this.set("overMembersLimitGroupMentions", []);
},
didInsertElement() {
@@ -1313,6 +1330,81 @@ export default Component.extend({
}
},
+ @action
+ updateMentions(mentions) {
+ const mentionsCount = mentions?.length;
+ this.set("mentionsCount", mentionsCount);
+
+ if (mentionsCount > 0) {
+ if (mentionsCount > this.siteSettings.max_mentions_per_chat_message) {
+ this.set("tooManyMentions", true);
+ } else {
+ this.set("tooManyMentions", false);
+ const newMentions = mentions.filter(
+ (mention) => !(mention in this._mentionWarningsSeen)
+ );
+
+ if (newMentions?.length > 0) {
+ this._recordNewWarnings(newMentions, mentions);
+ } else {
+ this._rebuildWarnings(mentions);
+ }
+ }
+ } else {
+ this.set("tooManyMentions", false);
+ this.set("unreachableGroupMentions", []);
+ this.set("overMembersLimitGroupMentions", []);
+ }
+ },
+
+ _recordNewWarnings(newMentions, mentions) {
+ ajax("/chat/api/mentions/groups.json", {
+ data: { mentions: newMentions },
+ })
+ .then((newWarnings) => {
+ newWarnings.unreachable.forEach((warning) => {
+ this._mentionWarningsSeen[warning] = MENTION_RESULT["unreachable"];
+ });
+
+ newWarnings.over_members_limit.forEach((warning) => {
+ this._mentionWarningsSeen[warning] =
+ MENTION_RESULT["over_members_limit"];
+ });
+
+ newWarnings.invalid.forEach((warning) => {
+ this._mentionWarningsSeen[warning] = MENTION_RESULT["invalid"];
+ });
+
+ this._rebuildWarnings(mentions);
+ })
+ .catch(this._rebuildWarnings(mentions));
+ },
+
+ _rebuildWarnings(mentions) {
+ const newWarnings = mentions.reduce(
+ (memo, mention) => {
+ if (
+ mention in this._mentionWarningsSeen &&
+ !(this._mentionWarningsSeen[mention] === MENTION_RESULT["invalid"])
+ ) {
+ if (
+ this._mentionWarningsSeen[mention] === MENTION_RESULT["unreachable"]
+ ) {
+ memo[0].push(mention);
+ } else {
+ memo[1].push(mention);
+ }
+ }
+
+ return memo;
+ },
+ [[], []]
+ );
+
+ this.set("unreachableGroupMentions", newWarnings[0]);
+ this.set("overMembersLimitGroupMentions", newWarnings[1]);
+ },
+
@action
reStickScrollIfNeeded() {
if (this.stickyScroll) {
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs
new file mode 100644
index 0000000000..a224e358aa
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs
@@ -0,0 +1,24 @@
+{{#if this.show}}
+
+
+ {{d-icon "exclamation-triangle"}}
+
+
+
+
+ {{#if @tooManyMentions}}
+ - {{this.tooManyMentionsBody}}
+ {{else}}
+ {{#if @unreachableGroupMentions}}
+ - {{this.unreachableBody}}
+ {{/if}}
+ {{#if @overMembersLimitGroupMentions}}
+ - {{this.overMembersLimitBody}}
+ {{/if}}
+ {{/if}}
+
+
+
+{{/if}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js
new file mode 100644
index 0000000000..e97bfefbda
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js
@@ -0,0 +1,163 @@
+import Component from "@glimmer/component";
+import I18n from "I18n";
+import { htmlSafe } from "@ember/template";
+import { inject as service } from "@ember/service";
+
+export default class ChatMentionWarnings extends Component {
+ @service siteSettings;
+ @service currentUser;
+
+ get unreachableGroupMentionsCount() {
+ return this.args?.unreachableGroupMentions.length;
+ }
+
+ get overMembersLimitMentionsCount() {
+ return this.args?.overMembersLimitGroupMentions.length;
+ }
+
+ get hasTooManyMentions() {
+ return this.args?.tooManyMentions;
+ }
+
+ get hasUnreachableGroupMentions() {
+ return this.unreachableGroupMentionsCount > 0;
+ }
+
+ get hasOverMembersLimitGroupMentions() {
+ return this.overMembersLimitMentionsCount > 0;
+ }
+
+ get warningsCount() {
+ return (
+ this.unreachableGroupMentionsCount + this.overMembersLimitMentionsCount
+ );
+ }
+
+ get show() {
+ return (
+ this.hasTooManyMentions ||
+ this.hasUnreachableGroupMentions ||
+ this.hasOverMembersLimitGroupMentions
+ );
+ }
+
+ get listStyleClass() {
+ if (this.hasTooManyMentions) {
+ return "chat-mention-warnings-list__simple";
+ }
+
+ if (this.warningsCount > 1) {
+ return "chat-mention-warnings-list__multiple";
+ } else {
+ return "chat-mention-warnings-list__simple";
+ }
+ }
+
+ get warningHeaderText() {
+ if (
+ this.args?.mentionsCount <= this.warningsCount ||
+ this.hasTooManyMentions
+ ) {
+ return I18n.t("chat.mention_warning.groups.header.all");
+ } else {
+ return I18n.t("chat.mention_warning.groups.header.some");
+ }
+ }
+
+ get tooManyMentionsBody() {
+ if (!this.hasTooManyMentions) {
+ return;
+ }
+
+ let notificationLimit = I18n.t(
+ "chat.mention_warning.groups.notification_limit"
+ );
+
+ if (this.currentUser.staff) {
+ notificationLimit = htmlSafe(
+ `
+ ${notificationLimit}
+ `
+ );
+ }
+
+ const settingLimit = I18n.t("chat.mention_warning.mentions_limit", {
+ count: this.siteSettings.max_mentions_per_chat_message,
+ });
+
+ return htmlSafe(
+ I18n.t("chat.mention_warning.too_many_mentions", {
+ notification_limit: notificationLimit,
+ limit: settingLimit,
+ })
+ );
+ }
+
+ get unreachableBody() {
+ if (!this.hasUnreachableGroupMentions) {
+ return;
+ }
+
+ if (this.unreachableGroupMentionsCount <= 2) {
+ return I18n.t("chat.mention_warning.groups.unreachable", {
+ group: this.args.unreachableGroupMentions[0],
+ group_2: this.args.unreachableGroupMentions[1],
+ count: this.unreachableGroupMentionsCount,
+ });
+ } else {
+ return I18n.t("chat.mention_warning.groups.unreachable_multiple", {
+ group: this.args.unreachableGroupMentions[0],
+ count: this.unreachableGroupMentionsCount - 1, //N others
+ });
+ }
+ }
+
+ get overMembersLimitBody() {
+ if (!this.hasOverMembersLimitGroupMentions) {
+ return;
+ }
+
+ let notificationLimit = I18n.t(
+ "chat.mention_warning.groups.notification_limit"
+ );
+
+ if (this.currentUser.staff) {
+ notificationLimit = htmlSafe(
+ `
+ ${notificationLimit}
+ `
+ );
+ }
+
+ const settingLimit = I18n.t("chat.mention_warning.groups.users_limit", {
+ count: this.siteSettings.max_users_notified_per_group_mention,
+ });
+
+ if (this.hasOverMembersLimitGroupMentions <= 2) {
+ return htmlSafe(
+ I18n.t("chat.mention_warning.groups.too_many_members", {
+ group: this.args.overMembersLimitGroupMentions[0],
+ group_2: this.args.overMembersLimitGroupMentions[1],
+ count: this.overMembersLimitMentionsCount,
+ notification_limit: notificationLimit,
+ limit: settingLimit,
+ })
+ );
+ } else {
+ return htmlSafe(
+ I18n.t("chat.mention_warning.groups.too_many_members_multiple", {
+ group: this.args.overMembersLimitGroupMentions[0],
+ count: this.overMembersLimitMentionsCount - 1, //N others
+ notification_limit: notificationLimit,
+ limit: settingLimit,
+ })
+ );
+ }
+ }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
index 4aa7c8c82d..f93d4213ca 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs
@@ -122,25 +122,25 @@
{{/if}}
- {{#if this.message.mentionWarning}}
+ {{#if this.mentionWarning}}
- {{#if this.message.mentionWarning.invitationSent}}
+ {{#if this.mentionWarning.invitationSent}}
{{d-icon "check"}}
{{i18n
"chat.mention_warning.invitations_sent"
- count=this.message.mentionWarning.without_membership.length
+ count=this.mentionWarning.without_membership.length
}}
{{else}}
- {{#if this.message.mentionWarning.cannot_see}}
-
{{this.mentionedCannotSeeText}}
+ {{#if this.mentionWarning.cannot_see}}
+
{{this.mentionedCannotSeeText}}
{{/if}}
- {{#if this.message.mentionWarning.without_membership}}
-
+ {{#if this.mentionWarning.without_membership}}
+
{{this.mentionedWithoutMembershipText}}
{{/if}}
+ {{#if this.mentionWarning.group_mentions_disabled}}
+
{{this.groupsWithDisabledMentions}}
+ {{/if}}
+
+ {{#if this.mentionWarning.groups_with_too_many_members}}
+
{{this.groupsWithTooManyMembers}}
+ {{/if}}
{{/if}}
{{/if}}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
index 27d951316a..867610bdf3 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js
@@ -41,7 +41,6 @@ export default Component.extend({
canInteractWithChat: false,
isHovered: false,
onHoverMessage: null,
- mentionWarning: null,
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
adminTools: optionalService(),
@@ -445,25 +444,56 @@ export default Component.extend({
return Object.values(reactions).some((r) => r.count > 0);
},
- @discourseComputed("message.mentionWarning.cannot_see")
+ @discourseComputed("message.mentionWarning")
+ mentionWarning() {
+ return this.message.mentionWarning;
+ },
+
+ @discourseComputed("mentionWarning.cannot_see")
mentionedCannotSeeText(users) {
return I18n.t("chat.mention_warning.cannot_see", {
- usernames: users.mapBy("username").join(", "),
+ username: users[0].username,
count: users.length,
+ others: this._othersTranslation(users.length - 1),
});
},
- @discourseComputed("message.mentionWarning.without_membership")
+ @discourseComputed("mentionWarning.without_membership")
mentionedWithoutMembershipText(users) {
return I18n.t("chat.mention_warning.without_membership", {
- usernames: users.mapBy("username").join(", "),
+ username: users[0].username,
count: users.length,
+ others: this._othersTranslation(users.length - 1),
+ });
+ },
+
+ @discourseComputed("mentionWarning.group_mentions_disabled")
+ groupsWithDisabledMentions(groups) {
+ return I18n.t("chat.mention_warning.group_mentions_disabled", {
+ group_name: groups[0],
+ count: groups.length,
+ others: this._othersTranslation(groups.length - 1),
+ });
+ },
+
+ @discourseComputed("mentionWarning.groups_with_too_many_members")
+ groupsWithTooManyMembers(groups) {
+ return I18n.t("chat.mention_warning.too_many_members", {
+ group_name: groups[0],
+ count: groups.length,
+ others: this._othersTranslation(groups.length - 1),
+ });
+ },
+
+ _othersTranslation(othersCount) {
+ return I18n.t("chat.mention_warning.warning_multiple", {
+ count: othersCount,
});
},
@action
inviteMentioned() {
- const user_ids = this.message.mentionWarning.without_membership.mapBy("id");
+ const user_ids = this.mentionWarning.without_membership.mapBy("id");
ajax(`/chat/${this.details.chat_channel_id}/invite`, {
method: "PUT",
diff --git a/plugins/chat/assets/stylesheets/common/chat-mention-warnings.scss b/plugins/chat/assets/stylesheets/common/chat-mention-warnings.scss
new file mode 100644
index 0000000000..09b80e9241
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/common/chat-mention-warnings.scss
@@ -0,0 +1,30 @@
+.chat-mention-warnings {
+ display: flex;
+ background: var(--tertiary-low);
+ padding: 0.5em 0 0.5em 1em;
+ color: var(--primary);
+ margin: 0.5em;
+
+ .chat-mention-warning__icon,
+ .chat-mention-warning__text {
+ margin: 0.5em;
+ }
+
+ .chat-mention-warnings-list__simple {
+ margin: 0.5em 0 0 0;
+ list-style: none;
+ }
+
+ .chat-mention-warnings-list__multiple {
+ margin: 0.5em 0 0 1em;
+ }
+
+ .chat-mention-warning__header,
+ .chat-mention-warning__icon {
+ font-size: var(--font-up-2);
+ }
+}
+
+.full-page-chat .chat-mention-warnings {
+ top: 4rem;
+}
diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss
index 2ca098e21b..6eb92b80ff 100644
--- a/plugins/chat/assets/stylesheets/common/chat-message.scss
+++ b/plugins/chat/assets/stylesheets/common/chat-message.scss
@@ -213,13 +213,12 @@
.dismiss-mention-warning {
position: absolute;
- top: 5px;
+ top: 15px;
right: 5px;
cursor: pointer;
}
- .cannot-see,
- .without-membership {
+ .warning-item {
margin: 0.25em 0;
}
diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml
index 93215b1a59..d18182eea7 100644
--- a/plugins/chat/config/locales/client.en.yml
+++ b/plugins/chat/config/locales/client.en.yml
@@ -105,17 +105,47 @@ en:
join: "Join"
new_messages: "new messages"
mention_warning:
- cannot_see:
- one: "%{usernames} cannot access this channel and was not notified."
- other: "%{usernames} cannot access this channel and were not notified."
dismiss: "dismiss"
+ cannot_see:
+ one: "%{username} cannot access this channel and was not notified."
+ other: "%{username} and %{others} cannot access this channel and were not notified."
invitations_sent:
one: "Invitation sent"
other: "Invitations sent"
invite: "Invite to channel"
without_membership:
- one: "%{usernames} has not joined this channel."
- other: "%{usernames} have not joined this channel."
+ one: "%{username} has not joined this channel."
+ other: "%{username} and %{others} have not joined this channel."
+ group_mentions_disabled:
+ one: "%{group_name} doesn't allow mentions"
+ other: "%{group_name} and %{others} doesn't allow mentions"
+ too_many_members:
+ one: "%{group_name} has too many members. No one was notified"
+ other: "%{group_name} and %{others} have too many members. No one was notified"
+ warning_multiple:
+ one: "%{count} other"
+ other: "%{count} others"
+
+ groups:
+ header:
+ some: "Some users won't be notified"
+ all: "Nobody will be notified"
+ unreachable:
+ one: "@%{group} doesn't allow mentions"
+ other: "@%{group} and @%{group_2} doesn't allow mentions"
+ unreachable_multiple: "@%{group} and %{count} others doesn't allow mentions"
+ too_many_members:
+ one: "Mentioning @%{group} exceeds the %{notification_limit} of %{limit}"
+ other: "Mentioning both @%{group} or @%{group_2} exceeds the %{notification_limit} of %{limit}"
+ too_many_members_multiple: "These %{count} groups exceed the %{notification_limit} of %{limit}"
+ users_limit:
+ one: "%{count} user"
+ other: "%{count} users"
+ notification_limit: "notification limit"
+ too_many_mentions: "This message exceeds the %{notification_limit} of %{limit}"
+ mentions_limit:
+ one: "%{count} mention"
+ other: "%{count} mentions"
aria_roles:
header: "Chat header"
composer: "Chat composer"
diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml
index 0a9838ce72..ce7ada209d 100644
--- a/plugins/chat/config/locales/server.en.yml
+++ b/plugins/chat/config/locales/server.en.yml
@@ -17,6 +17,7 @@ en:
default_emoji_reactions: "Default emoji reactions for chat messages. Add up to 5 emojis for quick reaction."
direct_message_enabled_groups: "Allow users within these groups to create user-to-user Personal Chats. Note: staff can always create Personal Chats, and users will be able to reply to Personal Chats initiated by users who have permission to create them."
chat_message_flag_allowed_groups: "Users in these groups are allowed to flag chat messages."
+ max_mentions_per_chat_message: "Maximum number of @name notifications a user can use in a chat message."
chat_max_direct_message_users: "Users cannot add more than this number of other users when creating a new direct message. Set to 0 to only allow messages to oneself. Staff are exempt from this setting."
chat_allow_archiving_channels: "Allow staff to archive messages to a topic when closing a channel."
errors:
diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml
index be2d86cf9f..36e9fd597b 100644
--- a/plugins/chat/config/settings.yml
+++ b/plugins/chat/config/settings.yml
@@ -104,3 +104,9 @@ chat:
client: true
allow_any: false
refresh: true
+ max_mentions_per_chat_message:
+ client: true
+ type: integer
+ default: 5
+ max: 10
+ min: 0
diff --git a/plugins/chat/lib/chat_notifier.rb b/plugins/chat/lib/chat_notifier.rb
index e8a8d66297..4aada3a946 100644
--- a/plugins/chat/lib/chat_notifier.rb
+++ b/plugins/chat/lib/chat_notifier.rb
@@ -56,17 +56,13 @@ class Chat::ChatNotifier
def notify_new
to_notify = list_users_to_notify
- inaccessible = to_notify.extract!(:unreachable, :welcome_to_join)
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
mentioned_user_ids.each do |member_id|
ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id)
end
- notify_creator_of_inaccessible_mentions(
- inaccessible[:unreachable],
- inaccessible[:welcome_to_join],
- )
+ notify_creator_of_inaccessible_mentions(to_notify)
notify_mentioned_users(to_notify)
notify_watching_users(except: mentioned_user_ids << @user.id)
@@ -80,7 +76,6 @@ class Chat::ChatNotifier
already_notified_user_ids = existing_notifications.map(&:user_id)
to_notify = list_users_to_notify
- inaccessible = to_notify.extract!(:unreachable, :welcome_to_join)
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
needs_deletion = already_notified_user_ids - mentioned_user_ids
@@ -93,10 +88,7 @@ class Chat::ChatNotifier
needs_notification_ids = mentioned_user_ids - already_notified_user_ids
return if needs_notification_ids.blank?
- notify_creator_of_inaccessible_mentions(
- inaccessible[:unreachable],
- inaccessible[:welcome_to_join],
- )
+ notify_creator_of_inaccessible_mentions(to_notify)
notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids)
@@ -106,16 +98,23 @@ class Chat::ChatNotifier
private
def list_users_to_notify
+ direct_mentions_count = direct_mentions_from_cooked.length
+ group_mentions_count = group_name_mentions.length
+
+ skip_notifications =
+ (direct_mentions_count + group_mentions_count) >
+ SiteSetting.max_mentions_per_chat_message
+
{}.tap do |to_notify|
# The order of these methods is the precedence
# between different mention types.
already_covered_ids = []
- expand_direct_mentions(to_notify, already_covered_ids)
- expand_group_mentions(to_notify, already_covered_ids)
- expand_here_mention(to_notify, already_covered_ids)
- expand_global_mention(to_notify, already_covered_ids)
+ expand_direct_mentions(to_notify, already_covered_ids, skip_notifications)
+ expand_group_mentions(to_notify, already_covered_ids, skip_notifications)
+ expand_here_mention(to_notify, already_covered_ids, skip_notifications)
+ expand_global_mention(to_notify, already_covered_ids, skip_notifications)
filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
@@ -161,10 +160,10 @@ class Chat::ChatNotifier
end
end
- def expand_global_mention(to_notify, already_covered_ids)
+ def expand_global_mention(to_notify, already_covered_ids, skip)
typed_global_mention = direct_mentions_from_cooked.include?("@all")
- if typed_global_mention && @chat_channel.allow_channel_wide_mentions
+ if typed_global_mention && @chat_channel.allow_channel_wide_mentions && !skip
to_notify[:global_mentions] = members_accepting_channel_wide_notifications
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
.where.not(id: already_covered_ids)
@@ -176,10 +175,10 @@ class Chat::ChatNotifier
end
end
- def expand_here_mention(to_notify, already_covered_ids)
+ def expand_here_mention(to_notify, already_covered_ids, skip)
typed_here_mention = direct_mentions_from_cooked.include?("@here")
- if typed_here_mention && @chat_channel.allow_channel_wide_mentions
+ if typed_here_mention && @chat_channel.allow_channel_wide_mentions && !skip
to_notify[:here_mentions] = members_accepting_channel_wide_notifications
.where("last_seen_at > ?", 5.minutes.ago)
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
@@ -215,11 +214,15 @@ class Chat::ChatNotifier
}
end
- def expand_direct_mentions(to_notify, already_covered_ids)
- direct_mentions =
- chat_users
- .where(username_lower: normalized_mentions(direct_mentions_from_cooked))
- .where.not(id: already_covered_ids)
+ def expand_direct_mentions(to_notify, already_covered_ids, skip)
+ if skip
+ direct_mentions = []
+ else
+ direct_mentions =
+ chat_users
+ .where(username_lower: normalized_mentions(direct_mentions_from_cooked))
+ .where.not(id: already_covered_ids)
+ end
grouped = group_users_to_notify(direct_mentions)
@@ -236,47 +239,62 @@ class Chat::ChatNotifier
)
end
- def mentionable_groups
- @mentionable_groups ||=
- Group.mentionable(@user, include_public: false).where(
- "LOWER(name) IN (?)",
- group_name_mentions,
- )
+ def visible_groups
+ @visible_groups ||=
+ Group
+ .where("LOWER(name) IN (?)", group_name_mentions)
+ .visible_groups(@user)
end
- def expand_group_mentions(to_notify, already_covered_ids)
- return [] if mentionable_groups.empty?
+ def expand_group_mentions(to_notify, already_covered_ids, skip)
+ return [] if skip || visible_groups.empty?
- mentionable_groups.each { |g| to_notify[g.name.downcase] = [] }
+ mentionable_groups = Group
+ .mentionable(@user, include_public: false)
+ .where(id: visible_groups.map(&:id))
+
+ mentions_disabled = visible_groups - mentionable_groups
+
+ too_many_members, mentionable = mentionable_groups.partition do |group|
+ group.user_count > SiteSetting.max_users_notified_per_group_mention
+ end
+
+ to_notify[:group_mentions_disabled] = mentions_disabled
+ to_notify[:too_many_members] = too_many_members
+
+ mentionable.each { |g| to_notify[g.name.downcase] = [] }
reached_by_group =
- chat_users.joins(:groups).where(groups: mentionable_groups).where.not(id: already_covered_ids)
+ chat_users.joins(:groups).where(groups: mentionable).where.not(id: already_covered_ids)
grouped = group_users_to_notify(reached_by_group)
grouped[:already_participating].each do |user|
# When a user is a member of multiple mentioned groups,
# the most far to the left should take precedence.
- ordered_group_names = group_name_mentions & mentionable_groups.map { |mg| mg.name.downcase }
+ ordered_group_names = group_name_mentions & mentionable.map { |mg| mg.name.downcase }
user_group_names = user.groups.map { |ug| ug.name.downcase }
group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) }
to_notify[group_name] << user.id
+ already_covered_ids << user.id
end
- already_covered_ids.concat(grouped[:already_participating])
to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join])
to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable])
end
- def notify_creator_of_inaccessible_mentions(unreachable, welcome_to_join)
- return if unreachable.empty? && welcome_to_join.empty?
+ def notify_creator_of_inaccessible_mentions(to_notify)
+ inaccessible = to_notify.extract!(:unreachable, :welcome_to_join, :too_many_members, :group_mentions_disabled)
+ return if inaccessible.values.all?(&:blank?)
ChatPublisher.publish_inaccessible_mentions(
@user.id,
@chat_message,
- unreachable,
- welcome_to_join,
+ inaccessible[:unreachable].to_a,
+ inaccessible[:welcome_to_join].to_a,
+ inaccessible[:too_many_members].to_a,
+ inaccessible[:group_mentions_disabled].to_a
)
end
@@ -284,30 +302,28 @@ class Chat::ChatNotifier
# ignoring or muting the creator of the message, so they will not receive
# a notification via the ChatNotifyMentioned job and are not prompted for
# invitation by the creator.
- #
- # already_covered_ids and to_notify sometimes contain IDs and sometimes contain
- # Users, hence the gymnastics to resolve the user_id
def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
- user_ids_to_screen =
- already_covered_ids
- .map { |ac| user_id_resolver(ac) }
- .concat(to_notify.values.flatten.map { |tn| user_id_resolver(tn) })
- .uniq
- screener = UserCommScreener.new(acting_user: @user, target_user_ids: user_ids_to_screen)
+ screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id))
+
+ return if screen_targets.blank?
+
+ screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets)
to_notify
- .except(:unreachable)
- .each do |key, users_or_ids|
- to_notify[key] = users_or_ids.reject do |user_or_id|
- screener.ignoring_or_muting_actor?(user_id_resolver(user_or_id))
+ .except(:unreachable, :welcome_to_join)
+ .each do |key, user_ids|
+ to_notify[key] = user_ids.reject do |user_id|
+ screener.ignoring_or_muting_actor?(user_id)
end
end
- already_covered_ids.reject! do |already_covered|
- screener.ignoring_or_muting_actor?(user_id_resolver(already_covered))
- end
- end
- def user_id_resolver(obj)
- obj.is_a?(User) ? obj.id : obj
+ # :welcome_to_join contains users because it's serialized by MB.
+ to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user|
+ screener.ignoring_or_muting_actor?(user.id)
+ end
+
+ already_covered_ids.reject! do |already_covered|
+ screener.ignoring_or_muting_actor?(already_covered)
+ end
end
def notify_mentioned_users(to_notify, already_notified_user_ids: [])
diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb
index b4fbd64ebe..fc5d33fdc5 100644
--- a/plugins/chat/plugin.rb
+++ b/plugins/chat/plugin.rb
@@ -66,6 +66,7 @@ register_asset "stylesheets/common/chat-onebox.scss"
register_asset "stylesheets/common/chat-skeleton.scss"
register_asset "stylesheets/colors.scss", :color_definitions
register_asset "stylesheets/common/reviewable-chat-message.scss"
+register_asset "stylesheets/common/chat-mention-warnings.scss"
register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss"
register_svg_icon "comments"
@@ -212,6 +213,7 @@ after_initialize do
__FILE__,
)
load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__)
+ load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__)
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
if Discourse.allow_dev_populate?
@@ -585,10 +587,13 @@ after_initialize do
put "/chat_channels/:chat_channel_id/notifications_settings" =>
"chat_channel_notifications_settings#update"
- # hints controller. Only used by staff members, we don't want to leak category permissions.
+ # Category chatables controller hints. Only used by staff members, we don't want to leak category permissions.
get "/category-chatables/:id/permissions" => "category_chatables#permissions",
:format => :json,
:constraints => StaffConstraint.new
+
+ # Hints for JIT warnings.
+ get "/mentions/groups" => "hints#check_group_mentions", :format => :json
end
# direct_messages_controller routes
diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat_notifier_spec.rb
index 4483777567..d452e15263 100644
--- a/plugins/chat/spec/lib/chat_notifier_spec.rb
+++ b/plugins/chat/spec/lib/chat_notifier_spec.rb
@@ -297,6 +297,38 @@ describe Chat::ChatNotifier do
expect(to_notify_2[@chat_group.name]).to be_empty
end
+ it "skips groups with too many members" do
+ SiteSetting.max_users_notified_per_group_mention = (group.user_count - 1)
+
+ msg = build_cooked_msg("Hello @#{group.name}", user_1)
+
+ to_notify = described_class.new(msg, msg.created_at).notify_new
+
+ expect(to_notify[group.name]).to be_nil
+ end
+
+ it "respects the 'max_mentions_per_chat_message' setting and skips notifications" do
+ SiteSetting.max_mentions_per_chat_message = 1
+
+ msg = build_cooked_msg("Hello @#{user_2.username} and @#{user_3.username}", user_1)
+
+ to_notify = described_class.new(msg, msg.created_at).notify_new
+
+ expect(to_notify[:direct_mentions]).to be_empty
+ expect(to_notify[group.name]).to be_nil
+ end
+
+ it "respects the max mentions setting and skips notifications when mixing users and groups" do
+ SiteSetting.max_mentions_per_chat_message = 1
+
+ msg = build_cooked_msg("Hello @#{user_2.username} and @#{group.name}", user_1)
+
+ to_notify = described_class.new(msg, msg.created_at).notify_new
+
+ expect(to_notify[:direct_mentions]).to be_empty
+ expect(to_notify[group.name]).to be_nil
+ end
+
describe "users ignoring or muting the user creating the message" do
it "does not send notifications to the user inside the group who is muting the acting user" do
group.add(user_3)
@@ -341,7 +373,7 @@ describe Chat::ChatNotifier do
expect(unreachable_msg).to be_present
expect(unreachable_msg.data[:without_membership]).to be_empty
- unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] }
+ unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u["id"] }
expect(unreachable_users).to contain_exactly(user_3.id)
end
@@ -375,7 +407,7 @@ describe Chat::ChatNotifier do
expect(unreachable_msg).to be_present
expect(unreachable_msg.data[:without_membership]).to be_empty
- unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] }
+ unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u["id"] }
expect(unreachable_users).to contain_exactly(user_3.id)
end
@@ -400,7 +432,7 @@ describe Chat::ChatNotifier do
expect(unreachable_msg).to be_present
expect(unreachable_msg.data[:without_membership]).to be_empty
- unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] }
+ unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u["id"] }
expect(unreachable_users).to contain_exactly(user_3.id)
end
end
@@ -425,7 +457,7 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
- not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] }
+ not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@@ -477,7 +509,7 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
- not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] }
+ not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@@ -501,7 +533,7 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
- not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] }
+ not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@@ -545,5 +577,48 @@ describe Chat::ChatNotifier do
expect(messages).to be_empty
end
end
+
+ describe "enforcing limits when mentioning groups" do
+ fab!(:user_3) { Fabricate(:user) }
+ fab!(:group) do
+ Fabricate(
+ :public_group,
+ users: [user_2, user_3],
+ mentionable_level: Group::ALIAS_LEVELS[:everyone],
+ )
+ end
+
+ it "sends a message to the client signaling the group has too many members" do
+ SiteSetting.max_users_notified_per_group_mention = (group.user_count - 1)
+ msg = build_cooked_msg("Hello @#{group.name}", user_1)
+
+ messages = MessageBus.track_publish("/chat/#{channel.id}") do
+ to_notify = described_class.new(msg, msg.created_at).notify_new
+
+ expect(to_notify[group.name]).to be_nil
+ end
+
+ too_many_members_msg = messages.first
+ expect(too_many_members_msg).to be_present
+ too_many_members = too_many_members_msg.data[:groups_with_too_many_members]
+ expect(too_many_members).to contain_exactly(group.name)
+ end
+
+ it "sends a message to the client signaling the group doesn't allow mentions" do
+ group.update!(mentionable_level: Group::ALIAS_LEVELS[:only_admins])
+ msg = build_cooked_msg("Hello @#{group.name}", user_1)
+
+ messages = MessageBus.track_publish("/chat/#{channel.id}") do
+ to_notify = described_class.new(msg, msg.created_at).notify_new
+
+ expect(to_notify[group.name]).to be_nil
+ end
+
+ mentions_disabled_msg = messages.first
+ expect(mentions_disabled_msg).to be_present
+ mentions_disabled = mentions_disabled_msg.data[:group_mentions_disabled]
+ expect(mentions_disabled).to contain_exactly(group.name)
+ end
+ end
end
end
diff --git a/plugins/chat/spec/requests/api/hints_controller_spec.rb b/plugins/chat/spec/requests/api/hints_controller_spec.rb
new file mode 100644
index 0000000000..1ca4d0b10f
--- /dev/null
+++ b/plugins/chat/spec/requests/api/hints_controller_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+RSpec.describe Chat::Api::HintsController do
+ describe "#check_group_mentions" do
+ context "for anons" do
+ it "returns a 404" do
+ get "/chat/api/mentions/groups.json", params: { mentions: %w[group1] }
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context "for logged in users" do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:mentionable_group) { Fabricate(:group, mentionable_level: Group::ALIAS_LEVELS[:everyone]) }
+ fab!(:admin_mentionable_group) { Fabricate(:group, mentionable_level: Group::ALIAS_LEVELS[:only_admins]) }
+
+ before { sign_in(user) }
+
+ it "returns a 400 when no mentions are given" do
+ get "/chat/api/mentions/groups.json"
+
+ expect(response.status).to eq(400)
+ end
+
+ it "returns a warning when a group is not mentionable" do
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [mentionable_group.name, admin_mentionable_group.name]
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["unreachable"]).to contain_exactly(admin_mentionable_group.name)
+ end
+
+ it "returns no warning if the user is allowed to mention" do
+ user.update!(admin: true)
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [mentionable_group.name, admin_mentionable_group.name]
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["unreachable"]).to be_empty
+ end
+
+ it "returns a warning if the group has too many users" do
+ user_1 = Fabricate(:user)
+ user_2 = Fabricate(:user)
+ mentionable_group.add(user_1)
+ mentionable_group.add(user_2)
+ SiteSetting.max_users_notified_per_group_mention = 1
+
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [mentionable_group.name, admin_mentionable_group.name]
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["over_members_limit"]).to contain_exactly(mentionable_group.name)
+ end
+
+ it "returns no warnings when the group doesn't exist" do
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: ["a_fake_group"]
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["unreachable"]).to be_empty
+ expect(response.parsed_body["over_members_limit"]).to be_empty
+ end
+
+ it "doesn't leak groups that are not visible" do
+ invisible_group = Fabricate(:group,
+ visibility_level: Group.visibility_levels[:staff],
+ mentionable_level: Group::ALIAS_LEVELS[:only_admins]
+ )
+
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [invisible_group.name]
+ }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["unreachable"]).to be_empty
+ expect(response.parsed_body["over_members_limit"]).to be_empty
+ expect(response.parsed_body["invalid"]).to contain_exactly(invisible_group.name)
+ end
+
+ it "triggers a rate-limit on too many requests" do
+ RateLimiter.enable
+
+ 5.times do
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [mentionable_group.name]
+ }
+ end
+
+ get "/chat/api/mentions/groups.json", params: {
+ mentions: [mentionable_group.name]
+ }
+
+ expect(response.status).to eq(429)
+ end
+ end
+ end
+end
diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
index 262e722d05..4b671cb5ed 100644
--- a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
+++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js
@@ -17,6 +17,8 @@ import {
chatChannelPretender,
} from "../helpers/chat-pretenders";
+const GROUP_NAME = "group1";
+
acceptance("Discourse Chat - Composer", function (needs) {
needs.user({ has_chat_enabled: true });
needs.settings({ chat_enabled: true, enable_rich_text_paste: true });
@@ -32,6 +34,14 @@ acceptance("Discourse Chat - Composer", function (needs) {
server.post("/chat/drafts", () => {
return helper.response([]);
});
+
+ server.get("/chat/api/mentions/groups.json", () => {
+ return helper.response({
+ unreachable: [GROUP_NAME],
+ over_members_limit: [],
+ invalid: [],
+ });
+ });
});
needs.hooks.beforeEach(function () {
@@ -105,6 +115,18 @@ acceptance("Discourse Chat - Composer", function (needs) {
"it tracks the emoji"
);
});
+
+ test("JIT warnings for group mentions", async function (assert) {
+ await visit("/chat/channel/11/-");
+ await fillIn(".chat-composer-input", `@${GROUP_NAME}`);
+
+ assert.equal(
+ query(".chat-mention-warnings .chat-mention-warnings-list__simple li")
+ .innerText,
+ `@${GROUP_NAME} doesn't allow mentions`,
+ "displays a warning when the group is unreachable"
+ );
+ });
});
let sendAttempt = 0;
diff --git a/plugins/chat/test/javascripts/acceptance/chat-test.js b/plugins/chat/test/javascripts/acceptance/chat-test.js
index a437de0c54..3f41e506e6 100644
--- a/plugins/chat/test/javascripts/acceptance/chat-test.js
+++ b/plugins/chat/test/javascripts/acceptance/chat-test.js
@@ -864,7 +864,7 @@ Widget.triangulate(arg: "test")
".chat-message-container[data-id='176'] .chat-message-mention-warning .without-membership"
).innerText;
assert.ok(withoutMembershipText.includes("eviltrout"));
- assert.ok(withoutMembershipText.includes("sam"));
+ assert.ok(withoutMembershipText.includes("1 other"));
await click(
".chat-message-container[data-id='176'] .chat-message-mention-warning .invite-link"