diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9be49058..3c976233 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -19,4 +19,5 @@ //= require lib/backbone //= require backbone/kandan //= require_tree . - +//= require lib/jquery.atwho +//= require lib/jquery.caret \ No newline at end of file diff --git a/app/assets/javascripts/backbone/kandan.js.coffee.erb b/app/assets/javascripts/backbone/kandan.js.coffee.erb index 0f268f1a..24323672 100644 --- a/app/assets/javascripts/backbone/kandan.js.coffee.erb +++ b/app/assets/javascripts/backbone/kandan.js.coffee.erb @@ -28,6 +28,7 @@ window.Kandan = registerPlugins: -> plugins = [ "UserList" + ,"Mentions" ,"Notifications" ,"MusicPlayer" ,"YouTubeEmbed" @@ -119,7 +120,8 @@ window.Kandan = Kandan.initTabs() Kandan.Widgets.initAll() Kandan.Helpers.Channels.scrollToLatestMessage() - + Kandan.Plugins.Mentions.initUsersMentions(Kandan.Helpers.ActiveUsers.all()) + return setCurrentUser: ()-> template = JST['current_user'] diff --git a/app/assets/javascripts/backbone/plugins/mentions.coffee b/app/assets/javascripts/backbone/plugins/mentions.coffee new file mode 100644 index 00000000..1912b497 --- /dev/null +++ b/app/assets/javascripts/backbone/plugins/mentions.coffee @@ -0,0 +1,25 @@ +# The mentions plugin takes care of highlighting the @useranme and passing the users to the atwho plugin. +# The show_activities addMessage method is the responsible of changing the look of a message body when a user is mentioned +class Kandan.Plugins.Mentions + @options: + regex: /@\S*/g + + template: _.template '''<%= mention %>''' + + @init: ()-> + Kandan.Data.ActiveUsers.registerCallback "change", (data)=> + @initUsersMentions(data.extra.active_users) + + Kandan.Modifiers.register @options.regex, (message, state) => + for mention in message.content.match(@options.regex) + replacement = @options.template({mention: mention}) + message.content = message.content.replace(mention, replacement) + + return Kandan.Helpers.Activities.buildFromMessageTemplate(message) + + @initUsersMentions: (activeUsers)-> + users = _.map activeUsers, (user)-> + user.username + + $(".chat-input").atwho("@", {data: users}); + return diff --git a/app/assets/javascripts/backbone/views/show_activity.js.coffee b/app/assets/javascripts/backbone/views/show_activity.js.coffee index c3a229c6..a34a9d53 100644 --- a/app/assets/javascripts/backbone/views/show_activity.js.coffee +++ b/app/assets/javascripts/backbone/views/show_activity.js.coffee @@ -16,9 +16,16 @@ class Kandan.Views.ShowActivity extends Backbone.View @compiledTemplate = Kandan.Helpers.Activities.buildFromMessageTemplate activity $(@el).data("activity-id", activity.id) - if activity.action == "message" && activity.user.id == Kandan.Helpers.Users.currentUser().id - $(@el).addClass("current_user") + if activity.action == "message" + user_mention_regex = new RegExp("@#{Kandan.Helpers.Users.currentUser().username}\\b") + all_mention_regex = new RegExp("@all") + + if activity.user.id == Kandan.Helpers.Users.currentUser().id + $(@el).addClass("current_user") + + if user_mention_regex.test(@compiledTemplate) || all_mention_regex.test(@compiledTemplate) + $(@el).addClass("mentioned_user") if activity.id == undefined $(@el).attr("id", "activity-c#{activity.cid}") diff --git a/app/assets/javascripts/lib/jquery.atwho.js b/app/assets/javascripts/lib/jquery.atwho.js new file mode 100644 index 00000000..5928761d --- /dev/null +++ b/app/assets/javascripts/lib/jquery.atwho.js @@ -0,0 +1,562 @@ + +/* + Implement Github like autocomplete mentions + http://ichord.github.com/At.js + + Copyright (c) 2013 chord.luo@gmail.com + Licensed under the MIT license. +*/ + + +(function() { + + (function(factory) { + if (typeof define === 'function' && define.amd) { + return define(['jquery'], factory); + } else { + return factory(window.jQuery); + } + })(function($) { + var Controller, DEFAULT_CALLBACKS, DEFAULT_TPL, KEY_CODE, Mirror, View; + Mirror = (function() { + + Mirror.prototype.css_attr = ["overflowY", "height", "width", "paddingTop", "paddingLeft", "paddingRight", "paddingBottom", "marginTop", "marginLeft", "marginRight", "marginBottom", 'fontFamily', 'borderStyle', 'borderWidth', 'wordWrap', 'fontSize', 'lineHeight', 'overflowX']; + + function Mirror($inputor) { + this.$inputor = $inputor; + } + + Mirror.prototype.copy_inputor_css = function() { + var css, + _this = this; + css = { + position: 'absolute', + left: -9999, + top: 0, + zIndex: -20000, + 'white-space': 'pre-wrap' + }; + $.each(this.css_attr, function(i, p) { + return css[p] = _this.$inputor.css(p); + }); + return css; + }; + + Mirror.prototype.create = function(html) { + this.$mirror = $('
'); + this.$mirror.css(this.copy_inputor_css()); + this.$mirror.html(html); + this.$inputor.after(this.$mirror); + return this; + }; + + Mirror.prototype.get_flag_rect = function() { + var $flag, pos, rect; + $flag = this.$mirror.find("span#flag"); + pos = $flag.position(); + rect = { + left: pos.left, + top: pos.top, + bottom: $flag.height() + pos.top + }; + this.$mirror.remove(); + return rect; + }; + + return Mirror; + + })(); + KEY_CODE = { + DOWN: 40, + UP: 38, + ESC: 27, + TAB: 9, + ENTER: 13 + }; + DEFAULT_CALLBACKS = { + data_refactor: function(data) { + return $.map(data, function(item, k) { + if (!$.isPlainObject(item)) { + item = { + name: item + }; + } + return item; + }); + }, + matcher: function(flag, subtext) { + var match, matched, regexp; + regexp = new RegExp(flag + '([A-Za-z0-9_\+\-]*)$|' + flag + '([^\\x00-\\xff]*)$', 'gi'); + match = regexp.exec(subtext); + matched = null; + if (match) { + matched = match[2] ? match[2] : match[1]; + } + return matched; + }, + filter: function(query, data, search_key) { + var _this = this; + return $.map(data, function(item, i) { + var name; + name = $.isPlainObject(item) ? item[search_key] : item; + if (name.toLowerCase().indexOf(query) >= 0) { + return item; + } + }); + }, + remote_filter: function(params, url, render_view) { + return $.ajax(url, { + data: params, + success: function(data) { + return render_view(data); + } + }); + }, + sorter: function(query, items, search_key) { + var item, results, text, _i, _len; + if (!query) { + items; + + } + results = []; + for (_i = 0, _len = items.length; _i < _len; _i++) { + item = items[_i]; + text = item[search_key]; + item.order = text.toLowerCase().indexOf(query); + results.push(item); + } + return results.sort(function(a, b) { + return a.order - b.order; + }); + }, + tpl_eval: function(tpl, map) { + var el; + try { + return el = tpl.replace(/\$\{([^\}]*)\}/g, function(tag, key, pos) { + return map[key]; + }); + } catch (error) { + return ""; + } + }, + highlighter: function(li, query) { + if (!query) { + return li; + } + return li.replace(new RegExp(">\\s*(\\w*)(" + query.replace("+", "\\+") + ")(\\w*)\\s*<", 'ig'), function(str, $1, $2, $3) { + return '> ' + $1 + '' + $2 + '' + $3 + ' <'; + }); + }, + selector: function($li) { + if ($li.length > 0) { + return this.replace_str($li.data("value") || ""); + } + } + }; + Controller = (function() { + + function Controller(inputor) { + this.settings = {}; + this.common_settings = {}; + this.pos = 0; + this.flags = null; + this.current_flag = null; + this.query = null; + this.$inputor = $(inputor); + this.mirror = new Mirror(this.$inputor); + this.common_settings = $.extend({}, $.fn.atwho["default"]); + this.view = new View(this, this.$el); + this.listen(); + } + + Controller.prototype.listen = function() { + var _this = this; + return this.$inputor.on('keyup.atwho', function(e) { + return _this.on_keyup(e); + }).on('keydown.atwho', function(e) { + return _this.on_keydown(e); + }).on('scroll.atwho', function(e) { + return _this.view.hide(); + }).on('blur.atwho', function(e) { + return _this.view.hide(_this.get_opt("display_timeout")); + }); + }; + + Controller.prototype.reg = function(flag, settings) { + var current_settings, data; + current_settings = {}; + current_settings = $.isPlainObject(flag) ? this.common_settings = $.extend({}, this.common_settings, flag) : !this.settings[flag] ? this.settings[flag] = $.extend({}, settings) : this.settings[flag] = $.extend({}, this.settings[flag], settings); + data = current_settings["data"]; + if (typeof data === "string") { + current_settings["data"] = data; + } else if (data) { + current_settings["data"] = this.callbacks("data_refactor").call(this, data); + } + return this; + }; + + Controller.prototype.trigger = function(name, data) { + data || (data = []); + data.push(this); + return this.$inputor.trigger("" + name + ".atwho", data); + }; + + Controller.prototype.data = function() { + return this.get_opt("data"); + }; + + Controller.prototype.callbacks = function(func_name) { + var func; + if (!(func = this.get_opt("callbacks", {})[func_name])) { + func = this.common_settings["callbacks"][func_name]; + } + return func; + }; + + Controller.prototype.get_opt = function(key, default_value) { + var value; + try { + if (this.current_flag) { + value = this.settings[this.current_flag][key]; + } + if (value === void 0) { + value = this.common_settings[key]; + } + return value = value === void 0 ? default_value : value; + } catch (e) { + return value = default_value === void 0 ? null : default_value; + } + }; + + Controller.prototype.rect = function() { + var $inputor, Sel, at_rect, bottom, format, html, offset, start_range, x, y; + $inputor = this.$inputor; + if (document.selection) { + Sel = document.selection.createRange(); + x = Sel.boundingLeft + $inputor.scrollLeft(); + y = Sel.boundingTop + $(window).scrollTop() + $inputor.scrollTop(); + bottom = y + Sel.boundingHeight; + return { + top: y - 2, + left: x - 2, + bottom: bottom - 2 + }; + } + format = function(value) { + return value.replace(//g, '>').replace(/`/g, '`').replace(/"/g, '"').replace(/\r\n|\r|\n/g, "
"); + }; + /* 克隆完inputor后将原来的文本内容根据 + @的位置进行分块,以获取@块在inputor(输入框)里的position + */ + + start_range = $inputor.val().slice(0, this.pos - 1); + html = "" + format(start_range) + ""; + html += "?"; + /* + 将inputor的 offset(相对于document) + 和@在inputor里的position相加 + 就得到了@相对于document的offset. + 当然,还要加上行高和滚动条的偏移量. + */ + + offset = $inputor.offset(); + at_rect = this.mirror.create(html).get_flag_rect(); + x = offset.left + at_rect.left - $inputor.scrollLeft(); + y = offset.top - $inputor.scrollTop(); + bottom = y + at_rect.bottom; + y += at_rect.top; + return { + top: y, + left: x, + bottom: bottom + 2 + }; + }; + + Controller.prototype.catch_query = function() { + var caret_pos, content, end, query, start, subtext, + _this = this; + content = this.$inputor.val(); + caret_pos = this.$inputor.caretPos(); + /* 向在插入符前的的文本进行正则匹配 + * 考虑会有多个 @ 的存在, 匹配离插入符最近的一个 + */ + + subtext = content.slice(0, caret_pos); + query = null; + $.each(this.settings, function(flag, settings) { + query = _this.callbacks("matcher").call(_this, flag, subtext); + if (query != null) { + _this.current_flag = flag; + return false; + } + }); + if (typeof query === "string" && query.length <= 20) { + start = caret_pos - query.length; + end = start + query.length; + this.pos = start; + query = { + 'text': query.toLowerCase(), + 'head_pos': start, + 'end_pos': end + }; + this.trigger("matched", [this.current_flag, query.text]); + } else { + this.view.hide(); + } + return this.query = query; + }; + + Controller.prototype.replace_str = function(str) { + var $inputor, flag_len, source, start_str, text; + $inputor = this.$inputor; + source = $inputor.val(); + flag_len = this.get_opt("display_flag") ? 0 : this.current_flag.length; + start_str = source.slice(0, (this.query['head_pos'] || 0) - flag_len); + text = "" + start_str + str + " " + (source.slice(this.query['end_pos'] || 0)); + $inputor.val(text); + $inputor.caretPos(start_str.length + str.length + 1); + return $inputor.change(); + }; + + Controller.prototype.on_keyup = function(e) { + switch (e.keyCode) { + case KEY_CODE.ESC: + e.preventDefault(); + this.view.hide(); + break; + case KEY_CODE.DOWN: + case KEY_CODE.UP: + $.noop(); + break; + default: + this.look_up(); + } + return e.stopPropagation(); + }; + + Controller.prototype.on_keydown = function(e) { + if (!this.view.visible()) { + return; + } + switch (e.keyCode) { + case KEY_CODE.ESC: + e.preventDefault(); + this.view.hide(); + break; + case KEY_CODE.UP: + e.preventDefault(); + this.view.prev(); + break; + case KEY_CODE.DOWN: + e.preventDefault(); + this.view.next(); + break; + case KEY_CODE.TAB: + case KEY_CODE.ENTER: + if (!this.view.visible()) { + return; + } + e.preventDefault(); + this.view.choose(); + break; + default: + $.noop(); + } + return e.stopPropagation(); + }; + + Controller.prototype.render_view = function(data) { + var search_key; + search_key = this.get_opt("search_key"); + data = this.callbacks("sorter").call(this, this.query.text, data, search_key); + data = data.splice(0, this.get_opt('limit')); + return this.view.render(data); + }; + + Controller.prototype.look_up = function() { + var data, origin_data, params, query, search_key; + query = this.catch_query(); + if (!query) { + return false; + } + origin_data = this.get_opt("data"); + search_key = this.get_opt("search_key"); + if (typeof origin_data === "string") { + params = { + q: query.text, + limit: this.get_opt("limit") + }; + this.callbacks('remote_filter').call(this, params, origin_data, $.proxy(this.render_view, this)); + } else if ((data = this.callbacks('filter').call(this, query.text, origin_data, search_key))) { + this.render_view(data); + } else { + this.view.hide(); + } + return $.noop(); + }; + + return Controller; + + })(); + View = (function() { + + function View(controller) { + this.controller = controller; + this.id = this.controller.get_opt("view_id", "at-view"); + this.timeout_id = null; + this.$el = $("#" + this.id); + this.create_view(); + } + + View.prototype.create_view = function() { + var $menu, tpl, + _this = this; + if (this.exist()) { + return; + } + tpl = "
"; + $("body").append(tpl); + this.$el = $("#" + this.id); + $menu = this.$el.find('ul'); + return $menu.on('mouseenter.view', 'li', function(e) { + $menu.find('.cur').removeClass('cur'); + return $(e.currentTarget).addClass('cur'); + }).on('click', function(e) { + e.stopPropagation(); + e.preventDefault(); + return _this.$el.data("_view").choose(); + }); + }; + + View.prototype.exist = function() { + return $("#" + this.id).length > 0; + }; + + View.prototype.visible = function() { + return this.$el.is(":visible"); + }; + + View.prototype.choose = function() { + var $li; + $li = this.$el.find(".cur"); + this.controller.callbacks("selector").call(this.controller, $li); + this.controller.trigger("choose", [$li]); + return this.hide(); + }; + + View.prototype.reposition = function() { + var offset, rect; + rect = this.controller.rect(); + if (rect.bottom + this.$el.height() - $(window).scrollTop() > $(window).height()) { + rect.bottom = rect.top - this.$el.height(); + } + offset = { + left: rect.left, + top: rect.bottom + }; + this.$el.offset(offset); + return this.controller.trigger("reposition", [offset]); + }; + + View.prototype.next = function() { + var cur, next; + cur = this.$el.find('.cur').removeClass('cur'); + next = cur.next(); + if (!next.length) { + next = $(this.$el.find('li')[0]); + } + return next.addClass('cur'); + }; + + View.prototype.prev = function() { + var cur, prev; + cur = this.$el.find('.cur').removeClass('cur'); + prev = cur.prev(); + if (!prev.length) { + prev = this.$el.find('li').last(); + } + return prev.addClass('cur'); + }; + + View.prototype.show = function() { + if (!this.visible()) { + this.$el.show(); + } + return this.reposition(); + }; + + View.prototype.hide = function(time) { + var callback, + _this = this; + if (isNaN(time)) { + if (this.visible()) { + return this.$el.hide(); + } + } else { + callback = function() { + return _this.hide(); + }; + clearTimeout(this.timeout_id); + return this.timeout_id = setTimeout(callback, time); + } + }; + + View.prototype.clear = function() { + return this.$el.find('ul').empty(); + }; + + View.prototype.render = function(list) { + var $ul, tpl, + _this = this; + if (!$.isArray(list)) { + return false; + } + if (list.length <= 0) { + this.hide(); + return true; + } + this.clear(); + this.$el.data("_view", this); + $ul = this.$el.find('ul'); + tpl = this.controller.get_opt('tpl', DEFAULT_TPL); + $.each(list, function(i, item) { + var $li, li; + li = _this.controller.callbacks("tpl_eval").call(_this.controller, tpl, item); + $li = $(_this.controller.callbacks("highlighter").call(_this.controller, li, _this.controller.query.text)); + $li.data("info", item); + return $ul.append($li); + }); + this.show(); + return $ul.find("li:eq(0)").addClass("cur"); + }; + + return View; + + })(); + DEFAULT_TPL = "
  • ${name}
  • "; + $.fn.atwho = function(flag, options) { + return this.filter('textarea, input').each(function() { + var $this, data; + $this = $(this); + data = $this.data("atwho"); + if (!data) { + $this.data('atwho', (data = new Controller(this))); + } + return data.reg(flag, options); + }); + }; + $.fn.atwho.Controller = Controller; + $.fn.atwho.View = View; + $.fn.atwho.Mirror = Mirror; + return $.fn.atwho["default"] = { + data: null, + search_key: "name", + callbacks: DEFAULT_CALLBACKS, + limit: 5, + display_flag: true, + display_timeout: 300, + tpl: DEFAULT_TPL + }; + }); + +}).call(this); diff --git a/app/assets/javascripts/lib/jquery.caret.js b/app/assets/javascripts/lib/jquery.caret.js new file mode 100644 index 00000000..93694a28 --- /dev/null +++ b/app/assets/javascripts/lib/jquery.caret.js @@ -0,0 +1,128 @@ + +/* + Implement Github like autocomplete mentions + http://ichord.github.com/At.js + + Copyright (c) 2013 chord.luo@gmail.com + Licensed under the MIT license. +*/ + + +/* +本插件操作 textarea 或者 input 内的插入符 +只实现了获得插入符在文本框中的位置,我设置 +插入符的位置. +*/ + + +(function() { + + (function(factory) { + if (typeof exports === 'object') { + return factory(require('jquery')); + } else if (typeof define === 'function' && define.amd) { + return define(['jquery']); + } else { + return factory(window.jQuery); + } + })(function($) { + var getCaretPos, setCaretPos; + getCaretPos = function(inputor) { + var end, endRange, len, normalizedValue, pos, range, start, textInputRange; + if (document.selection) { + /* + #assume we select "HATE" in the inputor such as textarea -> { }. + * start end-point. + * / + * < I really [HATE] IE > between the brackets is the selection range. + * \ + * end end-point. + */ + + range = document.selection.createRange(); + pos = 0; + if (range && range.parentElement() === inputor) { + normalizedValue = inputor.value.replace(/\r\n/g, "\n"); + /* SOMETIME !!! + "/r/n" is counted as two char. + one line is two, two will be four. balalala. + so we have to using the normalized one's length.; + */ + + len = normalizedValue.length; + /* + <[ I really HATE IE ]>: + the whole content in the inputor will be the textInputRange. + */ + + textInputRange = inputor.createTextRange(); + /* _here must be the position of bookmark. + / + <[ I really [HATE] IE ]> + [---------->[ ] : this is what moveToBookmark do. + < I really [[HATE] IE ]> : here is result. + \ two brackets in should be in line. + */ + + textInputRange.moveToBookmark(range.getBookmark()); + endRange = inputor.createTextRange(); + /* [--------------------->[] : if set false all end-point goto end. + < I really [[HATE] IE []]> + */ + + endRange.collapse(false); + /* + ___VS____ + / \ + < I really [[HATE] IE []]> + \_endRange end-point. + + " > -1" mean the start end-point will be the same or right to the end end-point + * simplelly, all in the end. + */ + + if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) { + start = end = len; + } else { + /* + I really |HATE] IE ]> + <-| + I really[ [HATE] IE ]> + <-[ + I reall[y [HATE] IE ]> + + will return how many unit have moved. + */ + + start = -textInputRange.moveStart("character", -len); + end = -textInputRange.moveEnd("character", -len); + } + } + } else { + start = inputor.selectionStart; + } + return start; + }; + setCaretPos = function(inputor, pos) { + var range; + if (document.selection) { + range = inputor.createTextRange(); + range.move("character", pos); + return range.select(); + } else { + return inputor.setSelectionRange(pos, pos); + } + }; + return $.fn.caretPos = function(pos) { + var inputor; + inputor = this[0]; + inputor.focus(); + if (pos) { + return setCaretPos(inputor, pos); + } else { + return getCaretPos(inputor); + } + }; + }); + +}).call(this); diff --git a/app/assets/stylesheets/_activity.sass b/app/assets/stylesheets/_activity.sass index d99ed04d..21f8700d 100644 --- a/app/assets/stylesheets/_activity.sass +++ b/app/assets/stylesheets/_activity.sass @@ -42,6 +42,12 @@ margin: 0 5px vertical-align: bottom border: 1px solid gray + .mention + border: 1px solid #bbb + background-color: #bcd3e8 + color: black + padding: 2px 3px + border-radius: 5px .emoticon-embed vertical-align: bottom .emoticon-embed.small @@ -74,8 +80,14 @@ .current_user background-color: #F5F5F5 .posted_at - background: #E6EAEB - color: #A9B1B6 + background: #bcd3e8 + color: #6F7477 + +.mentioned_user + background-color: #E2ECF5 + .posted_at + background: #bcd3e8 + color: #6F7477 img.image-embed max-width: 300px diff --git a/app/assets/stylesheets/application.css.sass b/app/assets/stylesheets/application.css.sass index ae087027..766666f4 100644 --- a/app/assets/stylesheets/application.css.sass +++ b/app/assets/stylesheets/application.css.sass @@ -9,4 +9,5 @@ @import sidebar @import plugins/attachments @import plugins/user_list -@import plugins/notification_list +@import lib/jquery.atwho +@import plugins/notification_list \ No newline at end of file diff --git a/app/assets/stylesheets/lib/jquery.atwho.css b/app/assets/stylesheets/lib/jquery.atwho.css new file mode 100644 index 00000000..a6904abc --- /dev/null +++ b/app/assets/stylesheets/lib/jquery.atwho.css @@ -0,0 +1,48 @@ +#at-view { + position:absolute; + top: 0; + left: 0; + display: none; + margin-top: 18px; + background: white; + border: 1px solid #DDD; + border-radius: 3px; + box-shadow: 0 0 5px rgba(0,0,0,0.1); + min-width: 120px; + z-index: 601; +} + +#at-view .cur { + background: #3366FF; + color: white; +} +#at-view .cur small { + color: white; +} +#at-view strong { + color: #3366FF; +} +#at-view .cur strong { + color: white; + font:bold; +} +#at-view ul { + /* width: 100px; */ + list-style:none; + padding:0; + margin:auto; +} +#at-view ul li { + display: block; + padding: 5px 10px; + border-bottom: 1px solid #DDD; + cursor: pointer; + /* border-top: 1px solid #C8C8C8; */ +} +#at-view small { + font-size: smaller; + color: #777; + font-weight: normal; +} + + diff --git a/app/models/activity_observer.rb b/app/models/activity_observer.rb index 41ac8ac4..ba745dab 100644 --- a/app/models/activity_observer.rb +++ b/app/models/activity_observer.rb @@ -11,7 +11,7 @@ def after_save(activity) def message_broadcast_data(activity) faye_channel = "/channels/#{activity.channel.to_param}" broadcast_data = activity.attributes.merge({ - :user => activity.user.as_json(:only => [:id, :ido_id, :email, :first_name, :last_name, :gravatar_hash, :active, :locale]), + :user => activity.user.as_json(:only => [:id, :ido_id, :email, :first_name, :last_name, :gravatar_hash, :active, :locale, :username]), :channel => activity.channel.attributes }) [faye_channel, broadcast_data] @@ -22,7 +22,7 @@ def upload_broadcast_data(activity) broadcast_data = { :event => "attachment#upload", :entity => activity.attributes.merge({ - :user => activity.user.as_json(:only => [:id, :ido_id, :email, :first_name, :last_name, :gravatar_hash, :active, :locale]), + :user => activity.user.as_json(:only => [:id, :ido_id, :email, :first_name, :last_name, :gravatar_hash, :active, :locale, :username]), :channel => activity.channel.attributes }), :extra => {