From 9c8043a4d2f780dc10627d8de5baf145572bbb2f Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 6 Dec 2022 14:54:04 -0300 Subject: [PATCH] FEATURE: Enforce mention limits for chat messages (#19034) * FEATURE: Enforce mention limits for chat messages The first part of these changes adds a new setting called `max_mentions_per_chat_message`, which skips notifications when the message contains too many mentions. It also respects the `max_users_notified_per_group_mention` setting and skips notifications if expanding a group mention would exceed it. We also include a new component to display JIT warning for these limits to the user while composing a message. * Simplify ignoring/muting filter in chat_notifier * Post-send warnings for unsent warnings * Improve pluralization * Address review feedback * Fix test * Address second feedback round * Third round of feedback Co-authored-by: Joffrey JAFFEUX --- .../javascripts/pretty-text/addon/mentions.js | 31 ++++ .../engines/discourse-markdown/mentions.js | 34 +--- config/site_settings.yml | 5 +- .../app/controllers/api/hints_controller.rb | 39 +++++ plugins/chat/app/services/chat_publisher.rb | 18 +- .../discourse/components/chat-composer.js | 59 ++++++- .../discourse/components/chat-live-pane.hbs | 24 ++- .../discourse/components/chat-live-pane.js | 92 ++++++++++ .../components/chat-mention-warnings.hbs | 24 +++ .../components/chat-mention-warnings.js | 163 ++++++++++++++++++ .../discourse/components/chat-message.hbs | 21 ++- .../discourse/components/chat-message.js | 42 ++++- .../common/chat-mention-warnings.scss | 30 ++++ .../stylesheets/common/chat-message.scss | 5 +- plugins/chat/config/locales/client.en.yml | 40 ++++- plugins/chat/config/locales/server.en.yml | 1 + plugins/chat/config/settings.yml | 6 + plugins/chat/lib/chat_notifier.rb | 132 +++++++------- plugins/chat/plugin.rb | 7 +- plugins/chat/spec/lib/chat_notifier_spec.rb | 87 +++++++++- .../requests/api/hints_controller_spec.rb | 103 +++++++++++ .../acceptance/chat-composer-test.js | 22 +++ .../test/javascripts/acceptance/chat-test.js | 2 +- 23 files changed, 851 insertions(+), 136 deletions(-) create mode 100644 app/assets/javascripts/pretty-text/addon/mentions.js create mode 100644 plugins/chat/app/controllers/api/hints_controller.rb create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.js create mode 100644 plugins/chat/assets/stylesheets/common/chat-mention-warnings.scss create mode 100644 plugins/chat/spec/requests/api/hints_controller_spec.rb 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 00000000000..a81fb2c5018 --- /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 02c48ebecae..2342c19dda0 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 c9de05c5e89..d9fd02a919a 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 00000000000..b06d325c532 --- /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 ab7c8bea28a..638903da40f 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 231c7b13ed3..0edc60c7607 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 875518b3183..2e1696714df 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 db465aa10c5..47db5ee3875 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 00000000000..a224e358aad --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-mention-warnings.hbs @@ -0,0 +1,24 @@ +{{#if this.show}} +
+
+ {{d-icon "exclamation-triangle"}} +
+
+
+ {{this.warningHeaderText}} +
+
    + {{#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 00000000000..e97bfefbda3 --- /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 4aa7c8c82d2..f93d4213caf 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 27d951316a0..867610bdf31 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 00000000000..09b80e92410 --- /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 2ca098e21bf..6eb92b80ff5 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 93215b1a59c..d18182eea7e 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 0a9838ce72f..ce7ada209d8 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 be2d86cf9f6..36e9fd597bf 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 e8a8d662978..4aada3a946d 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 b4fbd64ebe2..fc5d33fdc51 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 44837775673..d452e15263c 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 00000000000..1ca4d0b10f6 --- /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 262e722d058..4b671cb5ed4 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 a437de0c54e..3f41e506e6d 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"