Social
The Feed

The Feed

The Superhero home feed at https://superhero.com/ has two modes: a ranked popular feed and a chronological latest feed. Both are infinite, both blend on-chain posts with token activity, and the popular ranking is tunable per reader.

Choosing a mode

ModeURLQuery
Popular (default)/?sortBy=hotGET /api/posts/popular
Latest/?sortBy=latestGET /api/posts ordered by created_at descending

The sort key lives in the URL, so a feed state is linkable. The popular mode is behind the POPULAR_FEED_ENABLED configuration flag; when it is off the app forces latest and hides the popular option.

A separate filterBy query parameter filters the assembled list client-side:

ValueEffect
allEverything (default)
withMediaOnly items carrying media
withCommentsOnly items with at least one comment

What is eligible for popular ranking

The candidate set is posts that are:

  • not hidden: is_hidden = false. The indexer sets is_hidden when any entry in the post's media list contains the substring hidden;
  • top-level: post_id IS NULL, so comments are never ranked as standalone items;
  • within the newest 10,000 posts by created_at.

Popularity is computed inside that rolling pool, not across every post ever written.

Governance polls also compete, through a plugin interface: a contributor supplies content items whose ids contain a colon (poll:123), scored with votes_count standing in for comments and zero for tips and reads. A popular page can therefore return mixed item types.

The scoring formula

Ranking is all-time. Every candidate gets a base score from log-dampened engagement signals:

baseScore =
    w_comments            * ln(1 + comments)
  + w_tipsAmountAE        * ln(1 + tipsAmountAE)
  + w_tipsCount           * ln(1 + tipsCount)
  + w_uniqueTippers       * ln(1 + uniqueTippers)
  + w_reads               * ln(1 + effectiveReads)
  + w_trendingBoost       * trendingBoost
  + w_contentQuality      * contentQuality
  + w_freshnessBoost      * freshnessFactor
  + w_velocityBoost       * ln(1 + interactionsPerHour) * freshnessFactor
  + w_interactionsPerHour * ln(1 + interactionsPerHour)
  + tieRotation

then a daily shuffle, then age gravity, then a staleness penalty:

score = (baseScore * dailyShuffle) / (ageHours + 2) ^ 0.25  -  stalePenalty

ageHours is floored at 1. The + 2 in the divisor stops a brand-new post being divided by something near zero. The penalty is subtracted after gravity so that an older dead post does not outrank a younger dead one.

The weights

SignalQuery parameterDefault weight
Commentscomments2.5
Total tipped AEtipsAmountAE1.5
Number of tipstipsCount1.0
Distinct tippersuniqueTippers1.5
Trending-topic boosttrendingBoost0.5
Content qualitycontentQuality0.3
Readsreads1.5
Engagement velocityinteractionsPerHour0.6
Freshness liftnot scalable1.5
Freshness-gated velocity amplifiernot scalable0.6

Eight of the ten are reader-scalable. Each accepts low, med or high, which multiply that weight by 0.6, 1.0 and 1.5 respectively. med is the default and the client omits it, so a request carries only the signals you changed. In the app these are the Customize controls next to the sort selector. Because the multipliers scale the weights, high on every signal leaves the order unchanged; scaling only matters relative to other signals.

Where each input comes from

InputComputed from
commentsThe whole reply thread, counted recursively to a depth cap of 50, excluding replies written by the root post's own author. A hidden comment prunes its entire subtree.
tipsAmountAESUM(amount) over the post's tips, excluding tips from the post's author
tipsCountCOUNT(*) over the same set
uniqueTippersCOUNT(DISTINCT sender_address) over the same set
readsSUM(reads) from the daily read counts (see below)
trendingBoostmin(1, highestTrendingTagScore / 100) across the post's topics. The single strongest topic, not the sum.
contentQualityA heuristic over the post text (see below)
interactionsPerHourinteractionsInLast48h / max(1, min(ageHours, 48)), where interactions are comments plus tips

Velocity counts recent activity over a 48-hour window rather than the post's lifetime, so an old post that catches fire today registers full velocity.

Reads

A read is recorded by GET /api/posts/{id}, the single-post fetch, and is deduplicated and capped twice:

  • At recording time, a viewer is identified by a SHA-256 hash of client IP plus User-Agent, truncated to 16 hex characters, and a Redis key reads:seen:<date>:<postId>:<viewer> allows one counted read per viewer, per post, per UTC day. Requests whose User-Agent contains bot, spider, crawler, preview, uptime, monitor or curl are ignored, as is a request with no User-Agent. If Redis is unreachable the read counts.
  • At scoring time, the counted volume is clamped to 100 × (1 + comments + tipsCount + uniqueTippers) before the logarithm, so passive traffic alone cannot outvote real engagement.

Reads are stored per (post_id, date); there is no per-account read history.

Content quality

A pure function of the post text, in the range 0 to 1, with no author or account input:

len          = the post's length in Unicode code points
lengthScore  = clamp((len - 10) / 140, 0, 1)
emojiRatio   = min(1, emojiCount / max(1, len))       // \p{Extended_Pictographic}
alnumRatio   = min(1, alnumCount / max(1, len))       // \p{L} or \p{N}, any script
quality      = 0.6 * lengthScore + 0.2 * alnumRatio + 0.2 * (1 - emojiRatio)

if len < 10 and emojiRatio > 0.5:  quality = quality * 0.25

Length is counted in code points so emoji (surrogate pairs) are not double-counted, and the alphanumeric class covers every script, so a wholly Chinese, Arabic or Cyrillic post is not penalised against a Latin one. At weight 0.3 the term is an anti-spam tiebreaker, not a quality ranking.

Freshness, staleness and the two shuffles

TermRule
freshnessFactor1 for the first 120 hours (5 days), then a linear fade to 0 at 240 hours (10 days)
stalePenalty0 until 240 hours; then ramps linearly over a further 240 hours to a maximum of 5, only if the post has had no comment or tip in the last 48 hours. Any recent interaction zeroes it. Plugin items without interaction timestamps are never penalised.
tieRotationA deterministic value in [0, 0.05) from an FNV-1a hash of postId + hourBucket, added before gravity. It reorders near-tied posts every hour without touching genuinely different scores.
dailyShuffleA deterministic multiplier in [0.8, 1.2] from a hash of postId + UTC day. Applied only to the default feed. Any weight override switches it off so personalized queries stay deterministic.

After scoring, each page is reranked for author diversity: candidates are oversampled by 4x (minimum 80), one item per distinct author is taken first, and duplicates backfill only when there are not enough unique authors to fill the page.

The app then applies a second, client-side Fisher-Yates shuffle to each popular page, seeded per session, so the order is stable while you scroll but different on your next visit.

Caching

The default (unpersonalized) ranking lives in the Redis sorted set popular:all, refreshed by a cron every 10 minutes behind a SET NX lock, with a 1,800-second TTL. A cold key triggers a lazy recompute while the request in flight falls back to recent posts. Any weight override bypasses the cache and scores the candidate pool live, so a customized feed is slower than the default one.

If ranking fails outright, the endpoint returns recent posts with meta.fallback: true and a meta.error string rather than a 500.

Request shapes

GET /api/posts/popular?page=1&limit=10&tipsCount=high&reads=low
GET /api/posts?order_by=created_at&order_direction=DESC&limit=10&page=1

limit on the popular endpoint is 1 to 100, default 50; the web client asks for 10. debug=1 appends a debug array to the response, one entry per returned item:

{ "id": "3903_v3", "tx": "th_...", "author": "ak_...", "personalized": true,
  "appliedWeights": { "comments": 2.5, "reads": 2.25, "...": 0 } }

appliedWeights is the resolved weight map after your scale multipliers. It is the quickest way to check that an override landed.

GET /api/posts accepts more filters than the home feed uses:

ParameterValues
order_bytotal_comments or created_at
order_directionASC or DESC
searchFree-text over content and topics, at most 100 characters
account_addressRestrict to one author; this is what a profile feed uses
topicsComma-separated topic names, partial matching
limit, pagePagination

Both endpoints return the same envelope: an items array and a meta block with currentPage, totalPages, totalItems and itemCount. Paging is "fetch page N+1 while currentPage is below totalPages".

Each post item carries id, tx_hash, tx_args, sender_address, an embedded sender profile, contract_address, type, content, trend_mentions, media, total_comments and created_at, plus a slug used for SEO links.

What else is in the feed

In latest mode the app runs a second infinite query over GET /api/tokens ordered by created_at, maps each new token sale and trade into a feed item, and merges the two lists, so a brand-new community token shows up in the feed without anyone posting about it.

The API's token socket emits two channels, token-updated and token-history; see WebSocket API. Posts are not pushed over a socket: the feed refetches on window focus and treats data as fresh for 10 seconds. Your own new post is the exception: the composer inserts it optimistically before the indexer has seen it; see Posting.

When popular runs out

The popular feed ranks at most 10,000 candidates. When it is exhausted, or when the first page comes back with fewer than 10 items, the app appends latest posts below, de-duplicated against what popular already returned.

Topic feeds

A hashtag has its own feed. GET /api/topics/name/{name} returns the topic's posts and a post_count, and token pages render it as the token's discussion tab. That tab defaults to posts by token holders only, and falls back to everyone when the holders-only filter would leave it empty. The composer on a topic feed requires the topic's hashtag before the Post button enables.

See Hashtags & Topics for how a tag becomes a topic.

Related

  • Posting: how an item gets into the feed.
  • Tipping: three of the eight scalable ranking signals come from tips.
  • Comments: the thread count that carries the heaviest weight.
  • Hashtags & Topics: topic feeds and where trendingBoost comes from.
  • Posts API: the endpoint-level reference.
  • WebSocket API: the channels behind the live activity items.