diff --git a/r2/example.ini b/r2/example.ini index fffa39103..836274efd 100644 --- a/r2/example.ini +++ b/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 diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 428016bd8..00841d6bb 100644 --- a/r2/r2/config/routing.py +++ b/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') diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index f0aa15841..fbe7a7bc7 100755 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -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(), diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 3b6babe19..a00f5463f 100755 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -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): diff --git a/r2/r2/lib/app_globals.py b/r2/r2/lib/app_globals.py index 65f573215..6a3001ccd 100755 --- a/r2/r2/lib/app_globals.py +++ b/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', diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 4c7e2f409..a2a3b61e5 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -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) diff --git a/r2/r2/lib/recommender.py b/r2/r2/lib/recommender.py index f5b83801e..e9ad0785e 100644 --- a/r2/r2/lib/recommender.py +++ b/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 @@ 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 diff --git a/r2/r2/lib/rising.py b/r2/r2/lib/rising.py index 0e2dc9ade..cacc3036c 100644 --- a/r2/r2/lib/rising.py +++ b/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)] diff --git a/r2/r2/lib/utils/utils.py b/r2/r2/lib/utils/utils.py index e09f9e8ce..7c468bf3d 100644 --- a/r2/r2/lib/utils/utils.py +++ b/r2/r2/lib/utils/utils.py @@ -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)) diff --git a/r2/r2/models/recommend.py b/r2/r2/models/recommend.py new file mode 100644 index 000000000..d470c9239 --- /dev/null +++ b/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) diff --git a/r2/r2/public/static/css/reddit.less b/r2/r2/public/static/css/reddit.less index 0b767356c..45eabf58d 100755 --- a/r2/r2/public/static/css/reddit.less +++ b/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; + } } } } diff --git a/r2/r2/public/static/js/base.js b/r2/r2/public/static/js/base.js index 75329b9af..9b1426acf 100644 --- a/r2/r2/public/static/js/base.js +++ b/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) } diff --git a/r2/r2/public/static/js/multi.js b/r2/r2/public/static/js/multi.js index 198b40710..94a9eef3e 100644 --- a/r2/r2/public/static/js/multi.js +++ b/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', + className: 'multi-selector hover-bubble', template: _.template('
<%- title %>/r/<%- sr_name %>
'), itemTemplate: _.template(''), itemCreateTemplate: _.template(''), diff --git a/r2/r2/public/static/js/recommender.js b/r2/r2/public/static/js/recommender.js index 5ab59e21a..0d29a7602 100644 --- a/r2/r2/public/static/js/recommender.js +++ b/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' } + }) + } +}) diff --git a/r2/r2/public/static/js/ui.js b/r2/r2/public/static/js/ui.js index 6a7ac3a7e..7da73d0a6 100644 --- a/r2/r2/public/static/js/ui.js +++ b/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) diff --git a/r2/r2/templates/exploreitem.html b/r2/r2/templates/exploreitem.html new file mode 100644 index 000000000..48e584136 --- /dev/null +++ b/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 +%> + +
+
+ + ${_(thing.type)} in + + /r/${thing.sr.name} + + + + ${unsafe(Score.readers(thing.sr._ups))} + + + ${SubscribeButton(thing.sr, bubble_class="anchor-left explore-subscribe-bubble")} + + ${_("hide")} + + +
+ ${thing.link} + %if thing.comment: +
+ ${unsafe(safemarkdown(thing.comment.body))} +
+
+ + ${_("more comments")} + + %endif +
diff --git a/r2/r2/templates/exploreitemlisting.html b/r2/r2/templates/exploreitemlisting.html new file mode 100644 index 000000000..06ff3558f --- /dev/null +++ b/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" + %> +
+ + %if thing.things: +
+ ${_("Our robots thought you might like...")} + + ${_("feedback/suggestions")} + +
+ + %for a in thing.things: + ${a} + %endfor + + %else: +
+ + ${_("Our robots have no suggestions at the moment.")} + +
+ + %endif +
diff --git a/r2/r2/templates/subscribebutton.html b/r2/r2/templates/subscribebutton.html index a98b400c2..4baa6cc26 100644 --- a/r2/r2/templates/subscribebutton.html +++ b/r2/r2/templates/subscribebutton.html @@ -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, )}