#1085 Adds @ mentions to issues and PR comments
Merged 7 years ago by pingou. Opened 7 years ago by atelic.
atelic/pagure feature/at-mentions  into  master

Add @ mentions to issues and prs
Eric Barbour • 7 years ago  
file modified
+7 -1
@@ -271,7 +271,13 @@ 

      return flask.jsonify(

          {

              'total_users': len(users),

-             'users': [user.username for user in users]

+             'users': [user.username for user in users],

+             'mention': [{

+                 'username': user.username,

+                 'name': user.fullname,

+                 'image': pagure.lib.avatar_url_from_openid(user.default_email,

+                                                            size=32)

+             } for user in users]

          }

      )

  

@@ -0,0 +1,335 @@ 

+ /* =============================================================

+  * bootstrap-typeahead.js v2.3.2

+  * http://twitter.github.com/bootstrap/javascript.html#typeahead

+  * =============================================================

+  * Copyright 2012 Twitter, Inc.

+  *

+  * Licensed under the Apache License, Version 2.0 (the "License");

+  * you may not use this file except in compliance with the License.

+  * You may obtain a copy of the License at

+  *

+  * http://www.apache.org/licenses/LICENSE-2.0

+  *

+  * Unless required by applicable law or agreed to in writing, software

+  * distributed under the License is distributed on an "AS IS" BASIS,

+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

+  * See the License for the specific language governing permissions and

+  * limitations under the License.

+  * ============================================================ */

+ 

+ 

+ !function($){

+ 

+   "use strict"; // jshint ;_;

+ 

+ 

+  /* TYPEAHEAD PUBLIC CLASS DEFINITION

+   * ================================= */

+ 

+   var Typeahead = function (element, options) {

+     this.$element = $(element)

+     this.options = $.extend({}, $.fn.typeahead.defaults, options)

+     this.matcher = this.options.matcher || this.matcher

+     this.sorter = this.options.sorter || this.sorter

+     this.highlighter = this.options.highlighter || this.highlighter

+     this.updater = this.options.updater || this.updater

+     this.source = this.options.source

+     this.$menu = $(this.options.menu)

+     this.shown = false

+     this.listen()

+   }

+ 

+   Typeahead.prototype = {

+ 

+     constructor: Typeahead

+ 

+   , select: function () {

+       var val = this.$menu.find('.active').attr('data-value')

+       this.$element

+         .val(this.updater(val))

+         .change()

+       return this.hide()

+     }

+ 

+   , updater: function (item) {

+       return item

+     }

+ 

+   , show: function () {

+       var pos = $.extend({}, this.$element.position(), {

+         height: this.$element[0].offsetHeight

+       })

+ 

+       this.$menu

+         .insertAfter(this.$element)

+         .css({

+           top: (pos.top + pos.height) / 3

+         , left: pos.left

+         })

+         .show()

+ 

+       this.shown = true

+       return this

+     }

+ 

+   , hide: function () {

+       this.$menu.hide()

+       this.shown = false

+       return this

+     }

+ 

+   , lookup: function (event) {

+       var items

+ 

+       this.query = this.$element.val()

+ 

+       if (!this.query || this.query.length < this.options.minLength) {

+         return this.shown ? this.hide() : this

+       }

+ 

+       items = $.isFunction(this.source) ? this.source(this.query, $.proxy(this.process, this)) : this.source

+ 

+       return items ? this.process(items) : this

+     }

+ 

+   , process: function (items) {

+       var that = this

+ 

+       items = $.grep(items, function (item) {

+         return that.matcher(item)

+       })

+ 

+       items = this.sorter(items)

+ 

+       if (!items.length) {

+         return this.shown ? this.hide() : this

+       }

+ 

+       return this.render(items.slice(0, this.options.items)).show()

+     }

+ 

+   , matcher: function (item) {

+       return ~item.toLowerCase().indexOf(this.query.toLowerCase())

+     }

+ 

+   , sorter: function (items) {

+       var beginswith = []

+         , caseSensitive = []

+         , caseInsensitive = []

+         , item

+ 

+       while (item = items.shift()) {

+         if (!item.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item)

+         else if (~item.indexOf(this.query)) caseSensitive.push(item)

+         else caseInsensitive.push(item)

+       }

+ 

+       return beginswith.concat(caseSensitive, caseInsensitive)

+     }

+ 

+   , highlighter: function (item) {

+       var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&')

+       return item.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) {

+         return '<strong>' + match + '</strong>'

+       })

+     }

+ 

+   , render: function (items) {

+       var that = this

+ 

+       items = $(items).map(function (i, item) {

+         i = $(that.options.item).attr('data-value', item)

+         i.find('a').html(that.highlighter(item))

+         return i[0]

+       })

+ 

+       items.first().addClass('active')

+       this.$menu.html(items)

+       return this

+     }

+ 

+   , next: function (event) {

+       var active = this.$menu.find('.active').removeClass('active')

+         , next = active.next()

+ 

+       if (!next.length) {

+         next = $(this.$menu.find('li')[0])

+       }

+ 

+       next.addClass('active')

+     }

+ 

+   , prev: function (event) {

+       var active = this.$menu.find('.active').removeClass('active')

+         , prev = active.prev()

+ 

+       if (!prev.length) {

+         prev = this.$menu.find('li').last()

+       }

+ 

+       prev.addClass('active')

+     }

+ 

+   , listen: function () {

+       this.$element

+         .on('focus',    $.proxy(this.focus, this))

+         .on('blur',     $.proxy(this.blur, this))

+         .on('keypress', $.proxy(this.keypress, this))

+         .on('keyup',    $.proxy(this.keyup, this))

+ 

+       if (this.eventSupported('keydown')) {

+         this.$element.on('keydown', $.proxy(this.keydown, this))

+       }

+ 

+       this.$menu

+         .on('click', $.proxy(this.click, this))

+         .on('mouseenter', 'li', $.proxy(this.mouseenter, this))

+         .on('mouseleave', 'li', $.proxy(this.mouseleave, this))

+     }

+ 

+   , eventSupported: function(eventName) {

+       var isSupported = eventName in this.$element

+       if (!isSupported) {

+         this.$element.setAttribute(eventName, 'return;')

+         isSupported = typeof this.$element[eventName] === 'function'

+       }

+       return isSupported

+     }

+ 

+   , move: function (e) {

+       if (!this.shown) return

+ 

+       switch(e.keyCode) {

+         case 9: // tab

+         case 13: // enter

+         case 27: // escape

+           e.preventDefault()

+           break

+ 

+         case 38: // up arrow

+           e.preventDefault()

+           this.prev()

+           break

+ 

+         case 40: // down arrow

+           e.preventDefault()

+           this.next()

+           break

+       }

+ 

+       e.stopPropagation()

+     }

+ 

+   , keydown: function (e) {

+       this.suppressKeyPressRepeat = ~$.inArray(e.keyCode, [40,38,9,13,27])

+       this.move(e)

+     }

+ 

+   , keypress: function (e) {

+       if (this.suppressKeyPressRepeat) return

+       this.move(e)

+     }

+ 

+   , keyup: function (e) {

+       switch(e.keyCode) {

+         case 40: // down arrow

+         case 38: // up arrow

+         case 16: // shift

+         case 17: // ctrl

+         case 18: // alt

+           break

+ 

+         case 9: // tab

+         case 13: // enter

+           if (!this.shown) return

+           this.select()

+           break

+ 

+         case 27: // escape

+           if (!this.shown) return

+           this.hide()

+           break

+ 

+         default:

+           this.lookup()

+       }

+ 

+       e.stopPropagation()

+       e.preventDefault()

+   }

+ 

+   , focus: function (e) {

+       this.focused = true

+     }

+ 

+   , blur: function (e) {

+       this.focused = false

+       if (!this.mousedover && this.shown) this.hide()

+     }

+ 

+   , click: function (e) {

+       e.stopPropagation()

+       e.preventDefault()

+       this.select()

+       this.$element.focus()

+     }

+ 

+   , mouseenter: function (e) {

+       this.mousedover = true

+       this.$menu.find('.active').removeClass('active')

+       $(e.currentTarget).addClass('active')

+     }

+ 

+   , mouseleave: function (e) {

+       this.mousedover = false

+       if (!this.focused && this.shown) this.hide()

+     }

+ 

+   }

+ 

+ 

+   /* TYPEAHEAD PLUGIN DEFINITION

+    * =========================== */

+ 

+   var old = $.fn.typeahead

+ 

+   $.fn.typeahead = function (option) {

+     return this.each(function () {

+       var $this = $(this)

+         , data = $this.data('typeahead')

+         , options = typeof option == 'object' && option

+       if (!data) $this.data('typeahead', (data = new Typeahead(this, options)))

+       if (typeof option == 'string') data[option]()

+     })

+   }

+ 

+   $.fn.typeahead.defaults = {

+     source: []

+   , items: 8

+   , menu: '<ul class="typeahead dropdown-menu"></ul>'

+   , item: '<li><a href="#"></a></li>'

+   , minLength: 1

+   }

+ 

+   $.fn.typeahead.Constructor = Typeahead

+ 

+ 

+  /* TYPEAHEAD NO CONFLICT

+   * =================== */

+ 

+   $.fn.typeahead.noConflict = function () {

+     $.fn.typeahead = old

+     return this

+   }

+ 

+ 

+  /* TYPEAHEAD DATA-API

+   * ================== */

+ 

+   $(document).on('focus.typeahead.data-api', '[data-provide="typeahead"]', function (e) {

+     var $this = $(this)

+     if ($this.data('typeahead')) return

+     $this.typeahead($this.data())

+   })

+ 

+ }(window.jQuery);

@@ -0,0 +1,166 @@ 

+ /*jslint forin: true */

+ 

+ ;(function($) {

+     $.fn.extend({

+         mention: function(options) {

+             this.opts = {

+                 users: [],

+                 delimiter: '@',

+                 sensitive: true,

+                 emptyQuery: false,

+                 queryBy: ['name', 'username'],

+                 typeaheadOpts: {}

+             };

+ 

+             var settings = $.extend({}, this.opts, options),

+                 _checkDependencies = function() {

+                     if (typeof $ == 'undefined') {

+                         throw new Error("jQuery is Required");

+                     }

+                     else {

+                         if (typeof $.fn.typeahead == 'undefined') {

+                             throw new Error("Typeahead is Required");

+                         }

+                     }

+                     return true;

+                 },

+                 _extractCurrentQuery = function(query, caratPos) {

+                     var i;

+                     for (i = caratPos; i >= 0; i--) {

+                         if (query[i] == settings.delimiter) {

+                             break;

+                         }

+                     }

+                     return query.substring(i, caratPos);

+                 },

+                 _matcher = function(itemProps) {

+                     var i;

+                     

+                     if(settings.emptyQuery){

+ 	                    var q = (this.query.toLowerCase()),

+ 	                    	caratPos = this.$element[0].selectionStart,

+ 	                    	lastChar = q.slice(caratPos-1,caratPos);

+ 	                    if(lastChar==settings.delimiter){

+ 		                    return true;

+ 	                    }

+                     }

+                     

+                     for (i in settings.queryBy) {

+                         if (itemProps[settings.queryBy[i]]) {

+                             var item = itemProps[settings.queryBy[i]].toLowerCase(),

+                                 usernames = (this.query.toLowerCase()).match(new RegExp(settings.delimiter + '\\w+', "g")),

+                                 j;

+                             if ( !! usernames) {

+                                 for (j = 0; j < usernames.length; j++) {

+                                     var username = (usernames[j].substring(1)).toLowerCase(),

+                                         re = new RegExp(settings.delimiter + item, "g"),

+                                         used = ((this.query.toLowerCase()).match(re));

+ 

+                                     if (item.indexOf(username) != -1 && used === null) {

+                                         return true;

+                                     }

+                                 }

+                             }

+                         }

+                     }

+                 },

+                 _updater = function(item) {

+                     var data = this.query,

+                         caratPos = this.$element[0].selectionStart,

+                         i;

+                     

+                     for (i = caratPos; i >= 0; i--) {

+                         if (data[i] == settings.delimiter) {

+                             break;

+                         }

+                     }

+                     var replace = data.substring(i, caratPos),

+                     	textBefore = data.substring(0, i),

+                     	textAfter = data.substring(caratPos),

+                     	data = textBefore + settings.delimiter + item + textAfter;

+                     	

+                     this.tempQuery = data;

+ 

+                     return data;

+                 },

+                 _sorter = function(items) {

+                     if (items.length && settings.sensitive) {

+                         var currentUser = _extractCurrentQuery(this.query, this.$element[0].selectionStart).substring(1),

+                             i, len = items.length,

+                             priorities = {

+                                 highest: [],

+                                 high: [],

+                                 med: [],

+                                 low: []

+                             }, finals = [];

+                         if (currentUser.length == 1) {

+                             for (i = 0; i < len; i++) {

+                                 var currentRes = items[i];

+ 

+                                 if ((currentRes.username[0] == currentUser)) {

+                                     priorities.highest.push(currentRes);

+                                 }

+                                 else if ((currentRes.username[0].toLowerCase() == currentUser.toLowerCase())) {

+                                     priorities.high.push(currentRes);

+                                 }

+                                 else if (currentRes.username.indexOf(currentUser) != -1) {

+                                     priorities.med.push(currentRes);

+                                 }

+                                 else {

+                                     priorities.low.push(currentRes);

+                                 }

+                             }

+                             for (i in priorities) {

+                                 var j;

+                                 for (j in priorities[i]) {

+                                     finals.push(priorities[i][j]);

+                                 }

+                             }

+                             return finals;

+                         }

+                     }

+                     return items;

+                 },

+                 _render = function(items) {

+                     var that = this;

+                     items = $(items).map(function(i, item) {

+ 

+                         i = $(that.options.item).attr('data-value', item.username);

+ 

+                         var _linkHtml = $('<div />');

+ 

+                         if (item.image) {

+                             _linkHtml.append('<img class="mention_image" src="' + item.image + '">');

+                         }

+                         if (item.name) {

+                             _linkHtml.append('<b class="mention_name">' + item.name + '</b>');

+                         }

+                         if (item.username) {

+                             _linkHtml.append('<span class="mention_username"> ' + settings.delimiter + item.username + '</span>');

+                         }

+ 

+                         i.find('a').html(that.highlighter(_linkHtml.html()));

+                         return i[0];

+                     });

+ 

+                     items.first().addClass('active');

+                     this.$menu.html(items);

+                     return this;

+                 };

+ 

+             $.fn.typeahead.Constructor.prototype.render = _render;

+ 

+             return this.each(function() {

+                 var _this = $(this);

+                 if (_checkDependencies()) {

+                     _this.typeahead($.extend({

+                         source: settings.users,

+                         matcher: _matcher,

+                         updater: _updater,

+                         sorter: _sorter

+                     }, settings.typeaheadOpts));

+                 }

+             });

+         }

+     });

+ })(jQuery);

@@ -0,0 +1,19 @@ 

+ .mention_name{

+ 	font-size:12px;

+ 	}

+ .mention_username{

+ 	font-size:12px;

+ 	color:#999;

+ 	}

+ .mention_image{

+ 	float: left;

+ 	margin-right: 5px;

+ 	-webkit-border-radius: 3px;

+ 	-moz-border-radius: 3px;

+ 	border-radius: 3px;

+ 	width: 20px;

+ 	height: 20px;

+ 	}

+ .active .mention_username{

+ 	color:#fff;

+ 	}

@@ -11,6 +11,8 @@ 

    rel="stylesheet" />

  <link href="{{ url_for('static', filename='selectize.bootstrap3.css') }}"

    rel="stylesheet" />

+ <link href="{{ url_for('static', filename='mention/recommended-styles.css') }}"

+   rel="stylesheet" />

  {% endblock %}

  

  {% block repo %}
@@ -259,6 +261,9 @@ 

  </script>

  

  <script type="text/javascript" src="{{ url_for('static', filename='selectize.min.js') }}"></script>

+ <script type="text/javascript" src="{{ url_for('static', filename='mention/mention.js') }}"></script>

+ <script type="text/javascript" src="{{ url_for('static', filename='mention/bootstrap-typeahead.js') }}"></script>

+ 

  <script type="text/javascript">

  

  {% if authenticated and form %}
@@ -270,6 +275,13 @@ 

    $("#file-picker").on("change", function() {

      doUpload("{{ form.csrf_token.current_token }}", this.files);

    });

+   $.get("{{ url_for('api_ns.api_users') }}", {

+     pattern: '*'

+   }).done(function(resp) {

+     $("#comment").mention({

+       users: resp['mention']

+     });

+   });

  });

  {% endif %}

  function setup_edit_btns() {
@@ -464,6 +476,8 @@ 

  {% endif %}

  <script type="text/javascript">

  $( document ).ready(function() {

+ 

+ 

    var emojiStrategy;

    $.getJSON(

      '{{ url_for("static", filename="emoji/emoji_strategy.json") }}',

@@ -18,7 +18,9 @@ 

  {% block header %}

  <link href="{{ url_for('static', filename='emoji/emojione.sprites.css') }}"

    rel="stylesheet" />

-   <link href="{{ url_for('static', filename='selectize.bootstrap3.css') }}"

+ <link href="{{ url_for('static', filename='selectize.bootstrap3.css') }}"

+   rel="stylesheet" />

+ <link href="{{ url_for('static', filename='mention/recommended-styles.css') }}"

    rel="stylesheet" />

  {% endblock %}

  
@@ -749,6 +751,8 @@ 

      src="{{ url_for('static', filename='emoji/emojione.min.js') }}">

  </script>

  <script src="{{ url_for('static', filename='selectize.min.js') }}" type="text/javascript"> </script>

+ <script type="text/javascript" src="{{ url_for('static', filename='mention/mention.js') }}"></script>

+ <script type="text/javascript" src="{{ url_for('static', filename='mention/bootstrap-typeahead.js') }}"></script>

  

  <script type="text/javascript">

  function cancel_edit_btn() {
@@ -1182,6 +1186,13 @@ 

        }

      }

    );

+   $.get("{{ url_for('api_ns.api_users') }}", {

+     pattern: '*'

+   }).done(function(resp) {

+     $("#comment").mention({

+       data: resp['mention']

+     });

+   });

    {% endif %}

  

  } );

Adds visual feedback for @ mentions similar to that of GitHub and other places. Pagure already implements notifications for mentioning users (https://pagure.io/pagure/pull-request/735) so this just taps into that and adds a nice UI :)

Looks like:
http://paste.opensuse.org/images/5538694.png

rebased

7 years ago

Looks good to me, I'll try to test it a little, I'm kinda curious how it behaves if you have lots of users (say 200+)

I tested 200 names with this:

function randomString(length) { return Math.round((Math.pow(36, length + 1) - Math.random() * Math.pow(36, length))).toString(36).slice(1); }

var data = [];

for(var i = 0; i < 200; i++) { data[i] = randomString(10) }

$("#comment").atwho({at: "@", data: data})

And how does it scale? Does it look good? Is the list infinite?

It handled it really well. Here is a gif with 200 random strings.

This looks really good. For all practical purposes, this should work very well. Just curious, will that be difficult to show the avatar and/or name as well along with the usernames ? Because, that would turn this into awesome. :) :thumbsup:

This is indeed looking really nice :)

Btw, mind rebasing the PR on the top of master? I try to keep the history linear :)

Okay, I've got the username and full name showing side by side picture. I would like to add the avatar too but can't find a way to get that from a User instance. Any suggestion?

Using this. But, you should have asked @pingou first. He wanted to merge :stuck_out_tongue:

rebased

7 years ago

Now it has avatar, username, and full name. pic.

I rebased on master. Hopefully this clears things up.

rebased

7 years ago

rebased

7 years ago

rebased

7 years ago

I thought I had commented but, now I am not seeing it.

I was asking if there would be any way to keep the suggestion list below the cursor as you had in the first implementation? I think I like it better there than below the input field.

I've gotten it a bit better. How does this look: http://i.imgur.com/mDRXxGw.png

rebased

7 years ago

rebased

7 years ago

Pull-Request has been merged by pingou

7 years ago

While trying out few things on stg: when you want to mention let's @pingou, then after typing 'pi', the only user left is pingou, if you don't select the option at that moment and write 'n' after that, the option is gone.

^ and it's different for each username like while tagging @vivekanand1101, if i write 'a' after 'vivek', then it's gone.

@atelic it's not working while editing comment. #s also not working

@vivekanand1101 it's the wrong place for such report (that should be a new ticket) and it has already been fixed in PR #1130 :)