Show recommended content in /explore

Shows a mix of content from:
- subreddits recommended for the user (based on subscriptions and multis)
- rising threads
- items from discovery-focused subreddits

Listing items emphasize the subreddit name and have feedback controls.

The AccountSRPrefs class builds a user preferences model on-the-fly from
subscriptions, multireddits, and a record of recent user feedback.

The AccountSRFeedback column family stores a user's recent interactions with
the recommendation UI. For example, it records which srs the user dismissed
as uninteresting, and keeps track of which srs were recommended recently to
make sure we don't show the same ones too often.

Each type of feedback has a ttl after which it disappears from the db.
This commit is contained in:
shlurbee
2013-12-04 11:29:11 -08:00
parent 1619fe1cc1
commit 87aa75df59
18 changed files with 817 additions and 39 deletions

View File

@@ -607,6 +607,8 @@ listing_chooser_sample_multis = /user/reddit/m/hello, /user/reddit/m/world
# multi of subreddits to share with gold users
listing_chooser_gold_multi = /user/reddit/m/gold
# subreddit showcasing new multireddits
listing_chooser_explore_sr =
listing_chooser_explore_sr =
# subreddits that help people discover more subreddits (used in explore tab)
discovery_srs =
# historical cost to run a reddit server
pennies_per_server_second = 1970/1/1:1

View File

@@ -144,6 +144,9 @@ def make_map():
mc('/user/:username/:where/:show', controller='user', action='listing')
mc('/explore', controller='front', action='explore')
mc('/api/recommend/feedback', controller='api', action='rec_feedback')
mc('/about/sidebar', controller='front', action='sidebar')
mc('/about/sticky', controller='front', action='sticky')
mc('/about/flair', controller='front', action='flairlisting')

View File

@@ -78,6 +78,7 @@ from r2.controllers.ipn import generate_blob
from r2.lib.lock import TimeoutExpired
from r2.models import wiki
from r2.models.recommend import AccountSRFeedback
from r2.lib.merge import ConflictException
import csv
@@ -3623,6 +3624,16 @@ class ApiController(RedditController, OAuth2ResourceController):
return json.dumps(sr_data)
@validatedForm(VUser(),
VModhash(),
action=VOneOf("type", recommend.FEEDBACK_ACTIONS),
srs=VSRByNames("srnames"))
def POST_rec_feedback(self, form, jquery, action, srs):
if form.has_errors("type", errors.INVALID_OPTION):
return self.abort404()
AccountSRFeedback.record_feedback(c.user, srs.values(), action)
@validatedForm(
VUser(),
VModhash(),

View File

@@ -33,6 +33,7 @@ from r2.controllers.reddit_base import (
from r2 import config
from r2.models import *
from r2.config.extensions import is_api
from r2.lib import recommender
from r2.lib.pages import *
from r2.lib.pages.things import wrap_links
from r2.lib.pages import trafficpages
@@ -154,6 +155,16 @@ class FrontController(RedditController, OAuth2ResourceController):
kw['reverse'] = False
return DetailsPage(thing=thing, expand_children=False, **kw).render()
@validate(VUser())
def GET_explore(self):
recs = recommender.get_recommended_content_for_user(c.user,
record_views=True)
content = ExploreItemListing(recs)
return BoringPage(_("explore"),
show_sidebar=True,
show_chooser=True,
content=content).render()
@validate(article=VLink('article'))
def GET_shirt(self, article):
if not can_view_link_comments(article):

View File

@@ -256,6 +256,7 @@ class Globals(object):
ConfigValue.tuple: [
'fastlane_links',
'listing_chooser_sample_multis',
'discovery_srs',
],
ConfigValue.str: [
'listing_chooser_gold_multi',

View File

@@ -4195,6 +4195,7 @@ class ListingChooser(Templated):
self.sections = defaultdict(list)
self.add_item("global", _("subscribed"), site=Frontpage,
description=_("your front page"))
self.add_item("global", _("explore"), path="/explore")
self.add_item("other", _("everything"), site=All,
description=_("from all subreddits"))
if c.show_mod_mail:
@@ -4286,9 +4287,12 @@ class PolicyPage(BoringPage):
class SubscribeButton(Templated):
def __init__(self, sr):
def __init__(self, sr, bubble_class=None):
Templated.__init__(self)
self.sr = sr
self.data_attrs = {"sr_name": sr.name}
if bubble_class:
self.data_attrs["bubble_class"] = bubble_class
class SubredditSelector(Templated):
@@ -4345,3 +4349,41 @@ class ListingSuggestions(Templated):
self.suggestion_type = "random"
Templated.__init__(self)
class ExploreItem(Templated):
"""For managing recommended content."""
def __init__(self, item_type, rec_src, sr, link, comment=None):
"""Constructor.
item_type - string that helps templates know how to render this item.
rec_src - code that lets us track where the rec originally came from,
useful for comparing performance of data sources or algorithms
sr and link are required
comment is optional
See r2.lib.recommender for valid values of item_type and rec_src.
"""
self.sr = sr
self.link = link
self.comment = comment
self.type = item_type
self.src = rec_src
Templated.__init__(self)
class ExploreItemListing(Templated):
def __init__(self, recs):
self.things = []
if recs:
links, srs = zip(*[(rec.link, rec.sr) for rec in recs])
wrapped_links = {l._id: l for l in wrap_links(links).things}
wrapped_srs = {sr._id: sr for sr in wrap_things(*srs)}
for rec in recs:
if rec.link._id in wrapped_links:
rec.link = wrapped_links[rec.link._id]
rec.sr = wrapped_srs[rec.sr._id]
self.things.append(rec)
Templated.__init__(self)

View File

@@ -20,24 +20,43 @@
# Inc. All Rights Reserved.
###############################################################################
import itertools
from itertools import chain, izip_longest
import math
import random
from collections import defaultdict
from datetime import timedelta
from operator import itemgetter
from r2.models import Subreddit
from r2.lib import rising
from r2.lib.db import operators, tdb_cassandra
from r2.lib.pages import ExploreItem
from r2.lib.normalized_hot import normalized_hot
from r2.lib.utils import roundrobin, tup, to36
from r2.lib.sgm import sgm
from r2.lib.db import tdb_cassandra
from r2.lib.utils import tup
from r2.models import Account, Link, Subreddit
from r2.models.builder import CommentBuilder
from r2.models.listing import NestedListing
from r2.models.recommend import AccountSRPrefs, AccountSRFeedback
from pylons import g
from pylons.i18n import _
SRC_LINKVOTES = 'lv'
# recommendation sources
SRC_MULTIREDDITS = 'mr'
SRC_EXPLORE = 'e' # favors lesser known srs
# explore item types
TYPE_RISING = _("rising")
TYPE_DISCOVERY = _("discovery")
TYPE_HOT = _("hot")
TYPE_COMMENT = _("comment")
def get_recommendations(srs, count=10, source=SRC_MULTIREDDITS, to_omit=None):
def get_recommendations(srs,
count=10,
source=SRC_MULTIREDDITS,
to_omit=None,
match_set=True):
"""Return subreddits recommended if you like the given subreddits.
Args:
@@ -46,16 +65,19 @@ def get_recommendations(srs, count=10, source=SRC_MULTIREDDITS, to_omit=None):
- source is a prefix telling which set of recommendations to use
- to_omit is one Subreddit object or a list of Subreddits that should not
be included. (Useful for omitting recs that were already rejected.)
- match_set=True will return recs that are similar to each other, useful
for matching the "theme" of the original set
"""
srs = tup(srs)
to_omit = tup(to_omit) if to_omit else []
# fetch more recs than requested because some might get filtered out
rec_id36s = SRRecommendation.for_srs([sr._id36 for sr in srs],
[o._id36 for o in to_omit],
to_omit,
count * 2,
source)
source,
match_set=match_set)
# always check for private subreddits at runtime since type might change
rec_srs = Subreddit._byID36(rec_id36s, return_dict=False)
@@ -68,6 +90,157 @@ def get_recommendations(srs, count=10, source=SRC_MULTIREDDITS, to_omit=None):
return filtered[:count]
def get_recommended_content_for_user(account,
record_views=False,
src=SRC_EXPLORE):
"""Wrapper around get_recommended_content() that fills in user info.
If record_views == True, the srs will be noted in the user's preferences
to keep from showing them again too soon.
Returns a list of ExploreItems.
"""
prefs = AccountSRPrefs.for_user(account)
recs = get_recommended_content(prefs, src)
if record_views:
# mark as seen so they won't be shown again too soon
sr_data = {r.sr: r.src for r in recs}
AccountSRFeedback.record_views(account, sr_data)
return recs
def get_recommended_content(prefs, src):
"""Get a mix of content from subreddits recommended for someone with
the given preferences (likes and dislikes.)
Returns a list of ExploreItems.
"""
# numbers chosen empirically to give enough results for explore page
num_liked = 10 # how many liked srs to use when generating the recs
num_recs = 20 # how many recommended srs to ask for
num_discovery = 2 # how many discovery-related subreddits to mix in
num_rising = 4 # how many rising links to mix in
num_items = 20 # total items to return
# make a list of srs that shouldn't be recommended
default_srid36s = [to36(srid) for srid in Subreddit.default_subreddits()]
omit_srid36s = list(prefs.likes.union(prefs.dislikes,
prefs.recent_views,
default_srid36s))
# pick random subset of the user's liked srs
liked_srid36s = random_sample(prefs.likes, num_liked)
# pick random subset of discovery srs
candidates = set(get_discovery_srid36s()).difference(prefs.dislikes)
discovery_srid36s = random_sample(candidates, num_discovery)
# multiget subreddits
to_fetch = liked_srid36s + discovery_srid36s
srs = Subreddit._byID36(to_fetch)
liked_srs = [srs[sr_id36] for sr_id36 in liked_srid36s]
discovery_srs = [srs[sr_id36] for sr_id36 in discovery_srid36s]
# generate recs from srs we know the user likes
recommended_srs = get_recommendations(liked_srs,
count=num_recs,
to_omit=omit_srid36s,
source=src,
match_set=False)
random.shuffle(recommended_srs)
# split list of recommended srs in half
midpoint = len(recommended_srs) / 2
srs_slice1 = recommended_srs[:midpoint]
srs_slice2 = recommended_srs[midpoint:]
# get hot links plus top comments from one half
comment_items = get_comment_items(srs_slice1, src)
# just get hot links from the other half
hot_items = get_hot_items(srs_slice2, TYPE_HOT, src)
# get links from subreddits dedicated to discovery
discovery_items = get_hot_items(discovery_srs, TYPE_DISCOVERY, 'disc')
# grab some (non-personalized) rising items
omit_sr_ids = set(int(id36, 36) for id36 in omit_srid36s)
rising_items = get_rising_items(omit_sr_ids, count=num_rising)
# combine all items and randomize order to get a mix of types
all_recs = list(chain(rising_items,
comment_items,
discovery_items,
hot_items))
random.shuffle(all_recs)
# make sure subreddits aren't repeated
seen_srs = set()
recs = []
for r in all_recs:
if r.sr.over_18 or r.link.over_18 or Link._nsfw.findall(r.link.title):
continue
if r.sr._id not in seen_srs:
recs.append(r)
seen_srs.add(r.sr._id)
if len(recs) >= num_items:
break
return recs
def get_hot_items(srs, item_type, src):
"""Get hot links from specified srs."""
hot_srs = {sr._id: sr for sr in srs} # for looking up sr by id
hot_link_fullnames = normalized_hot(sr._id for sr in srs)
hot_links = Link._by_fullname(hot_link_fullnames, return_dict=False)
hot_items = []
for l in hot_links:
hot_items.append(ExploreItem(item_type, src, hot_srs[l.sr_id], l))
return hot_items
def get_rising_items(omit_sr_ids, count=4):
"""Get links that are rising right now."""
all_rising = rising.get_all_rising()
candidate_sr_ids = {sr_id for link, sr_id in all_rising}.difference(omit_sr_ids)
link_fullnames = [link for link, sr_id in all_rising if sr_id in candidate_sr_ids]
link_fullnames_to_show = random_sample(link_fullnames, count)
rising_links = Link._by_fullname(link_fullnames_to_show,
return_dict=False,
data=True)
rising_items = [ExploreItem(TYPE_RISING, 'ris', Subreddit._byID(l.sr_id), l)
for l in rising_links]
return rising_items
def get_comment_items(srs, src, count=4):
"""Get hot links from srs, plus top comment from each link."""
link_fullnames = normalized_hot([sr._id for sr in srs])
hot_links = Link._by_fullname(link_fullnames[:count], return_dict=False)
top_comments = []
for link in hot_links:
builder = CommentBuilder(link,
operators.desc('_confidence'),
comment=None,
context=None,
load_more=False)
listing = NestedListing(builder,
num=1,
parent_name=link._fullname).listing()
top_comments.extend(listing.things)
srs = Subreddit._byID([com.sr_id for com in top_comments])
links = Link._byID([com.link_id for com in top_comments])
comment_items = [ExploreItem(TYPE_COMMENT,
src,
srs[com.sr_id],
links[com.link_id],
com) for com in top_comments]
return comment_items
def get_discovery_srid36s():
"""Get list of srs that help people discover other srs."""
srs = Subreddit._by_name(g.live_config['discovery_srs'])
return [sr._id36 for sr in srs.itervalues()]
def random_sample(items, count):
"""Safe random sample that won't choke if len(items) < count."""
sample_size = min(count, len(items))
return random.sample(items, sample_size)
class SRRecommendation(tdb_cassandra.View):
_use_db = True
@@ -81,7 +254,7 @@ class SRRecommendation(tdb_cassandra.View):
_warn_on_partial_ttl = False
@classmethod
def for_srs(cls, srid36, to_omit, count=10, source=SRC_MULTIREDDITS):
def for_srs(cls, srid36, to_omit, count, source, match_set=True):
# It's usually better to use get_recommendations() than to call this
# function directly because it does privacy filtering.
@@ -94,12 +267,13 @@ class SRRecommendation(tdb_cassandra.View):
d = sgm(g.cache, rowkeys, SRRecommendation._byID, prefix='srr.')
rows = d.values()
sorted_recs = SRRecommendation._merge_and_sort_by_count(rows)
# heuristic: if the input set is large, rec should match more than one
min_count = math.floor(.1 * len(srid36s))
sorted_recs = (rec[0] for rec in sorted_recs if rec[1] > min_count)
if match_set:
sorted_recs = SRRecommendation._merge_and_sort_by_count(rows)
# heuristic: if input set is large, rec should match more than one
min_count = math.floor(.1 * len(srid36s))
sorted_recs = (rec[0] for rec in sorted_recs if rec[1] > min_count)
else:
sorted_recs = SRRecommendation._merge_roundrobin(rows)
# remove duplicates and ids listed in to_omit
filtered = []
for r in sorted_recs:
@@ -108,6 +282,20 @@ class SRRecommendation(tdb_cassandra.View):
to_omit.add(r)
return filtered[:count]
@classmethod
def _merge_roundrobin(cls, rows):
"""Combine multiple sets of recs, preserving order.
Picks items equally from each input sr, which can be useful for
getting a diverse set of recommendations instead of one that matches
a theme. Preserves ordering, so all rank 1 recs will be listed first,
then all rank 2, etc.
Returns a list of id36s.
"""
return roundrobin(*[row._values().itervalues() for row in rows])
@classmethod
def _merge_and_sort_by_count(cls, rows):
"""Combine and sort multiple sets of recs.
@@ -118,20 +306,15 @@ class SRRecommendation(tdb_cassandra.View):
"""
# combine recs from all input srs
rank_id36_pairs = itertools.chain(*[row._values().iteritems()
for row in rows])
rank_id36_pairs = chain.from_iterable(row._values().iteritems()
for row in rows)
ranks = defaultdict(list)
for rank, id36 in rank_id36_pairs:
ranks[id36].append(rank)
recs = [(id36, len(ranks), max(ranks)) for id36, ranks in ranks.iteritems()]
recs = [(id36, len(ranks), max(ranks))
for id36, ranks in ranks.iteritems()]
# first, sort ascending by rank
recs = sorted(recs, key=itemgetter(2))
# next, sort descending by number of times the rec appeared. since
# python sort is stable, tied items will still be ordered by rank
return sorted(recs, key=itemgetter(1), reverse=True)
def _to_recs(self):
recs = self._values() # [ {rank, srid} ]
recs = sorted(recs.items(), key=lambda x: int(x[0]))
recs = [x[1] for x in recs]
return recs

View File

@@ -64,6 +64,10 @@ def set_rising():
g.cache.set(CACHE_KEY, calc_rising())
def get_all_rising():
return g.cache.get(CACHE_KEY, [])
def get_rising(sr):
rising = g.cache.get(CACHE_KEY, [])
rising = get_all_rising()
return [link for link, sr_id in rising if sr.keep_for_rising(sr_id)]

View File

@@ -25,6 +25,7 @@ import base64
import traceback
import ConfigParser
import codecs
import itertools
from babel.dates import TIMEDELTA_UNITS
from urllib import unquote_plus
@@ -1518,10 +1519,23 @@ def parse_ini_file(config_file):
parser.readfp(config_file)
return parser
def fuzz_activity(count):
"""Add some jitter to an activity metric to maintain privacy."""
# decay constant is e**(-x / 60)
decay = math.exp(float(-count) / 60)
jitter = round(5 * decay)
return count + random.randint(0, jitter)
# http://docs.python.org/2/library/itertools.html#recipes
def roundrobin(*iterables):
"roundrobin('ABC', 'D', 'EF') --> A D E B F C"
# Recipe credited to George Sakkis
pending = len(iterables)
nexts = itertools.cycle(iter(it).next for it in iterables)
while pending:
try:
for next in nexts:
yield next()
except StopIteration:
pending -= 1
nexts = itertools.cycle(itertools.islice(nexts, pending))

129
r2/r2/models/recommend.py Normal file
View File

@@ -0,0 +1,129 @@
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of
# the Original Code is reddit Inc.
#
# All portions of the code written by reddit are Copyright (c) 2006-2013 reddit
# Inc. All Rights Reserved.
###############################################################################
import pycassa
import time
from collections import defaultdict
from datetime import datetime, timedelta
from itertools import chain
from pylons import g
from r2.lib.db import tdb_cassandra
from r2.lib.db.tdb_cassandra import max_column_count
from r2.lib.utils import utils, tup
from r2.models import Account, LabeledMulti, Subreddit
from r2.lib.pages import ExploreItem
VIEW = 'imp'
CLICK = 'clk'
DISMISS = 'dis'
FEEDBACK_ACTIONS = [VIEW, CLICK, DISMISS]
# how long to keep each type of feedback
FEEDBACK_TTL = {VIEW: timedelta(hours=6).total_seconds(), # link lifetime
CLICK: timedelta(minutes=30).total_seconds(), # one session
DISMISS: timedelta(days=60).total_seconds()} # two months
class AccountSRPrefs(object):
"""Class for managing user recommendation preferences.
Builds a user profile on-the-fly based on the user's subscriptions,
multireddits, and recent interactions with the recommender UI.
Likes are used to generate recommendations, dislikes to filter out
unwanted results, and recent views to make sure the same subreddits aren't
recommended too often.
"""
def __init__(self):
self.likes = set()
self.dislikes = set()
self.recent_views = set()
@classmethod
def for_user(cls, account):
"""Return a new AccountSRPrefs obj populated with user's data."""
prefs = cls()
multis = LabeledMulti.by_owner(account)
multi_srs = set(chain.from_iterable(multi.srs for multi in multis))
feedback = AccountSRFeedback.for_user(account)
# subscriptions and srs in the user's multis become likes
subscriptions = Subreddit.user_subreddits(account, limit=None)
prefs.likes.update(utils.to36(sr_id) for sr_id in subscriptions)
prefs.likes.update(sr._id36 for sr in multi_srs)
# recent clicks on explore tab items are also treated as likes
prefs.likes.update(feedback[CLICK])
# dismissed recommendations become dislikes
prefs.dislikes.update(feedback[DISMISS])
# dislikes take precedence over likes
prefs.likes = prefs.likes.difference(prefs.dislikes)
# recently recommended items won't be shown again right away
prefs.recent_views.update(feedback[VIEW])
return prefs
class AccountSRFeedback(tdb_cassandra.DenormalizedRelation):
"""Column family for storing users' recommendation feedback."""
_use_db = True
_views = []
_write_last_modified = False
_read_consistency_level = tdb_cassandra.CL.QUORUM
_write_consistency_level = tdb_cassandra.CL.QUORUM
@classmethod
def for_user(cls, account):
"""Return dict mapping each feedback type to a set of sr id36s."""
feedback = defaultdict(set)
try:
row = AccountSRFeedback._cf.get(account._id36,
column_count=max_column_count)
except pycassa.NotFoundException:
return feedback
for colkey, colval in row.iteritems():
action, sr_id36 = colkey.split('.')
feedback[action].add(sr_id36)
return feedback
@classmethod
def record_feedback(cls, account, srs, action):
if action not in FEEDBACK_ACTIONS:
g.log.error('Unrecognized feedback: %s' % action)
return
srs = tup(srs)
# update user feedback record, setting appropriate ttls
fb_rowkey = account._id36
fb_colkeys = ['%s.%s' % (action, sr._id36) for sr in srs]
col_data = {col: '' for col in fb_colkeys}
ttl = FEEDBACK_TTL.get(action, 0)
if ttl > 0:
AccountSRFeedback._cf.insert(fb_rowkey, col_data, ttl=ttl)
else:
AccountSRFeedback._cf.insert(fb_rowkey, col_data)
@classmethod
def record_views(cls, account, srs):
cls.record_feedback(account, srs, VIEW)

View File

@@ -987,6 +987,193 @@ a.author { margin-right: 0.5em; }
.thing.stickied a.title, .thing.stickied a.title:visited, .thing.stickied a.title.visited { font-weight: bold; color: @moderator-color; }
body.with-listing-chooser.explore-page #header .pagename {
position: static;
}
.explore-header {
margin-bottom: 7px;
padding: 5px 0;
font-weight: bold;
.explore-title {
font-size: 1.3em;
}
.explore-discuss-link {
float: right;
margin: 0.3em 10px 0 0;
}
}
.explore-item {
margin-bottom: 1em;
.explore-label {
border-radius: 2px;
display: inline-block;
margin: 0 5px 1px 0;
padding: 1px 2px 2px;
}
.explore-label-type, .explore-label-link {
padding: 0 5px;
}
.explore-sr-details {
color: #777;
display: inline-block;
font-size: x-small;
font-weight: normal;
margin-left: 3px;
}
.explore-feedback {
display: inline-block;
.fancy-toggle-button .add, .fancy-toggle-button .remove {
background-color: transparent;
background-image: none;
border: none;
color: #aaa;
border: 1px solid #ccc;
border-radius: 2px;
margin-left: 10px;
padding-top: 0;
.option {
line-height: 7px;
}
&:hover {
color: white;
border: 1px solid #444;
}
}
.fancy-toggle-button .add {
&:hover {
background-image: url(../bg-button-add.png); /* SPRITE stretch-x */
}
}
.fancy-toggle-button .remove {
&:hover {
background-image: url(../bg-button-remove.png); /* SPRITE stretch-x */
}
}
.subscribe-button {
display: inline-block;
margin: 0 4px 0 0;
}
}
.explore-feedback-dismiss {
cursor: pointer;
display: inline-block;
text-indent: -9999px;
width: 9px;
height: 9px;
background-image: url(../close-small.png); /* SPRITE */
background-repeat: no-repeat;
opacity: .3;
margin-left: 4px;
vertical-align: middle;
border: 3px solid transparent;
&:hover {
opacity: 1;
}
}
.link {
.title {
font-size: small;
}
.domain {
font-size: x-small;
}
.tagline, .buttons {
font-size: smaller;
}
}
.explore-sr {
display: inline-block;
font-size: 1.1em;
font-weight: bold;
margin-bottom: 3px;
padding: 2px 4px;
line-height: 13px;
height: 18px;
}
.midcol {
display: none;
}
.rank {
display: none;
}
}
.explore-comment {
.explore-label {
background-color: #cee3f8;
border: solid thin #5f99cf;
}
.tagline, .buttons, .thumbnail, .expando-button {
display: none;
}
.comment {
border-left: solid 2px #eee;
color: #888;
margin: -3px 0 3px 5px;
max-height: 100px;
overflow-x: hidden;
overflow-y: hidden;
position: relative;
.md {
font-size: x-small;
padding-bottom: 2px;
p {
margin: 5px;
}
}
}
/* make long comment boxes fade to white instead of cutting off mid-line */
.comment-fade {
background: -moz-linear-gradient(bottom, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%);
background: -webkit-gradient(linear, left bottom, left top, color-stop(0%,rgba(255,255,255,1)), color-stop(100%,rgba(255,255,255,0)));
bottom: 0;
border: none;
height: 10px;
position: absolute;
width: 100%;
}
.comment-link {
color: #888;
display: inline-block;
font-size: 0.8em;
font-weight: bold;
padding: 0 0 8px 5px;
}
}
.explore-hot .explore-label {
background-color: #fff088;
border: solid thin #c4b487;
}
.explore-rising .explore-label {
background-color: #d6fbcb;
border: solid thin #485;
}
.explore-discovery .explore-label {
background-color: #dedede;
border: solid thin #aaa;
}
.explore-subscribe-bubble {
margin-left: 22px;
}
.sitetable { list-style-type: none; }
.ajaxhook { position: absolute; top: -1000px; left: 0px; }
@@ -1121,20 +1308,34 @@ a.author { margin-right: 0.5em; }
}
}
&.anchor-right {
&.anchor-right, &.anchor-left {
&:before, &:after {
top: 8px;
border: 9px solid transparent;
}
&:before {
right: -19px;
border-left-color: gray;
&.anchor-right {
&:before {
right: -19px;
border-left-color: gray;
}
&:after {
right: -18px;
border-left-color: white;
}
}
&:after {
right: -18px;
border-left-color: white;
&.anchor-left {
&:before {
left: -19px;
border-right-color: gray;
}
&:after {
left: -18px;
border-right-color: white;
}
}
}
}

View File

@@ -91,6 +91,7 @@ $(function() {
r.wiki.init()
r.gold.init()
r.multi.init()
r.recommend.init()
} catch (err) {
r.sendError('Error during base.js init', err)
}

View File

@@ -502,12 +502,20 @@ r.multi.SubscribeButton = Backbone.View.extend({
group: this.options.bubbleGroup,
srName: String(this.$el.data('sr_name'))
})
var bubbleClass = this.$el.data('bubble_class')
if (bubbleClass) {
this.bubble.$el.addClass(bubbleClass)
} else {
this.bubble.$el.addClass('anchor-right')
}
this.bubble.queueShow()
}
})
r.multi.MultiSubscribeBubble = r.ui.Bubble.extend({
className: 'multi-selector hover-bubble anchor-right',
className: 'multi-selector hover-bubble',
template: _.template('<div class="title"><strong><%- title %></strong><a class="sr" href="/r/<%- sr_name %>">/r/<%- sr_name %></a></div><div class="throbber"></div>'),
itemTemplate: _.template('<label><input class="add-to-multi" type="checkbox" data-path="<%- path %>" <%- checked %>><%- name %><a href="<%- path %>" target="_blank" title="<%- open_multi %>">&rsaquo;</a></label>'),
itemCreateTemplate: _.template('<label><form class="create-multi"><input type="text" class="multi-name" placeholder="<%- create_msg %>"><div class="error create-multi-error"></div></form></label>'),

View File

@@ -1,4 +1,10 @@
r.recommend = {}
r.recommend = {
init: function() {
$('.explore-item').each(function(idx, el) {
new r.recommend.ExploreItem({el: el})
})
}
}
r.recommend.Recommendation = Backbone.Model.extend()
@@ -126,3 +132,40 @@ r.recommend.RecommendationsView = Backbone.View.extend({
this.collection.fetchNewRecs()
}
})
r.recommend.ExploreItem = Backbone.View.extend({
events: {
'click .explore-feedback-dismiss': 'dismissSubreddit',
'click a': 'recordClick'
},
dismissSubreddit: function(ev) {
var listing = $(ev.target).closest('.explore-item')
var sr_name = listing.data('sr_name')
var src = listing.data('src')
r.ajax({
type: 'POST',
url: '/api/recommend/feedback',
data: { type: 'dis',
srnames: sr_name,
src: src,
page: 'explore' }
})
this.$('.explore-feedback-dismiss').css({'font-weight':'bold'})
$(this.el).fadeOut('fast')
},
recordClick: function(ev) {
var listing = $(ev.target).closest('.explore-item')
var sr_name = listing.data('sr_name')
var src = listing.data('src')
r.ajax({
type: 'POST',
url: '/api/recommend/feedback',
data: { type: 'clk',
srnames: sr_name,
src: src,
page: 'explore' }
})
}
})

View File

@@ -211,6 +211,13 @@ r.ui.Bubble = Backbone.View.extend({
top: r.utils.clamp(parentPos.top - offsetY, 0, $(window).height() - this.$el.outerHeight()),
left: r.utils.clamp(parentPos.left - offsetX - this.$el.width(), 0, $(window).width())
})
} else if (this.$el.is('.anchor-left')) {
offsetX = this.$parent.outerWidth(true) + 16
offsetY = 0
this.$el.css({
left: parentPos.left + offsetX,
top: parentPos.top + offsetY - bodyOffset.top
})
}
},
@@ -280,6 +287,9 @@ r.ui.Bubble = Backbone.View.extend({
} else if (this.$el.is('.anchor-right-fixed')) {
animProp = 'right'
animOffset = '-=5'
} else if (this.$el.is('.anchor-left')) {
animProp = 'left'
animOffset = '+=5'
}
var curOffset = this.$el.css(animProp)

View File

@@ -0,0 +1,57 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2013
## reddit Inc. All Rights Reserved.
###############################################################################
<%!
from r2.lib.pages import SubscribeButton
from r2.lib.filters import unsafe, safemarkdown
from r2.lib.strings import Score
%>
<div class="explore-item explore-${thing.type}" data-sr_name="${thing.sr.name}" data-src="${thing.src}">
<div class="explore-sr">
<span class="explore-label">
<span class="explore-label-type">${_(thing.type)}</span> in
<a href="/r/${thing.sr.name}" class="explore-label-link" target="_blank">
/r/${thing.sr.name}
</a>
</span>
<span class="explore-sr-details">
<span>${unsafe(Score.readers(thing.sr._ups))}</span>
</span>
<span class="explore-feedback">
${SubscribeButton(thing.sr, bubble_class="anchor-left explore-subscribe-bubble")}
<span class="explore-feedback-dismiss" title="${_('not interested')}">
${_("hide")}
</span>
</span>
</div>
${thing.link}
%if thing.comment:
<div class="comment">
${unsafe(safemarkdown(thing.comment.body))}
<div class="comment-fade"></div>
</div>
<a class="comment-link" href="${thing.link.make_permalink(thing.sr)}" target="_blank">
${_("more comments")}
</a>
%endif
</div>

View File

@@ -0,0 +1,58 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2013
## reddit Inc. All Rights Reserved.
###############################################################################
<%namespace file="utils.html" import="plain_link" />
<%
_id = ("_%s" % thing.parent_name) if hasattr(thing, 'parent_name') else ''
cls = "exploreitemlisting"
%>
<div id="siteTable${_id}" class="sitetable ${cls}">
%if thing.things:
<div class="explore-header">
<span class="explore-title">${_("Our robots thought you might like...")}</span>
<span class="explore-discuss-link">
<a href="/r/exploretalk">${_("feedback/suggestions")}</a>
</span>
</div>
%for a in thing.things:
${a}
%endfor
<div class="nav-buttons">
<span class="nextprev">${_("view more:")}&#32;
${plain_link(unsafe(_("reload suggestions") + " &rsaquo;"), "/explore", _sr_path=False, nocname=True)}
</span>
</div>
%else:
<div class="explore-header">
<span class="explore-title">
${_("Our robots have no suggestions at the moment.")}
</span>
</div>
<div class="nav-buttons">
<span class="nextprev">
${plain_link(unsafe(_("try again") + " &rsaquo;"), "/explore", _sr_path=False, nocname=True)}
</span>
</div>
%endif
</div>

View File

@@ -32,5 +32,5 @@ ${toggle_button(
alt_css_class="remove",
reverse=thing.sr.subscriber,
login_required=True,
data_attrs=dict(sr_name=thing.sr.name),
data_attrs=thing.data_attrs,
)}