re-add hovercards

* added a presenter for hovercard json
* added new backbone view for handling hovercard JS
* refactoring of PeopleController

* finished the backbone js version of hovercards
* don't try to make people_controller more restfull, out of scope
  just add a new route and use that for hovercard json
* added spec for people_controller#hovercard
* add new exception for "AccountClosed" to be able to raise from anywhere

* removed legacy code, since everything got ported to backbone
  (except the "cache" stuff, but that's not strictly necessary)
This commit is contained in:
Florian Staudacher 2012-12-19 00:03:31 +01:00 committed by Florian Staudacher
parent 42a01f3a38
commit 0092acd492
13 changed files with 243 additions and 159 deletions

View file

@ -20,6 +20,7 @@
* Add progress percentage to upload images. [#3740](https://github.com/diaspora/diaspora/pull/3740) * Add progress percentage to upload images. [#3740](https://github.com/diaspora/diaspora/pull/3740)
* Mark all unread post-related notifications as read, if one of this gets opened. [#3787](https://github.com/diaspora/diaspora/pull/3787) * Mark all unread post-related notifications as read, if one of this gets opened. [#3787](https://github.com/diaspora/diaspora/pull/3787)
* Add flash-notice when sending messages to non-contacts. [#3723](https://github.com/diaspora/diaspora/pull/3723) * Add flash-notice when sending messages to non-contacts. [#3723](https://github.com/diaspora/diaspora/pull/3723)
* Re-add hovercards [#3802](https://github.com/diaspora/diaspora/pull/3802)
## Bug Fixes ## Bug Fixes

View file

@ -37,7 +37,7 @@ var app = {
app.currentUser = app.user(window.current_user_attributes) || new app.models.User() app.currentUser = app.user(window.current_user_attributes) || new app.models.User()
if(app.currentUser.authenticated()){ if(app.currentUser.authenticated()){
app.header = new app.views.Header; app.header = new app.views.Header();
$("header").prepend(app.header.el); $("header").prepend(app.header.el);
app.header.render(); app.header.render();
} }
@ -52,7 +52,9 @@ var app = {
$(".stream_title").text(link.text()) $(".stream_title").text(link.text())
app.router.navigate(link.attr("href").substring(1) ,true) app.router.navigate(link.attr("href").substring(1) ,true)
}) });
app.hovercard = new app.views.Hovercard();
}, },
hasPreload : function(prop) { hasPreload : function(prop) {

View file

@ -1,10 +1,10 @@
Handlebars.registerHelper('t', function(scope, values) { Handlebars.registerHelper('t', function(scope, values) {
return Diaspora.I18n.t(scope, values.hash) return Diaspora.I18n.t(scope, values.hash)
}) });
Handlebars.registerHelper('imageUrl', function(path){ Handlebars.registerHelper('imageUrl', function(path){
return app.baseImageUrl() + path; return app.baseImageUrl() + path;
}) });
Handlebars.registerHelper('linkToPerson', function(context, block) { Handlebars.registerHelper('linkToPerson', function(context, block) {
var html = "<a href=\"/people/" + context.guid + "\" class=\"author-name\">"; var html = "<a href=\"/people/" + context.guid + "\" class=\"author-name\">";
@ -12,15 +12,29 @@ Handlebars.registerHelper('linkToPerson', function(context, block) {
html+= "</a>"; html+= "</a>";
return html return html
}) });
// allow hovercards for users that are not the current user.
// returns the html class name used to trigger hovercards.
Handlebars.registerHelper('hovercardable', function(person) {
if( app.currentUser.get('guid') != person.guid ) {
return 'hovercardable';
}
return '';
});
Handlebars.registerHelper('personImage', function(person, size, imageClass) { Handlebars.registerHelper('personImage', function(person, size, imageClass) {
/* we return here if person.avatar is blank, because this happens when a /* we return here if person.avatar is blank, because this happens when a
* user is unauthenticated. we don't know why this happens... */ * user is unauthenticated. we don't know why this happens... */
if(typeof(person.avatar) == "undefined") { return } if( _.isUndefined(person.avatar) ) { return }
size = (typeof(size) != "string" ? "small" : size); size = ( !_.isString(size) ) ? "small" : size;
imageClass = (typeof(imageClass) != "string" ? size : imageClass); imageClass = ( !_.isString(imageClass) ) ? size : imageClass;
return "<img src=\"" + person.avatar[size] +"\" class=\"avatar " + imageClass + "\" title=\"" + _.escape(person.name) +"\" />"; return _.template('<img src="<%= src %>" class="avatar <%= img_class %>" title="<%= title %>" />', {
}) 'src': person.avatar[size],
'img_class': imageClass,
'title': _.escape(person.name)
});
});

View file

@ -0,0 +1,115 @@
app.views.Hovercard = Backbone.View.extend({
el: '#hovercard_container',
initialize: function() {
$('.hovercardable')
.live('mouseenter', _.bind(this._mouseenterHandler, this))
.live('mouseleave', _.bind(this._mouseleaveHandler, this));
this.show_me = false;
this.avatar = this.$('.avatar');
this.dropdown = this.$('.dropdown_list');
this.dropdown_container = this.$('#hovercard_dropdown_container');
this.hashtags = this.$('.hashtags');
this.person_link = this.$('a.person');
this.person_handle = this.$('p.handle');
},
href: function() {
return this.$el.parent().attr('href');
},
_mouseenterHandler: function(event) {
var el = $(event.target);
if( !el.is('a') ) {
el = el.parents('a');
}
if( el.attr('href').indexOf('/people') == -1 ) {
// can't fetch data from that URL, aborting
return false;
}
this.show_me = true;
this.showHovercardOn(el);
return false;
},
_mouseleaveHandler: function(event) {
this.show_me = false;
if( this.$el.is(':visible') ) {
this.$el.fadeOut('fast');
} else {
this.$el.hide();
}
this.dropdown_container.empty();
return false;
},
showHovercardOn: _.debounce(function(element) {
var el = $(element);
var hc = this.$el;
if( !this.show_me ) {
// mouse has left element
return;
}
hc.hide();
hc.prependTo(el);
this._positionHovercard();
this._populateHovercard();
}, 500, true),
_populateHovercard: function() {
var href = this.href();
href += "/hovercard.json";
var self = this;
$.get(href, function(person){
if( !person || person.length == 0 ) {
throw new Error("received data is not a person object");
}
self._populateHovercardWith(person);
self.$el.fadeIn('fast');
});
},
_populateHovercardWith: function(person) {
var self = this;
this.avatar.attr('src', person.avatar);
this.person_link.attr('href', person.url);
this.person_link.text(person.name);
this.person_handle.text(person.handle);
this.dropdown.attr('data-person-id', person.id);
// set hashtags
this.hashtags.empty();
this.hashtags.html( $(_.map(person.tags, function(tag){
return $('<a/>',{href: "/tags/"+tag.substring(1)}).text(tag)[0] ;
})) );
// set aspect dropdown
var href = this.href();
href += "/aspect_membership_button"
$.get(href, function(response) {
self.dropdown_container.html(response);
});
},
_positionHovercard: function() {
var p = this.$el.parent();
var p_pos = p.position();
var p_height = p.height();
this.$el.css({
top: p_pos.top + p_height - 25,
left: p_pos.left
});
}
});

View file

@ -64,7 +64,6 @@
events: function() { return Diaspora.page.eventsContainer.data("events"); }, events: function() { return Diaspora.page.eventsContainer.data("events"); },
flashMessages: this.instantiate("FlashMessages"), flashMessages: this.instantiate("FlashMessages"),
header: this.instantiate("Header", body.find("header")), header: this.instantiate("Header", body.find("header")),
hoverCard: this.instantiate("HoverCard", body.find("#hovercard")),
timeAgo: this.instantiate("TimeAgo") timeAgo: this.instantiate("TimeAgo")
}); });
}; };

View file

@ -1,138 +0,0 @@
(function() {
var HoverCard = function() {
var self = this;
self.jXHRs = [];
self.subscribe("widget/ready", function(evt, hoverCard) {
self.personCache = new self.Cache();
self.dropdownCache = new self.Cache();
self.hoverCard = {
tip: $("#hovercard_container"),
dropdownContainer: $("#hovercard_dropdown_container"),
offset: {
left: -10,
top: 13
},
personLink: hoverCard.find("a.person"),
personHandle: hoverCard.find("p.handle"),
avatar: hoverCard.find(".avatar"),
dropdown: hoverCard.find(".dropdown_list"),
hashtags: hoverCard.find(".hashtags")
};
$(document.body).delegate("a.hovercardable:not(.self)", "hover", self.handleHoverEvent);
self.hoverCard.tip.hover(self.hoverCardHover, self.clearTimeout);
self.subscribe("aspectDropdown/updated aspectDropdown/blurred", function(evt, personId, dropdownHtml) {
self.dropdownCache.cache["/people/" + personId + "/aspect_membership_button"] = $(dropdownHtml).removeClass("active").get(0).outerHTML;
});
});
this.handleHoverEvent = function(evt) {
self.target = $(evt.target);
if(evt.type === "mouseenter") {
self.startHover();
}
else {
self.clearTimeout(evt);
}
};
this.startHover = function(evt) {
if(!self.hoverCardTimeout) {
self.clearTimeout(false);
}
self.timeout = setTimeout(self.showHoverCard, 600);
};
this.showHoverCard = function() {
self.hoverCard.tip.hide();
self.hoverCard.tip.prependTo(self.target.parent());
self.personCache.get(self.target.attr("data-hovercard") + ".json?includes=tags", function(person) {
self.populateHovercard(person);
});
};
this.populateHovercard = function(person) {
var position = self.target.position();
self.hoverCard.tip.css({
left: position.left + self.hoverCard.offset.left,
top: position.top + self.hoverCard.offset.top
});
self.hoverCard.avatar.attr("src", person.avatar);
self.hoverCard.personLink.attr("href", person.url);
self.hoverCard.personLink.text(person.name);
self.hoverCard.personHandle.text(person.handle);
self.hoverCard.dropdown.attr("data-person-id", person.id);
self.hoverCard.hashtags.html("");
$.each(person.tags, function(index, hashtag) {
self.hoverCard.hashtags.append(
$("<a/>", {
href: "/tags/" + hashtag.substring(1)
}).text(hashtag)
);
});
self.dropdownCache.get(self.target.attr("data-hovercard") + "/aspect_membership_button", function(dropdown) {
self.hoverCard.dropdownContainer.html(dropdown);
self.hoverCard.tip.fadeIn(140);
});
};
this.clearTimeout = function(delayed) {
self.personCache.clearjXHRs();
self.dropdownCache.clearjXHRs();
function callback() {
self.timeout = clearTimeout(self.timeout);
self.hoverCard.tip.hide();
self.hoverCard.dropdownContainer.html("");
}
if((typeof delayed === "boolean" && delayed) || (typeof delayed === "object" && delayed.type === "mouseleave")) {
self.hoverCardTimeout = setTimeout(callback, 20);
}
else {
callback();
}
};
this.hoverCardHover = function() {
self.hoverCardTimeout = clearTimeout(self.hoverCardTimeout);
};
this.Cache = function() {
var self = this;
this.cache = {};
this.jXHRs = [];
this.get = function(key, callback) {
if(typeof self.cache[key] === "undefined") {
self.jXHRs.push($.get(key, function(response) {
self.cache[key] = response;
callback(response);
self.jXHRs.shift();
}));
}
else {
callback(self.cache[key]);
}
};
this.clearjXHRs = function() {
$.each(self.jXHRs, function(index, jXHR) {
jXHR.abort();
});
self.jXHRs = [];
};
};
};
Diaspora.Widgets.HoverCard = HoverCard;
})();

View file

@ -8,7 +8,7 @@
display: inline-block; display: inline-block;
min-width: 250px; min-width: 250px;
max-width: 400px; max-width: 400px;
background-color: $background; background-color: $background;
height: 70px; height: 70px;
border: 1px solid #999999; border: 1px solid #999999;
@ -32,13 +32,17 @@
& > h4, & > div, & > p { & > h4, & > div, & > p {
margin-left: $image_width; margin-left: $image_width;
} }
& > h4, & > div, & > p, .hashtags { & > h4, & > div, & > p, .hashtags {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#hovercard_dropdown_container {
overflow: visible !important; /* otherwise the aspect dropdown is cropped */
}
padding: 5px { padding: 5px {
bottom: 25px; bottom: 25px;
}; };

View file

@ -18,7 +18,7 @@
{{/if}} {{/if}}
{{#with author}} {{#with author}}
<a href="/people/{{guid}}" class="img"> <a href="/people/{{guid}}" class="img {{{hovercardable this}}}">
{{{personImage this}}} {{{personImage this}}}
</a> </a>
{{/with}} {{/with}}

View file

@ -17,6 +17,13 @@ class PeopleController < ApplicationController
:format => :html, :layout => false, :status => 404 :format => :html, :layout => false, :status => 404
end end
rescue_from Diaspora::AccountClosed do
respond_to do |format|
format.any { redirect_to :back, :notice => t("people.show.closed_account") }
format.json { render :nothing => true, :status => 410 } # 410 GONE
end
end
helper_method :search_query helper_method :search_query
def index def index
@ -36,7 +43,7 @@ class PeopleController < ApplicationController
if diaspora_id?(search_query) if diaspora_id?(search_query)
@people = Person.where(:diaspora_handle => search_query.downcase) @people = Person.where(:diaspora_handle => search_query.downcase)
if @people.empty? if @people.empty?
Webfinger.in_background(search_query) Webfinger.in_background(search_query)
@background_query = search_query.downcase @background_query = search_query.downcase
end end
end end
@ -66,11 +73,12 @@ class PeopleController < ApplicationController
respond_with @people respond_with @people
end end
# renders the persons user profile page
def show def show
@person = Person.find_from_guid_or_username(params) @person = Person.find_from_guid_or_username(params)
authenticate_user! if remote_profile_with_no_user_session? authenticate_user! if remote_profile_with_no_user_session?
return redirect_to :back, :notice => t("people.show.closed_account") if @person.closed_account? raise Diaspora::AccountClosed if @person.closed_account?
@post_type = :all @post_type = :all
@aspect = :profile @aspect = :profile
@ -108,6 +116,23 @@ class PeopleController < ApplicationController
end end
end end
# hovercards fetch some the persons public profile data via json and display
# it next to the avatar image in a nice box
def hovercard
@person = Person.find_from_guid_or_username({:id => params[:person_id]})
raise Diaspora::AccountClosed if @person.closed_account?
respond_to do |format|
format.all do
redirect_to :action => "show", :id => params[:person_id]
end
format.json do
render :json => HovercardPresenter.new(@person)
end
end
end
def last_post def last_post
@person = Person.find_from_guid_or_username(params) @person = Person.find_from_guid_or_username(params)
last_post = Post.visible_from_author(@person, current_user).order('posts.created_at DESC').first last_post = Post.visible_from_author(@person, current_user).order('posts.created_at DESC').first
@ -148,8 +173,6 @@ class PeopleController < ApplicationController
end end
end end
private
def redirect_if_tag_search def redirect_if_tag_search
if search_query.starts_with?('#') if search_query.starts_with?('#')
if search_query.length > 1 if search_query.length > 1
@ -162,6 +185,8 @@ class PeopleController < ApplicationController
end end
end end
private
def hashes_for_people(people, aspects) def hashes_for_people(people, aspects)
ids = people.map{|p| p.id} ids = people.map{|p| p.id}
contacts = {} contacts = {}

View file

@ -0,0 +1,39 @@
class HovercardPresenter
attr_accessor :person
# initialize the presenter with the given Person object
def initialize(person)
raise ArgumentError, "the given object is not a Person" unless person.class == Person
self.person = person
end
# returns the json representation of the Person object for use with the
# hovercard UI
def to_json(options={})
{ :id => person.id,
:avatar => avatar('small'),
:url => profile_url,
:name => person.name,
:handle => person.diaspora_handle,
:tags => person.tags.map { |t| "#"+t.name }
}.to_json(options)
end
# get the image url of the profile avatar for the given size
# possible sizes: 'small', 'medium', 'large'
def avatar(size="small")
if !["small", "medium", "large"].include?(size)
raise ArgumentError, "the given parameter is not a valid size"
end
person.image_url("thumb_#{size}".to_sym)
end
# return the (relative) url to the user profile page.
# uses the 'person_path' url helper from the rails routes
def profile_url
Rails.application.routes.url_helpers.person_path(person)
end
end

View file

@ -9,7 +9,7 @@ Diaspora::Application.routes.draw do
get "/atom.xml" => redirect('http://blog.diasporafoundation.org/feed/atom') #too many stupid redirects :() get "/atom.xml" => redirect('http://blog.diasporafoundation.org/feed/atom') #too many stupid redirects :()
get 'oembed' => 'posts#oembed', :as => 'oembed' get 'oembed' => 'posts#oembed', :as => 'oembed'
# Posting and Reading # Posting and Reading
resources :reshares resources :reshares
@ -48,7 +48,7 @@ Diaspora::Application.routes.draw do
get "liked" => "streams#liked", :as => "liked_stream" get "liked" => "streams#liked", :as => "liked_stream"
get "commented" => "streams#commented", :as => "commented_stream" get "commented" => "streams#commented", :as => "commented_stream"
get "aspects" => "streams#aspects", :as => "aspects_stream" get "aspects" => "streams#aspects", :as => "aspects_stream"
resources :aspects do resources :aspects do
put :toggle_contact_visibility put :toggle_contact_visibility
end end
@ -114,7 +114,7 @@ Diaspora::Application.routes.draw do
get 'invitations/email' => 'invitations#email', :as => 'invite_email' get 'invitations/email' => 'invitations#email', :as => 'invite_email'
get 'users/invitations' => 'invitations#new', :as => 'new_user_invitation' get 'users/invitations' => 'invitations#new', :as => 'new_user_invitation'
post 'users/invitations' => 'invitations#create', :as => 'new_user_invitation' post 'users/invitations' => 'invitations#create', :as => 'new_user_invitation'
get 'login' => redirect('/users/sign_in') get 'login' => redirect('/users/sign_in')
scope 'admins', :controller => :admins do scope 'admins', :controller => :admins do
@ -145,6 +145,7 @@ Diaspora::Application.routes.draw do
resources :photos resources :photos
get :contacts get :contacts
get "aspect_membership_button" => :aspect_membership_dropdown, :as => "aspect_membership_button" get "aspect_membership_button" => :aspect_membership_dropdown, :as => "aspect_membership_button"
get :hovercard
member do member do
get :last_post get :last_post

View file

@ -3,6 +3,12 @@
# the COPYRIGHT file. # the COPYRIGHT file.
module Diaspora module Diaspora
# the post in question is not public, and that is somehow a problem
class NonPublic < StandardError class NonPublic < StandardError
end end
# the account was closed and that should not be the case if we want
# to continue
class AccountClosed < StandardError
end
end end

View file

@ -367,7 +367,23 @@ describe PeopleController do
end end
end end
describe '#hovercard' do
before do
@hover_test = FactoryGirl.create(:person)
@hover_test.profile.tag_string = '#test #tags'
@hover_test.profile.save!
end
it 'redirects html requests' do
get :hovercard, :person_id => @hover_test.guid
response.should redirect_to person_path(:id => @hover_test.guid)
end
it 'returns json with profile stuff' do
get :hovercard, :person_id => @hover_test.guid, :format => 'json'
JSON.parse( response.body )['handle'].should == @hover_test.diaspora_handle
end
end
describe '#refresh_search ' do describe '#refresh_search ' do
before(:each)do before(:each)do