From 87aa75df59df0101eb7f0fa7f680498f036ff825 Mon Sep 17 00:00:00 2001 From: shlurbee Date: Wed, 4 Dec 2013 11:29:11 -0800 Subject: [PATCH] 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. --- r2/example.ini | 4 +- r2/r2/config/routing.py | 3 + r2/r2/controllers/api.py | 11 ++ r2/r2/controllers/front.py | 11 ++ r2/r2/lib/app_globals.py | 1 + r2/r2/lib/pages/pages.py | 44 ++++- r2/r2/lib/recommender.py | 233 +++++++++++++++++++++--- r2/r2/lib/rising.py | 6 +- r2/r2/lib/utils/utils.py | 16 +- r2/r2/models/recommend.py | 129 +++++++++++++ r2/r2/public/static/css/reddit.less | 215 +++++++++++++++++++++- r2/r2/public/static/js/base.js | 1 + r2/r2/public/static/js/multi.js | 10 +- r2/r2/public/static/js/recommender.js | 45 ++++- r2/r2/public/static/js/ui.js | 10 + r2/r2/templates/exploreitem.html | 57 ++++++ r2/r2/templates/exploreitemlisting.html | 58 ++++++ r2/r2/templates/subscribebutton.html | 2 +- 18 files changed, 817 insertions(+), 39 deletions(-) create mode 100644 r2/r2/models/recommend.py create mode 100644 r2/r2/templates/exploreitem.html create mode 100644 r2/r2/templates/exploreitemlisting.html 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, )}