mirror of
https://github.com/reddit-archive/reddit.git
synced 2026-01-28 16:28:01 -05:00
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -454,6 +454,7 @@ module["reddit"] = LocalizedModule("reddit.js",
|
||||
"apps.js",
|
||||
"gold.js",
|
||||
"multi.js",
|
||||
"recommender.js",
|
||||
PermissionsDataSource(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
137
r2/r2/public/static/js/recommender.js
Normal file
137
r2/r2/public/static/js/recommender.js
Normal 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()
|
||||
}
|
||||
})
|
||||
@@ -105,5 +105,40 @@
|
||||
${_("a multireddit for")} ${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>
|
||||
|
||||
Reference in New Issue
Block a user