Permalink
Browse files

Add autocomplete for '@' username mentions.

A simnple but effective autocomplete for '@' usernames. The
autocomplete pulls from the list of users that the user is following.
  • Loading branch information...
1 parent 71e6101 commit b4b8fb913b66ce2992d9aa4a4fabca181224b9ee @rjsamson rjsamson committed Sep 19, 2012
View
1 Gemfile
@@ -10,6 +10,7 @@ group :assets do
gem 'compass-rails', "~> 1.0.3"
gem 'coffee-rails', "~> 3.2.2"
gem 'uglifier', "~> 1.0.0"
+ gem 'jquery-ui-rails', "~> 2.0.0"
end
gem "haml", "~> 3.1.4"
View
4 Gemfile.lock
@@ -125,6 +125,9 @@ GEM
jquery-rails (1.0.12)
railties (~> 3.0)
thor (~> 0.14)
+ jquery-ui-rails (2.0.0)
+ jquery-rails
+ railties (>= 3.1.0)
json (1.7.4)
kgio (2.6.0)
launchy (2.0.5)
@@ -282,6 +285,7 @@ DEPENDENCIES
haml-rails (~> 0.3.4)
i18n (~> 0.6.0)
jquery-rails (~> 1.0.12)
+ jquery-ui-rails (~> 2.0.0)
launchy (~> 2.0.5)
minitest (~> 2.12.1)
mocha (~> 0.11.0)
View
1 app/assets/javascripts/application.js.coffee
@@ -1,4 +1,5 @@
#= require jquery
+#= require jquery.ui.autocomplete
#= require jquery_ujs
#= require jquery.timeago
#= require_tree .
View
261 app/assets/javascripts/jquery-ui.autocompleteTrigger.js
@@ -0,0 +1,261 @@
+/********************************************************************************
+/*
+ * triggeredAutocomplete (jQuery UI autocomplete widget)
+ * 2012 by Hawkee.com (hawkee@gmail.com)
+ *
+ * Version 1.4.3
+ *
+ * Requires jQuery 1.7 and jQuery UI 1.8
+ *
+ * Dual licensed under MIT or GPLv2 licenses
+ * http://en.wikipedia.org/wiki/MIT_License
+ * http://en.wikipedia.org/wiki/GNU_General_Public_License
+ *
+*/
+
+;(function ( $, window, document, undefined ) {
+ $.widget("ui.triggeredAutocomplete", $.extend(true, {}, $.ui.autocomplete.prototype, {
+
+ options: {
+ trigger: "@",
+ allowDuplicates: true
+ },
+
+ _create:function() {
+
+ var self = this;
+ this.id_map = new Object();
+ this.stopIndex = -1;
+ this.stopLength = -1;
+ this.contents = '';
+ this.cursorPos = 0;
+
+ /** Fixes some events improperly handled by ui.autocomplete */
+ this.element.bind('keydown.autocomplete.fix', function (e) {
+ switch (e.keyCode) {
+ case $.ui.keyCode.ESCAPE:
+ self.close(e);
+ e.stopImmediatePropagation();
+ break;
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ if (!self.menu.element.is(":visible")) {
+ e.stopImmediatePropagation();
+ }
+ }
+ });
+
+ // Check for the id_map as an attribute. This is for editing.
+
+ var id_map_string = this.element.attr('id_map');
+ if(id_map_string) this.id_map = jQuery.parseJSON(id_map_string);
+
+ this.ac = $.ui.autocomplete.prototype;
+ this.ac._create.apply(this, arguments);
+
+ this.updateHidden();
+
+ // Select function defined via options.
+ this.options.select = function(event, ui) {
+ var contents = self.contents;
+ var cursorPos = self.cursorPos;
+
+ // Save everything following the cursor (in case they went back to add a mention)
+ // Separate everything before the cursor
+ // Remove the trigger and search
+ // Rebuild: start + result + end
+
+ var end = contents.substring(cursorPos, contents.length);
+ var start = contents.substring(0, cursorPos);
+ start = start.substring(0, start.lastIndexOf(self.options.trigger));
+
+ var top = self.element.scrollTop();
+ this.value = start + self.options.trigger+ui.item.label+' ' + end;
+ self.element.scrollTop(top);
+
+ // Create an id map so we can create a hidden version of this string with id's instead of labels.
+
+ self.id_map[ui.item.label] = ui.item.value;
+ self.updateHidden();
+
+ /** Places the caret right after the inserted item. */
+ var index = start.length + self.options.trigger.length + ui.item.label.length + 2;
+ if (this.createTextRange) {
+ var range = this.createTextRange();
+ range.move('character', index);
+ range.select();
+ } else if (this.setSelectionRange) {
+ this.setSelectionRange(index, index);
+ }
+
+ return false;
+ };
+
+ // Don't change the input as you browse the results.
+ this.options.focus = function(event, ui) { return false; }
+ this.menu.options.blur = function(event, ui) { return false; }
+
+ // Any changes made need to update the hidden field.
+ this.element.focus(function() { self.updateHidden(); });
+ this.element.change(function() { self.updateHidden(); });
+ },
+
+ // If there is an 'img' then show it beside the label.
+
+ _renderItem: function( ul, item ) {
+ if(item.img != undefined) {
+ return $( "<li></li>" )
+ .data( "item.autocomplete", item )
+ .append( "<a>" + "<img src='" + item.img + "' /><span>"+item.label+"</span></a>" )
+ .appendTo( ul );
+ }
+ else {
+ return $( "<li></li>" )
+ .data( "item.autocomplete", item )
+ .append( $( "<a></a>" ).text( item.label ) )
+ .appendTo( ul );
+ }
+ },
+
+ // This stops the input box from being cleared when traversing the menu.
+
+ _move: function( direction, event ) {
+ if ( !this.menu.element.is(":visible") ) {
+ this.search( null, event );
+ return;
+ }
+ if ( this.menu.first() && /^previous/.test(direction) ||
+ this.menu.last() && /^next/.test(direction) ) {
+ this.menu.deactivate();
+ return;
+ }
+ this.menu[ direction ]( event );
+ },
+
+ search: function(value, event) {
+
+ var contents = this.element.val();
+ var cursorPos = this.getCursor();
+ this.contents = contents;
+ this.cursorPos = cursorPos;
+ var check_contents = contents.substring(contents.lastIndexOf(this.options.trigger) - 1, cursorPos);
+ var regex = new RegExp('\\B\\'+this.options.trigger+'([\\w\\-]+)');
+
+ if (contents.indexOf(this.options.trigger) >= 0 && check_contents.match(regex)) {
+
+ // Get the characters following the trigger and before the cursor position.
+ // Get the contents up to the cursortPos first then get the lastIndexOf the trigger to find the search term.
+
+ contents = contents.substring(0, cursorPos);
+ var term = contents.substring(contents.lastIndexOf(this.options.trigger) + 1, contents.length);
+
+ // Only query the server if we have a term and we haven't received a null response.
+ // First check the current query to see if it already returned a null response.
+
+ if(this.stopIndex == contents.lastIndexOf(this.options.trigger) && term.length > this.stopLength) { term = ''; }
+
+ if(term.length > 0) {
+ // Updates the hidden field to check if a name was removed so that we can put them back in the list.
+ this.updateHidden();
+ return this._search(term);
+ }
+ else this.close();
+ }
+ },
+
+ // Slightly altered the default ajax call to stop querying after the search produced no results.
+ // This is to prevent unnecessary querying.
+
+ _initSource: function() {
+ var self = this, array, url;
+ if ( $.isArray(this.options.source) ) {
+ array = this.options.source;
+ this.source = function( request, response ) {
+ response( $.ui.autocomplete.filter(array, request.term) );
+ };
+ } else if ( typeof this.options.source === "string" ) {
+ url = this.options.source;
+ this.source = function( request, response ) {
+ if ( self.xhr ) {
+ self.xhr.abort();
+ }
+ self.xhr = $.ajax({
+ url: url,
+ data: request,
+ dataType: 'json',
+ success: function(data) {
+ if(data != null) {
+ response($.map(data, function(item) {
+ if (typeof item === "string") {
+ label = item;
+ }
+ else {
+ label = item.label;
+ }
+ // If the item has already been selected don't re-include it.
+ if(!self.id_map[label] || self.options.allowDuplicates) {
+ return item
+ }
+ }));
+ self.stopLength = -1;
+ self.stopIndex = -1;
+ }
+ else {
+ // No results, record length of string and stop querying unless the length decreases
+ self.stopLength = request.term.length;
+ self.stopIndex = self.contents.lastIndexOf(self.options.trigger);
+ self.close();
+ }
+ }
+ });
+ };
+ } else {
+ this.source = this.options.source;
+ }
+ },
+
+ destroy: function() {
+ $.Widget.prototype.destroy.call(this);
+ },
+
+ // Gets the position of the cursor in the input box.
+
+ getCursor: function() {
+ var i = this.element[0];
+
+ if(i.selectionStart) {
+ return i.selectionStart;
+ }
+ else if(i.ownerDocument.selection) {
+ var range = i.ownerDocument.selection.createRange();
+ if(!range) return 0;
+ var textrange = i.createTextRange();
+ var textrange2 = textrange.duplicate();
+
+ textrange.moveToBookmark(range.getBookmark());
+ textrange2.setEndPoint('EndToStart', textrange);
+ return textrange2.text.length;
+ }
+ },
+
+ // Populates the hidden field with the contents of the entry box but with
+ // ID's instead of usernames. Better for storage.
+
+ updateHidden: function() {
+ var trigger = this.options.trigger;
+ var top = this.element.scrollTop();
+ var contents = this.element.val();
+ for(var key in this.id_map) {
+ var find = trigger+key;
+ find = find.replace(/[^a-zA-Z 0-9@]+/g,'\\$&');
+ var regex = new RegExp(find, "g");
+ var old_contents = contents;
+ contents = contents.replace(regex, trigger+'['+this.id_map[key]+']');
+ if(old_contents == contents) delete this.id_map[key];
+ }
+ $(this.options.hidden).val(contents);
+ this.element.scrollTop(top);
+ }
+
+ }));
+})( jQuery, window , document );
View
4 app/assets/javascripts/update.js.coffee
@@ -18,6 +18,10 @@ jQuery $ ->
textarea.keypress(updateCounter).keyup(updateCounter)
+ textarea.triggeredAutocomplete
+ trigger: "@"
+ source: "/autocomplete"
+
updateTickyboxes = ->
if userTickiedBox
return
View
1 app/assets/stylesheets/application.css.scss
@@ -2,6 +2,7 @@
*= require_self
*= require "lib/css3buttons"
*= require "typography"
+ *= require jquery.ui.autocomplete
*= require "layout"
*= require "static"
*= require "content"
View
6 app/controllers/users_controller.rb
@@ -106,6 +106,12 @@ def create
end
end
+ def autocomplete
+ @json = current_user.autocomplete(params[:term])
+
+ render :json => @json
+ end
+
# This is pretty much the same thing as /feeds/your_feed_id.atom, but we
# wanted to have a really nice URL for it, and not just the ugly one.
def feed
View
14 app/models/user.rb
@@ -236,6 +236,20 @@ def send_mention_notification(update_id, to_feed_id)
res = http.post(uri.path, envelope, {"Content-Type" => "application/magic-envelope+xml"})
end
+ def autocomplete(query)
+ if query.nil? || query.blank?
+ return []
+ end
+
+ query = '^' + query.downcase + '.*'
+ following.inject([]) do |result, obj|
+ if /#{query}/i =~ obj.author.username
+ result << { :label => obj.author.username.downcase }
+ end
+ result
+ end
+ end
+
def followed_by?(f)
followers.include? f
end
View
3 config/routes.rb
@@ -48,6 +48,9 @@
# Search
resource :search, :only => :show
+ # Autocomplete
+ get "/autocomplete" => "users#autocomplete", :format => "json"
+
# feeds
resources :feeds, :only => :show

0 comments on commit b4b8fb9

Please sign in to comment.