diff --git a/app/controllers/public/profile_controller.rb b/app/controllers/public/profile_controller.rb
index fca23a7..05f1ea3 100644
--- a/app/controllers/public/profile_controller.rb
+++ b/app/controllers/public/profile_controller.rb
@@ -434,6 +434,9 @@ class ProfileController < PublicController
end
end
+ def show_tracked_action
+ @activity = ProfileActivity.find_by(:activity_id => params[:activity_id]).activity
+ end
protected
diff --git a/app/controllers/public/search_controller.rb b/app/controllers/public/search_controller.rb
index ed70791..bf6a101 100644
--- a/app/controllers/public/search_controller.rb
+++ b/app/controllers/public/search_controller.rb
@@ -154,6 +154,33 @@ class SearchController < PublicController
render :text => find_suggestions(normalize_term(params[:term]), environment, params[:asset]).to_json
end
+ def search_for_users
+ # If it isn't a ajax call give to the user an access denied response
+ return render_access_denied unless request.xhr?
+
+ scope = user.friends.select(:id, :name, :identifier)
+
+ results = find_by_contents(
+ :people, environment, scope,
+ params['q'], {:page => 1}
+ )[:results]
+
+ if params[:community].present? and (community = Community.find_by_identifier params[:community])
+ scope = community.members # .select(:id, :name, :identifier) is not working here
+ results += find_by_contents(
+ :people, environment, scope,
+ params['q'], {:page => 1}
+ )[:results]
+
+ # because .select does not work on members
+ results.map! {|r|
+ {id: r.id, name: r.name, identifier: r.identifier}
+ }
+ end
+
+ render :json => results.to_json
+ end
+
#######################################################
protected
diff --git a/app/helpers/action_tracker_helper.rb b/app/helpers/action_tracker_helper.rb
index 8e66349..637f3b6 100644
--- a/app/helpers/action_tracker_helper.rb
+++ b/app/helpers/action_tracker_helper.rb
@@ -24,6 +24,11 @@ module ActionTrackerHelper
}
end
+ def notify_mentioned_users_description ta
+ source_link = link_to _("here"), ta.get_url
+ (_("mentioned you %{link}") % {link: source_link}).html_safe
+ end
+
def join_community_description ta
n_('has joined 1 community:
%{name}', 'has joined %{num} communities:
%{name}', ta.get_resource_name.size).html_safe % {
num: ta.get_resource_name.size,
@@ -62,19 +67,19 @@ module ActionTrackerHelper
end
def reply_scrap_description ta
- _('sent a message to %{receiver}:
"%{message}"') % {
+ (_('sent a message to %{receiver}:
"%{message}"') % {
receiver: link_to(ta.get_receiver_name, ta.get_receiver_url),
message: auto_link_urls(ta.get_content)
- }
+ }).html_safe
end
alias :leave_scrap_description :reply_scrap_description
alias :reply_scrap_on_self_description :reply_scrap_description
def leave_scrap_to_self_description ta
- _('wrote:
"%{text}"') % {
+ (_('wrote:
"%{text}"') % {
text: auto_link_urls(ta.get_content)
- }
+ }).html_safe
end
def favorite_enterprise_description ta
diff --git a/app/helpers/mention_helper.rb b/app/helpers/mention_helper.rb
new file mode 100644
index 0000000..5f00454
--- /dev/null
+++ b/app/helpers/mention_helper.rb
@@ -0,0 +1,51 @@
+module MentionHelper
+ def mention_search_regex
+ /(?:^\s?|\s)@([^\s]+)/m
+ end
+
+ def has_mentions? text=""
+ text.scan(mention_search_regex).present?
+ end
+
+ def get_mentions text=""
+ text.scan(mention_search_regex).flatten.uniq
+ end
+
+ def get_invalid_mentions text, person, profile=nil
+ mentions = get_mentions text
+ # remove the friends of the user
+ mentions -= person.friends.select(:identifier).where(:identifier => mentions).map(&:identifier)
+
+ if profile.present? and profile.kind_of?(Community)
+ # remove the members of the same community
+ mentions -= profile.members.where(:identifier => mentions).map(&:identifier)
+ end
+
+ # any remaining mention is invalid
+ mentions
+ end
+
+ def remove_invalid_mentions text, invalid_mentions=[]
+ if not invalid_mentions.empty?
+ # remove the "@" from the invalid mentions, so that the notify job dont send notifications to them
+ invalid_mentions.each do |mention|
+ text.gsub! "@#{mention}", mention
+ end
+ end
+
+ text
+ end
+
+ def has_valid_mentions? model, attribute, person, profile=nil
+ text = model.send attribute
+
+ if has_mentions? text
+ invalid_mentions = get_invalid_mentions text, person, profile
+ text = remove_invalid_mentions text, invalid_mentions
+ # to prevent invalid notifications, remove the invalid mentions
+ model.update_attribute attribute, text
+ end
+
+ has_mentions? text
+ end
+end
diff --git a/app/jobs/notify_mentioned_users_job.rb b/app/jobs/notify_mentioned_users_job.rb
new file mode 100644
index 0000000..d8da349
--- /dev/null
+++ b/app/jobs/notify_mentioned_users_job.rb
@@ -0,0 +1,36 @@
+class NotifyMentionedUsersJob < Struct.new(:tracked_action_id)
+ include MentionHelper
+
+ def perform
+ return unless ActionTracker::Record.exists?(tracked_action_id)
+ tracked_action = ActionTracker::Record.find(tracked_action_id)
+
+ mention_creator = Person.find(tracked_action.user_id)
+
+ remove_followers = false
+ mention_text = if tracked_action.target_type == "Comment"
+ tracked_action.params["body"]
+ else #scrap
+ remove_followers = true #scraps already notify followers.
+ tracked_action.params["content"]
+ end
+
+ people_identifiers = get_mentions mention_text
+ people_identifiers -= [mention_creator.identifier]
+
+ followers = mention_creator.followers
+ people_idenfifiers -= followers.map(&:identifier) if (followers.present? && remove_followers)
+
+ return if people_identifiers.empty?
+
+ people_identifiers = "'" + people_identifiers.join("','") + "'"
+
+ notification_sql = "INSERT INTO action_tracker_notifications(profile_id,action_tracker_id) " +
+ "SELECT p.id, #{tracked_action.id} FROM profiles AS p " +
+ "WHERE p.identifier IN (#{people_identifiers}) AND " +
+ "p.id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn " +
+ "WHERE atn.action_tracker_id = #{tracked_action.id})"
+
+ ActionTrackerNotification.connection.execute(notification_sql)
+ end
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 94c44f3..d95b19d 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -1,5 +1,13 @@
class Comment < ApplicationRecord
+ include MentionHelper
+
+ track_actions :notify_mentioned_users,
+ :after_create,
+ :keep_params => ["body", "url"],
+ :show_on_wall => false,
+ :if => Proc.new {|c| has_valid_mentions?(c, :body, c.author, profile) } # if there is a mention
+
SEARCHABLE_FIELDS = {
:title => {:label => _('Title'), :weight => 10},
:name => {:label => _('Name'), :weight => 4},
@@ -79,7 +87,11 @@ class Comment < ApplicationRecord
end
def url
- article.view_url.merge(:anchor => anchor)
+ if source.kind_of? ActionTracker::Record
+ source.url.merge(:anchor => anchor)
+ else
+ article.view_url.merge(:anchor => anchor)
+ end
end
def message
diff --git a/app/models/person.rb b/app/models/person.rb
index 828b536..1fddd63 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -504,7 +504,11 @@ class Person < Profile
end
def self.notify_activity(tracked_action)
- Delayed::Job.enqueue NotifyActivityToProfilesJob.new(tracked_action.id)
+ if tracked_action.verb != "notify_mentioned_users"
+ Delayed::Job.enqueue NotifyActivityToProfilesJob.new(tracked_action.id)
+ else
+ Delayed::Job.enqueue NotifyMentionedUsersJob.new(tracked_action.id)
+ end
end
def is_member_of?(profile)
diff --git a/app/models/scrap.rb b/app/models/scrap.rb
index 3cc869a..09a72be 100644
--- a/app/models/scrap.rb
+++ b/app/models/scrap.rb
@@ -1,5 +1,6 @@
class Scrap < ApplicationRecord
+ include MentionHelper
include SanitizeHelper
attr_accessible :content, :sender_id, :receiver_id, :scrap_id
@@ -32,6 +33,12 @@ class Scrap < ApplicationRecord
track_actions :reply_scrap_on_self, :after_create, :keep_params => ['sender.name', 'content'], :if => Proc.new{|s| s.sender != s.receiver && s.sender == s.top_root.receiver}, :custom_user => :sender
+ track_actions :notify_mentioned_users,
+ :after_create,
+ :keep_params => ["content","url"],
+ :show_on_wall => false,
+ :if => Proc.new {|s| has_valid_mentions?(s, :content, s.sender, s.receiver)}
+
after_create :send_notification
before_validation :strip_all_html_tags
@@ -57,6 +64,7 @@ class Scrap < ApplicationRecord
def scrap_wall_url
is_root? ? root.receiver.wall_url : receiver.wall_url
end
+ alias url scrap_wall_url
def send_notification?
sender != receiver && (is_root? ? root.receiver.receives_scrap_notification? : receiver.receives_scrap_notification?)
diff --git a/app/views/comment/_comment_form.html.erb b/app/views/comment/_comment_form.html.erb
index 743ce15..2279c86 100644
--- a/app/views/comment/_comment_form.html.erb
+++ b/app/views/comment/_comment_form.html.erb
@@ -102,3 +102,9 @@ function check_captcha(button, confirm_action) {
<%= javascript_include_tag 'comment_form'%>
+
+
+<%= javascript_include_tag 'vendor/jquery.elastic.min'%>
+<%= javascript_include_tag 'vendor/jquery.events.input.min'%>
+<%= javascript_include_tag 'vendor/jquery.mentionsInput.mod.min'%>
+<%= javascript_include_tag 'user-mention'%>
diff --git a/app/views/profile/_notify_mentioned_users.html.erb b/app/views/profile/_notify_mentioned_users.html.erb
new file mode 100644
index 0000000..3cc3227
--- /dev/null
+++ b/app/views/profile/_notify_mentioned_users.html.erb
@@ -0,0 +1 @@
+<%= render :partial => 'default_activity', :locals => { :activity => activity, :tab_action => tab_action } %>
diff --git a/app/views/profile/_profile_activities_list.html.erb b/app/views/profile/_profile_activities_list.html.erb
index 79e0f58..b81869e 100644
--- a/app/views/profile/_profile_activities_list.html.erb
+++ b/app/views/profile/_profile_activities_list.html.erb
@@ -2,7 +2,7 @@
<% activities.each do |profile_activity| %>
<% activity = profile_activity.activity %>
<% if activity.kind_of?(ActionTracker::Record) %>
- <%= render :partial => 'profile_activity', :locals => { :activity => activity, :tab_action => 'wall' } if activity.visible? %>
+ <%= render :partial => 'profile_activity', :locals => { :activity => activity, :tab_action => 'wall' } if (activity.visible? && activity.show_on_wall) %>
<% else %>
<%= render :partial => 'profile_scraps', :locals => { :activity => activity, :scrap => activity } %>
<% end %>
diff --git a/app/views/profile/_profile_wall.html.erb b/app/views/profile/_profile_wall.html.erb
index 831347a..676563b 100644
--- a/app/views/profile/_profile_wall.html.erb
+++ b/app/views/profile/_profile_wall.html.erb
@@ -10,3 +10,9 @@
<%= render :partial => 'profile_activities_list', :locals => {:activities => @activities} %>
+
+
+<%= javascript_include_tag 'vendor/jquery.elastic.min'%>
+<%= javascript_include_tag 'vendor/jquery.events.input.min'%>
+<%= javascript_include_tag 'vendor/jquery.mentionsInput.mod.min'%>
+<%= javascript_include_tag 'user-mention'%>
diff --git a/app/views/profile/show_tracked_action.html.erb b/app/views/profile/show_tracked_action.html.erb
new file mode 100644
index 0000000..a6a88a1
--- /dev/null
+++ b/app/views/profile/show_tracked_action.html.erb
@@ -0,0 +1,15 @@
+
+
+ <% if @activity.kind_of?(ActionTracker::Record) %>
+ <%= render :partial => 'profile_activity', :locals => { :activity => @activity, :tab_action => 'wall' } if (@activity.visible? && @activity.show_on_wall) %>
+ <% else %>
+ <%= render :partial => 'profile_scraps', :locals => { :activity => @activity, :scrap => @activity } %>
+ <% end %>
+
+
+
+
diff --git a/config/initializers/action_tracker.rb b/config/initializers/action_tracker.rb
index 9bfcfa3..63f891a 100644
--- a/config/initializers/action_tracker.rb
+++ b/config/initializers/action_tracker.rb
@@ -39,6 +39,8 @@ ActionTrackerConfig.verbs = {
favorite_enterprise: {
},
+ notify_mentioned_users: {
+ },
}
ActionTrackerConfig.current_user = proc do
diff --git a/db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb b/db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb
new file mode 100644
index 0000000..debcb76
--- /dev/null
+++ b/db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb
@@ -0,0 +1,5 @@
+class AddShowOnWallToActionTracker < ActiveRecord::Migration
+ def change
+ add_column :action_tracker, :show_on_wall, :boolean, :default => true
+ end
+end
diff --git a/public/javascripts/user-mention.js b/public/javascripts/user-mention.js
new file mode 100644
index 0000000..96ef75d
--- /dev/null
+++ b/public/javascripts/user-mention.js
@@ -0,0 +1,46 @@
+(function($, undefined) {
+ $(document).ready(function() {
+ var is_community = _.isEmpty($('html.profile-type-is-community')) !== true;
+ var community = null;
+
+ if (is_community) {
+ community = noosfero.profile;
+ }
+
+ $('#comment_body, #leave_scrap_content, form.profile-wall-reply-form textarea').mentionsInput({
+ onDataRequest: function (mode, keyword, onDataRequestCompleteCallback) {
+ var search_url = "/search/search_for_users?q="+keyword;
+
+ $.ajax({
+ method: "GET",
+ url: search_url,
+ dataType: "json",
+ data: {'community': community},
+ success: function (response) {
+ var data = response.map(function(item) {
+ return {
+ name: item.identifier,
+ fullName: item.name,
+ id: item.id,
+ type: 'contact'
+ };
+ });
+
+ // Call this to populate mention.
+ onDataRequestCompleteCallback.call(this, data);
+ }
+ }); // $.ajax end
+ },
+
+ triggerChar: '@',
+
+ allowRepeat: true,
+
+ minChars: 3,
+
+ keepTriggerCharacter: true
+ }); // mentionsInput end
+ }); // ready end
+
+}) (jQuery);
+
diff --git a/public/javascripts/vendor/jquery.elastic.js b/public/javascripts/vendor/jquery.elastic.js
new file mode 100644
index 0000000..c5e857f
--- /dev/null
+++ b/public/javascripts/vendor/jquery.elastic.js
@@ -0,0 +1,162 @@
+/**
+* @name Elastic
+* @descripton Elastic is jQuery plugin that grow and shrink your textareas automatically
+* @version 1.6.11
+* @requires jQuery 1.2.6+
+*
+* @author Jan Jarfalk
+* @author-email jan.jarfalk@unwrongest.com
+* @author-website http://www.unwrongest.com
+*
+* @licence MIT License - http://www.opensource.org/licenses/mit-license.php
+*/
+
+(function($){
+ jQuery.fn.extend({
+ elastic: function() {
+
+ // We will create a div clone of the textarea
+ // by copying these attributes from the textarea to the div.
+ var mimics = [
+ 'paddingTop',
+ 'paddingRight',
+ 'paddingBottom',
+ 'paddingLeft',
+ 'fontSize',
+ 'lineHeight',
+ 'fontFamily',
+ 'width',
+ 'fontWeight',
+ 'border-top-width',
+ 'border-right-width',
+ 'border-bottom-width',
+ 'border-left-width',
+ 'borderTopStyle',
+ 'borderTopColor',
+ 'borderRightStyle',
+ 'borderRightColor',
+ 'borderBottomStyle',
+ 'borderBottomColor',
+ 'borderLeftStyle',
+ 'borderLeftColor'
+ ];
+
+ return this.each( function() {
+
+ // Elastic only works on textareas
+ if ( this.type !== 'textarea' ) {
+ return false;
+ }
+
+ var $textarea = jQuery(this),
+ $twin = jQuery('').css({
+ 'position' : 'absolute',
+ 'display' : 'none',
+ 'word-wrap' : 'break-word',
+ 'white-space' :'pre-wrap'
+ }),
+ lineHeight = parseInt($textarea.css('line-height'),10) || parseInt($textarea.css('font-size'),'10'),
+ minheight = parseInt($textarea.css('height'),10) || lineHeight*3,
+ maxheight = parseInt($textarea.css('max-height'),10) || Number.MAX_VALUE,
+ goalheight = 0;
+
+ // Opera returns max-height of -1 if not set
+ if (maxheight < 0) { maxheight = Number.MAX_VALUE; }
+
+ // Append the twin to the DOM
+ // We are going to meassure the height of this, not the textarea.
+ $twin.appendTo($textarea.parent());
+
+ // Copy the essential styles (mimics) from the textarea to the twin
+ var i = mimics.length;
+ while(i--){
+ $twin.css(mimics[i].toString(),$textarea.css(mimics[i].toString()));
+ }
+
+ // Updates the width of the twin. (solution for textareas with widths in percent)
+ function setTwinWidth(){
+ var curatedWidth = Math.floor(parseInt($textarea.width(),10));
+ if($twin.width() !== curatedWidth){
+ $twin.css({'width': curatedWidth + 'px'});
+
+ // Update height of textarea
+ update(true);
+ }
+ }
+
+ // Sets a given height and overflow state on the textarea
+ function setHeightAndOverflow(height, overflow){
+
+ var curratedHeight = Math.floor(parseInt(height,10));
+ if($textarea.height() !== curratedHeight){
+ $textarea.css({'height': curratedHeight + 'px','overflow':overflow});
+ }
+ }
+
+ // This function will update the height of the textarea if necessary
+ function update(forced) {
+
+ // Get curated content from the textarea.
+ var textareaContent = $textarea.val().replace(/&/g,'&').replace(/ {2}/g, ' ').replace(/<|>/g, '>').replace(/\n/g, '
');
+
+ // Compare curated content with curated twin.
+ var twinContent = $twin.html().replace(/
/ig,'
');
+
+ if(forced || textareaContent+' ' !== twinContent){
+
+ // Add an extra white space so new rows are added when you are at the end of a row.
+ $twin.html(textareaContent+' ');
+
+ // Change textarea height if twin plus the height of one line differs more than 3 pixel from textarea height
+ if(Math.abs($twin.height() + lineHeight - $textarea.height()) > 3){
+
+ var goalheight = $twin.height()+lineHeight;
+ if(goalheight >= maxheight) {
+ setHeightAndOverflow(maxheight,'auto');
+ } else if(goalheight <= minheight) {
+ setHeightAndOverflow(minheight,'hidden');
+ } else {
+ setHeightAndOverflow(goalheight,'hidden');
+ }
+
+ }
+
+ }
+
+ }
+
+ // Hide scrollbars
+ $textarea.css({'overflow':'hidden'});
+
+ // Update textarea size on keyup, change, cut and paste
+ $textarea.bind('keyup change cut paste', function(){
+ update();
+ });
+
+ // Update width of twin if browser or textarea is resized (solution for textareas with widths in percent)
+ $(window).bind('resize', setTwinWidth);
+ $textarea.bind('resize', setTwinWidth);
+ $textarea.bind('update', update);
+
+ // Compact textarea on blur
+ $textarea.bind('blur',function(){
+ if($twin.height() < maxheight){
+ if($twin.height() > minheight) {
+ $textarea.height($twin.height());
+ } else {
+ $textarea.height(minheight);
+ }
+ }
+ });
+
+ // And this line is to catch the browser paste event
+ $textarea.bind('input paste',function(e){ setTimeout( update, 250); });
+
+ // Run update once when elastic is initialized
+ update();
+
+ });
+
+ }
+ });
+})(jQuery);
\ No newline at end of file
diff --git a/public/javascripts/vendor/jquery.elastic.min.js b/public/javascripts/vendor/jquery.elastic.min.js
new file mode 100644
index 0000000..d968756
--- /dev/null
+++ b/public/javascripts/vendor/jquery.elastic.min.js
@@ -0,0 +1 @@
+!function(t){jQuery.fn.extend({elastic:function(){var e=["paddingTop","paddingRight","paddingBottom","paddingLeft","fontSize","lineHeight","fontFamily","width","fontWeight","border-top-width","border-right-width","border-bottom-width","border-left-width","borderTopStyle","borderTopColor","borderRightStyle","borderRightColor","borderBottomStyle","borderBottomColor","borderLeftStyle","borderLeftColor"];return this.each(function(){function r(){var t=Math.floor(parseInt(n.width(),10));h.width()!==t&&(h.css({width:t+"px"}),o(!0))}function i(t,e){var r=Math.floor(parseInt(t,10));n.height()!==r&&n.css({height:r+"px",overflow:e})}function o(t){var e=n.val().replace(/&/g,"&").replace(/ {2}/g," ").replace(/<|>/g,">").replace(/\n/g,"
"),r=h.html().replace(/
/gi,"
");if((t||e+" "!==r)&&(h.html(e+" "),Math.abs(h.height()+d-n.height())>3)){var o=h.height()+d;o>=s?i(s,"auto"):o<=a?i(a,"hidden"):i(o,"hidden")}}if("textarea"!==this.type)return!1;var n=jQuery(this),h=jQuery("").css({position:"absolute",display:"none","word-wrap":"break-word","white-space":"pre-wrap"}),d=parseInt(n.css("line-height"),10)||parseInt(n.css("font-size"),"10"),a=parseInt(n.css("height"),10)||3*d,s=parseInt(n.css("max-height"),10)||Number.MAX_VALUE;s<0&&(s=Number.MAX_VALUE),h.appendTo(n.parent());for(var p=e.length;p--;)h.css(e[p].toString(),n.css(e[p].toString()));n.css({overflow:"hidden"}),n.bind("keyup change cut paste",function(){o()}),t(window).bind("resize",r),n.bind("resize",r),n.bind("update",o),n.bind("blur",function(){h.height()a?n.height(h.height()):n.height(a))}),n.bind("input paste",function(t){setTimeout(o,250)}),o()})}})}(jQuery);
diff --git a/public/javascripts/vendor/jquery.events.input.js b/public/javascripts/vendor/jquery.events.input.js
new file mode 100644
index 0000000..9b2bbbf
--- /dev/null
+++ b/public/javascripts/vendor/jquery.events.input.js
@@ -0,0 +1,132 @@
+/*
+ jQuery `input` special event v1.0
+
+ http://whattheheadsaid.com/projects/input-special-event
+
+ (c) 2010-2011 Andy Earnshaw
+ MIT license
+ www.opensource.org/licenses/mit-license.php
+
+ Modified by Kenneth Auchenberg
+ * Disabled usage of onPropertyChange event in IE, since its a bit delayed, if you type really fast.
+*/
+
+(function($) {
+ // Handler for propertychange events only
+ function propHandler() {
+ var $this = $(this);
+ if (window.event.propertyName == "value" && !$this.data("triggering.inputEvent")) {
+ $this.data("triggering.inputEvent", true).trigger("input");
+ window.setTimeout(function () {
+ $this.data("triggering.inputEvent", false);
+ }, 0);
+ }
+ }
+
+ $.event.special.input = {
+ setup: function(data, namespaces) {
+ var timer,
+ // Get a reference to the element
+ elem = this,
+ // Store the current state of the element
+ state = elem.value,
+ // Create a dummy element that we can use for testing event support
+ tester = document.createElement(this.tagName),
+ // Check for native oninput
+ oninput = "oninput" in tester || checkEvent(tester),
+ // Check for onpropertychange
+ onprop = "onpropertychange" in tester,
+ // Generate a random namespace for event bindings
+ ns = "inputEventNS" + ~~(Math.random() * 10000000),
+ // Last resort event names
+ evts = ["focus", "blur", "paste", "cut", "keydown", "drop", ""].join("." + ns + " ");
+
+ function checkState() {
+ var $this = $(elem);
+ if (elem.value != state && !$this.data("triggering.inputEvent")) {
+ state = elem.value;
+
+ $this.data("triggering.inputEvent", true).trigger("input");
+ window.setTimeout(function () {
+ $this.data("triggering.inputEvent", false);
+ }, 0);
+ }
+ }
+
+ // Set up a function to handle the different events that may fire
+ function handler(e) {
+ // When focusing, set a timer that polls for changes to the value
+ if (e.type == "focus") {
+ checkState();
+ clearInterval(timer);
+ timer = window.setInterval(checkState, 250);
+ } else if (e.type == "blur") {
+ // When blurring, cancel the aforeset timer
+ window.clearInterval(timer);
+ } else {
+ // For all other events, queue a timer to check state ASAP
+ window.setTimeout(checkState, 0);
+ }
+ }
+
+ // Bind to native event if available
+ if (oninput) {
+ return false;
+// } else if (onprop) {
+// // Else fall back to propertychange if available
+// $(this).find("input, textarea").andSelf().filter("input, textarea").bind("propertychange." + ns, propHandler);
+ } else {
+ // Else clutch at straws!
+ $(this).find("input, textarea").andSelf().filter("input, textarea").bind(evts, handler);
+ }
+ $(this).data("inputEventHandlerNS", ns);
+ },
+ teardown: function () {
+ var elem = $(this);
+ elem.find("input, textarea").unbind(elem.data("inputEventHandlerNS"));
+ elem.data("inputEventHandlerNS", "");
+ }
+ };
+
+ // Setup our jQuery shorthand method
+ $.fn.input = function (handler) {
+ return handler ? this.bind("input", handler) : this.trigger("input");
+ };
+
+ /*
+ The following function tests the element for oninput support in Firefox. Many thanks to
+ http://blog.danielfriesen.name/2010/02/16/html5-browser-maze-oninput-support/
+ */
+ function checkEvent(el) {
+ // First check, for if Firefox fixes its issue with el.oninput = function
+ el.setAttribute("oninput", "return");
+ if (typeof el.oninput == "function") {
+ return true;
+ }
+ // Second check, because Firefox doesn't map oninput attribute to oninput property
+ try {
+
+ // "* Note * : Disabled focus and dispatch of keypress event due to conflict with DOMready, which resulted in scrolling down to the bottom of the page, possibly because layout wasn't finished rendering.
+ var e = document.createEvent("KeyboardEvent"),
+ ok = false,
+ tester = function(e) {
+ ok = true;
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ // e.initKeyEvent("keypress", true, true, window, false, false, false, false, 0, "e".charCodeAt(0));
+
+ document.body.appendChild(el);
+ el.addEventListener("input", tester, false);
+ // el.focus();
+ // el.dispatchEvent(e);
+ el.removeEventListener("input", tester, false);
+ document.body.removeChild(el);
+ return ok;
+
+ } catch(error) {
+
+ }
+ }
+})(jQuery);
\ No newline at end of file
diff --git a/public/javascripts/vendor/jquery.events.input.min.js b/public/javascripts/vendor/jquery.events.input.min.js
new file mode 100644
index 0000000..be76e32
--- /dev/null
+++ b/public/javascripts/vendor/jquery.events.input.min.js
@@ -0,0 +1 @@
+!function(t){function n(t){if(t.setAttribute("oninput","return"),"function"==typeof t.oninput)return!0;try{var n=(document.createEvent("KeyboardEvent"),!1),e=function(t){n=!0,t.preventDefault(),t.stopPropagation()};return document.body.appendChild(t),t.addEventListener("input",e,!1),t.removeEventListener("input",e,!1),document.body.removeChild(t),n}catch(t){}}t.event.special.input={setup:function(e,i){function u(){var n=t(o);o.value==d||n.data("triggering.inputEvent")||(d=o.value,n.data("triggering.inputEvent",!0).trigger("input"),window.setTimeout(function(){n.data("triggering.inputEvent",!1)},0))}function r(t){"focus"==t.type?(u(),clearInterval(a),a=window.setInterval(u,250)):"blur"==t.type?window.clearInterval(a):window.setTimeout(u,0)}var a,o=this,d=o.value,p=document.createElement(this.tagName),v="oninput"in p||n(p),c="inputEventNS"+~~(1e7*Math.random()),f=["focus","blur","paste","cut","keydown","drop",""].join("."+c+" ");return!v&&(t(this).find("input, textarea").andSelf().filter("input, textarea").bind(f,r),void t(this).data("inputEventHandlerNS",c))},teardown:function(){var n=t(this);n.find("input, textarea").unbind(n.data("inputEventHandlerNS")),n.data("inputEventHandlerNS","")}},t.fn.input=function(t){return t?this.bind("input",t):this.trigger("input")}}(jQuery);
diff --git a/public/javascripts/vendor/jquery.mentionsInput.mod.js b/public/javascripts/vendor/jquery.mentionsInput.mod.js
new file mode 100644
index 0000000..c786cf8
--- /dev/null
+++ b/public/javascripts/vendor/jquery.mentionsInput.mod.js
@@ -0,0 +1,596 @@
+/*
+ * Mentions Input
+ * Version 1.0.2
+ * Written by: Kenneth Auchenberg (Podio)
+ *
+ * Using underscore.js
+ *
+ * License: MIT License - http://www.opensource.org/licenses/mit-license.php
+ */
+
+(function ($, _, undefined) {
+
+ // Fix for Noosfero rewrite on interpolate setting
+ _.templateSettings = {
+ interpolate: /<%=([\s\S]+?)%>/g
+ };
+
+ // Settings
+ var KEY = { BACKSPACE : 8, TAB : 9, RETURN : 13, ESC : 27, LEFT : 37, UP : 38, RIGHT : 39, DOWN : 40, COMMA : 188, SPACE : 32, HOME : 36, END : 35 }; // Keys "enum"
+
+ //Default settings
+ var defaultSettings = {
+ triggerChar : '@', //Char that respond to event
+ onDataRequest : $.noop, //Function where we can search the data
+ minChars : 2, //Minimum chars to fire the event
+ allowRepeat : false, //Allow repeat mentions
+ showAvatars : true, //Show the avatars
+ elastic : true, //Grow the textarea automatically
+ defaultValue : '',
+ onCaret : false,
+ keepTriggerCharacter: false,
+ classes : {
+ autoCompleteItemActive : "active" //Classes to apply in each item
+ },
+ templates : {
+ wrapper : _.template(''),
+ autocompleteList : _.template(''),
+ autocompleteListItem : _.template('<%= content %> <%= fullName %>'),
+ autocompleteListItemAvatar : _.template('
'),
+ autocompleteListItemIcon : _.template(''),
+ mentionsOverlay : _.template(''),
+ mentionItemSyntax : _.template('@[<%= value %>](<%= type %>:<%= id %>)'),
+ mentionItemHighlight : _.template('<%= value %>')
+ }
+ };
+
+ //Class util
+ var utils = {
+ //Encodes the character with _.escape function (undersocre)
+ htmlEncode : function (str) {
+ return _.escape(str);
+ },
+ //Encodes the character to be used with RegExp
+ regexpEncode : function (str) {
+ return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
+ },
+ highlightTerm : function (value, term) {
+ if (!term && !term.length) {
+ return value;
+ }
+ return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1");
+ },
+ //Sets the caret in a valid position
+ setCaratPosition : function (domNode, caretPos) {
+ if (domNode.createTextRange) {
+ var range = domNode.createTextRange();
+ range.move('character', caretPos);
+ range.select();
+ } else {
+ if (domNode.selectionStart) {
+ domNode.focus();
+ domNode.setSelectionRange(caretPos, caretPos);
+ } else {
+ domNode.focus();
+ }
+ }
+ },
+ //Deletes the white spaces
+ rtrim: function(string) {
+ return string.replace(/\s+$/,"");
+ }
+ };
+
+ //Main class of MentionsInput plugin
+ var MentionsInput = function (settings) {
+
+ var domInput,
+ elmInputBox,
+ elmInputWrapper,
+ elmAutocompleteList,
+ elmWrapperBox,
+ elmMentionsOverlay,
+ elmActiveAutoCompleteItem,
+ mentionsCollection = [],
+ autocompleteItemCollection = {},
+ inputBuffer = [],
+ currentDataQuery = '';
+
+ //Mix the default setting with the users settings
+ settings = $.extend(true, {}, defaultSettings, settings );
+
+ //Initializes the text area target
+ function initTextarea() {
+ elmInputBox = $(domInput); //Get the text area target
+
+ //If the text area is already configured, return
+ if (elmInputBox.attr('data-mentions-input') === 'true') {
+ return;
+ }
+
+ elmInputWrapper = elmInputBox.parent(); //Get the DOM element parent
+ elmWrapperBox = $(settings.templates.wrapper());
+ elmInputBox.wrapAll(elmWrapperBox); //Wrap all the text area into the div elmWrapperBox
+ elmWrapperBox = elmInputWrapper.find('> div.mentions-input-box'); //Obtains the div elmWrapperBox that now contains the text area
+
+ elmInputBox.attr('data-mentions-input', 'true'); //Sets the attribute data-mentions-input to true -> Defines if the text area is already configured
+ elmInputBox.bind('keydown', onInputBoxKeyDown); //Bind the keydown event to the text area
+ elmInputBox.bind('keypress', onInputBoxKeyPress); //Bind the keypress event to the text area
+ elmInputBox.bind('click', onInputBoxClick); //Bind the click event to the text area
+ elmInputBox.bind('blur', onInputBoxBlur); //Bind the blur event to the text area
+
+ if (navigator.userAgent.indexOf("MSIE 8") > -1) {
+ elmInputBox.bind('propertychange', onInputBoxInput); //IE8 won't fire the input event, so let's bind to the propertychange
+ } else {
+ elmInputBox.bind('input', onInputBoxInput); //Bind the input event to the text area
+ }
+
+ // Elastic textareas, grow automatically
+ if( settings.elastic ) {
+ elmInputBox.elastic();
+ }
+ }
+
+ //Initializes the autocomplete list, append to elmWrapperBox and delegate the mousedown event to li elements
+ function initAutocomplete() {
+ elmAutocompleteList = $(settings.templates.autocompleteList()); //Get the HTML code for the list
+ elmAutocompleteList.appendTo(elmWrapperBox); //Append to elmWrapperBox element
+ elmAutocompleteList.delegate('li', 'mousedown', onAutoCompleteItemClick); //Delegate the event
+ }
+
+ //Initializes the mentions' overlay
+ function initMentionsOverlay() {
+ elmMentionsOverlay = $(settings.templates.mentionsOverlay()); //Get the HTML code of the mentions' overlay
+ elmMentionsOverlay.prependTo(elmWrapperBox); //Insert into elmWrapperBox the mentions overlay
+ }
+
+ //Updates the values of the main variables
+ function updateValues() {
+ var syntaxMessage = getInputBoxValue(); //Get the actual value of the text area
+
+ _.each(mentionsCollection, function (mention) {
+ var textSyntax = settings.templates.mentionItemSyntax(mention);
+ syntaxMessage = syntaxMessage.replace(new RegExp(utils.regexpEncode(mention.value), 'g'), textSyntax);
+ });
+
+ var mentionText = utils.htmlEncode(syntaxMessage); //Encode the syntaxMessage
+
+ _.each(mentionsCollection, function (mention) {
+ var formattedMention = _.extend({}, mention, {value: utils.htmlEncode(mention.value)});
+ var textSyntax = settings.templates.mentionItemSyntax(formattedMention);
+ var textHighlight = settings.templates.mentionItemHighlight(formattedMention);
+
+ mentionText = mentionText.replace(new RegExp(utils.regexpEncode(textSyntax), 'g'), textHighlight);
+ });
+
+ mentionText = mentionText.replace(/\n/g, '
'); //Replace the escape character for
+ mentionText = mentionText.replace(/ {2}/g, ' '); //Replace the 2 preceding token to
+
+ elmInputBox.data('messageText', syntaxMessage); //Save the messageText to elmInputBox
+ elmInputBox.trigger('updated');
+ elmMentionsOverlay.find('div').html(mentionText); //Insert into a div of the elmMentionsOverlay the mention text
+ }
+
+ //Cleans the buffer
+ function resetBuffer() {
+ inputBuffer = [];
+ }
+
+ //Updates the mentions collection
+ function updateMentionsCollection() {
+ var inputText = getInputBoxValue(); //Get the actual value of text area
+
+ //Returns the values that doesn't match the condition
+ mentionsCollection = _.reject(mentionsCollection, function (mention, index) {
+ return !mention.value || inputText.indexOf(mention.value) == -1;
+ });
+ mentionsCollection = _.compact(mentionsCollection); //Delete all the falsy values of the array and return the new array
+ }
+
+ //Adds mention to mentions collections
+ function addMention(mention) {
+
+ var currentMessage = getInputBoxValue(),
+ caretStart = elmInputBox[0].selectionStart,
+ shortestDistance = false,
+ bestLastIndex = false;
+
+ // Using a regex to figure out positions
+ var regex = new RegExp("\\" + settings.triggerChar + currentDataQuery, "gi"),
+ regexMatch;
+
+ while(regexMatch = regex.exec(currentMessage)) {
+ if (shortestDistance === false || Math.abs(regex.lastIndex - caretStart) < shortestDistance) {
+ shortestDistance = Math.abs(regex.lastIndex - caretStart);
+ bestLastIndex = regex.lastIndex;
+ }
+ }
+
+ var startCaretPosition = bestLastIndex - currentDataQuery.length - 1; //Set the start caret position (right before the @)
+ var currentCaretPosition = bestLastIndex; //Set the current caret position (right after the end of the "mention")
+
+
+ var start = currentMessage.substr(0, startCaretPosition);
+ var end = currentMessage.substr(currentCaretPosition, currentMessage.length);
+ var startEndIndex = (start + mention.value).length + 1;
+
+ // See if there's the same mention in the list
+ if( !_.find(mentionsCollection, function (object) { return object.id == mention.id; }) ) {
+ mentionsCollection.push(mention);//Add the mention to mentionsColletions
+ }
+
+ // Cleaning before inserting the value, otherwise auto-complete would be triggered with "old" inputbuffer
+ resetBuffer();
+ currentDataQuery = '';
+ hideAutoComplete();
+
+ // Mentions and syntax message
+ var updatedMessageText = start;
+
+ if (settings.keepTriggerCharacter) {
+ updatedMessageText += settings.triggerChar;
+ }
+
+ updatedMessageText += mention.value + ' ' + end;
+ elmInputBox.val(updatedMessageText); //Set the value to the txt area
+ elmInputBox.trigger('mention');
+ updateValues();
+
+ // Set correct focus and selection
+ elmInputBox.focus();
+ utils.setCaratPosition(elmInputBox[0], startEndIndex);
+ }
+
+ //Gets the actual value of the text area without white spaces from the beginning and end of the value
+ function getInputBoxValue() {
+ return $.trim(elmInputBox.val());
+ }
+
+ // This is taken straight from live (as of Sep 2012) GitHub code. The
+ // technique is known around the web. Just google it. Github's is quite
+ // succint though. NOTE: relies on selectionEnd, which as far as IE is concerned,
+ // it'll only work on 9+. Good news is nothing will happen if the browser
+ // doesn't support it.
+ function textareaSelectionPosition($el) {
+ var a, b, c, d, e, f, g, h, i, j, k;
+ if (!(i = $el[0])) return;
+ if (!$(i).is("textarea")) return;
+ if (i.selectionEnd == null) return;
+ g = {
+ position: "absolute",
+ overflow: "auto",
+ whiteSpace: "pre-wrap",
+ wordWrap: "break-word",
+ boxSizing: "content-box",
+ top: 0,
+ left: -9999
+ }, h = ["boxSizing", "fontFamily", "fontSize", "fontStyle", "fontVariant", "fontWeight", "height", "letterSpacing", "lineHeight", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "textDecoration", "textIndent", "textTransform", "width", "word-spacing"];
+ for (j = 0, k = h.length; j < k; j++) e = h[j], g[e] = $(i).css(e);
+ return c = document.createElement("div"), $(c).css(g), $(i).after(c), b = document.createTextNode(i.value.substring(0, i.selectionEnd)), a = document.createTextNode(i.value.substring(i.selectionEnd)), d = document.createElement("span"), d.innerHTML = " ", c.appendChild(b), c.appendChild(d), c.appendChild(a), c.scrollTop = i.scrollTop, f = $(d).position(), $(c).remove(), f
+ }
+
+ //same as above function but return offset instead of position
+ function textareaSelectionOffset($el) {
+ var a, b, c, d, e, f, g, h, i, j, k;
+ if (!(i = $el[0])) return;
+ if (!$(i).is("textarea")) return;
+ if (i.selectionEnd == null) return;
+ g = {
+ position: "absolute",
+ overflow: "auto",
+ whiteSpace: "pre-wrap",
+ wordWrap: "break-word",
+ boxSizing: "content-box",
+ top: 0,
+ left: -9999
+ }, h = ["boxSizing", "fontFamily", "fontSize", "fontStyle", "fontVariant", "fontWeight", "height", "letterSpacing", "lineHeight", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "textDecoration", "textIndent", "textTransform", "width", "word-spacing"];
+ for (j = 0, k = h.length; j < k; j++) e = h[j], g[e] = $(i).css(e);
+ return c = document.createElement("div"), $(c).css(g), $(i).after(c), b = document.createTextNode(i.value.substring(0, i.selectionEnd)), a = document.createTextNode(i.value.substring(i.selectionEnd)), d = document.createElement("span"), d.innerHTML = " ", c.appendChild(b), c.appendChild(d), c.appendChild(a), c.scrollTop = i.scrollTop, f = $(d).offset(), $(c).remove(), f
+ }
+
+ //Scrolls back to the input after autocomplete if the window has scrolled past the input
+ function scrollToInput() {
+ var elmDistanceFromTop = $(elmInputBox).offset().top; //input offset
+ var bodyDistanceFromTop = $('body').offset().top; //body offset
+ var distanceScrolled = $(window).scrollTop(); //distance scrolled
+
+ if (distanceScrolled > elmDistanceFromTop) {
+ //subtracts body distance to handle fixed headers
+ $(window).scrollTop(elmDistanceFromTop - bodyDistanceFromTop);
+ }
+ }
+
+ //Takes the click event when the user select a item of the dropdown
+ function onAutoCompleteItemClick(e) {
+ var elmTarget = $(this); //Get the item selected
+ var mention = autocompleteItemCollection[elmTarget.attr('data-uid')]; //Obtains the mention
+
+ addMention(mention);
+ scrollToInput();
+ return false;
+ }
+
+ //Takes the click event on text area
+ function onInputBoxClick(e) {
+ resetBuffer();
+ }
+
+ //Takes the blur event on text area
+ function onInputBoxBlur(e) {
+ hideAutoComplete();
+ }
+
+ //Takes the input event when users write or delete something
+ function onInputBoxInput(e) {
+ updateValues();
+ updateMentionsCollection();
+
+ var triggerCharIndex = _.lastIndexOf(inputBuffer, settings.triggerChar); //Returns the last match of the triggerChar in the inputBuffer
+ if (triggerCharIndex > -1) { //If the triggerChar is present in the inputBuffer array
+ currentDataQuery = inputBuffer.slice(triggerCharIndex + 1).join(''); //Gets the currentDataQuery
+ currentDataQuery = utils.rtrim(currentDataQuery); //Deletes the whitespaces
+ _.defer(_.bind(doSearch, this, currentDataQuery)); //Invoking the function doSearch ( Bind the function to this)
+ }
+ }
+
+ //Takes the keypress event
+ function onInputBoxKeyPress(e) {
+ if(e.keyCode !== KEY.BACKSPACE) { //If the key pressed is not the backspace
+ var typedValue = String.fromCharCode(e.which || e.keyCode); //Takes the string that represent this CharCode
+ inputBuffer.push(typedValue); //Push the value pressed into inputBuffer
+ }
+ }
+
+ //Takes the keydown event
+ function onInputBoxKeyDown(e) {
+
+ // This also matches HOME/END on OSX which is CMD+LEFT, CMD+RIGHT
+ if (e.keyCode === KEY.LEFT || e.keyCode === KEY.RIGHT || e.keyCode === KEY.HOME || e.keyCode === KEY.END) {
+ // Defer execution to ensure carat pos has changed after HOME/END keys then call the resetBuffer function
+ _.defer(resetBuffer);
+
+ // IE9 doesn't fire the oninput event when backspace or delete is pressed. This causes the highlighting
+ // to stay on the screen whenever backspace is pressed after a highlighed word. This is simply a hack
+ // to force updateValues() to fire when backspace/delete is pressed in IE9.
+ if (navigator.userAgent.indexOf("MSIE 9") > -1) {
+ _.defer(updateValues); //Call the updateValues function
+ }
+
+ return;
+ }
+
+ //If the key pressed was the backspace
+ if (e.keyCode === KEY.BACKSPACE) {
+ inputBuffer = inputBuffer.slice(0, -1 + inputBuffer.length); // Can't use splice, not available in IE
+ return;
+ }
+
+ //If the elmAutocompleteList is hidden
+ if (!elmAutocompleteList.is(':visible')) {
+ return true;
+ }
+
+ switch (e.keyCode) {
+ case KEY.UP: //If the key pressed was UP or DOWN
+ case KEY.DOWN:
+ var elmCurrentAutoCompleteItem = null;
+ if (e.keyCode === KEY.DOWN) { //If the key pressed was DOWN
+ if (elmActiveAutoCompleteItem && elmActiveAutoCompleteItem.length) { //If elmActiveAutoCompleteItem exits
+ elmCurrentAutoCompleteItem = elmActiveAutoCompleteItem.next(); //Gets the next li element in the list
+ } else {
+ elmCurrentAutoCompleteItem = elmAutocompleteList.find('li').first(); //Gets the first li element found
+ }
+ } else {
+ elmCurrentAutoCompleteItem = $(elmActiveAutoCompleteItem).prev(); //The key pressed was UP and gets the previous li element
+ }
+ if (elmCurrentAutoCompleteItem.length) {
+ selectAutoCompleteItem(elmCurrentAutoCompleteItem);
+ }
+ return false;
+ case KEY.RETURN: //If the key pressed was RETURN or TAB
+ case KEY.TAB:
+ if (elmActiveAutoCompleteItem && elmActiveAutoCompleteItem.length) { //If the elmActiveAutoCompleteItem exists
+ elmActiveAutoCompleteItem.trigger('mousedown'); //Calls the mousedown event
+ return false;
+ }
+ break;
+ }
+
+ return true;
+ }
+
+ //Hides the autoomplete
+ function hideAutoComplete() {
+ elmActiveAutoCompleteItem = null;
+ elmAutocompleteList.empty().hide();
+ }
+
+ //Selects the item in the autocomplete list
+ function selectAutoCompleteItem(elmItem) {
+ elmItem.addClass(settings.classes.autoCompleteItemActive); //Add the class active to item
+ elmItem.siblings().removeClass(settings.classes.autoCompleteItemActive); //Gets all li elements in autocomplete list and remove the class active
+
+ elmActiveAutoCompleteItem = elmItem; //Sets the item to elmActiveAutoCompleteItem
+ }
+
+ //Populates dropdown
+ function populateDropdown(query, results) {
+ elmAutocompleteList.show(); //Shows the autocomplete list
+
+ if(!settings.allowRepeat) {
+ // Filter items that has already been mentioned
+ var mentionValues = _.pluck(mentionsCollection, 'value');
+ results = _.reject(results, function (item) {
+ return _.include(mentionValues, item.name);
+ });
+ }
+
+ if (!results.length) { //If there are not elements hide the autocomplete list
+ hideAutoComplete();
+ return;
+ }
+
+ elmAutocompleteList.empty(); //Remove all li elements in autocomplete list
+ var elmDropDownList = $("").appendTo(elmAutocompleteList).hide(); //Inserts a ul element to autocomplete div and hide it
+
+ _.each(results, function (item, index) {
+ var itemUid = _.uniqueId('mention_'); //Gets the item with unique id
+
+ autocompleteItemCollection[itemUid] = _.extend({}, item, {value: item.name}); //Inserts the new item to autocompleteItemCollection
+
+ var elmListItem = $(settings.templates.autocompleteListItem({
+ 'id' : utils.htmlEncode(item.id),
+ 'display' : utils.htmlEncode(item.name),
+ 'type' : utils.htmlEncode(item.type),
+ 'fullName': utils.htmlEncode(item.fullName),
+ 'content' : utils.highlightTerm(utils.htmlEncode((item.display ? item.display : item.name)), query)
+ })).attr('data-uid', itemUid); //Inserts the new item to list
+
+ //If the index is 0
+ if (index === 0) {
+ selectAutoCompleteItem(elmListItem);
+ }
+
+ //If show avatars is true
+ if (settings.showAvatars) {
+ var elmIcon;
+
+ //If the item has an avatar
+ if (item.avatar) {
+ elmIcon = $(settings.templates.autocompleteListItemAvatar({ avatar : item.avatar }));
+ } else { //If not then we set an default icon
+ elmIcon = $(settings.templates.autocompleteListItemIcon({ icon : item.icon }));
+ }
+ elmIcon.prependTo(elmListItem); //Inserts the elmIcon to elmListItem
+ }
+ elmListItem = elmListItem.appendTo(elmDropDownList); //Insets the elmListItem to elmDropDownList
+ });
+
+ elmAutocompleteList.show(); //Shows the elmAutocompleteList div
+ if (settings.onCaret) {
+ positionAutocomplete(elmAutocompleteList, elmInputBox);
+ }
+ elmDropDownList.show(); //Shows the elmDropDownList
+ }
+
+ //Search into data list passed as parameter
+ function doSearch(query) {
+ //If the query is not null, undefined, empty and has the minimum chars
+ if (query && query.length && query.length >= settings.minChars) {
+ //Call the onDataRequest function and then call the populateDropDrown
+ settings.onDataRequest.call(this, 'search', query, function (responseData) {
+ populateDropdown(query, responseData);
+ });
+ } else { //If the query is null, undefined, empty or has not the minimun chars
+ hideAutoComplete(); //Hide the autocompletelist
+ }
+ }
+
+ function positionAutocomplete(elmAutocompleteList, elmInputBox) {
+ var elmAutocompleteListPosition = elmAutocompleteList.css('position');
+ if (elmAutocompleteListPosition == 'absolute') {
+ var position = textareaSelectionPosition(elmInputBox),
+ lineHeight = parseInt(elmInputBox.css('line-height'), 10) || 18;
+ elmAutocompleteList.css('width', '15em'); // Sort of a guess
+ elmAutocompleteList.css('left', position.left);
+ elmAutocompleteList.css('top', lineHeight + position.top);
+
+ //check if the right position of auto complete is larger than the right position of the input
+ //if yes, reset the left of auto complete list to make it fit the input
+ var elmInputBoxRight = elmInputBox.offset().left + elmInputBox.width(),
+ elmAutocompleteListRight = elmAutocompleteList.offset().left + elmAutocompleteList.width();
+ if (elmInputBoxRight <= elmAutocompleteListRight) {
+ elmAutocompleteList.css('left', Math.abs(elmAutocompleteList.position().left - (elmAutocompleteListRight - elmInputBoxRight)));
+ }
+ }
+ else if (elmAutocompleteListPosition == 'fixed') {
+ var offset = textareaSelectionOffset(elmInputBox),
+ lineHeight = parseInt(elmInputBox.css('line-height'), 10) || 18;
+ elmAutocompleteList.css('width', '15em'); // Sort of a guess
+ elmAutocompleteList.css('left', offset.left + 10000);
+ elmAutocompleteList.css('top', lineHeight + offset.top);
+ }
+ }
+
+ //Resets the text area
+ function resetInput(currentVal) {
+ mentionsCollection = [];
+ var mentionText = utils.htmlEncode(currentVal);
+ var regex = new RegExp("(" + settings.triggerChar + ")\\[(.*?)\\]\\((.*?):(.*?)\\)", "gi");
+ var match, newMentionText = mentionText;
+ while ((match = regex.exec(mentionText)) != null) {
+ newMentionText = newMentionText.replace(match[0], match[1] + match[2]);
+ mentionsCollection.push({ 'id': match[4], 'type': match[3], 'value': match[2], 'trigger': match[1] });
+ }
+ elmInputBox.val(newMentionText);
+ updateValues();
+ }
+ // Public methods
+ return {
+ //Initializes the mentionsInput component on a specific element.
+ init : function (domTarget) {
+
+ domInput = domTarget;
+
+ initTextarea();
+ initAutocomplete();
+ initMentionsOverlay();
+ resetInput(settings.defaultValue);
+
+ //If the autocomplete list has prefill mentions
+ if( settings.prefillMention ) {
+ addMention( settings.prefillMention );
+ }
+ },
+
+ //An async method which accepts a callback function and returns a value of the input field (including markup) as a first parameter of this function. This is the value you want to send to your server.
+ val : function (callback) {
+ if (!_.isFunction(callback)) {
+ return;
+ }
+ callback.call(this, mentionsCollection.length ? elmInputBox.data('messageText') : getInputBoxValue());
+ },
+
+ //Resets the text area value and clears all mentions
+ reset : function () {
+ resetInput();
+ },
+
+ //Reinit with the text area value if it was changed programmatically
+ reinit : function () {
+ resetInput(false);
+ },
+
+ //An async method which accepts a callback function and returns a collection of mentions as hash objects as a first parameter.
+ getMentions : function (callback) {
+ if (!_.isFunction(callback)) {
+ return;
+ }
+ callback.call(this, mentionsCollection);
+ }
+ };
+ };
+
+ //Main function to include into jQuery and initialize the plugin
+ $.fn.mentionsInput = function (method, settings) {
+
+ var outerArguments = arguments; //Gets the arguments
+ //If method is not a function
+ if (typeof method === 'object' || !method) {
+ settings = method;
+ }
+
+ return this.each(function () {
+ var instance = $.data(this, 'mentionsInput') || $.data(this, 'mentionsInput', new MentionsInput(settings));
+
+ if (_.isFunction(instance[method])) {
+ return instance[method].apply(this, Array.prototype.slice.call(outerArguments, 1));
+ } else if (typeof method === 'object' || !method) {
+ return instance.init.call(this, this);
+ } else {
+ $.error('Method ' + method + ' does not exist');
+ }
+ });
+ };
+
+})(jQuery, _.runInContext());
diff --git a/public/javascripts/vendor/jquery.mentionsInput.mod.min.js b/public/javascripts/vendor/jquery.mentionsInput.mod.min.js
new file mode 100644
index 0000000..306032b
--- /dev/null
+++ b/public/javascripts/vendor/jquery.mentionsInput.mod.min.js
@@ -0,0 +1 @@
+!function(e,t,n){t.templateSettings={interpolate:/<%=([\s\S]+?)%>/g};var i={BACKSPACE:8,TAB:9,RETURN:13,ESC:27,LEFT:37,UP:38,RIGHT:39,DOWN:40,COMMA:188,SPACE:32,HOME:36,END:35},a={triggerChar:"@",onDataRequest:e.noop,minChars:2,allowRepeat:!1,showAvatars:!0,elastic:!0,defaultValue:"",onCaret:!1,keepTriggerCharacter:!1,classes:{autoCompleteItemActive:"active"},templates:{wrapper:t.template(''),autocompleteList:t.template(''),autocompleteListItem:t.template('- <%= content %> <%= fullName %>
'),autocompleteListItemAvatar:t.template('
'),autocompleteListItemIcon:t.template(''),mentionsOverlay:t.template(''),mentionItemSyntax:t.template("@[<%= value %>](<%= type %>:<%= id %>)"),mentionItemHighlight:t.template("<%= value %>")}},o={htmlEncode:function(e){return t.escape(e)},regexpEncode:function(e){return e.replace(/([.*+?^=!:${}()|\[\]\/\\])/g,"\\$1")},highlightTerm:function(e,t){return t||t.length?e.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)("+t+")(?![^<>]*>)(?![^&;]+;)","gi"),"$1"):e},setCaratPosition:function(e,t){if(e.createTextRange){var n=e.createTextRange();n.move("character",t),n.select()}else e.selectionStart?(e.focus(),e.setSelectionRange(t,t)):e.focus()},rtrim:function(e){return e.replace(/\s+$/,"")}},r=function(n){function r(){M=e(k),"true"!==M.attr("data-mentions-input")&&(L=M.parent(),O=e(n.templates.wrapper()),M.wrapAll(O),O=L.find("> div.mentions-input-box"),M.attr("data-mentions-input","true"),M.bind("keydown",b),M.bind("keypress",E),M.bind("click",x),M.bind("blur",C),navigator.userAgent.indexOf("MSIE 8")>-1?M.bind("propertychange",w):M.bind("input",w),n.elastic&&M.elastic())}function l(){N=e(n.templates.autocompleteList()),N.appendTo(O),N.delegate("li","mousedown",v)}function s(){H=e(n.templates.mentionsOverlay()),H.prependTo(O)}function c(){var e=f();t.each(P,function(t){var i=n.templates.mentionItemSyntax(t);e=e.replace(new RegExp(o.regexpEncode(t.value),"g"),i)});var i=o.htmlEncode(e);t.each(P,function(e){var a=t.extend({},e,{value:o.htmlEncode(e.value)}),r=n.templates.mentionItemSyntax(a),l=n.templates.mentionItemHighlight(a);i=i.replace(new RegExp(o.regexpEncode(r),"g"),l)}),i=i.replace(/\n/g,"
"),i=i.replace(/ {2}/g," "),M.data("messageText",e),M.trigger("updated"),H.find("div").html(i)}function d(){F=[]}function p(){var e=f();P=t.reject(P,function(t,n){return!t.value||e.indexOf(t.value)==-1}),P=t.compact(P)}function u(e){for(var i,a=f(),r=M[0].selectionStart,l=!1,s=!1,p=new RegExp("\\"+n.triggerChar+W,"gi");i=p.exec(a);)(l===!1||Math.abs(p.lastIndex-r)t&&e(window).scrollTop(t-n)}function v(t){var n=e(this),i=B[n.attr("data-uid")];return u(i),h(),!1}function x(e){d()}function C(e){y()}function w(e){c(),p();var i=t.lastIndexOf(F,n.triggerChar);i>-1&&(W=F.slice(i+1).join(""),W=o.rtrim(W),t.defer(t.bind(S,this,W)))}function E(e){if(e.keyCode!==i.BACKSPACE){var t=String.fromCharCode(e.which||e.keyCode);F.push(t)}}function b(n){if(n.keyCode===i.LEFT||n.keyCode===i.RIGHT||n.keyCode===i.HOME||n.keyCode===i.END)return t.defer(d),void(navigator.userAgent.indexOf("MSIE 9")>-1&&t.defer(c));if(n.keyCode===i.BACKSPACE)return void(F=F.slice(0,-1+F.length));if(!N.is(":visible"))return!0;switch(n.keyCode){case i.UP:case i.DOWN:var a=null;return a=n.keyCode===i.DOWN?D&&D.length?D.next():N.find("li").first():e(D).prev(),a.length&&T(a),!1;case i.RETURN:case i.TAB:if(D&&D.length)return D.trigger("mousedown"),!1}return!0}function y(){D=null,N.empty().hide()}function T(e){e.addClass(n.classes.autoCompleteItemActive),e.siblings().removeClass(n.classes.autoCompleteItemActive),D=e}function I(i,a){if(N.show(),!n.allowRepeat){var r=t.pluck(P,"value");a=t.reject(a,function(e){return t.include(r,e.name)})}if(!a.length)return void y();N.empty();var l=e("").appendTo(N).hide();t.each(a,function(a,r){var s=t.uniqueId("mention_");B[s]=t.extend({},a,{value:a.name});var c=e(n.templates.autocompleteListItem({id:o.htmlEncode(a.id),display:o.htmlEncode(a.name),type:o.htmlEncode(a.type),fullName:o.htmlEncode(a.fullName),content:o.highlightTerm(o.htmlEncode(a.display?a.display:a.name),i)})).attr("data-uid",s);if(0===r&&T(c),n.showAvatars){var d;d=e(a.avatar?n.templates.autocompleteListItemAvatar({avatar:a.avatar}):n.templates.autocompleteListItemIcon({icon:a.icon})),d.prependTo(c)}c=c.appendTo(l)}),N.show(),n.onCaret&&A(N,M),l.show()}function S(e){e&&e.length&&e.length>=n.minChars?n.onDataRequest.call(this,"search",e,function(t){I(e,t)}):y()}function A(e,t){var n=e.css("position");if("absolute"==n){var i=m(t),a=parseInt(t.css("line-height"),10)||18;e.css("width","15em"),e.css("left",i.left),e.css("top",a+i.top);var o=t.offset().left+t.width(),r=e.offset().left+e.width();o<=r&&e.css("left",Math.abs(e.position().left-(r-o)))}else if("fixed"==n){var l=g(t),a=parseInt(t.css("line-height"),10)||18;e.css("width","15em"),e.css("left",l.left+1e4),e.css("top",a+l.top)}}function R(e){P=[];for(var t,i=o.htmlEncode(e),a=new RegExp("("+n.triggerChar+")\\[(.*?)\\]\\((.*?):(.*?)\\)","gi"),r=i;null!=(t=a.exec(i));)r=r.replace(t[0],t[1]+t[2]),P.push({id:t[4],type:t[3],value:t[2],trigger:t[1]});M.val(r),c()}var k,M,L,N,O,H,D,P=[],B={},F=[],W="";return n=e.extend(!0,{},a,n),{init:function(e){k=e,r(),l(),s(),R(n.defaultValue),n.prefillMention&&u(n.prefillMention)},val:function(e){t.isFunction(e)&&e.call(this,P.length?M.data("messageText"):f())},reset:function(){R()},reinit:function(){R(!1)},getMentions:function(e){t.isFunction(e)&&e.call(this,P)}}};e.fn.mentionsInput=function(n,i){var a=arguments;return"object"!=typeof n&&n||(i=n),this.each(function(){var o=e.data(this,"mentionsInput")||e.data(this,"mentionsInput",new r(i));return t.isFunction(o[n])?o[n].apply(this,Array.prototype.slice.call(a,1)):"object"!=typeof n&&n?void e.error("Method "+n+" does not exist"):o.init.call(this,this)})}}(jQuery,_.runInContext());
diff --git a/public/stylesheets/application.scss b/public/stylesheets/application.scss
index 770613f..0af5e90 100644
--- a/public/stylesheets/application.scss
+++ b/public/stylesheets/application.scss
@@ -48,6 +48,7 @@
@import 'profile-members';
@import 'profile-search';
@import 'profile-activity';
+@import 'jquery.mentionsInput.min';
// admin
@import 'admin-panel';
@import 'manage-fields';
diff --git a/public/stylesheets/comments.scss b/public/stylesheets/comments.scss
index c71148f..a911245 100644
--- a/public/stylesheets/comments.scss
+++ b/public/stylesheets/comments.scss
@@ -366,3 +366,11 @@ a.comment-picture {
margin-bottom: 40px;
}
+textarea[data-mentions-input='true'] {
+ min-height: 70px;
+}
+
+.mentions-input-box .mentions > div > strong {
+ background: none;
+}
+
diff --git a/public/stylesheets/jquery.mentionsInput.css b/public/stylesheets/jquery.mentionsInput.css
new file mode 100644
index 0000000..32638b5
--- /dev/null
+++ b/public/stylesheets/jquery.mentionsInput.css
@@ -0,0 +1,121 @@
+
+.mentions-input-box {
+ position: relative;
+ background: #fff;
+}
+
+.mentions-input-box textarea {
+ width: 100%;
+ display: block;
+ height: 18px;
+ padding: 9px;
+ border: 1px solid #dcdcdc;
+ border-radius:3px;
+ overflow: hidden;
+ background: transparent;
+ position: relative;
+ outline: 0;
+ resize: none;
+
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+.mentions-input-box .mentions-autocomplete-list {
+ display: none;
+ background: #fff;
+ border: 1px solid #b2b2b2;
+ position: absolute;
+ left: 0;
+ right: 0;
+ z-index: 10000;
+ margin-top: -2px;
+
+ border-radius:5px;
+ border-top-right-radius:0;
+ border-top-left-radius:0;
+
+ -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438);
+ -moz-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438);
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.148438);
+}
+
+.mentions-input-box .mentions-autocomplete-list ul {
+ margin: 0;
+ padding: 0;
+}
+
+.mentions-input-box .mentions-autocomplete-list li {
+ background-color: #fff;
+ padding: 0 5px;
+ margin: 0;
+ width: auto;
+ border-bottom: 1px solid #eee;
+ height: 26px;
+ line-height: 26px;
+ overflow: hidden;
+ cursor: pointer;
+ list-style: none;
+ white-space: nowrap;
+}
+
+.mentions-input-box .mentions-autocomplete-list li:last-child {
+ border-radius:5px;
+}
+
+.mentions-input-box .mentions-autocomplete-list li > img,
+.mentions-input-box .mentions-autocomplete-list li > div.icon {
+ width: 16px;
+ height: 16px;
+ float: left;
+ margin-top:5px;
+ margin-right: 5px;
+ -moz-background-origin:3px;
+
+ border-radius:3px;
+}
+
+.mentions-input-box .mentions-autocomplete-list li em {
+ font-weight: bold;
+ font-style: none;
+}
+
+.mentions-input-box .mentions-autocomplete-list li:hover,
+.mentions-input-box .mentions-autocomplete-list li.active {
+ background-color: #f2f2f2;
+}
+
+.mentions-input-box .mentions-autocomplete-list li b {
+ background: #ffff99;
+ font-weight: normal;
+}
+
+.mentions-input-box .mentions {
+ position: absolute;
+ left: 1px;
+ right: 0;
+ top: 1px;
+ bottom: 0;
+ padding: 9px;
+ color: #fff;
+ overflow: hidden;
+
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.mentions-input-box .mentions > div {
+ color: #fff;
+ white-space: pre-wrap;
+ width: 100%;
+}
+
+.mentions-input-box .mentions > div > strong {
+ font-weight:normal;
+ background: #d8dfea;
+}
+
+.mentions-input-box .mentions > div > strong > span {
+ filter: progid:DXImageTransform.Microsoft.Alpha(opacity=0);
+}
diff --git a/public/stylesheets/jquery.mentionsInput.min.css b/public/stylesheets/jquery.mentionsInput.min.css
new file mode 100644
index 0000000..a778727
--- /dev/null
+++ b/public/stylesheets/jquery.mentionsInput.min.css
@@ -0,0 +1 @@
+.mentions-input-box{position:relative;background:#fff}.mentions-input-box textarea{width:100%;display:block;height:18px;padding:9px;border:1px solid #dcdcdc;border-radius:3px;overflow:hidden;background:transparent;position:relative;outline:0;resize:none;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.mentions-input-box .mentions-autocomplete-list{display:none;background:#fff;border:1px solid #b2b2b2;position:absolute;left:0;right:0;z-index:10000;margin-top:-2px;border-radius:5px;border-top-right-radius:0;border-top-left-radius:0;-webkit-box-shadow:0 2px 5px rgba(0,0,0,0.148438);-moz-box-shadow:0 2px 5px rgba(0,0,0,0.148438);box-shadow:0 2px 5px rgba(0,0,0,0.148438)}.mentions-input-box .mentions-autocomplete-list ul{margin:0;padding:0}.mentions-input-box .mentions-autocomplete-list li{background-color:#fff;padding:0 5px;margin:0;width:auto;border-bottom:1px solid #eee;height:26px;line-height:26px;overflow:hidden;cursor:pointer;list-style:none;white-space:nowrap}.mentions-input-box .mentions-autocomplete-list li:last-child{border-radius:5px}.mentions-input-box .mentions-autocomplete-list li>img,.mentions-input-box .mentions-autocomplete-list li>div.icon{width:16px;height:16px;float:left;margin-top:5px;margin-right:5px;-moz-background-origin:3px;border-radius:3px}.mentions-input-box .mentions-autocomplete-list li em{font-weight:bold;font-style:none}.mentions-input-box .mentions-autocomplete-list li:hover,.mentions-input-box .mentions-autocomplete-list li.active{background-color:#f2f2f2}.mentions-input-box .mentions-autocomplete-list li b{background:#ff9;font-weight:normal}.mentions-input-box .mentions{position:absolute;left:1px;right:0;top:1px;bottom:0;padding:9px;color:#fff;overflow:hidden;white-space:pre-wrap;word-wrap:break-word}.mentions-input-box .mentions>div{color:#fff;white-space:pre-wrap;width:100%}.mentions-input-box .mentions>div>strong{font-weight:normal;background:#d8dfea}.mentions-input-box .mentions>div>strong>span{filter:alpha(opacity=0)}
diff --git a/vendor/plugins/action_tracker/lib/action_tracker.rb b/vendor/plugins/action_tracker/lib/action_tracker.rb
index ef8e414..31fb619 100644
--- a/vendor/plugins/action_tracker/lib/action_tracker.rb
+++ b/vendor/plugins/action_tracker/lib/action_tracker.rb
@@ -68,7 +68,9 @@ module ActionTracker
post_proc = options.delete(:post_processing) || options.delete('post_processing') || Proc.new{}
custom_user = options.delete(:custom_user) || options.delete('custom_user') || nil
custom_target = options.delete(:custom_target) || options.delete('custom_target') || nil
- send(callback, Proc.new { |tracked| tracked.save_action_for_verb(verb.to_s, keep_params, post_proc, custom_user, custom_target) }, options)
+ show_on_wall = options.symbolize_keys.delete(:show_on_wall)
+ show_on_wall = true if show_on_wall.nil?
+ send(callback, Proc.new { |tracked| tracked.save_action_for_verb(verb.to_s, keep_params, post_proc, custom_user, custom_target, show_on_wall) }, options)
send :include, InstanceMethods
end
@@ -88,7 +90,7 @@ module ActionTracker
time.to_f
end
- def save_action_for_verb(verb, keep_params = :all, post_proc = Proc.new{}, custom_user = nil, custom_target = nil)
+ def save_action_for_verb(verb, keep_params = :all, post_proc = Proc.new{}, custom_user = nil, custom_target = nil, show_on_wall = true)
user = self.send(custom_user) unless custom_user.blank?
user ||= ActionTracker::Record.current_user
target = self.send(custom_target) unless custom_target.blank?
@@ -107,12 +109,13 @@ module ActionTracker
end
tracked_action = case ActionTrackerConfig.verb_type(verb)
when :groupable
- Record.add_or_create :verb => verb, :params => stored_params, :user => user, :target => target
+ Record.add_or_create :verb => verb, :params => stored_params, :user => user, :target => target, :show_on_wall => show_on_wall
when :updatable
- Record.update_or_create :verb => verb, :params => stored_params, :user => user, :target => target
+ Record.update_or_create :verb => verb, :params => stored_params, :user => user, :target => target, :show_on_wall => show_on_wall
when :single
- Record.new :verb => verb, :params => stored_params, :user => user
+ Record.new :verb => verb, :params => stored_params, :user => user, :show_on_wall => show_on_wall
end
+
tracked_action.target = target || self
user.tracked_actions << tracked_action
post_proc.call tracked_action
diff --git a/vendor/plugins/action_tracker/lib/action_tracker_model.rb b/vendor/plugins/action_tracker/lib/action_tracker_model.rb
index 447dfd9..fa585a2 100644
--- a/vendor/plugins/action_tracker/lib/action_tracker_model.rb
+++ b/vendor/plugins/action_tracker/lib/action_tracker_model.rb
@@ -1,6 +1,6 @@
module ActionTracker
class Record < ActiveRecord::Base
- attr_accessible :verb, :params, :user, :target
+ attr_accessible :verb, :params, :user, :target, :show_on_wall
self.table_name = 'action_tracker'
@@ -58,6 +58,10 @@ module ActionTracker
l
end
+ def url
+ {:controller => "profile", :action => "show_tracked_action", :activity_id => self.id}
+ end
+
def self.time_spent(conditions = {}) # In seconds
#FIXME Better if it could be completely done in the database, but SQLite does not support difference between two timestamps
time = 0
--
libgit2 0.21.2