User Profile enhancements:

- Added PreloadStore support to avoid duplicate requests
- preliminary SEO
- Support for opengraph/twitter cards
This commit is contained in:
Robin Ward 2013-03-08 15:04:37 -05:00
parent 0af114aff5
commit d1d4530efd
12 changed files with 180 additions and 72 deletions

View File

@ -274,34 +274,35 @@ Discourse.User.reopenClass({
}); });
}, },
/**
Find a user by username
@method find
@param {String} username the username of the user we want to find
**/
find: function(username) { find: function(username) {
var promise,
_this = this; // Check the preload store first
promise = new RSVP.Promise(); return PreloadStore.get("user_" + username, function() {
$.ajax({ return $.ajax({ url: "/users/" + username + '.json' });
url: "/users/" + username + '.json', }).then(function (json) {
success: function(json) {
// todo: decompose to object // Create a user from the resulting JSON
var user; json.user.stats = Discourse.User.groupStats(json.user.stats.map(function(s) {
json.user.stats = _this.groupStats(json.user.stats.map(function(s) { var stat = Em.Object.create(s);
var obj; stat.set('isPM', stat.get('action_type') === Discourse.UserAction.NEW_PRIVATE_MESSAGE ||
obj = Em.Object.create(s); stat.get('action_type') === Discourse.UserAction.GOT_PRIVATE_MESSAGE);
obj.isPM = obj.action_type === Discourse.UserAction.NEW_PRIVATE_MESSAGE || obj.action_type === Discourse.UserAction.GOT_PRIVATE_MESSAGE; return stat;
return obj; }));
if (json.user.stream) {
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) {
return Discourse.UserAction.create(ua);
})); }));
if (json.user.stream) {
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) {
return Discourse.UserAction.create(ua);
}));
}
user = Discourse.User.create(json.user);
return promise.resolve(user);
},
error: function(xhr) {
return promise.reject(xhr);
} }
return Discourse.User.create(json.user);
}); });
return promise;
}, },
createAccount: function(name, email, password, username, passwordConfirm, challenge) { createAccount: function(name, email, password, username, passwordConfirm, challenge) {

View File

@ -1,23 +1,45 @@
/* We can insert data into the PreloadStore when the document is loaded.
The data can be accessed once by a key, after which it is removed */ /**
We can insert data into the PreloadStore when the document is loaded.
The data can be accessed once by a key, after which it is removed
@class PreloadStore
**/
PreloadStore = { PreloadStore = {
data: {}, data: {},
/**
Store an object in the store
@method store
@param {String} key the key to store the object with
@param {String} value the object we're inserting into the store
**/
store: function(key, value) { store: function(key, value) {
this.data[key] = value; this.data[key] = value;
}, },
/* To retrieve a key, you provide the key you want, plus a finder to
load it if the key cannot be found. Once the key is used once, it is /**
removed from the store. So, for example, you can't load a preloaded topic To retrieve a key, you provide the key you want, plus a finder to
more than once. */ load it if the key cannot be found. Once the key is used once, it is
removed from the store. So, for example, you can't load a preloaded topic
more than once.
@method get
@param {String} key the key to look up the object with
@param {function} finder a function to find the object with
@returns {Promise} a promise that will eventually be the object we want.
**/
get: function(key, finder) { get: function(key, finder) {
var promise, result; var promise = new RSVP.Promise();
promise = new RSVP.Promise();
if (this.data[key]) { if (this.data[key]) {
promise.resolve(this.data[key]); promise.resolve(this.data[key]);
delete this.data[key]; delete this.data[key];
} else { } else {
if (finder) { if (finder) {
result = finder(); var result = finder();
// If the finder returns a promise, we support that too // If the finder returns a promise, we support that too
if (result.then) { if (result.then) {
@ -30,22 +52,35 @@ PreloadStore = {
promise.resolve(result); promise.resolve(result);
} }
} else { } else {
promise.resolve(void 0); promise.resolve(null);
} }
} }
return promise; return promise;
}, },
/* Does the store contain a particular key? Does not delete, just returns
true or false. */ /**
Does the store contain a particular key? Does not delete.
@method contains
@param {String} key the key to look up the object with
@returns {Boolean} whether the object exists
**/
contains: function(key) { contains: function(key) {
return this.data[key] !== void 0; return this.data[key] !== void 0;
}, },
/* If we are sure it's preloaded, we don't have to supply a finder. Just
returns undefined if it's not in the store. */ /**
If we are sure it's preloaded, we don't have to supply a finder. Just returns
undefined if it's not in the store.
@method getStatic
@param {String} key the key to look up the object with
@returns {Object} the object from the store
**/
getStatic: function(key) { getStatic: function(key) {
var result; var result = this.data[key];
result = this.data[key];
delete this.data[key]; delete this.data[key];
return result; return result;
} }
}; };

View File

@ -2,7 +2,7 @@ require_dependency 'discourse_hub'
class UsersController < ApplicationController class UsersController < ApplicationController
skip_before_filter :check_xhr, only: [:password_reset, :update, :activate_account, :avatar, :authorize_email, :user_preferences_redirect] skip_before_filter :check_xhr, only: [:show, :password_reset, :update, :activate_account, :avatar, :authorize_email, :user_preferences_redirect]
skip_before_filter :authorize_mini_profiler, only: [:avatar] skip_before_filter :authorize_mini_profiler, only: [:avatar]
skip_before_filter :check_restricted_access, only: [:avatar] skip_before_filter :check_restricted_access, only: [:avatar]
@ -10,8 +10,15 @@ class UsersController < ApplicationController
def show def show
@user = fetch_user_from_params @user = fetch_user_from_params
anonymous_etag(@user) do user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user')
render_serialized(@user, UserSerializer) respond_to do |format|
format.html do
store_preloaded("user_#{@user.username}", MultiJson.dump(user_serializer))
end
format.json do
render_json_dump(user_serializer)
end
end end
end end

View File

@ -32,23 +32,23 @@ module ApplicationHelper
current_user.try(:admin?) current_user.try(:admin?)
end end
def crawlable_meta_data(url, title, description) # Creates open graph and twitter card meta data
# Image to supply as meta data def crawlable_meta_data(opts=nil)
image = "#{Discourse.base_url}#{SiteSetting.logo_url}"
opts ||= {}
opts[:image] ||= "#{Discourse.base_url}#{SiteSetting.logo_url}"
opts[:url] ||= "#{Discourse.base_url}#{request.fullpath}"
# Add opengraph tags # Add opengraph tags
result = tag(:meta, property: 'og:site_name', content: SiteSetting.title) << "\n" result = tag(:meta, property: 'og:site_name', content: SiteSetting.title) << "\n"
result << tag(:meta, property: 'og:image', content: image) << "\n"
result << tag(:meta, property: 'og:url', content: url) << "\n"
result << tag(:meta, property: 'og:title', content: title) << "\n"
result << tag(:meta, property: 'og:description', content: description) << "\n"
# Add twitter card result << tag(:meta, property: 'twitter:card', content: "summary")
result << tag(:meta, property: 'twitter:card', content: "summary") << "\n" [:image, :url, :title, :description].each do |property|
result << tag(:meta, property: 'twitter:url', content: url) << "\n" if opts[property].present?
result << tag(:meta, property: 'twitter:title', content: title) << "\n" result << tag(:meta, property: "og:#{property}", content: opts[property]) << "\n"
result << tag(:meta, property: 'twitter:description', content: description) << "\n" result << tag(:meta, property: "twitter:#{property}", content: opts[property]) << "\n"
result << tag(:meta, property: 'twitter:image', content: image) << "\n" end
end
result result
end end

View File

@ -1,6 +1,7 @@
require_dependency 'email_token' require_dependency 'email_token'
require_dependency 'trust_level' require_dependency 'trust_level'
require_dependency 'pbkdf2' require_dependency 'pbkdf2'
require_dependency 'summarize'
class User < ActiveRecord::Base class User < ActiveRecord::Base
attr_accessible :name, :username, :password, :email, :bio_raw, :website attr_accessible :name, :username, :password, :email, :bio_raw, :website
@ -447,6 +448,11 @@ class User < ActiveRecord::Base
username username
end end
def bio_summary
return nil unless bio_cooked.present?
Summarize.new(bio_cooked).summary
end
protected protected
def cook def cook

View File

@ -24,5 +24,5 @@
<% content_for :head do %> <% content_for :head do %>
<%= auto_discovery_link_tag(@topic_view, {action: :feed, format: :rss}, title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %> <%= auto_discovery_link_tag(@topic_view, {action: :feed, format: :rss}, title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %>
<%= crawlable_meta_data(@topic_view.absolute_url, @topic_view.title, @topic_view.summary) %> <%= crawlable_meta_data(title: @topic_view.title, description: @topic_view.summary) %>
<% end %> <% end %>

View File

@ -0,0 +1,9 @@
<h2><%= @user.username %></h2>
<p><%= raw @user.bio_cooked %></p>
<p><%= t 'powered_by_html' %></p>
<% content_for :head do %>
<%= crawlable_meta_data(title: @user.username, description: @user.bio_summary) %>
<% end %>

View File

@ -4,7 +4,7 @@ require_dependency 'admin_constraint'
# This used to be User#username_format, but that causes a preload of the User object # This used to be User#username_format, but that causes a preload of the User object
# and makes Guard not work properly. # and makes Guard not work properly.
USERNAME_ROUTE_FORMAT = /[A-Za-z0-9\._]+/ USERNAME_ROUTE_FORMAT = /[A-Za-z0-9\_]+/
Discourse::Application.routes.draw do Discourse::Application.routes.draw do
@ -88,14 +88,14 @@ Discourse::Application.routes.draw do
get 'users/hp' => 'users#get_honeypot_value' get 'users/hp' => 'users#get_honeypot_value'
get 'user_preferences' => 'users#user_preferences_redirect' get 'user_preferences' => 'users#user_preferences_redirect'
get 'users/:username/private-messages' => 'user_actions#private_messages', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/private-messages' => 'user_actions#private_messages', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username' => 'users#show', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username' => 'users#show', :constraints => {:username => USERNAME_ROUTE_FORMAT}
put 'users/:username' => 'users#update', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} put 'users/:username' => 'users#update', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username/preferences' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT}, :as => :email_preferences get 'users/:username/preferences' => 'users#preferences', :constraints => {:username => USERNAME_ROUTE_FORMAT}, :as => :email_preferences
get 'users/:username/preferences/email' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/preferences/email' => 'users#preferences', :constraints => {:username => USERNAME_ROUTE_FORMAT}
put 'users/:username/preferences/email' => 'users#change_email', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} put 'users/:username/preferences/email' => 'users#change_email', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username/preferences/username' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/preferences/username' => 'users#preferences', :constraints => {:username => USERNAME_ROUTE_FORMAT}
put 'users/:username/preferences/username' => 'users#username', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} put 'users/:username/preferences/username' => 'users#username', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username/avatar(/:size)' => 'users#avatar', :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/avatar(/:size)' => 'users#avatar', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username/invited' => 'users#invited', :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/invited' => 'users#invited', :constraints => {:username => USERNAME_ROUTE_FORMAT}
get 'users/:username/send_activation_email' => 'users#send_activation_email', :constraints => {:username => USERNAME_ROUTE_FORMAT} get 'users/:username/send_activation_email' => 'users#send_activation_email', :constraints => {:username => USERNAME_ROUTE_FORMAT}

26
lib/summarize.rb Normal file
View File

@ -0,0 +1,26 @@
# Summarize a HTML field into regular text. Used currently
# for meta tags
class Summarize
include ActionView::Helpers
def initialize(text)
@text = text
end
def self.max_length
500
end
def summary
return nil if @text.blank?
result = sanitize(@text, tags: [], attributes: [])
result.gsub!(/\n/, ' ')
result.strip!
return result if result.length <= Summarize.max_length
"#{result[0..Summarize.max_length]}..."
end
end

View File

@ -1,8 +1,8 @@
require_dependency 'guardian' require_dependency 'guardian'
require_dependency 'topic_query' require_dependency 'topic_query'
require_dependency 'summarize'
class TopicView class TopicView
include ActionView::Helpers
attr_accessor :topic, :min, :max, :draft, :draft_key, :draft_sequence, :posts attr_accessor :topic, :min, :max, :draft, :draft_key, :draft_sequence, :posts
@ -76,11 +76,7 @@ class TopicView
def summary def summary
return nil if posts.blank? return nil if posts.blank?
first_post_content = sanitize(posts.first.cooked, tags: [], attributes: []) Summarize.new(posts.first.cooked).summary
first_post_content.gsub!(/\n/, ' ')
return first_post_content if first_post_content.length <= 500
"#{first_post_content[0..500]}..."
end end
def filter_posts(opts = {}) def filter_posts(opts = {})

View File

@ -0,0 +1,27 @@
require 'spec_helper'
require 'summarize'
describe Summarize do
it "is blank when the input is nil" do
Summarize.new(nil).summary.should be_blank
end
it "is blank when the input is an empty string" do
Summarize.new("").summary.should be_blank
end
it "removes html tags" do
Summarize.new("hello <b>robin</b>").summary.should == "hello robin"
end
it "strips leading and trailing space" do
Summarize.new("\t \t hello \t ").summary.should == "hello"
end
it "trims long strings and adds an ellipsis" do
Summarize.stubs(:max_length).returns(11)
Summarize.new("discourse is a cool forum").summary.should == "discourse is..."
end
end

View File

@ -218,6 +218,7 @@ describe User do
its(:email_tokens) { should be_present } its(:email_tokens) { should be_present }
its(:bio_cooked) { should be_present } its(:bio_cooked) { should be_present }
its(:bio_summary) { should be_present }
its(:topics_entered) { should == 0 } its(:topics_entered) { should == 0 }
its(:posts_read_count) { should == 0 } its(:posts_read_count) { should == 0 }
end end