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) { if (json.user.stream) {
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) { json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map(function(ua) {
return Discourse.UserAction.create(ua); return Discourse.UserAction.create(ua);
})); }));
} }
user = Discourse.User.create(json.user);
return promise.resolve(user); return Discourse.User.create(json.user);
},
error: function(xhr) {
return promise.reject(xhr);
}
}); });
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
/**
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 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 removed from the store. So, for example, you can't load a preloaded topic
more than once. */ 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