Skip to content
Browse files

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.
  • Loading branch information...
1 parent 1619fe1 commit 87aa75df59df0101eb7f0fa7f680498f036ff825 @shlurbee shlurbee committed
View
4 r2/example.ini
@@ -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
3 r2/r2/config/routing.py
@@ -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
11 r2/r2/controllers/api.py
@@ -78,6 +78,7 @@
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 @@ def GET_subreddit_recommendations(self, srs, to_omit):
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
11 r2/r2/controllers/front.py
@@ -33,6 +33,7 @@
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 @@ def GET_details(self, thing, oldid36, after, before, count):
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
1 r2/r2/lib/app_globals.py
@@ -256,6 +256,7 @@ class Globals(object):
ConfigValue.tuple: [
'fastlane_links',
'listing_chooser_sample_multis',
+ 'discovery_srs',
],
ConfigValue.str: [
'listing_chooser_gold_multi',
View
44 r2/r2/lib/pages/pages.py
@@ -4195,6 +4195,7 @@ def __init__(self):
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 @@ def build_toolbars(self):
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 @@ def __init__(self):
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
233 r2/r2/lib/recommender.py
@@ -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 @@ def for_srs(cls, srid36, to_omit, count=10, source=SRC_MULTIREDDITS):
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:
@@ -109,6 +283,20 @@ def for_srs(cls, srid36, to_omit, count=10, source=SRC_MULTIREDDITS):
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 @@ def _merge_and_sort_by_count(cls, rows):
"""
# 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
6 r2/r2/lib/rising.py
@@ -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
16 r2/r2/lib/utils/utils.py
@@ -25,6 +25,7 @@
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))
View
129 r2/r2/models/recommend.py
@@ -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
215 r2/r2/public/static/css/reddit.less
@@ -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
1 r2/r2/public/static/js/base.js
@@ -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
10 r2/r2/public/static/js/multi.js
@@ -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',
@dgj2ba
dgj2ba added a note

The removal of anchor-right is causing the selector to display at the bottom left of the multireddit page in multiple browsers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ 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
45 r2/r2/public/static/js/recommender.js
@@ -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
10 r2/r2/public/static/js/ui.js
@@ -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
57 r2/r2/templates/exploreitem.html
@@ -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
58 r2/r2/templates/exploreitemlisting.html
@@ -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
2 r2/r2/templates/subscribebutton.html
@@ -32,5 +32,5 @@
alt_css_class="remove",
reverse=thing.sr.subscriber,
login_required=True,
- data_attrs=dict(sr_name=thing.sr.name),
+ data_attrs=thing.data_attrs,
)}

0 comments on commit 87aa75d

Please sign in to comment.
Something went wrong with that request. Please try again.