Commit ebd761e268ff290248bb9a25ade95a060f10c5ad

Authored by Fabio Teixeira
Committed by Marcos Pereira
1 parent f2b2ff07
Exists in user_mention

Add mentions system to comments and scraps

Signed-off-by: Fabio Teixeira <fabio1079@gmail.com>
Signed-off-by: Gustavo Jaruga <darksshades@gmail.com>
Signed-off-by: Marcos Ronaldo <marcos.rpj2@gmail.com>
Signed-off-by: Matheus Miranda <matheusmirandalacerda@gmail.com>
Signed-off-by: Sabryna Sousa <sabryna.sousa1323@gmail.com>
app/controllers/public/profile_controller.rb
... ... @@ -434,6 +434,9 @@ class ProfileController &lt; PublicController
434 434 end
435 435 end
436 436  
  437 + def show_tracked_action
  438 + @activity = ProfileActivity.find_by(:activity_id => params[:activity_id]).activity
  439 + end
437 440  
438 441 protected
439 442  
... ...
app/controllers/public/search_controller.rb
... ... @@ -154,6 +154,33 @@ class SearchController &lt; PublicController
154 154 render :text => find_suggestions(normalize_term(params[:term]), environment, params[:asset]).to_json
155 155 end
156 156  
  157 + def search_for_friends_and_members
  158 + # If it isn't a ajax call give to the user an access denied response
  159 + return render_access_denied unless request.xhr?
  160 +
  161 + scope = user.friends.select(:id, :name, :identifier)
  162 +
  163 + results = find_by_contents(
  164 + :people, environment, scope,
  165 + params['q'], {:page => 1}
  166 + )[:results]
  167 +
  168 + if params[:community].present? and (community = Community.find_by_identifier params[:community])
  169 + scope = community.members.where.not(id: user.id) # .select(:id, :name, :identifier) is not working here
  170 + results += find_by_contents(
  171 + :people, environment, scope,
  172 + params['q'], {:page => 1}
  173 + )[:results]
  174 +
  175 + # because .select does not work on members
  176 + results.map! {|r|
  177 + {id: r.id, name: r.name, identifier: r.identifier}
  178 + }
  179 + end
  180 +
  181 + render :json => results.to_json
  182 + end
  183 +
157 184 #######################################################
158 185 protected
159 186  
... ...
app/helpers/action_tracker_helper.rb
... ... @@ -24,6 +24,11 @@ module ActionTrackerHelper
24 24 }
25 25 end
26 26  
  27 + def notify_mentioned_users_description ta
  28 + source_link = link_to _("here"), ta.get_url
  29 + (_("mentioned you %{link}") % {link: source_link}).html_safe
  30 + end
  31 +
27 32 def join_community_description ta
28 33 n_('has joined 1 community:<br />%{name}', 'has joined %{num} communities:<br />%{name}', ta.get_resource_name.size).html_safe % {
29 34 num: ta.get_resource_name.size,
... ... @@ -62,19 +67,19 @@ module ActionTrackerHelper
62 67 end
63 68  
64 69 def reply_scrap_description ta
65   - _('sent a message to %{receiver}: <br /> "%{message}"') % {
  70 + (_('sent a message to %{receiver}: <br /> "%{message}"') % {
66 71 receiver: link_to(ta.get_receiver_name, ta.get_receiver_url),
67 72 message: auto_link_urls(ta.get_content)
68   - }
  73 + }).html_safe
69 74 end
70 75  
71 76 alias :leave_scrap_description :reply_scrap_description
72 77 alias :reply_scrap_on_self_description :reply_scrap_description
73 78  
74 79 def leave_scrap_to_self_description ta
75   - _('wrote: <br /> "%{text}"') % {
  80 + (_('wrote: <br /> "%{text}"') % {
76 81 text: auto_link_urls(ta.get_content)
77   - }
  82 + }).html_safe
78 83 end
79 84  
80 85 def favorite_enterprise_description ta
... ...
app/helpers/mention_helper.rb 0 → 100644
... ... @@ -0,0 +1,51 @@
  1 +module MentionHelper
  2 + def mention_search_regex
  3 + /(?:^\s?|\s)@([^\s?:^\.?:^\,]+)/m
  4 + end
  5 +
  6 + def has_mentions? text=""
  7 + text.scan(mention_search_regex).present?
  8 + end
  9 +
  10 + def get_mentions text=""
  11 + text.scan(mention_search_regex).flatten.uniq
  12 + end
  13 +
  14 + def get_invalid_mentions text, person, profile=nil
  15 + mentions = get_mentions text
  16 + # remove the friends of the user
  17 + mentions -= person.friends.select(:identifier).where(:identifier => mentions).map(&:identifier)
  18 +
  19 + if profile.present? and profile.kind_of?(Community)
  20 + # remove the members of the same community
  21 + mentions -= profile.members.where(:identifier => mentions).map(&:identifier)
  22 + end
  23 +
  24 + # any remaining mention is invalid
  25 + mentions
  26 + end
  27 +
  28 + def remove_invalid_mentions text, invalid_mentions=[]
  29 + if not invalid_mentions.empty?
  30 + # remove the "@" from the invalid mentions, so that the notify job dont send notifications to them
  31 + invalid_mentions.each do |mention|
  32 + text.gsub! "@#{mention}", mention
  33 + end
  34 + end
  35 +
  36 + text
  37 + end
  38 +
  39 + def has_valid_mentions? model, attribute, person, profile=nil
  40 + text = model.send attribute
  41 +
  42 + if has_mentions? text
  43 + invalid_mentions = get_invalid_mentions text, person, profile
  44 + text = remove_invalid_mentions text, invalid_mentions
  45 + # to prevent invalid notifications, remove the invalid mentions
  46 + model.update_attribute attribute, text
  47 + end
  48 +
  49 + has_mentions? text
  50 + end
  51 +end
... ...
app/jobs/notify_mentioned_users_job.rb 0 → 100644
... ... @@ -0,0 +1,36 @@
  1 +class NotifyMentionedUsersJob < Struct.new(:tracked_action_id)
  2 + include MentionHelper
  3 +
  4 + def perform
  5 + return unless ActionTracker::Record.exists?(tracked_action_id)
  6 + tracked_action = ActionTracker::Record.find(tracked_action_id)
  7 +
  8 + mention_creator = Person.find(tracked_action.user_id)
  9 +
  10 + remove_followers = false
  11 + mention_text = if tracked_action.target_type == "Comment"
  12 + tracked_action.params["body"]
  13 + else #scrap
  14 + remove_followers = true #scraps already notify followers.
  15 + tracked_action.params["content"]
  16 + end
  17 +
  18 + people_identifiers = get_mentions mention_text
  19 + people_identifiers -= [mention_creator.identifier]
  20 +
  21 + followers = mention_creator.followers
  22 + people_identifiers -= followers.map(&:identifier) if (followers.present? && remove_followers)
  23 +
  24 + return if people_identifiers.empty?
  25 +
  26 + people_identifiers = "'" + people_identifiers.join("','") + "'"
  27 +
  28 + notification_sql = "INSERT INTO action_tracker_notifications(profile_id,action_tracker_id) " +
  29 + "SELECT p.id, #{tracked_action.id} FROM profiles AS p " +
  30 + "WHERE p.identifier IN (#{people_identifiers}) AND " +
  31 + "p.id NOT IN (SELECT atn.profile_id FROM action_tracker_notifications AS atn " +
  32 + "WHERE atn.action_tracker_id = #{tracked_action.id})"
  33 +
  34 + ActionTrackerNotification.connection.execute(notification_sql)
  35 + end
  36 +end
... ...
app/models/comment.rb
1 1 class Comment < ApplicationRecord
2 2  
  3 + include MentionHelper
  4 +
  5 + track_actions :notify_mentioned_users,
  6 + :after_create,
  7 + :keep_params => ["body", "url"],
  8 + :show_on_wall => false,
  9 + :if => Proc.new {|c| has_valid_mentions?(c, :body, c.author, profile) } # if there is a mention
  10 +
3 11 SEARCHABLE_FIELDS = {
4 12 :title => {:label => _('Title'), :weight => 10},
5 13 :name => {:label => _('Name'), :weight => 4},
... ... @@ -79,7 +87,11 @@ class Comment &lt; ApplicationRecord
79 87 end
80 88  
81 89 def url
82   - article.view_url.merge(:anchor => anchor)
  90 + if source.kind_of? ActionTracker::Record
  91 + source.url.merge(:anchor => anchor)
  92 + else
  93 + article.view_url.merge(:anchor => anchor)
  94 + end
83 95 end
84 96  
85 97 def message
... ...
app/models/person.rb
... ... @@ -504,7 +504,11 @@ class Person &lt; Profile
504 504 end
505 505  
506 506 def self.notify_activity(tracked_action)
507   - Delayed::Job.enqueue NotifyActivityToProfilesJob.new(tracked_action.id)
  507 + if tracked_action.verb != "notify_mentioned_users"
  508 + Delayed::Job.enqueue NotifyActivityToProfilesJob.new(tracked_action.id)
  509 + else
  510 + Delayed::Job.enqueue NotifyMentionedUsersJob.new(tracked_action.id)
  511 + end
508 512 end
509 513  
510 514 def is_member_of?(profile)
... ...
app/models/scrap.rb
1 1 class Scrap < ApplicationRecord
2 2  
  3 + include MentionHelper
3 4 include SanitizeHelper
4 5  
5 6 attr_accessible :content, :sender_id, :receiver_id, :scrap_id
... ... @@ -32,6 +33,12 @@ class Scrap &lt; ApplicationRecord
32 33  
33 34 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
34 35  
  36 + track_actions :notify_mentioned_users,
  37 + :after_create,
  38 + :keep_params => ["content","url"],
  39 + :show_on_wall => false,
  40 + :if => Proc.new {|s| has_valid_mentions?(s, :content, s.sender, s.receiver)}
  41 +
35 42 after_create :send_notification
36 43  
37 44 before_validation :strip_all_html_tags
... ... @@ -57,6 +64,7 @@ class Scrap &lt; ApplicationRecord
57 64 def scrap_wall_url
58 65 is_root? ? root.receiver.wall_url : receiver.wall_url
59 66 end
  67 + alias url scrap_wall_url
60 68  
61 69 def send_notification?
62 70 sender != receiver && (is_root? ? root.receiver.receives_scrap_notification? : receiver.receives_scrap_notification?)
... ...
app/views/comment/_comment_form.html.erb
... ... @@ -102,3 +102,9 @@ function check_captcha(button, confirm_action) {
102 102 </div><!-- end class="page-comment-form" -->
103 103  
104 104 <%= javascript_include_tag 'comment_form'%>
  105 +
  106 +<!-- Files needed for mention system -->
  107 +<%= javascript_include_tag 'vendor/jquery.elastic.min'%>
  108 +<%= javascript_include_tag 'vendor/jquery.events.input.min'%>
  109 +<%= javascript_include_tag 'vendor/jquery.mentionsInput.mod.min'%>
  110 +<%= javascript_include_tag 'user-mention'%>
... ...
app/views/profile/_notify_mentioned_users.html.erb 0 → 100644
... ... @@ -0,0 +1 @@
  1 +<%= render :partial => 'default_activity', :locals => { :activity => activity, :tab_action => tab_action } %>
... ...
app/views/profile/_profile_activities_list.html.erb
... ... @@ -2,7 +2,7 @@
2 2 <% activities.each do |profile_activity| %>
3 3 <% activity = profile_activity.activity %>
4 4 <% if activity.kind_of?(ActionTracker::Record) %>
5   - <%= render :partial => 'profile_activity', :locals => { :activity => activity, :tab_action => 'wall' } if activity.visible? %>
  5 + <%= render :partial => 'profile_activity', :locals => { :activity => activity, :tab_action => 'wall' } if (activity.visible? && activity.show_on_wall) %>
6 6 <% else %>
7 7 <%= render :partial => 'profile_scraps', :locals => { :activity => activity, :scrap => activity } %>
8 8 <% end %>
... ...
app/views/profile/_profile_wall.html.erb
... ... @@ -10,3 +10,9 @@
10 10 <ul id='profile_activities' class='profile-activities'>
11 11 <%= render :partial => 'profile_activities_list', :locals => {:activities => @activities} %>
12 12 </ul>
  13 +
  14 +<!-- Files needed for mention system -->
  15 +<%= javascript_include_tag 'vendor/jquery.elastic.min'%>
  16 +<%= javascript_include_tag 'vendor/jquery.events.input.min'%>
  17 +<%= javascript_include_tag 'vendor/jquery.mentionsInput.mod.min'%>
  18 +<%= javascript_include_tag 'user-mention'%>
... ...
app/views/profile/show_tracked_action.html.erb 0 → 100644
... ... @@ -0,0 +1,15 @@
  1 +<div id='profile-network'>
  2 + <ul id='network-activities' class='profile-activities'>
  3 + <% if @activity.kind_of?(ActionTracker::Record) %>
  4 + <%= render :partial => 'profile_activity', :locals => { :activity => @activity, :tab_action => 'wall' } if (@activity.visible? && @activity.show_on_wall) %>
  5 + <% else %>
  6 + <%= render :partial => 'profile_scraps', :locals => { :activity => @activity, :scrap => @activity } %>
  7 + <% end %>
  8 + </ul>
  9 +</div>
  10 +
  11 +<script>
  12 + $(document).ready(function() {
  13 + $(".profile-wall-activities-comments a").trigger("click");
  14 + });
  15 +</script>
... ...
config/initializers/action_tracker.rb
... ... @@ -39,6 +39,8 @@ ActionTrackerConfig.verbs = {
39 39 favorite_enterprise: {
40 40 },
41 41  
  42 + notify_mentioned_users: {
  43 + },
42 44 }
43 45  
44 46 ActionTrackerConfig.current_user = proc do
... ...
db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb 0 → 100644
... ... @@ -0,0 +1,5 @@
  1 +class AddShowOnWallToActionTracker < ActiveRecord::Migration
  2 + def change
  3 + add_column :action_tracker, :show_on_wall, :boolean, :default => true
  4 + end
  5 +end
... ...
public/javascripts/user-mention.js 0 → 100644
... ... @@ -0,0 +1,46 @@
  1 +(function($, undefined) {
  2 + $(document).ready(function() {
  3 + var is_community = _.isEmpty($('html.profile-type-is-community')) !== true;
  4 + var community = null;
  5 +
  6 + if (is_community) {
  7 + community = noosfero.profile;
  8 + }
  9 +
  10 + $('#comment_body, #leave_scrap_content, form.profile-wall-reply-form textarea').mentionsInput({
  11 + onDataRequest: function (mode, keyword, onDataRequestCompleteCallback) {
  12 + var search_url = "/search/search_for_friends_and_members?q="+keyword;
  13 +
  14 + $.ajax({
  15 + method: "GET",
  16 + url: search_url,
  17 + dataType: "json",
  18 + data: {'community': community},
  19 + success: function (response) {
  20 + var data = response.map(function(item) {
  21 + return {
  22 + name: item.identifier,
  23 + fullName: item.name,
  24 + id: item.id,
  25 + type: 'contact'
  26 + };
  27 + });
  28 +
  29 + // Call this to populate mention.
  30 + onDataRequestCompleteCallback.call(this, data);
  31 + }
  32 + }); // $.ajax end
  33 + },
  34 +
  35 + triggerChar: '@',
  36 +
  37 + allowRepeat: true,
  38 +
  39 + minChars: 3,
  40 +
  41 + keepTriggerCharacter: true
  42 + }); // mentionsInput end
  43 + }); // ready end
  44 +
  45 +}) (jQuery);
  46 +
... ...
public/javascripts/vendor/jquery.elastic.js 0 → 100644
... ... @@ -0,0 +1,162 @@
  1 +/**
  2 +* @name Elastic
  3 +* @descripton Elastic is jQuery plugin that grow and shrink your textareas automatically
  4 +* @version 1.6.11
  5 +* @requires jQuery 1.2.6+
  6 +*
  7 +* @author Jan Jarfalk
  8 +* @author-email jan.jarfalk@unwrongest.com
  9 +* @author-website http://www.unwrongest.com
  10 +*
  11 +* @licence MIT License - http://www.opensource.org/licenses/mit-license.php
  12 +*/
  13 +
  14 +(function($){
  15 + jQuery.fn.extend({
  16 + elastic: function() {
  17 +
  18 + // We will create a div clone of the textarea
  19 + // by copying these attributes from the textarea to the div.
  20 + var mimics = [
  21 + 'paddingTop',
  22 + 'paddingRight',
  23 + 'paddingBottom',
  24 + 'paddingLeft',
  25 + 'fontSize',
  26 + 'lineHeight',
  27 + 'fontFamily',
  28 + 'width',
  29 + 'fontWeight',
  30 + 'border-top-width',
  31 + 'border-right-width',
  32 + 'border-bottom-width',
  33 + 'border-left-width',
  34 + 'borderTopStyle',
  35 + 'borderTopColor',
  36 + 'borderRightStyle',
  37 + 'borderRightColor',
  38 + 'borderBottomStyle',
  39 + 'borderBottomColor',
  40 + 'borderLeftStyle',
  41 + 'borderLeftColor'
  42 + ];
  43 +
  44 + return this.each( function() {
  45 +
  46 + // Elastic only works on textareas
  47 + if ( this.type !== 'textarea' ) {
  48 + return false;
  49 + }
  50 +
  51 + var $textarea = jQuery(this),
  52 + $twin = jQuery('<div />').css({
  53 + 'position' : 'absolute',
  54 + 'display' : 'none',
  55 + 'word-wrap' : 'break-word',
  56 + 'white-space' :'pre-wrap'
  57 + }),
  58 + lineHeight = parseInt($textarea.css('line-height'),10) || parseInt($textarea.css('font-size'),'10'),
  59 + minheight = parseInt($textarea.css('height'),10) || lineHeight*3,
  60 + maxheight = parseInt($textarea.css('max-height'),10) || Number.MAX_VALUE,
  61 + goalheight = 0;
  62 +
  63 + // Opera returns max-height of -1 if not set
  64 + if (maxheight < 0) { maxheight = Number.MAX_VALUE; }
  65 +
  66 + // Append the twin to the DOM
  67 + // We are going to meassure the height of this, not the textarea.
  68 + $twin.appendTo($textarea.parent());
  69 +
  70 + // Copy the essential styles (mimics) from the textarea to the twin
  71 + var i = mimics.length;
  72 + while(i--){
  73 + $twin.css(mimics[i].toString(),$textarea.css(mimics[i].toString()));
  74 + }
  75 +
  76 + // Updates the width of the twin. (solution for textareas with widths in percent)
  77 + function setTwinWidth(){
  78 + var curatedWidth = Math.floor(parseInt($textarea.width(),10));
  79 + if($twin.width() !== curatedWidth){
  80 + $twin.css({'width': curatedWidth + 'px'});
  81 +
  82 + // Update height of textarea
  83 + update(true);
  84 + }
  85 + }
  86 +
  87 + // Sets a given height and overflow state on the textarea
  88 + function setHeightAndOverflow(height, overflow){
  89 +
  90 + var curratedHeight = Math.floor(parseInt(height,10));
  91 + if($textarea.height() !== curratedHeight){
  92 + $textarea.css({'height': curratedHeight + 'px','overflow':overflow});
  93 + }
  94 + }
  95 +
  96 + // This function will update the height of the textarea if necessary
  97 + function update(forced) {
  98 +
  99 + // Get curated content from the textarea.
  100 + var textareaContent = $textarea.val().replace(/&/g,'&amp;').replace(/ {2}/g, '&nbsp;').replace(/<|>/g, '&gt;').replace(/\n/g, '<br />');
  101 +
  102 + // Compare curated content with curated twin.
  103 + var twinContent = $twin.html().replace(/<br>/ig,'<br />');
  104 +
  105 + if(forced || textareaContent+'&nbsp;' !== twinContent){
  106 +
  107 + // Add an extra white space so new rows are added when you are at the end of a row.
  108 + $twin.html(textareaContent+'&nbsp;');
  109 +
  110 + // Change textarea height if twin plus the height of one line differs more than 3 pixel from textarea height
  111 + if(Math.abs($twin.height() + lineHeight - $textarea.height()) > 3){
  112 +
  113 + var goalheight = $twin.height()+lineHeight;
  114 + if(goalheight >= maxheight) {
  115 + setHeightAndOverflow(maxheight,'auto');
  116 + } else if(goalheight <= minheight) {
  117 + setHeightAndOverflow(minheight,'hidden');
  118 + } else {
  119 + setHeightAndOverflow(goalheight,'hidden');
  120 + }
  121 +
  122 + }
  123 +
  124 + }
  125 +
  126 + }
  127 +
  128 + // Hide scrollbars
  129 + $textarea.css({'overflow':'hidden'});
  130 +
  131 + // Update textarea size on keyup, change, cut and paste
  132 + $textarea.bind('keyup change cut paste', function(){
  133 + update();
  134 + });
  135 +
  136 + // Update width of twin if browser or textarea is resized (solution for textareas with widths in percent)
  137 + $(window).bind('resize', setTwinWidth);
  138 + $textarea.bind('resize', setTwinWidth);
  139 + $textarea.bind('update', update);
  140 +
  141 + // Compact textarea on blur
  142 + $textarea.bind('blur',function(){
  143 + if($twin.height() < maxheight){
  144 + if($twin.height() > minheight) {
  145 + $textarea.height($twin.height());
  146 + } else {
  147 + $textarea.height(minheight);
  148 + }
  149 + }
  150 + });
  151 +
  152 + // And this line is to catch the browser paste event
  153 + $textarea.bind('input paste',function(e){ setTimeout( update, 250); });
  154 +
  155 + // Run update once when elastic is initialized
  156 + update();
  157 +
  158 + });
  159 +
  160 + }
  161 + });
  162 +})(jQuery);
0 163 \ No newline at end of file
... ...
public/javascripts/vendor/jquery.elastic.min.js 0 → 100644
... ... @@ -0,0 +1 @@
  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,"&amp;").replace(/ {2}/g,"&nbsp;").replace(/<|>/g,"&gt;").replace(/\n/g,"<br />"),r=h.html().replace(/<br>/gi,"<br />");if((t||e+"&nbsp;"!==r)&&(h.html(e+"&nbsp;"),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("<div />").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()<s&&(h.height()>a?n.height(h.height()):n.height(a))}),n.bind("input paste",function(t){setTimeout(o,250)}),o()})}})}(jQuery);
... ...
public/javascripts/vendor/jquery.events.input.js 0 → 100644
... ... @@ -0,0 +1,132 @@
  1 +/*
  2 + jQuery `input` special event v1.0
  3 +
  4 + http://whattheheadsaid.com/projects/input-special-event
  5 +
  6 + (c) 2010-2011 Andy Earnshaw
  7 + MIT license
  8 + www.opensource.org/licenses/mit-license.php
  9 +
  10 + Modified by Kenneth Auchenberg
  11 + * Disabled usage of onPropertyChange event in IE, since its a bit delayed, if you type really fast.
  12 +*/
  13 +
  14 +(function($) {
  15 + // Handler for propertychange events only
  16 + function propHandler() {
  17 + var $this = $(this);
  18 + if (window.event.propertyName == "value" && !$this.data("triggering.inputEvent")) {
  19 + $this.data("triggering.inputEvent", true).trigger("input");
  20 + window.setTimeout(function () {
  21 + $this.data("triggering.inputEvent", false);
  22 + }, 0);
  23 + }
  24 + }
  25 +
  26 + $.event.special.input = {
  27 + setup: function(data, namespaces) {
  28 + var timer,
  29 + // Get a reference to the element
  30 + elem = this,
  31 + // Store the current state of the element
  32 + state = elem.value,
  33 + // Create a dummy element that we can use for testing event support
  34 + tester = document.createElement(this.tagName),
  35 + // Check for native oninput
  36 + oninput = "oninput" in tester || checkEvent(tester),
  37 + // Check for onpropertychange
  38 + onprop = "onpropertychange" in tester,
  39 + // Generate a random namespace for event bindings
  40 + ns = "inputEventNS" + ~~(Math.random() * 10000000),
  41 + // Last resort event names
  42 + evts = ["focus", "blur", "paste", "cut", "keydown", "drop", ""].join("." + ns + " ");
  43 +
  44 + function checkState() {
  45 + var $this = $(elem);
  46 + if (elem.value != state && !$this.data("triggering.inputEvent")) {
  47 + state = elem.value;
  48 +
  49 + $this.data("triggering.inputEvent", true).trigger("input");
  50 + window.setTimeout(function () {
  51 + $this.data("triggering.inputEvent", false);
  52 + }, 0);
  53 + }
  54 + }
  55 +
  56 + // Set up a function to handle the different events that may fire
  57 + function handler(e) {
  58 + // When focusing, set a timer that polls for changes to the value
  59 + if (e.type == "focus") {
  60 + checkState();
  61 + clearInterval(timer);
  62 + timer = window.setInterval(checkState, 250);
  63 + } else if (e.type == "blur") {
  64 + // When blurring, cancel the aforeset timer
  65 + window.clearInterval(timer);
  66 + } else {
  67 + // For all other events, queue a timer to check state ASAP
  68 + window.setTimeout(checkState, 0);
  69 + }
  70 + }
  71 +
  72 + // Bind to native event if available
  73 + if (oninput) {
  74 + return false;
  75 +// } else if (onprop) {
  76 +// // Else fall back to propertychange if available
  77 +// $(this).find("input, textarea").andSelf().filter("input, textarea").bind("propertychange." + ns, propHandler);
  78 + } else {
  79 + // Else clutch at straws!
  80 + $(this).find("input, textarea").andSelf().filter("input, textarea").bind(evts, handler);
  81 + }
  82 + $(this).data("inputEventHandlerNS", ns);
  83 + },
  84 + teardown: function () {
  85 + var elem = $(this);
  86 + elem.find("input, textarea").unbind(elem.data("inputEventHandlerNS"));
  87 + elem.data("inputEventHandlerNS", "");
  88 + }
  89 + };
  90 +
  91 + // Setup our jQuery shorthand method
  92 + $.fn.input = function (handler) {
  93 + return handler ? this.bind("input", handler) : this.trigger("input");
  94 + };
  95 +
  96 + /*
  97 + The following function tests the element for oninput support in Firefox. Many thanks to
  98 + http://blog.danielfriesen.name/2010/02/16/html5-browser-maze-oninput-support/
  99 + */
  100 + function checkEvent(el) {
  101 + // First check, for if Firefox fixes its issue with el.oninput = function
  102 + el.setAttribute("oninput", "return");
  103 + if (typeof el.oninput == "function") {
  104 + return true;
  105 + }
  106 + // Second check, because Firefox doesn't map oninput attribute to oninput property
  107 + try {
  108 +
  109 + // "* 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.
  110 + var e = document.createEvent("KeyboardEvent"),
  111 + ok = false,
  112 + tester = function(e) {
  113 + ok = true;
  114 + e.preventDefault();
  115 + e.stopPropagation();
  116 + };
  117 +
  118 + // e.initKeyEvent("keypress", true, true, window, false, false, false, false, 0, "e".charCodeAt(0));
  119 +
  120 + document.body.appendChild(el);
  121 + el.addEventListener("input", tester, false);
  122 + // el.focus();
  123 + // el.dispatchEvent(e);
  124 + el.removeEventListener("input", tester, false);
  125 + document.body.removeChild(el);
  126 + return ok;
  127 +
  128 + } catch(error) {
  129 +
  130 + }
  131 + }
  132 +})(jQuery);
0 133 \ No newline at end of file
... ...
public/javascripts/vendor/jquery.events.input.min.js 0 → 100644
... ... @@ -0,0 +1 @@
  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);
... ...
public/javascripts/vendor/jquery.mentionsInput.mod.js 0 → 100644
... ... @@ -0,0 +1,596 @@
  1 +/*
  2 + * Mentions Input
  3 + * Version 1.0.2
  4 + * Written by: Kenneth Auchenberg (Podio)
  5 + *
  6 + * Using underscore.js
  7 + *
  8 + * License: MIT License - http://www.opensource.org/licenses/mit-license.php
  9 + */
  10 +
  11 +(function ($, _, undefined) {
  12 +
  13 + // Fix for Noosfero rewrite on interpolate setting
  14 + _.templateSettings = {
  15 + interpolate: /<%=([\s\S]+?)%>/g
  16 + };
  17 +
  18 + // Settings
  19 + 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"
  20 +
  21 + //Default settings
  22 + var defaultSettings = {
  23 + triggerChar : '@', //Char that respond to event
  24 + onDataRequest : $.noop, //Function where we can search the data
  25 + minChars : 2, //Minimum chars to fire the event
  26 + allowRepeat : false, //Allow repeat mentions
  27 + showAvatars : true, //Show the avatars
  28 + elastic : true, //Grow the textarea automatically
  29 + defaultValue : '',
  30 + onCaret : false,
  31 + keepTriggerCharacter: false,
  32 + classes : {
  33 + autoCompleteItemActive : "active" //Classes to apply in each item
  34 + },
  35 + templates : {
  36 + wrapper : _.template('<div class="mentions-input-box"></div>'),
  37 + autocompleteList : _.template('<div class="mentions-autocomplete-list"></div>'),
  38 + autocompleteListItem : _.template('<li data-ref-id="<%= id %>" data-ref-type="<%= type %>" data-display="<%= display %>"><%= content %> <small><%= fullName %></small></li>'),
  39 + autocompleteListItemAvatar : _.template('<img src="<%= avatar %>" />'),
  40 + autocompleteListItemIcon : _.template('<div class="icon <%= icon %>"></div>'),
  41 + mentionsOverlay : _.template('<div class="mentions"><div></div></div>'),
  42 + mentionItemSyntax : _.template('@[<%= value %>](<%= type %>:<%= id %>)'),
  43 + mentionItemHighlight : _.template('<strong><span><%= value %></span></strong>')
  44 + }
  45 + };
  46 +
  47 + //Class util
  48 + var utils = {
  49 + //Encodes the character with _.escape function (undersocre)
  50 + htmlEncode : function (str) {
  51 + return _.escape(str);
  52 + },
  53 + //Encodes the character to be used with RegExp
  54 + regexpEncode : function (str) {
  55 + return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
  56 + },
  57 + highlightTerm : function (value, term) {
  58 + if (!term && !term.length) {
  59 + return value;
  60 + }
  61 + return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<b>$1</b>");
  62 + },
  63 + //Sets the caret in a valid position
  64 + setCaratPosition : function (domNode, caretPos) {
  65 + if (domNode.createTextRange) {
  66 + var range = domNode.createTextRange();
  67 + range.move('character', caretPos);
  68 + range.select();
  69 + } else {
  70 + if (domNode.selectionStart) {
  71 + domNode.focus();
  72 + domNode.setSelectionRange(caretPos, caretPos);
  73 + } else {
  74 + domNode.focus();
  75 + }
  76 + }
  77 + },
  78 + //Deletes the white spaces
  79 + rtrim: function(string) {
  80 + return string.replace(/\s+$/,"");
  81 + }
  82 + };
  83 +
  84 + //Main class of MentionsInput plugin
  85 + var MentionsInput = function (settings) {
  86 +
  87 + var domInput,
  88 + elmInputBox,
  89 + elmInputWrapper,
  90 + elmAutocompleteList,
  91 + elmWrapperBox,
  92 + elmMentionsOverlay,
  93 + elmActiveAutoCompleteItem,
  94 + mentionsCollection = [],
  95 + autocompleteItemCollection = {},
  96 + inputBuffer = [],
  97 + currentDataQuery = '';
  98 +
  99 + //Mix the default setting with the users settings
  100 + settings = $.extend(true, {}, defaultSettings, settings );
  101 +
  102 + //Initializes the text area target
  103 + function initTextarea() {
  104 + elmInputBox = $(domInput); //Get the text area target
  105 +
  106 + //If the text area is already configured, return
  107 + if (elmInputBox.attr('data-mentions-input') === 'true') {
  108 + return;
  109 + }
  110 +
  111 + elmInputWrapper = elmInputBox.parent(); //Get the DOM element parent
  112 + elmWrapperBox = $(settings.templates.wrapper());
  113 + elmInputBox.wrapAll(elmWrapperBox); //Wrap all the text area into the div elmWrapperBox
  114 + elmWrapperBox = elmInputWrapper.find('> div.mentions-input-box'); //Obtains the div elmWrapperBox that now contains the text area
  115 +
  116 + elmInputBox.attr('data-mentions-input', 'true'); //Sets the attribute data-mentions-input to true -> Defines if the text area is already configured
  117 + elmInputBox.bind('keydown', onInputBoxKeyDown); //Bind the keydown event to the text area
  118 + elmInputBox.bind('keypress', onInputBoxKeyPress); //Bind the keypress event to the text area
  119 + elmInputBox.bind('click', onInputBoxClick); //Bind the click event to the text area
  120 + elmInputBox.bind('blur', onInputBoxBlur); //Bind the blur event to the text area
  121 +
  122 + if (navigator.userAgent.indexOf("MSIE 8") > -1) {
  123 + elmInputBox.bind('propertychange', onInputBoxInput); //IE8 won't fire the input event, so let's bind to the propertychange
  124 + } else {
  125 + elmInputBox.bind('input', onInputBoxInput); //Bind the input event to the text area
  126 + }
  127 +
  128 + // Elastic textareas, grow automatically
  129 + if( settings.elastic ) {
  130 + elmInputBox.elastic();
  131 + }
  132 + }
  133 +
  134 + //Initializes the autocomplete list, append to elmWrapperBox and delegate the mousedown event to li elements
  135 + function initAutocomplete() {
  136 + elmAutocompleteList = $(settings.templates.autocompleteList()); //Get the HTML code for the list
  137 + elmAutocompleteList.appendTo(elmWrapperBox); //Append to elmWrapperBox element
  138 + elmAutocompleteList.delegate('li', 'mousedown', onAutoCompleteItemClick); //Delegate the event
  139 + }
  140 +
  141 + //Initializes the mentions' overlay
  142 + function initMentionsOverlay() {
  143 + elmMentionsOverlay = $(settings.templates.mentionsOverlay()); //Get the HTML code of the mentions' overlay
  144 + elmMentionsOverlay.prependTo(elmWrapperBox); //Insert into elmWrapperBox the mentions overlay
  145 + }
  146 +
  147 + //Updates the values of the main variables
  148 + function updateValues() {
  149 + var syntaxMessage = getInputBoxValue(); //Get the actual value of the text area
  150 +
  151 + _.each(mentionsCollection, function (mention) {
  152 + var textSyntax = settings.templates.mentionItemSyntax(mention);
  153 + syntaxMessage = syntaxMessage.replace(new RegExp(utils.regexpEncode(mention.value), 'g'), textSyntax);
  154 + });
  155 +
  156 + var mentionText = utils.htmlEncode(syntaxMessage); //Encode the syntaxMessage
  157 +
  158 + _.each(mentionsCollection, function (mention) {
  159 + var formattedMention = _.extend({}, mention, {value: utils.htmlEncode(mention.value)});
  160 + var textSyntax = settings.templates.mentionItemSyntax(formattedMention);
  161 + var textHighlight = settings.templates.mentionItemHighlight(formattedMention);
  162 +
  163 + mentionText = mentionText.replace(new RegExp(utils.regexpEncode(textSyntax), 'g'), textHighlight);
  164 + });
  165 +
  166 + mentionText = mentionText.replace(/\n/g, '<br />'); //Replace the escape character for <br />
  167 + mentionText = mentionText.replace(/ {2}/g, '&nbsp; '); //Replace the 2 preceding token to &nbsp;
  168 +
  169 + elmInputBox.data('messageText', syntaxMessage); //Save the messageText to elmInputBox
  170 + elmInputBox.trigger('updated');
  171 + elmMentionsOverlay.find('div').html(mentionText); //Insert into a div of the elmMentionsOverlay the mention text
  172 + }
  173 +
  174 + //Cleans the buffer
  175 + function resetBuffer() {
  176 + inputBuffer = [];
  177 + }
  178 +
  179 + //Updates the mentions collection
  180 + function updateMentionsCollection() {
  181 + var inputText = getInputBoxValue(); //Get the actual value of text area
  182 +
  183 + //Returns the values that doesn't match the condition
  184 + mentionsCollection = _.reject(mentionsCollection, function (mention, index) {
  185 + return !mention.value || inputText.indexOf(mention.value) == -1;
  186 + });
  187 + mentionsCollection = _.compact(mentionsCollection); //Delete all the falsy values of the array and return the new array
  188 + }
  189 +
  190 + //Adds mention to mentions collections
  191 + function addMention(mention) {
  192 +
  193 + var currentMessage = getInputBoxValue(),
  194 + caretStart = elmInputBox[0].selectionStart,
  195 + shortestDistance = false,
  196 + bestLastIndex = false;
  197 +
  198 + // Using a regex to figure out positions
  199 + var regex = new RegExp("\\" + settings.triggerChar + currentDataQuery, "gi"),
  200 + regexMatch;
  201 +
  202 + while(regexMatch = regex.exec(currentMessage)) {
  203 + if (shortestDistance === false || Math.abs(regex.lastIndex - caretStart) < shortestDistance) {
  204 + shortestDistance = Math.abs(regex.lastIndex - caretStart);
  205 + bestLastIndex = regex.lastIndex;
  206 + }
  207 + }
  208 +
  209 + var startCaretPosition = bestLastIndex - currentDataQuery.length - 1; //Set the start caret position (right before the @)
  210 + var currentCaretPosition = bestLastIndex; //Set the current caret position (right after the end of the "mention")
  211 +
  212 +
  213 + var start = currentMessage.substr(0, startCaretPosition);
  214 + var end = currentMessage.substr(currentCaretPosition, currentMessage.length);
  215 + var startEndIndex = (start + mention.value).length + 1;
  216 +
  217 + // See if there's the same mention in the list
  218 + if( !_.find(mentionsCollection, function (object) { return object.id == mention.id; }) ) {
  219 + mentionsCollection.push(mention);//Add the mention to mentionsColletions
  220 + }
  221 +
  222 + // Cleaning before inserting the value, otherwise auto-complete would be triggered with "old" inputbuffer
  223 + resetBuffer();
  224 + currentDataQuery = '';
  225 + hideAutoComplete();
  226 +
  227 + // Mentions and syntax message
  228 + var updatedMessageText = start;
  229 +
  230 + if (settings.keepTriggerCharacter) {
  231 + updatedMessageText += settings.triggerChar;
  232 + }
  233 +
  234 + updatedMessageText += mention.value + ' ' + end;
  235 + elmInputBox.val(updatedMessageText); //Set the value to the txt area
  236 + elmInputBox.trigger('mention');
  237 + updateValues();
  238 +
  239 + // Set correct focus and selection
  240 + elmInputBox.focus();
  241 + utils.setCaratPosition(elmInputBox[0], startEndIndex);
  242 + }
  243 +
  244 + //Gets the actual value of the text area without white spaces from the beginning and end of the value
  245 + function getInputBoxValue() {
  246 + return $.trim(elmInputBox.val());
  247 + }
  248 +
  249 + // This is taken straight from live (as of Sep 2012) GitHub code. The
  250 + // technique is known around the web. Just google it. Github's is quite
  251 + // succint though. NOTE: relies on selectionEnd, which as far as IE is concerned,
  252 + // it'll only work on 9+. Good news is nothing will happen if the browser
  253 + // doesn't support it.
  254 + function textareaSelectionPosition($el) {
  255 + var a, b, c, d, e, f, g, h, i, j, k;
  256 + if (!(i = $el[0])) return;
  257 + if (!$(i).is("textarea")) return;
  258 + if (i.selectionEnd == null) return;
  259 + g = {
  260 + position: "absolute",
  261 + overflow: "auto",
  262 + whiteSpace: "pre-wrap",
  263 + wordWrap: "break-word",
  264 + boxSizing: "content-box",
  265 + top: 0,
  266 + left: -9999
  267 + }, h = ["boxSizing", "fontFamily", "fontSize", "fontStyle", "fontVariant", "fontWeight", "height", "letterSpacing", "lineHeight", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "textDecoration", "textIndent", "textTransform", "width", "word-spacing"];
  268 + for (j = 0, k = h.length; j < k; j++) e = h[j], g[e] = $(i).css(e);
  269 + 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 = "&nbsp;", c.appendChild(b), c.appendChild(d), c.appendChild(a), c.scrollTop = i.scrollTop, f = $(d).position(), $(c).remove(), f
  270 + }
  271 +
  272 + //same as above function but return offset instead of position
  273 + function textareaSelectionOffset($el) {
  274 + var a, b, c, d, e, f, g, h, i, j, k;
  275 + if (!(i = $el[0])) return;
  276 + if (!$(i).is("textarea")) return;
  277 + if (i.selectionEnd == null) return;
  278 + g = {
  279 + position: "absolute",
  280 + overflow: "auto",
  281 + whiteSpace: "pre-wrap",
  282 + wordWrap: "break-word",
  283 + boxSizing: "content-box",
  284 + top: 0,
  285 + left: -9999
  286 + }, h = ["boxSizing", "fontFamily", "fontSize", "fontStyle", "fontVariant", "fontWeight", "height", "letterSpacing", "lineHeight", "paddingBottom", "paddingLeft", "paddingRight", "paddingTop", "textDecoration", "textIndent", "textTransform", "width", "word-spacing"];
  287 + for (j = 0, k = h.length; j < k; j++) e = h[j], g[e] = $(i).css(e);
  288 + 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 = "&nbsp;", c.appendChild(b), c.appendChild(d), c.appendChild(a), c.scrollTop = i.scrollTop, f = $(d).offset(), $(c).remove(), f
  289 + }
  290 +
  291 + //Scrolls back to the input after autocomplete if the window has scrolled past the input
  292 + function scrollToInput() {
  293 + var elmDistanceFromTop = $(elmInputBox).offset().top; //input offset
  294 + var bodyDistanceFromTop = $('body').offset().top; //body offset
  295 + var distanceScrolled = $(window).scrollTop(); //distance scrolled
  296 +
  297 + if (distanceScrolled > elmDistanceFromTop) {
  298 + //subtracts body distance to handle fixed headers
  299 + $(window).scrollTop(elmDistanceFromTop - bodyDistanceFromTop);
  300 + }
  301 + }
  302 +
  303 + //Takes the click event when the user select a item of the dropdown
  304 + function onAutoCompleteItemClick(e) {
  305 + var elmTarget = $(this); //Get the item selected
  306 + var mention = autocompleteItemCollection[elmTarget.attr('data-uid')]; //Obtains the mention
  307 +
  308 + addMention(mention);
  309 + scrollToInput();
  310 + return false;
  311 + }
  312 +
  313 + //Takes the click event on text area
  314 + function onInputBoxClick(e) {
  315 + resetBuffer();
  316 + }
  317 +
  318 + //Takes the blur event on text area
  319 + function onInputBoxBlur(e) {
  320 + hideAutoComplete();
  321 + }
  322 +
  323 + //Takes the input event when users write or delete something
  324 + function onInputBoxInput(e) {
  325 + updateValues();
  326 + updateMentionsCollection();
  327 +
  328 + var triggerCharIndex = _.lastIndexOf(inputBuffer, settings.triggerChar); //Returns the last match of the triggerChar in the inputBuffer
  329 + if (triggerCharIndex > -1) { //If the triggerChar is present in the inputBuffer array
  330 + currentDataQuery = inputBuffer.slice(triggerCharIndex + 1).join(''); //Gets the currentDataQuery
  331 + currentDataQuery = utils.rtrim(currentDataQuery); //Deletes the whitespaces
  332 + _.defer(_.bind(doSearch, this, currentDataQuery)); //Invoking the function doSearch ( Bind the function to this)
  333 + }
  334 + }
  335 +
  336 + //Takes the keypress event
  337 + function onInputBoxKeyPress(e) {
  338 + if(e.keyCode !== KEY.BACKSPACE) { //If the key pressed is not the backspace
  339 + var typedValue = String.fromCharCode(e.which || e.keyCode); //Takes the string that represent this CharCode
  340 + inputBuffer.push(typedValue); //Push the value pressed into inputBuffer
  341 + }
  342 + }
  343 +
  344 + //Takes the keydown event
  345 + function onInputBoxKeyDown(e) {
  346 +
  347 + // This also matches HOME/END on OSX which is CMD+LEFT, CMD+RIGHT
  348 + if (e.keyCode === KEY.LEFT || e.keyCode === KEY.RIGHT || e.keyCode === KEY.HOME || e.keyCode === KEY.END) {
  349 + // Defer execution to ensure carat pos has changed after HOME/END keys then call the resetBuffer function
  350 + _.defer(resetBuffer);
  351 +
  352 + // IE9 doesn't fire the oninput event when backspace or delete is pressed. This causes the highlighting
  353 + // to stay on the screen whenever backspace is pressed after a highlighed word. This is simply a hack
  354 + // to force updateValues() to fire when backspace/delete is pressed in IE9.