Add subreddit suggestions UI to multi page.

When user edits a multi, a box below the edit control shows a list of
subreddits recommended based on the items already in the multi. A "more
suggestions" link pages through the list, showing a list of subreddit
discovery links after all suggestions have been dismissed.
This commit is contained in:
shlurbee
2013-07-30 16:26:52 -07:00
parent 2083e88100
commit 46b36dbc2b
9 changed files with 352 additions and 2 deletions

View File

@@ -339,6 +339,9 @@ def make_map():
requirements=dict(type='wikibannednote|bannednote'),
action='relnote')
mc('/api/:action', controller='api')
mc('/api/recommend/sr/:srnames', controller='api',
action='subreddit_recommendations')
mc("/api/multi/mine", controller="multiapi", action="my_multis")
mc("/api/multi/copy", controller="multiapi", action="multi_copy")

View File

@@ -38,6 +38,7 @@ from r2.lib.validator import *
from r2.models import *
from r2.lib import amqp
from r2.lib import recommender
from r2.lib.utils import get_title, sanitize_url, timeuntil, set_last_modified
from r2.lib.utils import query_string, timefromnow, randstr
@@ -3507,3 +3508,17 @@ class ApiController(RedditController, OAuth2ResourceController):
c.user.pref_collapse_left_bar = collapsed
c.user._commit()
@validate(srs=VSRByNames("srnames"),
to_omit=VSRByNames("omit", required=False))
@api_doc(api_section.subreddits)
def GET_subreddit_recommendations(self, srs, to_omit):
"""Return subreddits recommended for the given subreddit(s).
Gets a list of subreddits recommended for `srnames`, filtering out any
that appear in the optional `omit` param.
"""
rec_srs = recommender.get_recommendations(srs.values(),
to_omit=to_omit.values())
sr_names = [sr.name for sr in rec_srs]
return json.dumps(sr_names)

View File

@@ -454,6 +454,7 @@ module["reddit"] = LocalizedModule("reddit.js",
"apps.js",
"gold.js",
"multi.js",
"recommender.js",
PermissionsDataSource(),
)

View File

@@ -29,7 +29,6 @@ from operator import itemgetter
from r2.models import Subreddit
from r2.lib.sgm import sgm
from r2.lib.db import tdb_cassandra
from r2.lib.memoize import memoize
from r2.lib.utils import tup
from pylons import g

View File

@@ -669,6 +669,33 @@ class VSRByName(Validator):
}
class VSRByNames(Validator):
"""Returns a dict mapping subreddit names to subreddit objects.
sr_names_csv - a comma delimited string of subreddit names
required - if true (default) an empty subreddit name list is an error
"""
def __init__(self, sr_names_csv, required=True):
self.required = required
Validator.__init__(self, sr_names_csv)
def run(self, sr_names_csv):
if sr_names_csv:
sr_names = [s.strip() for s in sr_names_csv.split(',')]
return Subreddit._by_name(sr_names)
elif self.required:
self.set_error(errors.BAD_SR_NAME, code=400)
return
else:
return {}
def param_docs(self):
return {
self.param: "comma-delimited list of subreddit names",
}
class VSubredditTitle(Validator):
def run(self, title):
if not title:

View File

@@ -5409,6 +5409,103 @@ table.calendar {
}
}
.side .recommend-box {
margin: 15px 5px 30px 0px;
opacity: 0;
transition: all .1s ease-in-out;
h1 {
display: inline-block;
font-size: 1.35em;
font-weight: bold;
white-space: nowrap;
}
ul {
margin: 4px 0;
}
.rec-item {
background-color: rgb(247, 247, 247);
border: solid thin silver;
display: inline-block;
font-size: 1.0em;
margin: 2px;
padding: 0 0 1px 5px;
position: relative;
width: 136px;
white-space: nowrap;
a {
display: inline-block;
height: 100%;
overflow: hidden;
line-height: 1.8em;
padding-left: 2px;
text-overflow: ellipsis;
vertical-align: middle;
width: 111px;
}
button.add {
background-color: rgb(247, 247, 247);
background-image: none;
border: none;
cursor: pointer;
height: 100%;
opacity: 0.3;
&:after {
background-image: url(../add.png); /* SPRITE */
content: "";
display: block;
height: 15px;
width: 15px;
}
&:hover {
opacity: 1.0;
}
}
}
.more {
color: #369;
cursor: pointer;
display: inline-block;
font-weight: bold;
vertical-align: top;
}
.endoflist {
background-color: #f7f7f7;
padding: 15px 25px;
h1 {
margin-bottom: 10px;
}
.heading {
color: #555;
font-weight: bold;
}
ul {
font-size: x-small;
list-style-type: disc;
margin: 4px 0 0 20px;
}
.reset {
cursor: pointer;
}
}
}
.readonly .recommend-box li > button {
display: none;
}
.hover-bubble.multi-add-notice {
@bg-color: lighten(orange, 42%);
@border-color: lighten(orangered, 30%);

View File

@@ -19,6 +19,12 @@ r.multi = {
if (location.hash == '#created') {
detailsView.focusAdd()
}
// if page has a recs box, wire it up to refresh with the multi.
var recsEl = $('#multi-recs')
if (recsEl.length) {
detailsView.initRecommendations(recsEl)
}
}
var subscribeBubbleGroup = {}
@@ -171,6 +177,10 @@ r.multi.MultiReddit = Backbone.Model.extend({
renameTo: function(newCollection, name) {
return this._copyOp('rename', newCollection, name)
},
getSubredditNames: function() {
return this.subreddits.pluck('name')
}
})
@@ -291,6 +301,32 @@ r.multi.MultiDetails = Backbone.View.extend({
this.model.subreddits.each(this.addOne, this)
},
// create child model and view to manage recommendations
initRecommendations: function(recsEl) {
var recs = new r.recommend.RecommendationList()
this.recsView = new r.recommend.RecommendationsView({
collection: recs,
el: recsEl
})
// fetch initial data
if (!this.model.subreddits.isEmpty()) {
recs.fetchForSrs(this.model.getSubredditNames())
}
// update recs when multi changes
this.listenTo(this.model.subreddits, 'add remove reset',
function() {
var srNames = this.model.getSubredditNames()
recs.fetchForSrs(srNames)
})
// update multi when a rec is selected
this.recsView.bind('recs:select',
function(data) {
this.model.addSubreddit(data['srName'])
}, this)
},
render: function() {
var canEdit = this.model.get('can_edit')
if (canEdit) {

View File

@@ -0,0 +1,137 @@
r.recommend = {}
r.recommend.Recommendation = Backbone.Model.extend()
/**
* Example usage:
* // generate recs for people who like book subreddits
* var recs = r.recommend.RecommendationList()
* recs.fetchForSrs(['books', 'writing']) // triggers reset event
* // get a new set of recs
* recs.fetchNewRecs() // triggers reset event
* // the user also likes /r/excerpts so generate recs for it too
* recs.fetchForSrs(['books', 'writing', 'excerpts'])
* // keep fetching until none are left
* while (recs.models.length > 0) {
* recs.fetchNewRecs()
* }
* // allow previously seen recs to appear again (but results might not be the
* // same as above because srNames has changed)
* recs.clearHistory()
* recs.fetchRecs()
*/
r.recommend.RecommendationList = Backbone.Collection.extend({
// { srName: 'books' }
model: r.recommend.Recommendation,
// names of subreddits for which recommendations are generated
// (if user likes srNames, he will also like...)
srNames: [],
// names of subreddits that should be excluded from future recs because
// the user has already seen and dismissed them
dismissed: [],
// loads recs for srNames and stores srNames so they can be used in future
// fetches. fires reset event
fetchForSrs: function(srNames) {
if (!srNames.length) { // skip unnecessary request
this.srNames = []
this.reset([])
return
}
this.srNames = srNames
this.fetchRecs()
},
// adds current recs to the dismissed list so they won't be shown again
// and refetches. fires reset event
fetchNewRecs: function() {
var currentRecs = this.pluck('srName')
this.dismissed = _.union(this.dismissed, currentRecs)
this.fetchRecs()
},
// requests data from the server based on values of member vars
fetchRecs: function() {
var url = '/api/recommend/sr/' + this.srNames.join(',')
this.fetch({ url: url,
data: {'omit': this.dismissed.join(',')},
reset: true,
error: _.bind(function() {
this.reset([])
}, this)})
},
parse: function(resp) {
if ($.isArray(resp)) {
return _.map(resp, function(srName) {
return new r.recommend.Recommendation({'srName': srName})
})
}
return []
},
// allows previously dismissed recs to be shown again
clearHistory: function() {
this.dismissed = []
}
})
r.recommend.RecommendationsView = Backbone.View.extend({
collection: r.recommend.RecommendationList,
tagName: 'div',
itemTemplate: _.template('<li class="rec-item"><a href="/r/<%- sr_name %>" title="<%- sr_name %>" target="_blank">/r/<%- sr_name %></a><button class="add add-rec" data-srname="<%- sr_name %>"></button></li>'),
initialize: function() {
this.listenTo(this.collection, 'add remove reset', this.render)
},
events: {
'click .add-rec': 'onAddClick',
'click .more': 'showMore',
'click .reset': 'resetRecommendations'
},
render: function() {
this.$('.recommendations').empty()
// if there are results, show them
if (this.collection.models.length > 0) {
this.$('.recs').show()
this.$('.endoflist').hide()
var el = this.$el
var view = this
this.collection.each(function(rec) {
this.$('.recommendations').append(view.itemTemplate({sr_name: rec.get('srName')}))
}, this)
this.$el.css({opacity: 1.0})
// if recs are empty but the dismissed list is not, all available recs
// have been seen and we give user an option to start over
} else if (this.collection.dismissed.length > 0) {
this.$('.recs').hide()
this.$('.endoflist').show()
// if there were no results at all, hide the panel
} else {
this.$el.css({opacity: 0})
}
return this
},
resetRecommendations: function() {
this.collection.clearHistory()
this.collection.fetchRecs()
},
// get sr name of selected rec and fire it in a custom event
onAddClick: function(ev) {
var srName = $(ev.target).data('srname')
this.trigger('recs:select', {'srName': srName})
},
showMore: function(ev) {
this.collection.fetchNewRecs()
}
})

View File

@@ -105,5 +105,40 @@
${_("a multireddit for")}&#32;${thing_timestamp(thing.multi)}
</span>
</div>
</div>
<div id="multi-recs" class="recommend-box">
<div class="recs">
<div>
<h1>
${_("people also added:")}
</h1>
</div>
<ul class="recommendations"></ul>
<span class="more">${_("more suggestions")}</span>
</div>
<div class="endoflist">
<h1>${_("no more suggestions!")}</h1>
<div class="heading">${_("would you like to...")}</div>
<ul>
<li>
<a href="/r/ModeratorDuck/wiki/subreddit_classification" target="_blank">
${_("check out subreddits by category")}
</a>
</li>
<li>
<a href="/r/random" target="_blank">
${_("explore a random subreddit")}
</a>
</li>
<li>
<a class="reset">
${_("see the suggestions again")}
</a>
</li>
</ul>
</div>
</div>
</div>