News feed of links shared across the fediverse#310
Conversation
Introduce the storage for the News feature's popularity scoring: seven columns on `post_link` (score, weighted_mass, recency_component, post_count, first_shared_at, latest_activity_at, score_updated) populated later by an idempotent batch job, plus three partial indexes for the feed sorts (popular/newest/all-time). Each index carries an `id DESC` tiebreaker so it fully covers the `(sortKey, id)` keyset pagination order, and all three share the partial predicate `latest_activity_at IS NOT NULL` (the canonical "has a public share" flag) so scraped-but-never-publicly-shared links stay out of every feed query. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Implement the idempotent batch that ranks links shared across the fediverse for the News feature. `recomputeNewsScores` computes, per link, a Reddit/Hacker-News-style additive-log score from its public sharing posts: a weighted engagement mass (quotes > replies > reactions, scaled by source weight — local > generic remote > Bluesky bridge — and the sharer's follower/following reputation) plus a recency term anchored to a fixed epoch. Anchoring to a constant (not `now()`) makes the score a pure function of the underlying posts and engagement, so it is idempotent and never goes stale as the clock advances. The recompute runs as one set-based SQL aggregation and supports three scopes: a full rebuild, an explicit `linkIds` set (for the incremental single-link refresh on write), and `activeSince` (for the periodic sweep, which derives its target set from source timestamps so a fresh reaction on an older story is still picked up). `getNewsStories` paginates the feed by keyset over the partial indexes, and `getNewsScoreStatus` backs the admin page. Adds test fixtures (insertPostLink, insertReaction, seedInstance, link and count options) and a TDD suite that cross-checks the SQL against an independent re-implementation of the formula. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Wire the post-persist paths to re-score a link the moment it is shared (or unshared), so a new story appears in the news feed without waiting for the periodic sweep. `syncPostFromNoteSource` (local notes) and `persistPost` (federated posts, top level only) now call `refreshNewsScores(db, [post.linkId, existingPost?.linkId])` — the new link plus the previous one, so an edit that removes a link drops the old story out. `refreshNewsScores` runs the recompute in its own savepoint and swallows (logs) any failure, so a scoring error can never poison the caller's transaction or block the post from being saved. Engagement-driven re-ranking (new replies, quotes, reactions on a story) is intentionally left to the periodic sweep, which derives its target set from source timestamps and therefore reads correct, settled counts. Also fixes a latent bug surfaced by this change: a removed link in a local note left `link_id` set (drizzle drops `undefined` from the update set), which would have kept a dropped story in the feed; the no-link case now uses explicit `null`, matching `persistPost`. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Add the read side of the News feature. A new root `newsStories` connection ranks links shared across the fediverse, with a `NewsOrder` argument (POPULAR by default, plus NEWEST and ALL_TIME). Pagination is forward-only keyset over the partial feed indexes; `first_shared_at` is truncated to milliseconds on recompute so the JS-Date NEWEST cursor cannot skip sub-millisecond-close links. The existing `PostLink` node gains the scoring fields (`score`, `weightedMass`, `postCount`, `firstSharedAt`, `latestActivityAt`), a `sharingPosts` connection (the visibility-filtered roots of the link's discussion), and a batched `sourceBreakdown` (local / remote / Bluesky- bridge share counts). The Bluesky-bridge classification is factored into one boolean-total SQL condition reused by the recompute and the breakdown, so a NULL-software remote instance is counted as remote in both places rather than dropped. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Give moderators a way to rebuild news scores and watch their state, and keep engagement-driven rankings fresh automatically. `recomputeNewsScores` is a moderator-only mutation that runs a full, idempotent rebuild (the authoritative reconciler and dev backstop); `newsScoreStatus` exposes the scored-link count and last-recompute time, returning null for non-moderators. A `Deno.cron` in the GraphQL server entry point sweeps recently-active links every five minutes so a new reply, quote, or reaction on an existing story re-ranks it without a manual trigger. The sweep's active set now also keys off `post.updated`, so a federated Update that revises a remote share's engagement totals is picked up even when it creates no local engagement row. Also fixes `getNewsScoreStatus`: it read the last-recompute time via `max()`, which handed back a raw Postgres timestamp string the GraphQL `DateTime` scalar rejected; it now reads the column directly so drizzle maps it to a `Date`. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Surface the News experience in web-next. A new `/news` route lists the
links circulating across the fediverse, ranked by the popularity score,
with Popular / Newest / All-time sort pills driven by `?sort=`. Each
story is a border-b row showing the link (opening in a new tab), its
host, the share count and first-shared time, a "Discussion" link to the
per-link permalink, and a "Share this link" button that opens the
composer pre-filled with the URL (so the published note shares the same
link and is attributed back to the story).
Plumbing this needed: the logged-out `/` redirect now goes to `/news`; a
"News" item sits at the top of the sidebar's Timeline group for everyone;
and the note composer gained an `openWithContent` path so a fresh compose
can be pre-filled (the reply-target effect no longer wipes a deliberate
pre-fill). On the GraphQL side, `PostLink` gains a `uuid` for the
`/news/{uuid}` permalink and a `newsStory(id:)` lookup backs the
discussion route that follows.
Translations for the new strings are filled for ja, ko, zh-CN, and zh-TW.
Assisted-by: Claude Code:claude-opus-4-8
Add the stable discussion permalink `/news/{uuid}`. The page header is
the link itself (image, title opening in a new tab, host, description,
share count, first-shared time, and a local/remote/Bluesky source
breakdown), with a "Share this link" button. Below it the federated
conversation renders as a tree: each public post that shares the link is
a root, and its replies and quotes nest beneath it.
The tree auto-expands a few levels deep and loads deeper threads only on
request, so a busy discussion does not fetch everything up front. Every
post is anchored as `#post-<uuid>`; opening such a deep link expands the
thread down to that post and scrolls it into view. Each post carries the
usual inline reply / quote / share / react controls.
Posts are de-duplicated discussion-wide (a post can be both a sharing
post and a reply elsewhere), ancestor cycles are broken, and the async
child loads are owner-scoped and disposed cleanly. Translations for the
new strings are filled for ja, ko, zh-CN, and zh-TW.
Assisted-by: Claude Code:claude-opus-4-8
Assisted-by: Codex:gpt-5.5
Add /admin/news, a moderator-only page that shows how many links are in the news feed and when scores were last recomputed, with a button that triggers a full, idempotent rebuild via the recomputeNewsScores mutation. Scores stay fresh on their own (incrementally on share plus the periodic sweep), so this is mainly a manual backstop and a development aid. A "News scores" entry is added to the Admin sidebar group, and the page redirects non-moderators away. Translations for the new strings are filled for ja, ko, zh-CN, and zh-TW. Assisted-by: Claude Code:claude-opus-4-8
Two correctness fixes found reviewing the whole feature. Keep private discussion out of public scores: the weighted mass used the denormalized `replies_count`/`quotes_count`, which include followers-only and direct posts, so a public link's score (and `weightedMass`) could be driven by, and leak, non-public discussion volume. The mass now counts only `public`/`unlisted` replies and quotes, matching the rule already used for `latestActivityAt`. Reactions keep the denormalized count (reactions have no visibility). Refresh scores on destructive paths: the periodic sweep derives its work from surviving source rows, so it can't see a deletion or a reaction undo. Post delete (local cascade and federated), quote revoke, and reaction undo (local and federated) now re-score the affected links, so a deleted share drops out of the feed and a removed reply/quote/reaction no longer leaves a link's mass inflated until a manual recompute. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Links shared only by bot accounts (fediverse actors whose type is `Service` or `Application`, e.g. automated link feeds) no longer surface as news. The exclusion is applied wherever a "sharing post" or its source is defined: the `shares` CTE in `recomputeAggregate`, the sweep's `activeLinkIdsSubquery`, the `zeroStaleLinks` qualifying-share check, `getNewsSourceBreakdowns`, and the GraphQL `sharingPosts` discussion roots. `Person`, `Group`, and `Organization` actors stay eligible. Only the sharing post's author type is filtered; replies, quotes, and reactions still count regardless of who made them. A single `nonBotSharerCondition` SQL fragment and the exported `NEWS_BOT_ACTOR_TYPES` constant keep the SQL and the Relay filter in lockstep. When a federated actor crosses the bot/non-bot boundary (e.g. a Mastodon user toggles the bot flag, changing its type between `Person` and `Service`), `persistActor` now re-scores the links that actor shares via `refreshNewsScoresForActor`, since the periodic sweep cannot detect such a transition on its own. Already-scored bot-only links from before this change are cleared by a full recompute (the moderator recompute button / `recomputeNewsScores` mutation with no `activeSince`), which scans every scored link. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
When the same account re-shared the same link, each share previously added another full base contribution to the link's weighted mass and refreshed its freshness timestamp, letting an account inflate or pin a link by re-posting it. A sharing post now carries the gap since that account's previous share of the same link, via a window over `(actor_id, link_id)` ordered by `published`. The base `W_SHARE` term of a repeat is scaled by `NEWS_REPEAT_CAP * (1 - exp(-gap / NEWS_REPEAT_RECOVERY_TAU_SECONDS))`, so a rapid repeat adds almost nothing and a long-gap re-share recovers toward the cap but never reaches a first share's weight. The first share of each account keeps full weight, and different accounts are independent. The discount also covers recency: a repeat refreshes the link's freshness only if it is a first share or its gap is at least `NEWS_REPEAT_FRESH_MIN_SECONDS`, so rapid re-sharing cannot keep a link pinned at the top. Replies, quotes, and reactions are never discounted and still refresh freshness, so genuine engagement on a repeat post still counts. `postCount` still counts every share. The new factors depend only on fixed `published` timestamps, so the recompute stays idempotent and time-stable. Assisted-by: Claude Code:claude-opus-4-8
Adds a `discussionCount` field to the `PostLink` GraphQL type: the size of a link's federated discussion, namely its non-bot public sharing posts plus their direct public replies and quotes, deduplicated. Unlike `postCount` (sharing posts only) this reflects the whole conversation, so the feed can invite readers in with a comment-style count rather than a bare "N shares". `getNewsDiscussionCounts` (models/news.ts) is a batched query patterned on `getNewsSourceBreakdowns`: a `shares` CTE of the non-bot public sharing posts, then a `union` of `(link_id, post_id)` pairs over those shares and their direct public replies and quotes, counted per link. `union` (not `union all`) deduplicates, so a post that is both a reply and a quote of the link's shares is counted once, matching the deduplicated discussion tree. Exposed as a batched `t.loadable` Int field, mirroring `sourceBreakdown`. Counts direct children only (deeper nesting is not traversed) and is viewer-independent (public posts only). Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Reworks each News feed card to be compact and discussion-led so more
stories fit per screen and the conversation is the invitation. Layout:
- A prominent left column shows a chat icon over the link's
`discussionCount` and is itself the link into the discussion at
`/news/{uuid}` (merging the old "Discussion" button and the count, with
an "{n} opinions" accessible label), instead of a separate "N shares"
stat and button.
- The middle column keeps the external title link, adds the link
description as a teaser (when present), and shows "Last active" (the
freshest share/reply/quote/reaction) rather than the first-shared time.
- A small thumbnail sits on the right (decorative, mouse-only; the title
link already opens the same URL), hidden on mobile.
The discussion-page header gains the same opinions count and last-active
time alongside first-shared and the source breakdown. Styling stays
black-and-white and uses the post-card row idiom; the count is a quiet
achromatic figure (the raw recency-dominated score is not human-facing).
New strings ("# opinion/# opinions", "Last active") are translated for
ja-JP, ko-KR, zh-CN, and zh-TW.
Assisted-by: Claude Code:claude-opus-4-8
Assisted-by: Codex:gpt-5.5
Puts the short-form note composer at the top of `/news/[link_id]` so a reader can post an opinion in place, Reddit/HN style, instead of reaching for the floating composer or the share modal. A new `NewsDiscussionComposer` reuses `NoteComposer` (Markdown editor with its edit/preview tabs and the usual controls). When signed in it renders the form; otherwise a "Sign in to post" prompt linking to `/sign?next=…`. On submit the discussed link's URL is appended to the bottom of the content unless the author already included it, so the note becomes a public sharing post (a new root) of this link's discussion. The append-if-absent rule is a small pure helper, `ensureLinkInContent`, with unit tests; `NoteComposer` gained an optional `ensureLinkUrl` prop that applies it to new notes only (edits/quotes/replies are unaffected). `NewsDiscussion` exposes the link `url` on its fragment, renders the composer above the roots list, and on a successful post refetches the sharing-posts connection with `network-only` so the new opinion appears immediately (an unchanged-variables refetch would otherwise resolve from the store). New strings are translated for ja-JP, ko-KR, zh-CN, zh-TW. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Adds the persisted state for two upcoming moderation tools: - `post_link.score_penalty` (double, default 0): a moderator penalty subtracted from a link's `score` to demote it in the feed; persisted so it survives recomputes. - `post_link.excluded_from_news` (boolean, default false): set when the link's URL matches a moderator pattern, to hide it from the feed list. - `news_excluded_pattern`: moderator-managed URLPattern strings (with an optional note and creator) that drive the exclusion flag. Additive migration (NOT NULL columns have defaults), no behavior change yet; the model and GraphQL layers follow. Assisted-by: Claude Code:claude-opus-4-8
Implements the moderation logic over the columns/table added previously: - Score penalty: `recomputeAggregate` now subtracts the persisted `pl.score_penalty` from a link's `score` (the POPULAR order), so a penalty survives every recompute. `setNewsScorePenalty` updates the column and recomputes the link in one transaction; it rejects negative or non-finite penalties (which would boost or poison ranking). Presets: `NEWS_PENALTY_DEMOTE` (push well down) and `NEWS_PENALTY_BURY` (sink to the bottom). `getNewsPenalizedStories` lists penalized links for review. - URL exclusions: `applyNewsExclusions` matches link URLs against the stored patterns with the Web-standard `URLPattern` (invalid patterns skipped + logged) and sets `excluded_from_news` in one no-op-skipping update; it runs inside every recompute for the scoped links so new links get flagged. `addNewsExcludedPattern` (validates the pattern, idempotent) / `removeNewsExcludedPattern` / `getNewsExcludedPatterns` manage the set, each re-applying exclusions in a transaction. `getNewsStories` now also filters `excluded_from_news = false` in every sort order; the per-link discussion page is unaffected. Tests cover penalty demotion/clear, exclusion across all orders + restore, exclusion of newly-shared matching links, invalid-pattern rejection, and the penalized-links list. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Adds the moderator-only GraphQL surface for the penalty + exclusion model functions: - `NewsPenalty` enum (`NONE`/`DEMOTE`/`BURY`) and `PostLink.penalty` (moderator-only; `null` for everyone else) derived from the stored `scorePenalty`. - `setNewsScorePenalty(id, penalty)` mutation returning the updated `PostLink`, and `newsPenalizedStories` listing penalized links for review. - `addNewsExcludedPattern(pattern, note)` (maps an invalid `URLPattern` to `InvalidInputError`), `removeNewsExcludedPattern(id)`, and `newsExcludedPatterns` (the `NewsExcludedPattern` list). All guard `ctx.session`/`ctx.account.moderator` like the existing `recomputeNewsScores`/`newsScoreStatus`, returning `null` (queries/fields) or `NotAuthenticatedError`/`NotAuthorizedError` (mutations). Tests cover the auth matrix, demotion reordering the popular feed, and a pattern hiding then restoring a link; schema regenerated. Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
Renames the "News scores" admin page to "News" and turns it into the moderation hub, and adds an inline penalty control to the feed. Admin `/admin/news` gains two sections beside the existing recompute: - Excluded URL patterns: an add form (a URLPattern field + optional note) and a list with Remove buttons, wired to add/removeNewsExcludedPattern. - Penalized links: lists `newsPenalizedStories` with a Clear button each (a buried link leaves the feed top, so this is where it is reviewed). The feed (`/news`) now threads `viewer.moderator` through `NewsList` into each `NewsStoryCard`; for moderators only, a "Moderate" dropdown offers Demote / Bury / Clear via `setNewsScorePenalty`, shows the current state, and refetches the connection (`network-only`) so the reordering shows. Non-moderators see nothing new. The sidebar label is updated, and 26 new strings are translated for ja-JP, ko-KR, zh-CN, and zh-TW (using the `moderator` glossary term). Assisted-by: Claude Code:claude-opus-4-8 Assisted-by: Codex:gpt-5.5
|
@codex review |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds a ranked News feed: DB migrations and indexes, scoring engine with incremental/periodic recompute, GraphQL API and moderator controls, model refresh hooks, frontend feed/discussion/admin UI, and comprehensive tests. ChangesNews Feed: Backend & Frontend
Sequence Diagram(s)sequenceDiagram
participant Client
participant GraphQLServer
participant NewsResolver
participant ModelsNews as models/news.ts
participant Database
Client->>GraphQLServer: Query newsStories(first, after, order)
GraphQLServer->>NewsResolver: resolve newsStories -> getNewsStories(options)
NewsResolver->>ModelsNews: call getNewsStories(db, options)
ModelsNews->>Database: SELECT post_link WHERE latest_activity_at IS NOT NULL ORDER BY score/ts LIMIT
Database-->>ModelsNews: rows
ModelsNews-->>GraphQLServer: PostLink nodes with loaders (sourceBreakdown, discussionCount)
GraphQLServer-->>Client: paginated newsStories response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Suggested labels
Suggested reviewers
|
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive 'News' feature, introducing a ranked feed of shared links across the fediverse. It includes database schema updates, core scoring models (with recency damping and bot exclusions), GraphQL queries/mutations for fetching and moderating stories (penalties and URL pattern exclusions), a periodic cron sweep, and a SolidJS frontend with discussion threads and moderator admin tools. The feedback highlights a critical issue in deletePost where parent post links are not re-scored during bulk deletions, leading to stale discussion counts. Additionally, minor import ordering violations against the repository style guide were identified in graphql/news.ts and graphql/main.ts.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9eaa9e589d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
models/post.ts (1)
581-590:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftRefresh the parent story link for local replies and quotes too.
This only rescored the note's own current/previous
linkId. A public reply or quote changes the parent sharing post's score immediately, and an edit can change both the old and new parent story. Please include the parent links fromreplyTargetId/quotedPostIdhere as well, or reuserefreshNewsScoresForPostLinksfor both the new and previous post state.Also applies to: 751-753
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@models/post.ts` around lines 581 - 590, The query that loads existingPost via db.query.postTable.findFirst only gathers the note's own linkId and misses parent story links for replies/quotes; update the logic that prepares links for rescoring to also include the parent linking fields from replyTargetId and quotedPostId (both current and previous states) or simply call refreshNewsScoresForPostLinks for the new and previous post objects so parent story linkIds are refreshed as well; locate the code using existingPost (from db.query.postTable.findFirst) and ensure it collects replyTargetId/quotedPostId and feeds those parent linkIds into the same rescoring path (or reuse refreshNewsScoresForPostLinks) for both old and new states.
🧹 Nitpick comments (2)
graphql/news.test.ts (1)
1-18: 💤 Low valueConsider grouping external imports before internal imports.
The imports mix external libraries (
@std,drizzle-orm,graphql) with internal modules (@hackerspub/models/*). Per coding guidelines, external imports should come first, followed by internal imports (alphabetically within groups).Suggested order:
import { assert } from "`@std/assert/assert`"; import { assertEquals } from "`@std/assert/equals`"; -import { recomputeNewsScores } from "`@hackerspub/models/news`"; -import { accountTable } from "`@hackerspub/models/schema`"; import { eq } from "drizzle-orm"; import { execute, parse } from "graphql"; + +import { recomputeNewsScores } from "`@hackerspub/models/news`"; +import { accountTable } from "`@hackerspub/models/schema`"; import type { Transaction } from "`@hackerspub/models/db`"; import { schema } from "./mod.ts";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@graphql/news.test.ts` around lines 1 - 18, Reorder the import statements in graphql/news.test.ts so external packages come first and internal project modules after: group imports from "`@std/assert/assert`", "`@std/assert/equals`", "drizzle-orm", and "graphql" before any "`@hackerspub/`..." imports; within each group sort alphabetically (e.g., "`@std/`*" then "drizzle-orm" then "graphql"), and then list internal imports like "`@hackerspub/models/news`", "`@hackerspub/models/schema`", "`@hackerspub/models/db`", and the test helpers from "../test/postgres.ts" alphabetically so identifiers like recomputeNewsScores, accountTable, eq, execute/parse, Transaction, schema, and insertAccountWithActor/insertNotePost/etc. are grouped per the guideline.graphql/news.ts (1)
176-192: ⚡ Quick winDescribe the generated news connection and result types.
These additions publish new schema types like
PostLinkSharingPostsConnection,QueryNewsStoriesConnection, and the news mutation result unions, but they still land in the emitted schema without descriptions. Please add descriptions for those generated public types too, otherwise this new API surface is only partially documented.As per coding guidelines
graphql/**/*.ts: Every element in the GraphQL schema (types, interfaces, unions, enums, enum values, fields, arguments, and mutations) must have adescription.Also applies to: 293-344, 425-630
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@graphql/news.ts` around lines 176 - 192, The new GraphQL connection and result types (e.g., PostLinkSharingPostsConnection, QueryNewsStoriesConnection, and the news mutation result unions) lack descriptions and must be documented; locate where those types are declared/generated (look for the relatedConnection call creating sharingPosts and nearby code that defines the news queries/mutations and their result unions) and add descriptive description strings to each generated public type, union, and any connection/result types emitted by the new code (including enum values, fields, and arguments mentioned in the additions around lines 293-344 and 425-630) to satisfy the graphql/**/*.ts schema-description guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@graphql/news.ts`:
- Around line 346-364: The newsStory resolver currently returns any post_link
row by UUID (newsStory, ctx.db.query.postLinkTable.findFirst) which can leak
non-public link metadata; after validateUuid(args.id) update the database query
to require that the found post_link has an associated qualifying public share
(the same “qualifying public share exists” predicate used by your story-scoring
logic) before returning it — e.g., replace the simple where: { id: args.id }
with a compound where that enforces id: args.id AND an exists/join condition for
a related post/share record matching the scoring predicate (reuse the existing
predicate function or expression used for scoring so both behaviors stay in
sync).
In `@models/post.ts`:
- Around line 975-982: The federated create/update path currently only rescoring
the post's own linkId and misses refreshing the parent story's link when the
incoming remote post is a public reply or quote; update the handling around
db.query.postTable.findFirst / existingPost to detect when existingPost.linkId
changes or when existingPost.quotedPostId/linkId refers to a parent story and
call the same link-rescore/refresh routine for that parent story (and similarly
patch the same logic in the other block around lines referenced 1304-1308) so
the parent story's link score is updated immediately on federated create/update.
- Around line 1957-1965: When deleting a post that is a quote, we also need to
decrement the quoted post's denormalized quotesCount; add a branch similar to
the reply handling that checks deletedPost.quotedPostId, loads the quoted post
via db.query.postTable.findFirst({ where: { id: deletedPost.quotedPostId } }),
and then decrement its quotes count (e.g., call a new or existing helper like
updateQuotesCount(db, quotedPost, -1) or perform a db.update to decrement
quotesCount) before calling refreshNewsScoresForPostLinks so the quoted post's
visible quote count stays in sync.
---
Outside diff comments:
In `@models/post.ts`:
- Around line 581-590: The query that loads existingPost via
db.query.postTable.findFirst only gathers the note's own linkId and misses
parent story links for replies/quotes; update the logic that prepares links for
rescoring to also include the parent linking fields from replyTargetId and
quotedPostId (both current and previous states) or simply call
refreshNewsScoresForPostLinks for the new and previous post objects so parent
story linkIds are refreshed as well; locate the code using existingPost (from
db.query.postTable.findFirst) and ensure it collects replyTargetId/quotedPostId
and feeds those parent linkIds into the same rescoring path (or reuse
refreshNewsScoresForPostLinks) for both old and new states.
---
Nitpick comments:
In `@graphql/news.test.ts`:
- Around line 1-18: Reorder the import statements in graphql/news.test.ts so
external packages come first and internal project modules after: group imports
from "`@std/assert/assert`", "`@std/assert/equals`", "drizzle-orm", and "graphql"
before any "`@hackerspub/`..." imports; within each group sort alphabetically
(e.g., "`@std/`*" then "drizzle-orm" then "graphql"), and then list internal
imports like "`@hackerspub/models/news`", "`@hackerspub/models/schema`",
"`@hackerspub/models/db`", and the test helpers from "../test/postgres.ts"
alphabetically so identifiers like recomputeNewsScores, accountTable, eq,
execute/parse, Transaction, schema, and
insertAccountWithActor/insertNotePost/etc. are grouped per the guideline.
In `@graphql/news.ts`:
- Around line 176-192: The new GraphQL connection and result types (e.g.,
PostLinkSharingPostsConnection, QueryNewsStoriesConnection, and the news
mutation result unions) lack descriptions and must be documented; locate where
those types are declared/generated (look for the relatedConnection call creating
sharingPosts and nearby code that defines the news queries/mutations and their
result unions) and add descriptive description strings to each generated public
type, union, and any connection/result types emitted by the new code (including
enum values, fields, and arguments mentioned in the additions around lines
293-344 and 425-630) to satisfy the graphql/**/*.ts schema-description
guideline.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f0d282ba-b339-404b-8a1f-9795ffdff4f7
📒 Files selected for processing (41)
drizzle/20260530050716_overrated_captain_cross/migration.sqldrizzle/20260530050716_overrated_captain_cross/snapshot.jsondrizzle/20260530145328_pretty_blacklash/migration.sqldrizzle/20260530145328_pretty_blacklash/snapshot.jsonfederation/inbox/subscribe.tsgraphql/main.tsgraphql/mod.tsgraphql/news.test.tsgraphql/news.tsgraphql/post.tsgraphql/schema.graphqlmodels/actor.tsmodels/deno.jsonmodels/news.test.tsmodels/news.tsmodels/post.tsmodels/reaction.tsmodels/schema.tstest/postgres.tsweb-next/src/components/AppSidebar.tsxweb-next/src/components/NewsDiscussion.tsxweb-next/src/components/NewsDiscussionComposer.tsxweb-next/src/components/NewsDiscussionThread.tsxweb-next/src/components/NewsList.tsxweb-next/src/components/NewsStoryCard.tsxweb-next/src/components/NewsStoryHeader.tsxweb-next/src/components/NoteComposeModal.tsxweb-next/src/components/NoteComposer.tsxweb-next/src/contexts/NoteComposeContext.tsxweb-next/src/lib/composerLink.test.tsweb-next/src/lib/composerLink.tsweb-next/src/lib/useNewsSort.tsweb-next/src/locales/en-US/messages.poweb-next/src/locales/ja-JP/messages.poweb-next/src/locales/ko-KR/messages.poweb-next/src/locales/zh-CN/messages.poweb-next/src/locales/zh-TW/messages.poweb-next/src/routes/(root)/admin/news.tsxweb-next/src/routes/(root)/index.tsxweb-next/src/routes/(root)/news/[link_id]/index.tsxweb-next/src/routes/(root)/news/index.tsx
deletePost collected only the deleted and original posts' own link ids, so a bulk-deleted reply or quote that pointed at a different story (for example a post quoting the deleted one while also replying to another story) left that story's news score stale. The periodic sweep cannot recover it because a deletion leaves no fresh source timestamp. Collect the bulk-deleted interactions' reply and quote parents and refresh their links too. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
deletePersistedPost decremented the reply target's repliesCount but never the quoted post's quotesCount, so deleting a remote quote left the quoted post's visible quote count inflated. Mirror the reply branch for deletedPost.quotedPostId. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
Per the repository style guide, external imports come before internal ones, alphabetically within each group. graphql-yoga sat after the @hackerspub model imports in news.ts, and main.ts interleaved the @hongminhee and @LogTape externals with a model import. Reorder both. hackers-pub#310 (comment) hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
newsStory returned any post_link row by UUID, so anyone holding a link's id could read its scraped metadata even when the link had only ever appeared in followers-only or direct posts, breaking the news feature's public-only contract. Require a qualifying public share (latest_activity_at is not null), the same predicate that admits a link to scoring. Links excluded from the feed keep their activity timestamp, so the discussion permalink still resolves for them. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
main.ts registers the periodic news-score sweep with Deno.cron at module load, but Deno.cron is unstable and aborts unless the feature is enabled. The dev and start tasks ran main.ts without it, so the GraphQL server crashed on startup. Add --unstable-cron to both (codegen runs mod.ts and needs no cron). hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
recomputeNewsScores applied exclusions to every scored link on every run whenever the scope was not an explicit id list, so the five-minute activeSince sweep re-read and URLPattern-tested the whole feed each time, growing O(all scored links). Pass the active link set through for the activeSince scope so the sweep stays O(recently active links); a full recompute still re-evaluates everything. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive federated 'News' feature, including database migrations for link scoring and URL exclusion patterns, a periodic scoring cron job, GraphQL schema additions, backend scoring and moderation models, and frontend UI components for the news feed and discussion threads. The review feedback highlights a critical reactivity bug in the Solid.js discussion thread component that breaks post deduplication, a severe recursive fetching issue when following deep links, a potential unhandled server error with non-positive pagination limits, and several style guide violations regarding missing GraphQL descriptions and unsorted imports.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 05e58b2bbe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Every GraphQL argument needs a description per the schema-docs rule; the newsStory id argument had none. Document it and regenerate the schema. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
A negative or zero first reached the query as a negative SQL LIMIT, which Postgres rejects with an unhandled error. Validate it the same way the upper bound is, returning a PAGINATION_ERROR instead. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
The @hackerspub/models/uuid import was wedged between the @solidjs imports. Move it to the front of the external group, alphabetically ahead of the @solidjs/relay/solid packages. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
The discussion thread fetched quotes once with first:20 and no load-more, so quote hackers-pub#21 and beyond never appeared even though the discussion count included them. Quotes now paginate with their own cursor and a "Load more quotes" control, alongside replies. Following a #hash link also passed targetUuid to every node and auto-paginated all of each node's replies, so a deep link could fan out into fetching the entire tree. Auto-expansion for a target is now capped to a maximum depth and a maximum number of reply pages per node; deeper or later matches stay reachable through the manual load-more controls. hackers-pub#310 (comment) hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web-next/src/components/NewsDiscussionThread.tsx`:
- Around line 214-216: The current auto-expansion/pagination logic (autoPaginate
calculation around autoPaginate, props.targetUuid,
NEWS_DISCUSSION_TARGET_MAX_DEPTH, autoPages, NEWS_DISCUSSION_TARGET_MAX_PAGES)
blindly expands every mounted node when a targetUuid exists; change it to only
auto-expand/paginate when the branch is known or likely to contain the target by
adding a positive signal (e.g. pass in and check ancestry ids from the
route/query like props.targetAncestryIds or a propagated boolean such as
node.containsTargetInSubtree) and only allow expansion past
NEWS_DISCUSSION_AUTO_DEPTH (or pagination) if that signal is true; update the
same guard usage at the other occurrences you flagged (lines around 227-230 and
245-249) to use the new check (e.g. hasTargetInSubtree(node) ||
noTargetSpecified) so unrelated branches do not auto-load.
- Around line 39-53: The pagination call is refetching the untouched connection
because when invoking loadChildren you reset the other cursor to null; update
the loadChildren invocation (and/or its implementation) so it always supplies
both GraphQL variables ($cursor and $quoteCursor) and preserves the current
endCursor for the untouched connection instead of nulling it. Specifically, when
loading "replies" pass cursor = repliesPageInfo.endCursor (or next cursor) and
quoteCursor = current quotesPageInfo.endCursor; when loading "quotes" pass
quoteCursor = quotesPageInfo.endCursor and cursor = current
repliesPageInfo.endCursor, so the NewsDiscussionThreadChildrenQuery for replies
and quotes no longer requests the other connection unnecessarily.
- Around line 159-166: When loadChildren() runs with mode === "initial" it
resets seen, autoPages, reply/quote children and cursors but does not reset the
pagination flags replyHasMore and quoteHasMore; update the initial block in
loadChildren (the branch checking mode === "initial") to also clear those flags
(e.g., call the setters that control replyHasMore and quoteHasMore) so the UI
won't show stale "Load more" buttons after a failed refresh; locate the block
using symbols mode, loadChildren, seen, autoPages, setReplyChildren,
setQuoteChildren, setReplyCursor and setQuoteCursor to add the missing
setReplyHasMore(...) and setQuoteHasMore(...) calls.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1399a208-4692-468c-ab3c-ebc30a71bc07
📒 Files selected for processing (9)
graphql/news.tsgraphql/schema.graphqlweb-next/src/components/NewsDiscussionThread.tsxweb-next/src/locales/en-US/messages.poweb-next/src/locales/ja-JP/messages.poweb-next/src/locales/ko-KR/messages.poweb-next/src/locales/zh-CN/messages.poweb-next/src/locales/zh-TW/messages.poweb-next/src/routes/(root)/news/[link_id]/index.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
- web-next/src/routes/(root)/news/[link_id]/index.tsx
- graphql/news.ts
- graphql/schema.graphql
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive "News" feature that aggregates, ranks, and moderates shared links circulating across the fediverse. It includes database migrations to support scoring and exclusion patterns, standalone backend logic for idempotent popularity calculations, GraphQL schema updates, and a complete frontend SolidStart implementation for viewing and managing stories. The review comments are highly valuable, pointing out critical issues such as Drizzle relational query builder limitations, strict TypeScript template literal type mismatches with Relay-generated strings, memory leaks from uncleaned subscriptions, non-reactive signal reads in SolidJS, and unsafe URL parsing that could cause runtime crashes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f2a5006bbf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Loading more replies refetched quotes (and the reverse) because the unused cursor was reset to null while the query still requested both connections. Gate each connection with @include so a pagination click or deep-link auto-pagination fetches only the connection it advances. Also reset replyHasMore/quoteHasMore on a fresh load, so a reload that errors does not leave stale "Load more" buttons against an empty thread. hackers-pub#310 (comment) hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
engagementBase passed a post URL straight to new URL(), which throws on a malformed or relative value. Wrap it in try/catch and fall back to null, matching how the other news components parse external URLs. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive federated 'News' feature, adding database schema migrations, backend scoring models with repeated-share damping and bot exclusions, GraphQL APIs, and Solid.js/Relay frontend components for the news feed, discussion threads, and moderator admin views. The reviewer feedback identifies a critical non-reactive setup in the Solid.js discussion thread component that could cause duplicate post rendering, as well as overly restrictive template literal types for UUID parameters in several frontend files.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cc3b706096
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The bounded deep-link auto-pagination only advanced the replies cursor, so a target that is a quote past the first quote page of an ancestor was never fetched or scrolled to. Drive it from a single maybeAutoPaginate that walks replies then quotes, each capped per connection and by depth. hackers-pub#310 (comment) Assisted-by: Claude Code:claude-opus-4-8
|
@codex review |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive "News" feature that ranks and displays shared links across the fediverse. It includes database migrations to track scores, engagement mass, and moderation penalties; a scoring model in models/news.ts that handles bot exclusions, repeated-share damping, and recency; a periodic cron job to recompute scores; GraphQL schema additions; and a complete Solid.js frontend with news feeds, discussion threads, and moderator admin controls. The review feedback highlights two important issues: a potential false positive in the link-appending logic (ensureLinkInContent) when checking for URL existence via a simple substring match, and a Solid.js anti-pattern in NewsDiscussionThread where a reactive signal (post()) is accessed directly in the component setup function, which could bypass duplicate checks during hydration.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 247c1c94ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Hackers' Pub already extracts the first external link from each post and stores it in
post_link. This branch uses that data for News: a feed of links shared across the fediverse, ranked by a time-and-engagement score instead of votes. The discussion for a link is the federated conversation that already formed around the posts sharing it, and each link has a stable page for reading and joining that conversation.How links are ranked
A link's score is
log10(max(1, weightedMass)) + recency, where the recency term measures the link's latest-activity time against a fixed epoch, 2024-01-01, instead ofnow(). This follows the same basic idea as Reddit's older ranking formula: the score is a pure function of the stored posts and engagement, so recomputing it is idempotent. It changes only when the underlying data changes.weightedMasssums, over each public non-boost post that shares the link, a source weight, an account-reputation weight based on the sharer's follower/following ratio, and weighted engagement from public quotes, replies, and reactions. Local Hackers' Pub posts count most, generic remote instances count less, and Bluesky-bridged accounts count least. Bot-only links, from ActivityPubServiceorApplicationactors, are excluded from the feed. Repeated shares from the same account get a reduced, gap-dependent weight after the first share, and rapid repeats do not refresh recency, so one account cannot keep the same link pinned by reposting it.The feed and the discussion pages
The feed at /news has three orders: popular, newest, and all-time. Popular uses the full score, newest uses first-shared time, and all-time uses recency-independent mass. Each card shows the link, a short description, the latest activity time, and a discussion-count link. The per-link page at /news/[link_id] shows the sharing posts as roots, with replies and quotes loaded as a depth-limited tree. It supports
#post-<uuid>anchors, deep-link expansion, and a compose box for adding a note from the page. If the note does not already contain the link URL, the URL is appended so the note joins the link's discussion. Signed-out visitors who hit the site root are redirected to /news.Moderation
Moderators get /admin/news and an inline feed-card control. They can exclude links by domain or URL pattern using the Web-standard
URLPattern; matching links are hidden from every feed order, while direct links to their discussion pages still work. Moderators can also apply a per-link score penalty, demote or bury, from each feed card. Penalized links are listed on the admin page so they can be found and cleared later.Data model and freshness
The scores live on
post_linkitself: a fewdouble precisionand timestamp columns, plus partial indexes for the feed orders. I kept them there instead of adding a separate table because scoring runs off the post hot path, and feed reads need link metadata and score together. The scoring code is in models/news.ts. Scores refresh incrementally when a link is shared, on a five-minuteDeno.cronsweep in the GraphQL server, and through the manual recompute button. Because recompute is idempotent, the sweep and manual button are safe to rerun, including after adding exclusion patterns that should apply to already-scored links.