From ba4fc5021ca4825b32c91b4bf4d1d30d6d0a1125 Mon Sep 17 00:00:00 2001 From: Fabio Teixeira Date: Thu, 9 Jun 2016 15:58:53 -0300 Subject: [PATCH] Add mentions system to comments and scraps --- app/controllers/public/profile_controller.rb | 3 +++ app/controllers/public/search_controller.rb | 27 +++++++++++++++++++++++++++ app/helpers/action_tracker_helper.rb | 13 +++++++++---- app/helpers/mention_helper.rb | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ app/jobs/notify_mentioned_users_job.rb | 36 ++++++++++++++++++++++++++++++++++++ app/models/comment.rb | 14 +++++++++++++- app/models/person.rb | 6 +++++- app/models/scrap.rb | 8 ++++++++ app/views/comment/_comment_form.html.erb | 6 ++++++ app/views/profile/_notify_mentioned_users.html.erb | 1 + app/views/profile/_profile_activities_list.html.erb | 2 +- app/views/profile/_profile_wall.html.erb | 6 ++++++ app/views/profile/show_tracked_action.html.erb | 15 +++++++++++++++ config/initializers/action_tracker.rb | 2 ++ db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb | 5 +++++ public/javascripts/user-mention.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ public/javascripts/vendor/jquery.elastic.js | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/javascripts/vendor/jquery.elastic.min.js | 1 + public/javascripts/vendor/jquery.events.input.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/javascripts/vendor/jquery.events.input.min.js | 1 + public/javascripts/vendor/jquery.mentionsInput.mod.js | 596 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/javascripts/vendor/jquery.mentionsInput.mod.min.js | 1 + public/stylesheets/application.scss | 1 + public/stylesheets/comments.scss | 8 ++++++++ public/stylesheets/jquery.mentionsInput.css | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/stylesheets/jquery.mentionsInput.min.css | 1 + vendor/plugins/action_tracker/lib/action_tracker.rb | 13 ++++++++----- vendor/plugins/action_tracker/lib/action_tracker_model.rb | 6 +++++- 28 files changed, 1271 insertions(+), 13 deletions(-) create mode 100644 app/helpers/mention_helper.rb create mode 100644 app/jobs/notify_mentioned_users_job.rb create mode 100644 app/views/profile/_notify_mentioned_users.html.erb create mode 100644 app/views/profile/show_tracked_action.html.erb create mode 100644 db/migrate/20160714175829_add_show_on_wall_to_action_tracker.rb create mode 100644 public/javascripts/user-mention.js create mode 100644 public/javascripts/vendor/jquery.elastic.js create mode 100644 public/javascripts/vendor/jquery.elastic.min.js create mode 100644 public/javascripts/vendor/jquery.events.input.js create mode 100644 public/javascripts/vendor/jquery.events.input.min.js create mode 100644 public/javascripts/vendor/jquery.mentionsInput.mod.js create mode 100644 public/javascripts/vendor/jquery.mentionsInput.mod.min.js create mode 100644 public/stylesheets/jquery.mentionsInput.css create mode 100644 public/stylesheets/jquery.mentionsInput.min.css 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 @@ + + +<%= 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 @@ +
+ +
+ + 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