From 55dd00d685a61ad2379382590082ae072a799ab0 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:57:43 +0300 Subject: [PATCH 1/2] feat(storybook): policy-backed card reasons and a personalization center Explain mode now runs a copy of the For You challenger/5.0 policy over the real /history posts, so every reason, chip and score comes from the same math the feed uses, and adds a Personalization panel for full control. - Cards keep their exact layout: the explanation overlays the real grid or list card with the source and title at the card's own offsets, and the summary bar is gone. - Reason chips show what moved each post (topic match, follow, reader response, freshness, seen before, rotation, variety) and open a popover with the math and the policy knob behind it. - The Personalization panel shows the full inferred profile with every weight editable, sources and people, every ranking knob with its raw retriever_config key, what was left out and why, links to explicit settings, and resets, with a live preview of the next page. - A spec story maps each control to what daily-feed supports today. --- .../feed-explain/FeedExplain.stories.tsx | 455 +++++++++++---- .../components/AnchoredPopover.tsx | 99 ++++ .../feed-explain/components/ChangesDock.tsx | 72 +++ .../feed-explain/components/ExplainBar.tsx | 268 --------- .../components/ExplainDetails.tsx | 337 ++++------- .../feed-explain/components/ExplainPanel.tsx | 444 +++++++++------ .../feed-explain/components/FactorChips.tsx | 427 ++++++++++++++ .../components/FeedExplainPage.tsx | 536 +++++++++--------- .../components/FeedScreenContext.ts | 43 ++ .../components/FeedTuningContext.tsx | 188 ------ .../components/PersonalizationContext.tsx | 407 +++++++++++++ .../components/PersonalizationPanel.tsx | 293 ++++++++++ .../feed-explain/components/controls.tsx | 166 ++++++ .../feed-explain/components/feedExplain.ts | 201 ------- .../components/panel/InterestsSection.tsx | 518 +++++++++++++++++ .../feed-explain/components/panel/Preview.tsx | 323 +++++++++++ .../components/panel/RankingSection.tsx | 209 +++++++ .../feed-explain/components/panel/Section.tsx | 81 +++ .../components/panel/SettingsSection.tsx | 240 ++++++++ .../components/panel/SourcesSection.tsx | 203 +++++++ .../feed-explain/feedExplain.mocks.ts | 63 -- .../stories/features/feed-explain/personas.ts | 107 ++++ .../feed-explain/policy/challengerConfig.ts | 82 +++ .../features/feed-explain/policy/describe.ts | 279 +++++++++ .../features/feed-explain/policy/params.ts | 419 ++++++++++++++ .../features/feed-explain/policy/profile.ts | 200 +++++++ .../features/feed-explain/policy/rank.ts | 512 +++++++++++++++++ 27 files changed, 5690 insertions(+), 1482 deletions(-) create mode 100644 packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/FactorChips.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts delete mode 100644 packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/controls.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/components/feedExplain.ts create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Section.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts create mode 100644 packages/storybook/stories/features/feed-explain/personas.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/describe.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/params.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/profile.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/rank.ts diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 9c2b7b6992..1a71b2916d 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -1,24 +1,31 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { waitFor } from 'storybook/test'; import { graphql, HttpResponse } from 'msw'; import ExtensionProviders from '../../extension/_providers'; import { providerHandlers } from '../why-recommended/whyRecommended.mocks'; -import type { TuningSeed } from './components/FeedTuningContext'; -import { FeedTuningProvider } from './components/FeedTuningContext'; -import { ExplainDetailsHost } from './components/ExplainDetails'; +import type { Decision, PersonalizationState } from './policy/rank'; +import { getExclusion } from './policy/rank'; +import { TopicMode } from './policy/profile'; +import { policyVersion } from './policy/params'; import { + PersonalizationProvider, + usePersonalization, +} from './components/PersonalizationContext'; +import { + ControlSection, + FeedScreenContext, + getSignalCounts, +} from './components/FeedScreenContext'; +import { ExplainDetailsModal } from './components/ExplainDetails'; +import { + AppView, CardFrame, FeedExplainPage, FeedLayout, } from './components/FeedExplainPage'; -import { TuneLevel } from './components/feedExplain'; -import { - feedItem, - forYouFeed, - newViewer, - reserveFeed, -} from './feedExplain.mocks'; +import { candidatePool, PersonaId, personas } from './personas'; // Follow and block in the full explanation update the feed settings cache // optimistically and roll back on error, so the mutations have to succeed. @@ -49,23 +56,24 @@ export default meta; type Story = StoryObj; const Screen = ({ + persona = PersonaId.TwoWeeksIn, layout = FeedLayout.Grid, - initialExplain, - initialDetailsId, + ...props }: { + persona?: PersonaId; layout?: FeedLayout; initialExplain?: boolean; + initialView?: AppView; + initialSection?: ControlSection; initialDetailsId?: string; }): ReactElement => (
@@ -81,124 +89,204 @@ export const ExplainOn: Story = { render: () => , }; +export const ReasonControls: Story = { + name: '3 · Reason controls', + render: () => , + play: async ({ canvasElement }) => { + await waitFor(() => { + const chip = canvasElement.querySelector( + 'ul[aria-label="What moved this post"] button', + ); + if (!chip) { + throw new Error('Waiting for reason chips'); + } + chip.click(); + }); + }, +}; + export const FullExplanation: Story = { - name: '3 · Full explanation', - render: () => , + name: '4 · Full explanation', + render: () => , +}; + +export const Controls: Story = { + name: '5 · Personalization controls', + render: () => , +}; + +export const JustSignedUp: Story = { + name: '6 · Just signed up', + render: () => , }; export const Phone: Story = { - name: '4 · Phone', + name: '7 · Phone', globals: { viewport: { value: 'mobile2', isRotated: false } }, render: () => , }; -const feedCounts = forYouFeed.reduce>( - (counts, { signals }) => { - signals.forEach(({ key }) => { - counts[key] = (counts[key] ?? 0) + 1; - }); - return counts; - }, - {}, -); +export const PhoneControls: Story = { + name: '8 · Phone controls', + globals: { viewport: { value: 'mobile2', isRotated: false } }, + render: () => ( + + ), +}; interface CardState { title: string; note: string; id: string; - seed?: TuningSeed; + persona?: PersonaId; + change?: (state: PersonalizationState) => PersonalizationState; layout?: FeedLayout; } +const withControl = (tag: string, mode: TopicMode) => ( + state: PersonalizationState, +): PersonalizationState => ({ + ...state, + controls: { ...state.controls, [tag]: { mode } }, +}); + const cardStates: CardState[] = [ { - title: 'Topic you picked', - note: 'The reason names the onboarding pick that matched.', - id: 'XCmmeq8ZC', + title: 'Topic you follow', + note: 'A followed topic you read often: the strongest kind of match.', + id: 'EYFcaEcVd', }, { title: 'Source you follow', - note: 'Follows outrank topics, so the source row leads.', - id: 'Hd2eBXFkz', + note: 'A follow brings every post in and multiplies it.', + id: 'Rvo54Shm1', }, { - title: 'From your reading', - note: 'Nothing followed matched; a topic you keep opening did.', - id: 'VNv8IMYss', + title: 'Learned from reading', + note: 'Never followed; six stories in two weeks made it strong.', + id: 'MsFCKRbGk', }, { - title: 'Popular pick', - note: 'No match at all. New feeds lean on these until they learn more.', - id: 'BM2onQaux', + title: "A topic's turn", + note: 'Docker had not been on the first screen lately, so it jumped ahead.', + id: 'JKsEou23l', }, { - title: 'Showing more', - note: 'Plus pressed on #rust. The row turns green.', - id: '9tLNItvh5', - seed: { levels: { 'topic:rust': TuneLevel.More } }, + title: 'Variety pushed it down', + note: 'The third Fireship post on the page, so it gives way.', + id: '1XMQ9zlpA', }, { - title: 'Showing less', - note: 'Minus pressed on #ai. The post stays, its topic ranks lower.', - id: 'AzxnBHN3V', - seed: { levels: { 'topic:ai': TuneLevel.Less } }, + title: 'Popular fill-in', + note: 'A new viewer with two topics; the popular lane tops up the page.', + id: 'JKsEou23l', + persona: PersonaId.JustSignedUp, + }, + { + title: 'Showing more', + note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.', + id: 'BjYAz50EA', + change: withControl('rust', TopicMode.Boosted), }, { - title: 'Blocked topic', - note: 'Blocking anything on the post marks it to leave on refresh.', - id: 'BM2onQaux', - seed: { levels: { 'topic:gaming': TuneLevel.Blocked } }, + title: 'Muted a learned topic', + note: 'Reading about #career stops counting, so this card leaves.', + id: 'MsFCKRbGk', + change: withControl('career', TopicMode.Muted), }, { - title: 'Blocked source', - note: 'Blocked from another card: every XDA post shows this at once.', - id: 'j4duNN9S2', - seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } }, + title: 'Blocked a source', + note: 'Every XDA Developers card shows this at once.', + id: 'DFJTpz2Tw', + change: (state) => ({ + ...state, + blockedSources: [...state.blockedSources, 'xda-developers'], + }), }, { title: 'Fewer like this', - note: 'The post-level escape hatch when no single topic is the problem.', - id: 'DVZBShH7e', - seed: { dismissed: ['DVZBShH7e'] }, + note: 'The post-level escape hatch when no single reason is the problem.', + id: 'AzxnBHN3V', + change: (state) => ({ ...state, hidden: [...state.hidden, 'AzxnBHN3V'] }), }, ]; const listStates: CardState[] = [ { title: 'List card', - note: 'On phones the card folds the same way, so a few posts fit on one screen.', + note: 'On phones the explanation covers the real list card, so nothing in the feed moves.', id: 'DFJTpz2Tw', layout: FeedLayout.List, }, { title: 'List card, leaving', - note: 'The title fades; the notice keeps its Undo.', - id: 'CrehqCUVO', + note: 'The title fades; the notice offers the matching fix.', + id: 'DFJTpz2Tw', layout: FeedLayout.List, - seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } }, + change: (state) => ({ + ...state, + blockedSources: [...state.blockedSources, 'xda-developers'], + }), }, ]; -const StateCard = ({ state }: { state: CardState }): ReactElement => ( -
-
- - {state.title} - - {state.note} -
- - - - - -
-); +const StateCardBody = ({ + id, + layout, +}: { + id: string; + layout: FeedLayout; +}): ReactElement => { + const store = usePersonalization(); + const [details, setDetails] = useState(null); + const counts = useMemo(() => getSignalCounts(store.served), [store.served]); + const screen = useMemo(() => ({ counts, onOpenDetails: setDetails }), [counts]); + const decision = store.served.find(({ post }) => post.id === id); + if (!decision) { + return

Not on the page.

; + } + + return ( + + + {details && ( + setDetails(null)} /> + )} + + ); +}; + +const StateCard = ({ state }: { state: CardState }): ReactElement => { + const base = personas[state.persona ?? PersonaId.TwoWeeksIn].state; + + return ( +
+
+ + {state.title} + + {state.note} +
+ + + +
+ ); +}; const Section = ({ title, @@ -214,7 +302,7 @@ const Section = ({ ); export const CardStates: Story = { - name: '5 · Card states', + name: '9 · Card states', render: () => (
@@ -223,15 +311,12 @@ export const CardStates: Story = { Explain mode, card by card

- The Explain switch on For You keeps every card where it is and - folds it to its source and title, then says why the post is here: - one sentence for the reason, then the post's top topics and - source, each with less, more and block. Full explanation opens the - "Why am I seeing this?" view with every topic and the - score breakdown. Changes save as you tap, every card sharing a - topic or source updates at once, and Refresh feed swaps out what - you blocked. Less and more are new: today the API only has follow - and block. + Each card keeps its source and title and says why it is here: one + sentence for the reason, chips for what moved it up or down (tap + one to see the math and change that part of the policy), and the + post's topics and source with less, more and block. Full + explanation opens the "Why am I seeing this?" view with + the score breakdown.

@@ -252,3 +337,183 @@ export const CardStates: Story = { ), }; + +enum Status { + Exists = 'exists', + Config = 'config', + Missing = 'missing', +} + +const statusLabel: Record = { + [Status.Exists]: { + label: 'Exists', + className: 'bg-accent-avocado-flat text-accent-avocado-default', + }, + [Status.Config]: { + label: 'In the policy, not per user', + className: 'bg-accent-cheese-flat text-accent-cheese-default', + }, + [Status.Missing]: { + label: 'New', + className: 'bg-accent-bun-flat text-accent-bun-default', + }, +}; + +const specRows: Array<{ + control: string; + status: Status; + today: string; + needs: string; +}> = [ + { + control: 'Follow and unfollow topics, sources, squads, people', + status: Status.Exists, + today: 'allowed_tags, followed_sources, squad_ids, followed_user_ids', + needs: 'Nothing', + }, + { + control: 'Block topics, sources, people', + status: Status.Exists, + today: 'blocked_tags, blocked_sources, blocked_author_ids', + needs: 'Nothing', + }, + { + control: 'Fewer like this', + status: Status.Exists, + today: 'Hide; remove_engaged_posts drops it', + needs: 'Nothing', + }, + { + control: 'More and Less on a topic', + status: Status.Config, + today: + 'control_boost and control_demote are in the config but BuildInterests never applies them', + needs: 'Per-user, per-topic controls stored and applied in BuildInterests', + }, + { + control: 'Mute a learned topic', + status: Status.Missing, + today: 'Only in the proof-of-concept notebook', + needs: 'Same storage as More and Less; drop the inferred interest', + }, + { + control: 'Exact topic weight', + status: Status.Missing, + today: 'Weights are computed per request', + needs: 'A per-topic override that replaces the computed weight', + }, + { + control: 'Less from a source', + status: Status.Missing, + today: 'Sources only have follow and block', + needs: 'A per-source demote on the follow factor', + }, + { + control: 'Ranking knobs', + status: Status.Config, + today: + 'retriever_config is global per cohort; it travels inline on every request and is part of the cache key', + needs: 'daily-api or lofn merges the user’s overrides into retriever_config, clamped to policy.Parse ranges', + }, + { + control: 'Learn from my reading', + status: Status.Config, + today: 'inferred_interests.enabled is per provider', + needs: 'Set it per user', + }, + { + control: 'Popular fill-ins', + status: Status.Config, + today: 'The cs and po lanes fill what the challenger leaves empty', + needs: 'Drop those lanes per user', + }, + { + control: 'Forget what the feed learned', + status: Status.Missing, + today: + 'The delta job rebuilds 90 days of evidence every 5 minutes, so deleting the profile does not stick', + needs: 'A per-user evidence cutoff date the job respects', + }, + { + control: 'Forget what you scrolled past', + status: Status.Exists, + today: 'Impression counts in Redis per user', + needs: 'An endpoint that clears them', + }, + { + control: 'Reasons and factors on each card', + status: Status.Missing, + today: + 'The policy computes them and logs them to fss_events provider_metadata; the API returns only post ids', + needs: 'Return the decision with each post, or look it up by request id', + }, + { + control: 'Left out list', + status: Status.Missing, + today: 'feed_candidates logs up to 500 eligible cards per request', + needs: 'Log exclusion reasons too, and an endpoint to read them', + }, + { + control: 'Your interest profile', + status: Status.Missing, + today: 'Stored per user in Redis and Postgres; no API returns it', + needs: 'An endpoint that returns the labelled, weighted profile', + }, +]; + +export const Spec: Story = { + name: '10 · What it needs', + render: () => ( +
+
+

+ Full control over For You: what exists and what it needs +

+

+ The story runs a copy of {policyVersion}, the challenger + policy For You uses, over real posts. Every reason, chip, weight and + left-out group comes from that copy, so changing a control re-ranks + the page the same way the feed would. It simplifies four things: + reader response compares posts here instead of using the source + prior, the first tag is the main topic, topic similarity is tag + overlap instead of tag embeddings, and placement swaps are left out. +

+
+
+ + + + + + + + + + + {specRows.map((row) => ( + + + + + + + ))} + +
ControlStatusTodayNeeds
+ {row.control} + + + {statusLabel[row.status].label} + + {row.today}{row.needs}
+
+
+ ), +}; diff --git a/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx b/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx new file mode 100644 index 0000000000..323c0dc8d8 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx @@ -0,0 +1,99 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; + +interface AnchoredPopoverProps { + anchor: HTMLElement | null; + onClose: () => void; + children: ReactNode; + label: string; + className?: string; +} + +const gutter = 16; +const gap = 8; + +/** + * A small dialog pinned to its trigger. Portaled, so card overlays with + * overflow hidden don't clip it; it flips above the trigger near the bottom + * of the screen. + */ +export const AnchoredPopover = ({ + anchor, + onClose, + children, + label, + className, +}: AnchoredPopoverProps): ReactElement | null => { + const ref = useRef(null); + const [position, setPosition] = useState<{ top: number; left: number }>(); + + useLayoutEffect(() => { + if (!anchor) { + return undefined; + } + const place = () => { + const popover = ref.current; + if (!popover) { + return; + } + const rect = anchor.getBoundingClientRect(); + const { width, height } = popover.getBoundingClientRect(); + const below = rect.bottom + gap; + const top = + below + height > window.innerHeight - gutter && rect.top - gap > height + ? rect.top - gap - height + : below; + const left = Math.min( + Math.max(gutter, rect.left), + window.innerWidth - width - gutter, + ); + setPosition({ top, left }); + }; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (!ref.current?.contains(target) && !anchor.contains(target)) { + onClose(); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + anchor.focus(); + } + }; + place(); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [anchor, onClose]); + + if (!anchor) { + return null; + } + + return createPortal( +
+ {children} +
, + document.body, + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx new file mode 100644 index 0000000000..d469d0cbca --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; +import { SettingsIcon } from '@dailydotdev/shared/src/components/icons/Settings'; +import { usePersonalization } from './PersonalizationContext'; + +/** Floats at the bottom: the last change, Undo, and the screen's next step. */ +export const ChangesDock = ({ + primaryLabel, + onPrimary, + defaultMessage, + onOpenControls, + isCompact, +}: { + primaryLabel: string; + onPrimary: () => void; + defaultMessage: string; + onOpenControls?: () => void; + isCompact?: boolean; +}): ReactElement => { + const { lastMessage, notice, pendingCount, undo } = usePersonalization(); + + return ( +
+
+ + {lastMessage ?? notice ?? defaultMessage} + + {pendingCount > 0 && ( + + )} + {onOpenControls && ( + + )} + +
+
+ ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx deleted file mode 100644 index 8010f374dc..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuOptions, - DropdownMenuTrigger, -} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; -import type { MenuItemProps } from '@dailydotdev/shared/src/components/dropdown/common'; -import { MenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; -import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; -import { MinusIcon } from '@dailydotdev/shared/src/components/icons/Minus'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; -import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; -import type { ExplainedPost, FeedSignal } from './feedExplain'; -import { - FeedReason, - getSignalName, - levelLabel, - levelTextClassName, - reasonMeta, - reasonOrder, - SignalKind, - TuneLevel, -} from './feedExplain'; -import { useFeedTuning } from './FeedTuningContext'; - -const topicLimit = 8; - -const getTopTopics = (items: ExplainedPost[]) => { - const byKey = new Map(); - items.forEach(({ signals }) => - signals - .filter(({ kind }) => kind === SignalKind.Topic) - .forEach((signal) => { - const entry = byKey.get(signal.key); - byKey.set(signal.key, { - signal, - count: (entry?.count ?? 0) + 1, - }); - }), - ); - return [...byKey.values()] - .filter(({ count }) => count > 1) - .sort((a, b) => b.count - a.count) - .slice(0, topicLimit); -}; - -const TopicChip = ({ - signal, - count, -}: { - signal: FeedSignal; - count: number; -}): ReactElement => { - const { levels, setLevel, setHoverKey } = useFeedTuning(); - const level = levels[signal.key]; - const name = getSignalName(signal); - const options: MenuItemProps[] = [ - { - icon: , - label: `Show more ${name}`, - action: () => setLevel(signal, TuneLevel.More), - }, - { - icon: , - label: `Show less ${name}`, - action: () => setLevel(signal, TuneLevel.Less), - }, - { - icon: , - label: `Block ${name}`, - action: () => setLevel(signal, TuneLevel.Blocked), - }, - ...(level - ? [ - { - icon: , - label: level === TuneLevel.Blocked ? 'Unblock' : 'Back to normal', - action: () => setLevel(signal, undefined), - }, - ] - : []), - ]; - - return ( - - - - - - - - - ); -}; - -interface ExplainBarProps { - items: ExplainedPost[]; - isCompact?: boolean; - onPickTopics: () => void; -} - -export const ExplainBar = ({ - items, - isCompact, - onPickTopics, -}: ExplainBarProps): ReactElement => { - const { focus, setFocus, getLeaveReason } = useFeedTuning(); - const staying = items.filter((item) => !getLeaveReason(item)); - const counts = reasonOrder - .map((reason) => ({ - reason, - count: staying.filter((item) => item.reason === reason).length, - })) - .filter(({ count }) => count > 0); - const popularCount = - counts.find(({ reason }) => reason === FeedReason.Popular)?.count ?? 0; - const isLearning = staying.length > 0 && popularCount / staying.length >= 0.25; - const topics = getTopTopics(staying); - const focusedReason = focus?.type === 'reason' ? focus.reason : null; - - return ( -
-
-

- Why these posts are in your feed -

- {!isCompact && ( -

- Every post now shows what put it here. Use{' '} - or{' '} - to see less or - more of something, or block it. -

- )} -
-
-
- {counts.map(({ reason, count }) => ( - - ))} -
-
    - {counts.map(({ reason, count }) => ( -
  • - -
  • - ))} -
-
- {isLearning && ( -
-

- - Your feed is still learning what you like. - {' '} - {popularCount} of these {staying.length} posts are popular picks. - Pick a few more topics and they'll make room for posts that fit - you. -

- -
- )} - {topics.length > 0 && ( -
-

- Most shown topics -

-
- {topics.map(({ signal, count }) => ( - - ))} -
-
- )} -
- ); -}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx index c1dc23ea2b..4807ec6761 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx @@ -1,5 +1,5 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import useFeedSettings, { @@ -19,146 +19,98 @@ import { RecommendationMatchOrigin, RecommendationMatchRole, } from '../../why-recommended/components/whyRecommended'; -import type { ExplainedPost, ViewerProfile } from './feedExplain'; -import { - FeedReason, - getSignalKey, - SignalKind, - SignalOrigin, - TuneLevel, -} from './feedExplain'; -import { useFeedTuning } from './FeedTuningContext'; -import { FeedScreenContext } from './ExplainPanel'; - -const matchOrigin: Partial> = - { - [SignalOrigin.Picked]: RecommendationMatchOrigin.Selected, - [SignalOrigin.Following]: RecommendationMatchOrigin.Following, - [SignalOrigin.Reading]: RecommendationMatchOrigin.Reading, - }; - -const matchPoints: Partial> = { - [SignalOrigin.Following]: 120, - [SignalOrigin.Picked]: 100, - [SignalOrigin.Reading]: 70, -}; - -const topicWeight: Partial> = { - [SignalOrigin.Picked]: 0.9, - [SignalOrigin.Reading]: 0.55, -}; - -const levelMultiplier: Partial> = { - [TuneLevel.More]: 1.25, - [TuneLevel.Less]: 0.6, -}; - -/** Ages are measured from the day the history posts were captured. */ -const capturedAt = Date.parse('2026-10-15'); +import type { Decision, PersonalizationState } from '../policy/rank'; +import { FollowKind, Lane, TopicRole } from '../policy/rank'; +import type { Interest } from '../policy/profile'; +import { InterestOrigin } from '../policy/profile'; +import { usePersonalization } from './PersonalizationContext'; const round = (value: number): number => Math.round(value * 1000) / 1000; /** - * The ranking explanation the feed service would return for this post, so - * the full view shows the same matches and the tuning applied in the feed. + * The served decision as the explanation the feed service would return. The + * breakdown shows each multiplier on a log scale, so they add up to the log + * of the card's pick score. */ const buildExplanation = ( - item: ExplainedPost, - levels: Record, - boost: number, + decision: Decision, + interests: Interest[], ): RecommendationExplanation | undefined => { - const matched = item.signals.filter( - ({ origin }) => origin !== SignalOrigin.Post, - ); - if (!matched.length) { + if (decision.lane === Lane.Popular) { return undefined; } - - const matches = matched.map( - (signal, index): RecommendationMatch => ({ - id: signal.id, - label: signal.label, - kind: - signal.kind === SignalKind.Topic - ? RecommendationMatchKind.Topic - : RecommendationMatchKind.Source, - origin: matchOrigin[signal.origin] ?? RecommendationMatchOrigin.Reading, - ...(signal.kind === SignalKind.Topic && { + const matches: RecommendationMatch[] = [ + ...(decision.followedBy === FollowKind.Source && decision.post.source?.id + ? [ + { + id: decision.post.source.id, + label: decision.post.source.name ?? decision.post.source.id, + kind: RecommendationMatchKind.Source, + origin: RecommendationMatchOrigin.Following, + points: Math.round((decision.factors.follow - 1) * 100), + }, + ] + : []), + ...decision.matches.map((match): RecommendationMatch => { + const interest = interests.find(({ tag }) => tag === match.tag); + return { + id: match.tag, + label: match.tag, + kind: RecommendationMatchKind.Topic, + origin: + match.origin === InterestOrigin.Declared + ? RecommendationMatchOrigin.Selected + : RecommendationMatchOrigin.Reading, role: - signal.key === item.reasonSignal?.key + match.role === TopicRole.Main ? RecommendationMatchRole.Main : RecommendationMatchRole.Supporting, - }), - points: Math.round( - (matchPoints[signal.origin] ?? 0) * - (index ? 0.7 : 1) * - (levelMultiplier[levels[signal.key]] ?? 1), - ), - }), - ); - const { post } = item; - const daysOld = Math.max( - 0, - (capturedAt - Date.parse(post.createdAt ?? '')) / 86_400_000, - ); - const factors = [ - { - label: 'interest match', - value: round( - matched.reduce( - (sum, { origin, key }) => - sum + - (topicWeight[origin] ?? 0) * (levelMultiplier[levels[key]] ?? 1), - 0, + points: Math.round( + (interest ? match.contribution : match.contribution) * 100, ), - ), - }, - { - label: 'followed source', - value: item.reason === FeedReason.Following ? 1.2 : 0, - }, - { - label: 'community signal', - value: round(Math.log10((post.numUpvotes ?? 0) + 1) * 0.12), - }, - { label: 'your tuning', value: round(boost * 0.5) }, - { label: 'age', value: -round(Math.min(0.4, (daysOld || 0) * 0.012)) }, + }; + }), + ]; + if (!matches.length) { + return undefined; + } + const { factors, slot } = decision; + const log = (value: number) => round(Math.log(value)); + const breakdown = [ + { label: 'reader response', value: log(factors.base) }, + { label: 'topic match', value: log(factors.match) }, + { label: 'follow', value: log(factors.follow) }, + { label: 'freshness', value: log(factors.age) }, + { label: 'seen before', value: log(factors.seen) }, + ...(slot + ? [ + { label: 'interest rotation', value: log(slot.waiting) }, + { label: 'source variety', value: log(slot.sourceRepeat) }, + { label: 'topic variety', value: log(slot.topicRepeat) }, + { label: 'same story', value: log(slot.sameHeadline) }, + ] + : []), ]; return { matches, - factors, - finalScore: round(factors.reduce((sum, { value }) => sum + value, 0)), + factors: breakdown, + finalScore: round(breakdown.reduce((sum, { value }) => sum + value, 0)), }; }; const toFeedSettings = ( - item: ExplainedPost, - viewer: ViewerProfile, - levels: Record, + decision: Decision, + state: PersonalizationState, ): FeedSettings => { - const topicsAt = (level: TuneLevel) => - Object.keys(levels) - .filter((key) => levels[key] === level && key.startsWith('topic:')) - .map((key) => key.slice('topic:'.length)); - const blockedTags = topicsAt(TuneLevel.Blocked); - const { source } = item.post; - const sourceLevel = source?.id - ? levels[getSignalKey(SignalKind.Source, source.id)] - : undefined; - const isFollowingSource = - !!source?.id && - (viewer.followedSources.includes(source.id) || - sourceLevel === TuneLevel.More); - + const source = decision.post.source as Source | undefined; return { - includeTags: [ - ...new Set([...viewer.pickedTags, ...topicsAt(TuneLevel.More)]), - ].filter((tag) => !blockedTags.includes(tag)), - blockedTags, - includeSources: isFollowingSource && source ? [source as Source] : [], + includeTags: state.followedTags, + blockedTags: state.blockedTags, + includeSources: + source && state.followedSources.includes(source.id) ? [source] : [], excludeSources: - sourceLevel === TuneLevel.Blocked && source ? [source as Source] : [], + source && state.blockedSources.includes(source.id) ? [source] : [], advancedSettings: [], }; }; @@ -168,54 +120,54 @@ const diff = (before: string[] = [], after: string[] = []) => ({ removed: before.filter((value) => !after.includes(value)), }); -const sourceIds = (sources?: Array>) => +const ids = (sources?: Array>) => (sources ?? []).map(({ id }) => id); -interface ExplainDetailsModalProps { - item: ExplainedPost; - viewer: ViewerProfile; - onClose: () => void; -} - /** - * The production "Why am I seeing this?" modal over the tuned feed. Follow - * and block in the modal write the feed settings cache; they flow back into - * the tuning state, so the cards behind it update too. + * The production "Why am I seeing this?" modal, fed the served decision. + * Follow and block in it write the feed settings cache, and those writes go + * back into the personalization state so the feed behind it updates. */ -const ExplainDetailsModal = ({ - item, - viewer, +export const ExplainDetailsModal = ({ + decision, onClose, -}: ExplainDetailsModalProps): ReactElement | null => { +}: { + decision: Decision; + onClose: () => void; +}): ReactElement | null => { const client = useQueryClient(); const { user } = useAuthContext(); - const tuning = useFeedTuning(); + const store = usePersonalization(); const [explanation] = useState(() => - buildExplanation(item, tuning.levels, tuning.getBoost(item)), + buildExplanation(decision, store.next.interests), ); const [isSeeded, setIsSeeded] = useState(false); const seenRef = useRef(); const { feedSettings } = useFeedSettings({ enabled: isSeeded }); + const source = { + id: decision.post.source?.id ?? '', + name: decision.post.source?.name ?? 'this source', + }; useEffect(() => { if (!user || isSeeded) { return; } - const settings = toFeedSettings(item, viewer, tuning.levels); + const settings = toFeedSettings(decision, store.state); seenRef.current = settings; client.setQueryData(getFeedSettingsQueryKey(user), { tagsCategories: [], feedSettings: settings, advancedSettings: [], }); - client.setQueryData(getPostByIdKey(item.post.id), { + client.setQueryData(getPostByIdKey(decision.post.id), { post: - item.reason === FeedReason.Popular - ? { ...item.post, trending: item.post.trending || 1 } - : item.post, + decision.lane === Lane.Popular + ? { ...decision.post, trending: decision.post.trending || 1 } + : decision.post, }); setIsSeeded(true); - }, [client, user, item, viewer, tuning.levels, isSeeded]); + }, [client, user, decision, store.state, isSeeded]); useEffect(() => { const seen = seenRef.current; @@ -223,56 +175,34 @@ const ExplainDetailsModal = ({ return; } seenRef.current = feedSettings; - - const next = new Map(); - const unfollow = (key: string) => - next.set( - key, - tuning.levels[key] === TuneLevel.More ? undefined : TuneLevel.Less, - ); - const tags = diff(seen.includeTags, feedSettings.includeTags); - tags.added.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), TuneLevel.More), - ); - tags.removed.forEach((tag) => - unfollow(getSignalKey(SignalKind.Topic, tag)), - ); - const sources = diff( - sourceIds(seen.includeSources), - sourceIds(feedSettings.includeSources), - ); - sources.added.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), TuneLevel.More), - ); - sources.removed.forEach((id) => - unfollow(getSignalKey(SignalKind.Source, id)), - ); - // Blocks win over the follow changes the same action makes. const blocked = diff(seen.blockedTags, feedSettings.blockedTags); - blocked.added.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), TuneLevel.Blocked), - ); - blocked.removed.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), undefined), - ); + const followed = diff(seen.includeTags, feedSettings.includeTags); + // Blocking a followed tag also unfollows it; the block says it all. + blocked.added.forEach((tag) => store.setTagBlocked(tag, true)); + blocked.removed.forEach((tag) => store.setTagBlocked(tag, false)); + followed.added.forEach((tag) => store.setTagFollowed(tag, true)); + followed.removed + .filter((tag) => !blocked.added.includes(tag)) + .forEach((tag) => store.setTagFollowed(tag, false)); const excluded = diff( - sourceIds(seen.excludeSources), - sourceIds(feedSettings.excludeSources), - ); - excluded.added.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), TuneLevel.Blocked), + ids(seen.excludeSources), + ids(feedSettings.excludeSources), ); - excluded.removed.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), undefined), + const included = diff( + ids(seen.includeSources), + ids(feedSettings.includeSources), ); - - next.forEach((level, key) => { - const signal = item.signals.find((candidate) => candidate.key === key); - if (signal) { - tuning.setLevel(signal, level); - } - }); - }, [feedSettings, isSeeded, item, tuning]); + if (excluded.added.length) { + store.setSourceBlocked(source, true); + } else if (excluded.removed.length) { + store.setSourceBlocked(source, false); + } + if (included.added.length) { + store.setSourceFollowed(source, true); + } else if (included.removed.length && !excluded.added.length) { + store.setSourceFollowed(source, false); + } + }, [feedSettings, isSeeded, source, store]); if (!isSeeded) { return null; @@ -281,42 +211,11 @@ const ExplainDetailsModal = ({ return ( tuning.setDismissed(item.post.id, true)} + onShowFewer={() => store.setHidden(decision.post.id, true)} onRequestClose={onClose} /> ); }; - -interface ExplainDetailsHostProps { - counts: Record; - viewer: ViewerProfile; - initialItem?: ExplainedPost; -} - -/** Gives cards their on-screen counts and the way into the full view. */ -export const ExplainDetailsHost = ({ - counts, - viewer, - initialItem, - children, -}: PropsWithChildren): ReactElement => { - const [item, setItem] = useState(initialItem ?? null); - const value = useMemo(() => ({ counts, onOpenDetails: setItem }), [counts]); - - return ( - - {children} - {item && ( - setItem(null)} - /> - )} - - ); -}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index 521fef1f2f..fb2d16ea0d 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -1,5 +1,5 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { createContext, useContext } from 'react'; +import type { ReactElement } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { Button, @@ -18,98 +18,109 @@ import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import type { ExplainedPost, FeedSignal } from './feedExplain'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import type { Decision } from '../policy/rank'; +import { Exclusion, FollowKind, SourceMode } from '../policy/rank'; +import { InterestOrigin, TopicMode } from '../policy/profile'; import { - FeedReason, - getCardSignals, - getSignalName, - levelLabel, - levelTextClassName, - originLabel, - reasonMeta, - SignalKind, - SignalOrigin, - TuneLevel, -} from './feedExplain'; -import type { LeaveReason } from './FeedTuningContext'; -import { useFeedTuning } from './FeedTuningContext'; - -interface FeedScreenValue { - /** How many posts on screen carry each signal key. */ - counts: Record; - /** Opens the full "Why am I seeing this?" explanation. */ - onOpenDetails?: (item: ExplainedPost) => void; -} - -export const FeedScreenContext = createContext({ - counts: {}, -}); + getCategory, + getCategoryDot, + getLeaveMessage, + getReason, +} from '../policy/describe'; +import { defaultParams } from '../policy/params'; +import { usePersonalization } from './PersonalizationContext'; +import { useFeedScreen } from './FeedScreenContext'; +import { FactorChips } from './FactorChips'; +import { Strong } from './controls'; export enum ExplainVariant { Grid = 'grid', List = 'list', } -const Strong = ({ children }: { children: ReactNode }): ReactElement => ( - {children} -); +enum SignalKind { + Topic = 'topic', + Source = 'source', +} + +interface CardSignal { + key: string; + kind: SignalKind; + id: string; + label: string; + source?: Source; +} + +const getCardSignals = (decision: Decision, limit: number): CardSignal[] => { + const { post } = decision; + const matched = decision.matches.map(({ tag }) => tag); + const topics = [ + ...matched, + ...(post.tags ?? []).filter((tag) => !matched.includes(tag)), + ].map( + (tag): CardSignal => ({ + key: `topic:${tag}`, + kind: SignalKind.Topic, + id: tag, + label: `#${tag}`, + }), + ); + if (!post.source?.id) { + return topics.slice(0, limit); + } + const source: CardSignal = { + key: `source:${post.source.id}`, + kind: SignalKind.Source, + id: post.source.id, + label: post.source.name ?? post.source.id, + source: post.source as Source, + }; + return decision.followedBy === FollowKind.Source + ? [source, ...topics.slice(0, limit - 1)] + : [...topics.slice(0, limit - 1), source]; +}; export const ReasonLine = ({ - item, + decision, isClamped, }: { - item: ExplainedPost; + decision: Decision; isClamped?: boolean; }): ReactElement => { - const name = item.reasonSignal && getSignalName(item.reasonSignal); - const copy: Record = { - [FeedReason.Following]: ( - <> - You follow {name}. - - ), - [FeedReason.Picked]: ( - <> - You picked {name} when you signed up. - - ), - [FeedReason.Reading]: ( - <> - You've been reading about {name}. - - ), - [FeedReason.Popular]: ( - <> - Popular this week. Shown while your feed learns what - you like. - - ), - }; + const { next } = usePersonalization(); + const { lead, name, tail } = getReason(decision, next.interests); return (

- {copy[item.reason]} + {name ? ( + <> + {lead} + {name} + {tail} + + ) : ( + <> + {lead} + {tail} + + )}

); }; -const kindLabel: Record = { - [SignalKind.Topic]: 'Topic', - [SignalKind.Source]: 'Source', -}; - -const SignalGlyph = ({ signal }: { signal: FeedSignal }): ReactElement => - signal.kind === SignalKind.Source && signal.source ? ( +const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement => + signal.source ? ( ); -const getActionLabels = (signal: FeedSignal) => { - const name = getSignalName(signal); - return signal.kind === SignalKind.Source - ? { - less: `Less from ${name}`, - more: `More from ${name}`, - block: `Block ${name}`, - } - : { less: `Less ${name}`, more: `More ${name}`, block: `Block ${name}` }; -}; - -export const SignalRow = ({ +const SignalRow = ({ signal, - size = ButtonSize.XSmall, + size, }: { - signal: FeedSignal; - size?: ButtonSize; + signal: CardSignal; + size: ButtonSize; }): ReactElement => { - const { levels, setLevel, setHoverKey } = useFeedTuning(); - const { counts } = useContext(FeedScreenContext); - const level = levels[signal.key]; + const store = usePersonalization(); + const { state, next, setHoverKey } = store; + const { counts } = useFeedScreen(); const count = counts[signal.key] ?? 0; - const labels = getActionLabels(signal); - const isBlocked = level === TuneLevel.Blocked; - const meta = [ - signal.origin === SignalOrigin.Post - ? kindLabel[signal.kind] - : originLabel[signal.origin], - count > 1 && `in ${count} posts`, - ] - .filter(Boolean) - .join(' · '); - const toggle = (next: TuneLevel) => - setLevel(signal, level === next ? undefined : next); + const isTopic = signal.kind === SignalKind.Topic; + const entity = { id: signal.id, name: signal.label }; + const isBlocked = isTopic + ? state.blockedTags.includes(signal.id) + : state.blockedSources.includes(signal.id); + const mode = isTopic ? state.controls[signal.id]?.mode : undefined; + const isFollowedSource = + !isTopic && state.followedSources.includes(signal.id); + const isDemotedSource = + !isTopic && state.sourceControls[signal.id] === SourceMode.Demoted; + const interest = next.interests.find(({ tag }) => tag === signal.id); + const evidence = state.evidence.find(({ topic }) => topic === signal.id); + let meta: string; + let metaClassName = 'text-text-quaternary'; + + if (isBlocked) { + meta = 'Blocked'; + metaClassName = 'text-accent-ketchup-default'; + } else if (mode === TopicMode.Muted) { + meta = 'Muted'; + metaClassName = 'text-accent-ketchup-default'; + } else if (mode === TopicMode.Boosted) { + meta = 'Showing more'; + metaClassName = 'text-accent-avocado-default'; + } else if (mode === TopicMode.Demoted || isDemotedSource) { + meta = 'Showing less'; + metaClassName = 'text-accent-bun-default'; + } else { + let origin = isTopic ? 'Topic' : 'Source'; + if (isFollowedSource) { + origin = 'You follow it'; + } else if (interest?.origin === InterestOrigin.Declared) { + origin = 'You follow it'; + } else if (interest && interest.weight > 0) { + origin = `Learned · ${interest.evidence?.stories} stories`; + } else if (evidence) { + origin = 'Early signal · 1 story'; + } + meta = [origin, count > 1 && `in ${count} posts`].filter(Boolean).join(' · '); + } + + const onBlock = () => + isTopic + ? store.setTagBlocked(signal.id, !isBlocked) + : store.setSourceBlocked(entity, !isBlocked); return (
  • - {getSignalName(signal)} + {signal.label} - - {level ? levelLabel[level] : meta} + + {meta} - {isBlocked ? ( + {isBlocked || mode === TopicMode.Muted ? ( ) : ( @@ -203,10 +233,20 @@ export const SignalRow = ({ color={ButtonColor.Bun} size={size} icon={} - pressed={level === TuneLevel.Less} - aria-label={labels.less} - title={labels.less} - onClick={() => toggle(TuneLevel.Less)} + pressed={mode === TopicMode.Demoted || isDemotedSource} + aria-label={isTopic ? `Less ${signal.label}` : `Less from ${signal.label}`} + title={isTopic ? `Less ${signal.label}` : `Less from ${signal.label}`} + onClick={() => + isTopic + ? store.setTopicMode( + signal.id, + mode === TopicMode.Demoted ? undefined : TopicMode.Demoted, + ) + : store.setSourceMode( + entity, + isDemotedSource ? undefined : SourceMode.Demoted, + ) + } /> + {fix && ( + + )}
  • ); }; interface ExplainPanelProps { - item: ExplainedPost; + decision: Decision; + /** Set when the current settings would drop this card. */ + leaving?: Decision; variant: ExplainVariant; className?: string; } -const cardRows: Record = { +const rowLimit: Record = { + [ExplainVariant.Grid]: 3, + [ExplainVariant.List]: 3, +}; + +const chipLimit: Record = { [ExplainVariant.Grid]: 4, [ExplainVariant.List]: 3, }; export const ExplainPanel = ({ - item, + decision, + leaving, variant, className, }: ExplainPanelProps): ReactElement => { - const { getLeaveReason, setDismissed } = useFeedTuning(); - const { onOpenDetails } = useContext(FeedScreenContext); - const leaveReason = getLeaveReason(item); + const { setHidden } = usePersonalization(); + const { onOpenDetails } = useFeedScreen(); const isGrid = variant === ExplainVariant.Grid; - if (leaveReason) { + if (leaving) { return ( -
    - +
    +
    ); } @@ -331,14 +418,15 @@ export const ExplainPanel = ({ return (
    - -
      - {getCardSignals(item, cardRows[variant]).map((signal) => ( + + +
        + {getCardSignals(decision, rowLimit[variant]).map((signal) => ( } className="!text-text-tertiary" - onClick={() => setDismissed(item.post.id, true)} + onClick={() => setHidden(decision.post.id, true)} > Fewer like this @@ -365,7 +453,7 @@ export const ExplainPanel = ({ icon={} iconPosition={ButtonIconPosition.Right} aria-haspopup="dialog" - onClick={() => onOpenDetails(item)} + onClick={() => onOpenDetails(decision)} > Full explanation diff --git a/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx b/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx new file mode 100644 index 0000000000..6f9b05c01e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx @@ -0,0 +1,427 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { Decision } from '../policy/rank'; +import { TopicRole } from '../policy/rank'; +import type { ParamKey } from '../policy/params'; +import { defaultParams } from '../policy/params'; +import { InterestOrigin, strengthLabel, TopicMode } from '../policy/profile'; +import type { FactorChip } from '../policy/describe'; +import { FactorId, getFactorChips } from '../policy/describe'; +import { usePersonalization } from './PersonalizationContext'; +import { AnchoredPopover } from './AnchoredPopover'; +import { Segmented, Strong, ToggleRow, ParamSlider } from './controls'; +import { ControlSection, useFeedScreen } from './FeedScreenContext'; + +export const formatMultiplier = (value: number): string => + `×${value.toFixed(value >= 10 ? 0 : 2)}`; + +const getTone = (chip: FactorChip): 'up' | 'down' | 'neutral' => { + if (chip.id === FactorId.Popular || Math.abs(Math.log(chip.value)) < 0.05) { + return 'neutral'; + } + return chip.value > 1 ? 'up' : 'down'; +}; + +const toneClassName = { + up: 'text-accent-avocado-default', + down: 'text-accent-bun-default', + neutral: 'text-text-tertiary', +}; + +/** A knob that is either at its default or switched off. */ +const ParamToggle = ({ + id, + paramKey, + offValue, + label, + description, +}: { + id: string; + paramKey: ParamKey; + offValue: number; + label: string; + description?: ReactNode; +}): ReactElement => { + const { state, setParam } = usePersonalization(); + const isOn = state.params[paramKey] !== offValue; + + return ( + + setParam(paramKey, isOn ? offValue : defaultParams[paramKey]) + } + /> + ); +}; + +const freshnessOptions = [ + { value: 3, label: 'Newest' }, + { value: 7, label: 'Balanced' }, + { value: 21, label: 'Evergreen' }, +]; + +const FactorDetails = ({ + chip, + decision, +}: { + chip: FactorChip; + decision: Decision; +}): ReactElement => { + const store = usePersonalization(); + const { state, next } = store; + const { onOpenControls } = useFeedScreen(); + const { params } = state; + const sourceName = decision.post.source?.name ?? 'this source'; + const id = `${decision.post.id}-${chip.id}`; + let body: ReactNode; + let section = ControlSection.Ranking; + + switch (chip.id) { + case FactorId.Match: { + const best = decision.best!; + const interest = next.interests.find(({ tag }) => tag === best.tag); + const mode = state.controls[best.tag]?.mode; + const isFollowed = state.followedTags.includes(best.tag); + section = ControlSection.Interests; + body = ( + <> +

        + #{best.tag} is a{' '} + {best.origin === InterestOrigin.Declared ? 'topic you follow' : 'topic learned from your reading'} + {interest?.strength && ( + <> + {' '} + ({strengthLabel[interest.strength].toLowerCase()},{' '} + {interest.evidence?.stories}{' '} + {interest.evidence?.stories === 1 ? 'story' : 'stories'}) + + )} + . Its weight, {best.weight.toFixed(2)} + {best.role === TopicRole.Side && + `, counted at ${Math.round(best.roleWeight * 100)}% as a side topic`} + , lifts this post {formatMultiplier(decision.factors.match)}. +

        + store.setTopicMode(best.tag, value)} + options={[ + ...(isFollowed + ? [] + : [{ value: TopicMode.Muted, label: 'Mute' }]), + { + value: TopicMode.Demoted, + label: 'Less', + activeClassName: 'text-accent-bun-default', + }, + { value: undefined, label: 'Normal' }, + { + value: TopicMode.Boosted, + label: 'More', + activeClassName: 'text-accent-avocado-default', + }, + ]} + /> +
        + + +
        + + ); + break; + } + case FactorId.Follow: + body = ( + <> +

        + You follow {sourceName}, so every post from it is + in and multiplied by {formatMultiplier(decision.factors.follow)}. +

        + + + + ); + break; + case FactorId.Base: + body = ( +

        + Per view, readers upvoted and commented on this post{' '} + {formatMultiplier(decision.factors.base)} as much as + the typical post here. Well-liked posts also stay fresh longer. This + one is about other readers, so there is nothing to turn off. +

        + ); + break; + case FactorId.Age: + body = ( + <> +

        + Posted {Math.round(decision.ageDays)} days ago. A post loses half + its pull every {params['age.half_life_days']} days + , stretched up to {params['age.max_stretch']}× for well-liked + posts. +

        + store.setParam('age.half_life_days', value)} + options={freshnessOptions} + /> + + ); + break; + case FactorId.Seen: + body = ( + <> +

        + It was on your screen {decision.impressions}{' '} + {decision.impressions === 1 ? 'time' : 'times'} without a click. + Each view keeps{' '} + {Math.round(params['history.seen_factor_per_show'] * 100)}% of its + pull, and at {params['history.max_impressions_without_action']}{' '} + views it leaves. +

        + + + + ); + break; + case FactorId.Waiting: + body = ( + <> +

        + #{decision.slot?.waitingTag} had not been on your + first screen lately, so its first post gets a turn and jumps ahead + of topics you just saw. +

        + + + ); + break; + case FactorId.SourceRepeat: + body = ( + <> +

        + {decision.slot?.sourceCount}{' '} + {decision.slot?.sourceCount === 1 ? 'post' : 'posts'} from{' '} + {sourceName} already sit above it. Each one lowers the next by{' '} + {Math.round(params['diversity.source_repeat_discount'] * 100)}%. +

        + + + ); + break; + case FactorId.TopicRepeat: + body = ( + <> +

        + Posts on similar topics already sit above it, so it moves down to + mix the page. +

        + + + ); + break; + case FactorId.SameHeadline: + body = ( + <> +

        + Its headline overlaps a post above it, so it is likely the same + story. +

        + + + ); + break; + default: + section = ControlSection.Interests; + body = ( + <> +

        + Your topics and follows found fewer posts than a page, so popular + posts fill the rest. Following a few more topics replaces them. +

        + store.setFilling(!state.isFillingWithPopular)} + /> + + ); + } + + return ( + <> +
        + + {chip.label} + + {chip.id !== FactorId.Popular && ( + + {formatMultiplier(chip.value)} on this post + + )} +
        + {body} +
        + Changes apply when you refresh. + {onOpenControls && ( + + )} +
        + + ); +}; + +const Chip = ({ + chip, + decision, +}: { + chip: FactorChip; + decision: Decision; +}): ReactElement => { + const [anchor, setAnchor] = useState(null); + const tone = getTone(chip); + + return ( + <> + + {anchor && ( + setAnchor(null)} + > + + + )} + + ); +}; + +export const FactorChips = ({ + decision, + limit, +}: { + decision: Decision; + limit: number; +}): ReactElement => ( +
          + {getFactorChips(decision) + .slice(0, limit) + .map((chip) => ( +
        • + +
        • + ))} +
        +); diff --git a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx index 5fd2de5dab..88b4d34cc4 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx +++ b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx @@ -1,7 +1,8 @@ import type { MouseEvent, ReactElement } from 'react'; -import React, { useContext, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import { Button, ButtonSize, @@ -9,22 +10,28 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; import { FilterIcon } from '@dailydotdev/shared/src/components/icons/Filter'; -import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; import { ArticleList } from '@dailydotdev/shared/src/components/cards/article/ArticleList'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { AuthorSourceStack } from '@dailydotdev/shared/src/components/cards/common/AuthorSourceStack'; +import { TypeLabel } from '@dailydotdev/shared/src/components/cards/common/list/TypeLabel'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import type { ExplainedPost, ViewerProfile } from './feedExplain'; -import { FeedReason, TuneLevel } from './feedExplain'; -import type { TuningSeed } from './FeedTuningContext'; +import type { Decision, PersonalizationState } from '../policy/rank'; +import { getExclusion, Lane } from '../policy/rank'; import { - describeChange, - FeedTuningProvider, - useFeedTuning, -} from './FeedTuningContext'; -import { ExplainPanel, ExplainVariant, FeedScreenContext } from './ExplainPanel'; -import { ExplainBar } from './ExplainBar'; -import { ExplainDetailsHost } from './ExplainDetails'; + PersonalizationProvider, + usePersonalization, +} from './PersonalizationContext'; +import type { ControlSection } from './FeedScreenContext'; +import { + FeedScreenContext, + getSignalCounts, + useFeedScreen, +} from './FeedScreenContext'; +import { ExplainPanel, ExplainVariant } from './ExplainPanel'; +import { ExplainDetailsModal } from './ExplainDetails'; +import { PersonalizationPanel } from './PersonalizationPanel'; +import { ChangesDock } from './ChangesDock'; export enum FeedLayout { Grid = 'grid', @@ -44,127 +51,175 @@ const cardHandlers = { }; /** Card links point at app.daily.dev; keep the canvas on the story. */ -const preventNavigation = (event: MouseEvent) => { +export const preventNavigation = (event: MouseEvent): void => { if ((event.target as HTMLElement).closest('a')) { event.preventDefault(); } }; -/** In Explain mode a card keeps only where the post is from and its title. */ +/** + * In Explain mode a card keeps only where the post is from and its title, + * drawn at the exact offsets the real card uses so nothing on it moves. + */ const CardHead = ({ - item, + decision, layout, isLeaving, }: { - item: ExplainedPost; + decision: Decision; layout: FeedLayout; isLeaving: boolean; }): ReactElement => { const isGrid = layout === FeedLayout.Grid; + const { post } = decision; + const position = ( + + #{decision.position} + + ); return (
        - - - - {item.post.source?.name} + {isGrid ? ( + + + + {post.source?.name} + + {position} + + ) : ( + + {post.author ? ( + + ) : ( + + )} + + {post.source?.name} + + {position} - + )}

        - {item.post.title} + {post.title}

        ); }; interface CardFrameProps { - item: ExplainedPost; + decision: Decision; + leaving?: Decision; index: number; layout: FeedLayout; isExplain: boolean; } export const CardFrame = ({ - item, + decision, + leaving, index, layout, isExplain, }: CardFrameProps): ReactElement => { - const { focus, hoverKey, getLeaveReason } = useFeedTuning(); - const { counts } = useContext(FeedScreenContext); - const isLeaving = isExplain && !!getLeaveReason(item); - const isDimmed = - isExplain && focus?.type === 'reason' && focus.reason !== item.reason; + const { hoverKey } = usePersonalization(); + const { counts } = useFeedScreen(); + const isLeaving = isExplain && !!leaving; + const isGrid = layout === FeedLayout.Grid; + const { post } = decision; + const keys = [ + ...(post.tags ?? []).map((tag) => `topic:${tag}`), + `source:${post.source?.id}`, + ]; // Only worth pointing at when the signal is on more than this card. const isHighlighted = isExplain && !!hoverKey && (counts[hoverKey] ?? 0) > 1 && - item.signals.some(({ key }) => key === hoverKey); - const frameClassName = classNames( - 'relative rounded-16 transition-[opacity,box-shadow] duration-200', - isDimmed && 'opacity-30', - isHighlighted && - 'ring-2 ring-accent-cabbage-default ring-offset-2 ring-offset-background-default', - ); + keys.includes(hoverKey); - // A phone fits one list card per screen, so Explain trades the cover and - // action bar for the reason, letting you triage a few posts per screen. - if (layout === FeedLayout.List) { - return ( -
        - {isExplain ? ( -
        - - -
        + // The real card stays underneath so the feed keeps every size and gap; the + // overlay covers it with the explanation. + return ( +
        +
        + {isGrid ? ( + ) : ( - + )}
        - ); - } - - // The real card stays underneath so the grid keeps its size; the overlay - // lines its source and title up with the card's own. - return ( -
        -
        - -
        - - + {isExplain && ( + <> + {!isGrid && ( + + )} + + + + )}
        ); @@ -187,17 +242,15 @@ const FeedTab = ({ ); -interface FeedHeaderProps { - layout: FeedLayout; - isExplain: boolean; - onToggleExplain: () => void; -} - const FeedHeader = ({ layout, isExplain, onToggleExplain, -}: FeedHeaderProps): ReactElement => { +}: { + layout: FeedLayout; + isExplain: boolean; + onToggleExplain: () => void; +}): ReactElement => { const isList = layout === FeedLayout.List; return ( @@ -237,230 +290,173 @@ const FeedHeader = ({ ); }; -interface DockProps { - message: string; - canUndo: boolean; - hasChanges: boolean; - onUndo: () => void; - onRefresh: () => void; - onDone: () => void; -} +const FeedExplainScreen = ({ + layout, + isExplain, + onToggleExplain, +}: { + layout: FeedLayout; + isExplain: boolean; + onToggleExplain: () => void; +}): ReactElement => { + const { served, pool, state, pendingCount, refresh } = usePersonalization(); + const { onOpenControls } = useFeedScreen(); + const isList = layout === FeedLayout.List; + const leaving = useMemo( + () => + new Map( + served.map((decision) => [ + decision.post.id, + getExclusion(decision, pool, state), + ]), + ), + [served, pool, state], + ); -const ExplainDock = ({ - message, - canUndo, - hasChanges, - onUndo, - onRefresh, - onDone, -}: DockProps): ReactElement => ( -
        + return (
        - - {message} - - {canUndo && ( - + className={classNames( + 'flex min-h-screen flex-col bg-background-default', + isList ? 'gap-4 px-4 py-4' : 'gap-6 px-6 py-6 laptop:px-10', )} - + {served.map((decision, index) => ( + + ))} +
        + {isExplain && ( + onOpenControls())} + isCompact={isList} + /> + )}
        -
        -); + ); +}; + +export enum AppView { + Feed = 'feed', + Controls = 'controls', +} -interface FeedExplainScreenProps { - items: ExplainedPost[]; - reserve: ExplainedPost[]; - viewer: ViewerProfile; +interface AppProps { layout: FeedLayout; initialExplain?: boolean; - /** Opens the full explanation for this post on load. */ + initialView?: AppView; + initialSection?: ControlSection; initialDetailsId?: string; } -const FeedExplainScreen = ({ - items: initialItems, - reserve: initialReserve, - viewer, +const App = ({ layout, initialExplain = false, + initialView = AppView.Feed, + initialSection, initialDetailsId, -}: FeedExplainScreenProps): ReactElement => { - const tuning = useFeedTuning(); - const { changes, getLeaveReason, getBoost } = tuning; +}: AppProps): ReactElement => { + const store = usePersonalization(); + const [view, setView] = useState(initialView); + const [section, setSection] = useState(initialSection); const [isExplain, setIsExplain] = useState(initialExplain); - const [items, setItems] = useState(initialItems); - const [reserve, setReserve] = useState(initialReserve); - const [notice, setNotice] = useState(null); - const isList = layout === FeedLayout.List; - - const rank = useMemo( + const [details, setDetails] = useState( () => - new Map( - [...initialItems, ...initialReserve].map((item, index) => [ - item.post.id, - index, - ]), - ), - [initialItems, initialReserve], + store.served.find(({ post }) => post.id === initialDetailsId) ?? null, + ); + const counts = useMemo(() => getSignalCounts(store.served), [store.served]); + const screen = useMemo( + () => ({ + counts, + onOpenDetails: setDetails, + onOpenControls: (nextSection?: ControlSection) => { + setSection(nextSection); + setView(AppView.Controls); + window.scrollTo({ top: 0 }); + }, + }), + [counts], ); - const counts = useMemo(() => { - const byKey: Record = {}; - items.forEach(({ signals }) => - signals.forEach(({ key }) => { - byKey[key] = (byKey[key] ?? 0) + 1; - }), - ); - return byKey; - }, [items]); - - const refresh = () => { - const score = (item: ExplainedPost) => - getBoost(item) * 4 - (rank.get(item.post.id) ?? 0); - const byScore = (a: ExplainedPost, b: ExplainedPost) => score(b) - score(a); - const staying = items.filter((item) => !getLeaveReason(item)); - const candidates = reserve - .filter((item) => !getLeaveReason(item)) - .sort(byScore); - const boosted = candidates.filter((item) => getBoost(item) > 0); - // Posts you asked for more of take the popular picks' slots first. - const replaceable = staying - .filter( - (item) => item.reason === FeedReason.Popular && getBoost(item) <= 0, - ) - .reverse() - .slice(0, boosted.length); - const kept = staying.filter((item) => !replaceable.includes(item)); - const incoming = candidates.slice(0, items.length - kept.length); - - setItems([...kept, ...incoming].sort(byScore)); - setReserve(reserve.filter((item) => !incoming.includes(item))); - tuning.commit(); - tuning.setFocus(null); - const outCount = items.length - kept.length; - setNotice( - outCount - ? `Feed refreshed: ${outCount} ${ - outCount === 1 ? 'post' : 'posts' - } out, ${incoming.length} new in.` - : 'Feed refreshed with your changes.', - ); - }; const onToggleExplain = () => { - if (isExplain && changes.length) { - refresh(); + if (isExplain && store.pendingCount) { + store.refresh(); } - tuning.setFocus(null); - tuning.setHoverKey(null); - setNotice(null); + store.setHoverKey(null); setIsExplain(!isExplain); }; - const lastChange = changes[changes.length - 1]; - const getDockMessage = (): string => { - if (!lastChange) { - return notice ?? 'Tune anything that looks off. Changes save as you go.'; + const onBack = () => { + if (store.pendingCount) { + store.refresh(); } - const message = describeChange(lastChange); - if (lastChange.type === 'level' && lastChange.to === TuneLevel.Blocked) { - const leaving = items.filter((item) => { - const reason = getLeaveReason(item); - return ( - reason?.type === 'blocked' && - reason.signal.key === lastChange.signal.key - ); - }).length; - return leaving - ? `${message} ${leaving} ${ - leaving === 1 ? 'post leaves' : 'posts leave' - } on refresh.` - : message; - } - return message; + setView(AppView.Feed); + setIsExplain(true); + window.scrollTo({ top: 0 }); }; return ( - post.id === initialDetailsId)} - > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
        - - {isExplain && ( - - )} -
        - {items.map((item, index) => ( - - ))} -
        - {isExplain && ( - 0} - hasChanges={changes.length > 0} - onUndo={tuning.undo} - onRefresh={refresh} - onDone={onToggleExplain} +
        + {view === AppView.Feed ? ( + + ) : ( + )}
        - + {details && ( + setDetails(null)} + /> + )} + ); }; -export interface FeedExplainPageProps extends FeedExplainScreenProps { - seed?: TuningSeed; +export interface FeedExplainPageProps extends AppProps { + pool: Post[]; + initialState: PersonalizationState; } export const FeedExplainPage = ({ - seed, + pool, + initialState, ...props }: FeedExplainPageProps): ReactElement => ( - - - + + + ); diff --git a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts new file mode 100644 index 0000000000..7d86e5d24e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts @@ -0,0 +1,43 @@ +import { createContext, useContext } from 'react'; +import type { Decision } from '../policy/rank'; + +export enum ControlSection { + Overview = 'overview', + Interests = 'interests', + Sources = 'sources', + Ranking = 'ranking', + LeftOut = 'left-out', + Settings = 'settings', + Reset = 'reset', +} + +interface FeedScreenValue { + /** How many posts on screen carry each signal key. */ + counts: Record; + /** Opens the full "Why am I seeing this?" explanation. */ + onOpenDetails?: (decision: Decision) => void; + /** Opens the personalization controls, at a section. */ + onOpenControls?: (section?: ControlSection) => void; +} + +export const FeedScreenContext = createContext({ + counts: {}, +}); + +export const useFeedScreen = (): FeedScreenValue => + useContext(FeedScreenContext); + +export const getSignalCounts = ( + decisions: Decision[], +): Record => { + const counts: Record = {}; + decisions.forEach(({ post }) => { + [ + ...(post.tags ?? []).map((tag) => `topic:${tag}`), + ...(post.source?.id ? [`source:${post.source.id}`] : []), + ].forEach((key) => { + counts[key] = (counts[key] ?? 0) + 1; + }); + }); + return counts; +}; diff --git a/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx b/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx deleted file mode 100644 index bd573ab821..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import React, { createContext, useContext, useMemo, useState } from 'react'; -import type { ExplainedPost, FeedReason, FeedSignal } from './feedExplain'; -import { getSignalKey, getSignalName, SignalKind, TuneLevel } from './feedExplain'; - -interface LevelChange { - type: 'level'; - signal: FeedSignal; - from?: TuneLevel; - to?: TuneLevel; -} - -interface DismissChange { - type: 'dismiss'; - postId: string; - isDismissed: boolean; -} - -export type TuningChange = LevelChange | DismissChange; - -export type LeaveReason = - | { type: 'blocked'; signal: Pick } - | { type: 'dismissed' }; - -export type FeedFocus = - | { type: 'reason'; reason: FeedReason } - | { type: 'signal'; key: string }; - -export interface TuningSeed { - levels?: Record; - dismissed?: string[]; -} - -interface FeedTuningValue { - levels: Record; - /** Changes since the feed last refreshed, oldest first. */ - changes: TuningChange[]; - focus: FeedFocus | null; - hoverKey: string | null; - setLevel: (signal: FeedSignal, level?: TuneLevel) => void; - setDismissed: (postId: string, isDismissed: boolean) => void; - undo: () => void; - commit: () => void; - setFocus: (focus: FeedFocus | null) => void; - setHoverKey: (key: string | null) => void; - getLeaveReason: (item: ExplainedPost) => LeaveReason | undefined; - getBoost: (item: ExplainedPost) => number; -} - -const FeedTuningContext = createContext(null); - -const getPostKeys = (item: ExplainedPost) => [ - ...(item.post.source?.id - ? [getSignalKey(SignalKind.Source, item.post.source.id)] - : []), - ...(item.post.tags ?? []).map((tag) => getSignalKey(SignalKind.Topic, tag)), -]; - -export const FeedTuningProvider = ({ - seed, - children, -}: PropsWithChildren<{ seed?: TuningSeed }>): ReactElement => { - const [levels, setLevels] = useState>( - () => seed?.levels ?? {}, - ); - const [dismissed, setDismissedState] = useState>( - () => - Object.fromEntries((seed?.dismissed ?? []).map((id) => [id, true])), - ); - const [changes, setChanges] = useState([]); - const [focus, setFocus] = useState(null); - const [hoverKey, setHoverKey] = useState(null); - - const value = useMemo((): FeedTuningValue => { - const applyLevel = (key: string, level?: TuneLevel) => - setLevels((current) => { - const next = { ...current }; - if (level) { - next[key] = level; - } else { - delete next[key]; - } - return next; - }); - - return { - levels, - changes, - focus, - hoverKey, - setLevel: (signal, level) => { - const from = levels[signal.key]; - if (from === level) { - return; - } - applyLevel(signal.key, level); - // The hovered row unmounts when blocked, so it never sees mouseleave. - if (level === TuneLevel.Blocked) { - setHoverKey(null); - } - setChanges((current) => [ - ...current, - { type: 'level', signal, from, to: level }, - ]); - }, - setDismissed: (postId, isDismissed) => { - setDismissedState((current) => ({ ...current, [postId]: isDismissed })); - setChanges((current) => [ - ...current, - { type: 'dismiss', postId, isDismissed }, - ]); - }, - undo: () => { - const last = changes[changes.length - 1]; - if (!last) { - return; - } - if (last.type === 'level') { - applyLevel(last.signal.key, last.from); - } else { - setDismissedState((current) => ({ - ...current, - [last.postId]: !last.isDismissed, - })); - } - setChanges(changes.slice(0, -1)); - }, - commit: () => setChanges([]), - setFocus, - setHoverKey, - getLeaveReason: (item) => { - if (dismissed[item.post.id]) { - return { type: 'dismissed' }; - } - const blockedKey = getPostKeys(item).find( - (key) => levels[key] === TuneLevel.Blocked, - ); - if (!blockedKey) { - return undefined; - } - const signal = item.signals.find(({ key }) => key === blockedKey); - return signal ? { type: 'blocked', signal } : undefined; - }, - getBoost: (item) => - getPostKeys(item).reduce((boost, key) => { - if (levels[key] === TuneLevel.More) { - return boost + 1; - } - return levels[key] === TuneLevel.Less ? boost - 1 : boost; - }, 0), - }; - }, [levels, dismissed, changes, focus, hoverKey]); - - return ( - - {children} - - ); -}; - -export const useFeedTuning = (): FeedTuningValue => { - const value = useContext(FeedTuningContext); - if (!value) { - throw new Error('useFeedTuning must be used inside FeedTuningProvider'); - } - return value; -}; - -export const describeChange = (change: TuningChange): string => { - if (change.type === 'dismiss') { - return change.isDismissed - ? 'Got it, fewer posts like this one.' - : 'Post is back in your feed.'; - } - const name = getSignalName(change.signal); - switch (change.to) { - case TuneLevel.More: - return `You'll see more ${name}.`; - case TuneLevel.Less: - return `You'll see less ${name}.`; - case TuneLevel.Blocked: - return `Blocked ${name}.`; - default: - return change.from === TuneLevel.Blocked - ? `Unblocked ${name}.` - : `${name} is back to normal.`; - } -}; diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx new file mode 100644 index 0000000000..42307e5dde --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -0,0 +1,407 @@ +import type { PropsWithChildren, ReactElement } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, formatParam, getParamMeta } from '../policy/params'; +import type { TopicControl } from '../policy/profile'; +import { TopicMode } from '../policy/profile'; +import type { + Decision, + PersonalizationState, + RankResult, +} from '../policy/rank'; +import { rankFeed, SourceMode } from '../policy/rank'; + +interface Change { + message: string; + previous: PersonalizationState; + /** Consecutive changes with the same key, like a slider drag, merge. */ + mergeKey?: string; +} + +interface StoreState { + state: PersonalizationState; + changes: Change[]; + /** How many changes the served page already reflects. */ + refreshedAt: number; + served: Decision[]; + notice?: string; +} + +interface NamedEntity { + id: string; + name: string; +} + +interface PersonalizationValue { + state: PersonalizationState; + pool: Post[]; + /** The page as served at the last refresh. */ + served: Decision[]; + /** The page the current settings would serve. */ + next: RankResult; + pendingCount: number; + lastMessage?: string; + notice?: string; + undo: () => void; + refresh: () => void; + hoverKey: string | null; + setHoverKey: (key: string | null) => void; + setTopicMode: (tag: string, mode?: TopicMode) => void; + setTopicWeight: (tag: string, weight?: number) => void; + setTagFollowed: (tag: string, isFollowed: boolean) => void; + setTagBlocked: (tag: string, isBlocked: boolean) => void; + setSourceFollowed: (source: NamedEntity, isFollowed: boolean) => void; + setSourceMode: (source: NamedEntity, mode?: SourceMode) => void; + setSourceBlocked: (source: NamedEntity, isBlocked: boolean) => void; + setAuthorFollowed: (author: NamedEntity, isFollowed: boolean) => void; + setAuthorBlocked: (author: NamedEntity, isBlocked: boolean) => void; + setParam: (key: ParamKey, value: number) => void; + resetParam: (key: ParamKey) => void; + setLearning: (isLearning: boolean) => void; + setFilling: (isFilling: boolean) => void; + setHidden: (postId: string, isHidden: boolean) => void; + resetLearned: () => void; + resetControls: () => void; + resetParams: () => void; + clearSeen: () => void; + resetAll: () => void; +} + +const PersonalizationContext = createContext( + null, +); + +const toggle = (list: string[], value: string, isOn: boolean): string[] => + isOn + ? [...new Set([...list, value])] + : list.filter((item) => item !== value); + +const setControl = ( + controls: Record, + tag: string, + control: TopicControl, +): Record => { + const next = { ...controls }; + if (control.mode === undefined && control.weight === undefined) { + delete next[tag]; + } else { + next[tag] = control; + } + return next; +}; + +const modeMessage: Record string> = { + [TopicMode.Boosted]: (name) => `You'll see more ${name}.`, + [TopicMode.Demoted]: (name) => `You'll see less ${name}.`, + [TopicMode.Muted]: (name) => `Muted ${name}. Reading about it no longer counts.`, +}; + +export const PersonalizationProvider = ({ + pool, + initialState, + servedState, + children, +}: PropsWithChildren<{ + pool: Post[]; + initialState: PersonalizationState; + /** Serves the page from these settings instead, so changes show as pending. */ + servedState?: PersonalizationState; +}>): ReactElement => { + const [store, setStore] = useState(() => ({ + state: initialState, + changes: [], + refreshedAt: 0, + served: rankFeed(pool, servedState ?? initialState).served, + })); + const [hoverKey, setHoverKey] = useState(null); + const { state, changes, refreshedAt, served, notice } = store; + const next = useMemo(() => rankFeed(pool, state), [pool, state]); + + const value = useMemo((): PersonalizationValue => { + const apply = ( + message: string, + update: (current: PersonalizationState) => PersonalizationState, + mergeKey?: string, + ) => + setStore((current) => { + const last = current.changes[current.changes.length - 1]; + const isMerge = + !!mergeKey && + last?.mergeKey === mergeKey && + current.changes.length > current.refreshedAt; + return { + ...current, + notice: undefined, + state: update(current.state), + changes: [ + ...(isMerge ? current.changes.slice(0, -1) : current.changes), + { + message, + previous: isMerge ? last.previous : current.state, + mergeKey, + }, + ], + }; + }); + const tagName = (tag: string) => `#${tag}`; + const paramMessage = (key: ParamKey, value: number) => { + const meta = getParamMeta(key); + return `${meta.label}: ${formatParam(meta.format, value)}.`; + }; + + return { + state, + pool, + served, + next, + pendingCount: changes.length - refreshedAt, + lastMessage: + changes.length > refreshedAt + ? changes[changes.length - 1].message + : undefined, + notice, + undo: () => + setStore((current) => { + const last = current.changes[current.changes.length - 1]; + if (!last) { + return current; + } + return { + ...current, + state: last.previous, + changes: current.changes.slice(0, -1), + refreshedAt: Math.min( + current.refreshedAt, + current.changes.length - 1, + ), + }; + }), + refresh: () => + setStore((current) => { + const result = rankFeed(pool, current.state); + const servedIds = new Set(current.served.map(({ post }) => post.id)); + const incoming = result.served.filter( + ({ post }) => !servedIds.has(post.id), + ).length; + return { + ...current, + served: result.served, + refreshedAt: current.changes.length, + notice: incoming + ? `Feed refreshed: ${incoming} new ${ + incoming === 1 ? 'post' : 'posts' + } in.` + : 'Feed refreshed with your changes.', + }; + }), + hoverKey, + setHoverKey, + setTopicMode: (tag, mode) => + apply( + mode ? modeMessage[mode](tagName(tag)) : `${tagName(tag)} is back to normal.`, + (current) => ({ + ...current, + controls: setControl(current.controls, tag, { mode }), + }), + ), + setTopicWeight: (tag, weight) => + apply( + weight === undefined + ? `${tagName(tag)} uses its learned weight again.` + : `${tagName(tag)} weight set to ${weight.toFixed(2)}.`, + (current) => ({ + ...current, + controls: setControl(current.controls, tag, { weight }), + }), + `weight:${tag}`, + ), + setTagFollowed: (tag, isFollowed) => + apply( + isFollowed ? `Following ${tagName(tag)}.` : `Unfollowed ${tagName(tag)}.`, + (current) => ({ + ...current, + followedTags: toggle(current.followedTags, tag, isFollowed), + controls: isFollowed + ? setControl(current.controls, tag, { + ...current.controls[tag], + mode: + current.controls[tag]?.mode === TopicMode.Muted + ? undefined + : current.controls[tag]?.mode, + }) + : current.controls, + }), + ), + setTagBlocked: (tag, isBlocked) => + apply( + isBlocked ? `Blocked ${tagName(tag)}.` : `Unblocked ${tagName(tag)}.`, + (current) => ({ + ...current, + blockedTags: toggle(current.blockedTags, tag, isBlocked), + followedTags: isBlocked + ? toggle(current.followedTags, tag, false) + : current.followedTags, + }), + ), + setSourceFollowed: (source, isFollowed) => + apply( + isFollowed ? `Following ${source.name}.` : `Unfollowed ${source.name}.`, + (current) => ({ + ...current, + followedSources: toggle( + current.followedSources, + source.id, + isFollowed, + ), + }), + ), + setSourceMode: (source, mode) => + apply( + mode ? `You'll see less from ${source.name}.` : `${source.name} is back to normal.`, + (current) => { + const sourceControls = { ...current.sourceControls }; + if (mode) { + sourceControls[source.id] = mode; + } else { + delete sourceControls[source.id]; + } + return { ...current, sourceControls }; + }, + ), + setSourceBlocked: (source, isBlocked) => + apply( + isBlocked ? `Blocked ${source.name}.` : `Unblocked ${source.name}.`, + (current) => ({ + ...current, + blockedSources: toggle( + current.blockedSources, + source.id, + isBlocked, + ), + followedSources: isBlocked + ? toggle(current.followedSources, source.id, false) + : current.followedSources, + }), + ), + setAuthorFollowed: (author, isFollowed) => + apply( + isFollowed ? `Following ${author.name}.` : `Unfollowed ${author.name}.`, + (current) => ({ + ...current, + followedAuthors: toggle( + current.followedAuthors, + author.id, + isFollowed, + ), + }), + ), + setAuthorBlocked: (author, isBlocked) => + apply( + isBlocked ? `Blocked ${author.name}.` : `Unblocked ${author.name}.`, + (current) => ({ + ...current, + blockedAuthors: toggle( + current.blockedAuthors, + author.id, + isBlocked, + ), + }), + ), + setParam: (key, paramValue) => + apply( + paramMessage(key, paramValue), + (current) => ({ + ...current, + params: { ...current.params, [key]: paramValue }, + }), + `param:${key}`, + ), + resetParam: (key) => + apply( + `${getParamMeta(key).label} is back to its default.`, + (current) => ({ + ...current, + params: { ...current.params, [key]: defaultParams[key] }, + }), + ), + setLearning: (isLearning) => + apply( + isLearning + ? 'Learning from your reading again.' + : 'Stopped learning from your reading. Only follows count.', + (current) => ({ ...current, isLearning }), + ), + setFilling: (isFillingWithPopular) => + apply( + isFillingWithPopular + ? 'Popular posts fill a short feed again.' + : 'No more popular fill-ins. A short feed stays short.', + (current) => ({ ...current, isFillingWithPopular }), + ), + setHidden: (postId, isHidden) => + apply( + isHidden ? 'Got it, fewer posts like this one.' : 'Post is back in your feed.', + (current) => ({ + ...current, + hidden: toggle(current.hidden, postId, isHidden), + }), + ), + resetLearned: () => + apply( + 'Forgot everything learned from your reading. Follows and blocks stay.', + (current) => ({ ...current, evidence: [], tagShownDays: {} }), + ), + resetControls: () => + apply('Cleared your More, Less, Mute and exact weights.', (current) => ({ + ...current, + controls: {}, + sourceControls: {}, + })), + resetParams: () => + apply('Ranking is back to the defaults.', (current) => ({ + ...current, + params: defaultParams, + isFillingWithPopular: true, + isLearning: true, + })), + clearSeen: () => + apply('Forgot what you scrolled past.', (current) => ({ + ...current, + impressions: {}, + })), + resetAll: () => + apply( + 'Personalization reset. Only your follows, blocks and filters remain.', + (current) => ({ + ...current, + evidence: [], + tagShownDays: {}, + controls: {}, + sourceControls: {}, + params: defaultParams, + isFillingWithPopular: true, + isLearning: true, + impressions: {}, + hidden: [], + }), + ), + }; + }, [state, pool, served, next, changes, refreshedAt, notice, hoverKey]); + + return ( + + {children} + + ); +}; + +export const usePersonalization = (): PersonalizationValue => { + const value = useContext(PersonalizationContext); + if (!value) { + throw new Error( + 'usePersonalization must be used inside PersonalizationProvider', + ); + } + return value; +}; diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx new file mode 100644 index 0000000000..a8312d4bab --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx @@ -0,0 +1,293 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, policyVersion } from '../policy/params'; +import { InterestOrigin, Strength } from '../policy/profile'; +import { usePersonalization } from './PersonalizationContext'; +import { ControlSection } from './FeedScreenContext'; +import { ChangesDock } from './ChangesDock'; +import { PanelCard, PanelSection, sectionId } from './panel/Section'; +import { InterestsSection } from './panel/InterestsSection'; +import { SourcesSection } from './panel/SourcesSection'; +import { RankingSection } from './panel/RankingSection'; +import { ResetSection, SettingsSection } from './panel/SettingsSection'; +import { LeftOutList, NextPageList } from './panel/Preview'; + +const navItems: Array<{ section: ControlSection; label: string }> = [ + { section: ControlSection.Overview, label: 'Overview' }, + { section: ControlSection.Interests, label: 'Interests' }, + { section: ControlSection.Sources, label: 'Sources and people' }, + { section: ControlSection.Ranking, label: 'Ranking' }, + { section: ControlSection.LeftOut, label: 'Left out' }, + { section: ControlSection.Settings, label: 'Your choices' }, + { section: ControlSection.Reset, label: 'Start over' }, +]; + +enum RailTab { + Next = 'next', + LeftOut = 'left-out', +} + +const railQuery = '(min-width: 1020px)'; + +const scrollTo = (section: ControlSection) => + document + .getElementById(sectionId(section)) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + +const Tile = ({ + label, + value, + detail, + onClick, +}: { + label: string; + value: string | number; + detail: string; + onClick: () => void; +}): ReactElement => ( + +); + +const Rail = ({ + tab, + onTabChange, +}: { + tab: RailTab; + onTabChange: (tab: RailTab) => void; +}): ReactElement => { + const { next } = usePersonalization(); + + return ( + +
        + {[ + { id: RailTab.Next, label: 'Your next page' }, + { id: RailTab.LeftOut, label: `Left out (${next.leftOut.length})` }, + ].map(({ id, label }) => ( + + ))} +
        + {tab === RailTab.Next ? : } +
        + ); +}; + +export const PersonalizationPanel = ({ + section, + isCompact, + onBack, +}: { + section?: ControlSection; + isCompact?: boolean; + onBack: () => void; +}): ReactElement => { + const store = usePersonalization(); + const { state, next } = store; + const [railTab, setRailTab] = useState( + section === ControlSection.LeftOut ? RailTab.LeftOut : RailTab.Next, + ); + const hasRail = () => !isCompact && window.matchMedia(railQuery).matches; + const followedCount = next.interests.filter( + ({ origin }) => origin === InterestOrigin.Declared, + ).length; + const learnedCount = next.interests.filter( + ({ origin, weight }) => origin === InterestOrigin.Inferred && weight > 0, + ).length; + const earlyCount = next.interests.filter( + ({ strength }) => strength === Strength.EarlySignal, + ).length; + const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length; + + const goTo = (target: ControlSection) => { + if (target === ControlSection.LeftOut && hasRail()) { + setRailTab(RailTab.LeftOut); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + scrollTo(target); + }; + + useEffect(() => { + if (section && section !== ControlSection.Overview) { + if (section === ControlSection.LeftOut && hasRail()) { + return; + } + document.getElementById(sectionId(section))?.scrollIntoView(); + } + // Only where the panel opened, not on every change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
        +
        + +
        +
        +

        + Personalization +

        +

        + Everything that decides what For You shows you, and every knob + behind it. Ranked by {policyVersion}; your profile + updates within minutes of what you read. +

        +
        + store.setLearning(!state.isLearning)} + compact={false} + > + Learn from my reading + +
        +
        + +
        +
        + +
        + goTo(ControlSection.Interests)} + /> + goTo(ControlSection.Sources)} + /> + goTo(ControlSection.Ranking)} + /> + goTo(ControlSection.LeftOut)} + /> +
        +
        + {isCompact && ( + + + + + )} + + + + {!isCompact && ( + + + + )} + + +
        + {!isCompact && ( + + )} +
        + +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/controls.tsx b/packages/storybook/stories/features/feed-explain/components/controls.tsx new file mode 100644 index 0000000000..837ddec2ae --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/controls.tsx @@ -0,0 +1,166 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, formatParam, getParamMeta } from '../policy/params'; +import { usePersonalization } from './PersonalizationContext'; + +export const Strong = ({ children }: { children: ReactNode }): ReactElement => ( + {children} +); + +export interface SegmentOption { + value: T; + label: string; + activeClassName?: string; +} + +/** One choice out of a few, as a row of pills. */ +export const Segmented = ({ + label, + value, + options, + onChange, + className, +}: { + label: string; + value: T; + options: SegmentOption[]; + onChange: (value: T) => void; + className?: string; +}): ReactElement => ( +
        + {options.map((option) => { + const isActive = option.value === value; + return ( + + ); + })} +
        +); + +export const ToggleRow = ({ + id, + label, + description, + checked, + onToggle, +}: { + id: string; + label: string; + description?: ReactNode; + checked: boolean; + onToggle: () => void; +}): ReactElement => ( +
        + + + {description && ( + {description} + )} + + +
        +); + +/** A labelled slider for one policy knob, with its default and raw key. */ +export const ParamSlider = ({ + paramKey, + isCompact, +}: { + paramKey: ParamKey; + isCompact?: boolean; +}): ReactElement => { + const { state, setParam, resetParam } = usePersonalization(); + const meta = getParamMeta(paramKey); + const value = state.params[paramKey]; + const isDefault = value === defaultParams[paramKey]; + + return ( +
        +
        + + {meta.label} + + + {formatParam(meta.format, value)} + +
        + setParam(paramKey, next)} + /> + {!isCompact && ( + {meta.help} + )} + + {paramKey} + {isDefault ? ( + Default + ) : ( + + )} + +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/feedExplain.ts b/packages/storybook/stories/features/feed-explain/components/feedExplain.ts deleted file mode 100644 index 39b5e5e8d2..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/feedExplain.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { Post } from '@dailydotdev/shared/src/graphql/posts'; -import type { Source } from '@dailydotdev/shared/src/graphql/sources'; - -/** Why the ranker put a post in For You, in the order it checks them. */ -export enum FeedReason { - Following = 'following', - Picked = 'picked', - Reading = 'reading', - Popular = 'popular', -} - -export enum SignalKind { - Topic = 'topic', - Source = 'source', -} - -/** Where a signal came from for this viewer. `Post` means it only appears. */ -export enum SignalOrigin { - Following = 'following', - Picked = 'picked', - Reading = 'reading', - Post = 'post', -} - -/** - * Proposed per-topic and per-source weight. Today's API only has follow and - * block, so `Less` and `More` are new. - */ -export enum TuneLevel { - Less = 'less', - More = 'more', - Blocked = 'blocked', -} - -export interface FeedSignal { - key: string; - kind: SignalKind; - id: string; - label: string; - origin: SignalOrigin; - source?: Source; -} - -export interface ExplainedPost { - post: Post; - reason: FeedReason; - /** The signal the reason sentence names; absent for popular picks. */ - reasonSignal?: FeedSignal; - /** Every topic and the source, matched ones first. */ - signals: FeedSignal[]; -} - -/** What a new viewer told us, or what we inferred, a few days in. */ -export interface ViewerProfile { - pickedTags: string[]; - readingTags: string[]; - followedSources: string[]; -} - -export const getSignalKey = (kind: SignalKind, id: string): string => - `${kind}:${id}`; - -export const getSignalName = (signal: Pick) => - signal.kind === SignalKind.Topic ? `#${signal.label}` : signal.label; - -const originRank: Record = { - [SignalOrigin.Following]: 0, - [SignalOrigin.Picked]: 1, - [SignalOrigin.Reading]: 2, - [SignalOrigin.Post]: 3, -}; - -const reasonByOrigin: Partial> = { - [SignalOrigin.Following]: FeedReason.Following, - [SignalOrigin.Picked]: FeedReason.Picked, - [SignalOrigin.Reading]: FeedReason.Reading, -}; - -export const explainPost = ( - post: Post, - profile: ViewerProfile, -): ExplainedPost => { - const getTagOrigin = (tag: string): SignalOrigin => { - if (profile.pickedTags.includes(tag)) { - return SignalOrigin.Picked; - } - return profile.readingTags.includes(tag) - ? SignalOrigin.Reading - : SignalOrigin.Post; - }; - const topics = (post.tags ?? []).map( - (tag): FeedSignal => ({ - key: getSignalKey(SignalKind.Topic, tag), - kind: SignalKind.Topic, - id: tag, - label: tag, - origin: getTagOrigin(tag), - }), - ); - const { source } = post; - const sourceSignal: FeedSignal[] = source?.id - ? [ - { - key: getSignalKey(SignalKind.Source, source.id), - kind: SignalKind.Source, - id: source.id, - label: source.name ?? source.id, - origin: profile.followedSources.includes(source.id) - ? SignalOrigin.Following - : SignalOrigin.Post, - source, - }, - ] - : []; - const signals = [...sourceSignal, ...topics].sort( - (a, b) => originRank[a.origin] - originRank[b.origin], - ); - const reasonSignal = signals.find( - ({ origin }) => origin !== SignalOrigin.Post, - ); - - return { - post, - reason: reasonSignal - ? reasonByOrigin[reasonSignal.origin] ?? FeedReason.Popular - : FeedReason.Popular, - reasonSignal, - signals, - }; -}; - -/** - * The rows a card has room for: matched topics, then the post's other - * topics, and the source last so it can always be blocked. - */ -export const getCardSignals = ( - item: ExplainedPost, - limit: number, -): FeedSignal[] => { - const source = item.signals.find(({ kind }) => kind === SignalKind.Source); - const topics = item.signals.filter(({ kind }) => kind === SignalKind.Topic); - const isSourceLed = source && source.origin !== SignalOrigin.Post; - - if (!source) { - return topics.slice(0, limit); - } - - return isSourceLed - ? [source, ...topics.slice(0, limit - 1)] - : [...topics.slice(0, limit - 1), source]; -}; - -interface ReasonMeta { - label: string; - dotClassName: string; -} - -export const reasonMeta: Record = { - [FeedReason.Picked]: { - label: 'Topics you picked', - dotClassName: 'bg-accent-cabbage-default', - }, - [FeedReason.Reading]: { - label: 'Like what you read', - dotClassName: 'bg-accent-blueCheese-default', - }, - [FeedReason.Following]: { - label: 'Sources you follow', - dotClassName: 'bg-accent-water-default', - }, - [FeedReason.Popular]: { - label: 'Popular picks', - dotClassName: 'bg-accent-cheese-default', - }, -}; - -export const reasonOrder: FeedReason[] = [ - FeedReason.Picked, - FeedReason.Reading, - FeedReason.Following, - FeedReason.Popular, -]; - -export const originLabel: Record = { - [SignalOrigin.Following]: 'You follow it', - [SignalOrigin.Picked]: 'You picked it', - [SignalOrigin.Reading]: 'From your reading', - [SignalOrigin.Post]: '', -}; - -export const levelLabel: Record = { - [TuneLevel.More]: 'Showing more', - [TuneLevel.Less]: 'Showing less', - [TuneLevel.Blocked]: 'Blocked', -}; - -export const levelTextClassName: Record = { - [TuneLevel.More]: 'text-accent-avocado-default', - [TuneLevel.Less]: 'text-accent-bun-default', - [TuneLevel.Blocked]: 'text-accent-ketchup-default', -}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx new file mode 100644 index 0000000000..2442291a23 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx @@ -0,0 +1,518 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { Interest, TopicEvidence } from '../../policy/profile'; +import { + InterestOrigin, + Strength, + strengthLabel, + strengthRule, + TopicMode, +} from '../../policy/profile'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { Segmented } from '../controls'; +import { Badge, PanelCard, PanelSection } from './Section'; + +enum RowStatus { + Followed = 'followed', + Learned = 'learned', + Early = 'early', + Muted = 'muted', + Blocked = 'blocked', +} + +enum Filter { + All = 'all', + Followed = 'followed', + Learned = 'learned', + Early = 'early', + Off = 'off', +} + +interface ProfileRow { + tag: string; + status: RowStatus; + interest?: Interest; + evidence?: TopicEvidence; +} + +const statusBadge: Record = { + [RowStatus.Followed]: { + label: 'You follow', + className: 'bg-accent-cabbage-flat text-accent-cabbage-default', + }, + [RowStatus.Learned]: { + label: 'Learned', + className: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', + }, + [RowStatus.Early]: { + label: 'Not counted yet', + className: 'bg-surface-float text-text-tertiary', + }, + [RowStatus.Muted]: { + label: 'Muted', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, + [RowStatus.Blocked]: { + label: 'Blocked', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, +}; + +const filterStatuses: Record = { + [Filter.All]: Object.values(RowStatus), + [Filter.Followed]: [RowStatus.Followed], + [Filter.Learned]: [RowStatus.Learned], + [Filter.Early]: [RowStatus.Early], + [Filter.Off]: [RowStatus.Muted, RowStatus.Blocked], +}; + +const maxWeight = 1.5; + +const formatDays = (days: number): string => { + if (days < 1) { + return 'today'; + } + return days === 1 ? 'yesterday' : `${days} days ago`; +}; + +const WeightBar = ({ interest }: { interest?: Interest }): ReactElement => { + const weight = interest?.weight ?? 0; + const computed = interest?.computedWeight ?? 0; + const isAdjusted = !!interest?.control && weight !== computed; + + return ( + + + + {isAdjusted && ( + + )} + + + {weight.toFixed(2)} + + + ); +}; + +const Formula = ({ row }: { row: ProfileRow }): ReactElement => { + const { state } = usePersonalization(); + const { params } = state; + const { interest } = row; + if (!interest || row.status === RowStatus.Blocked) { + return ( +

        + Blocked topics are not interests. Posts with this topic never show. +

        + ); + } + const recency = interest.recency.toFixed(2); + let base: string; + if (row.status === RowStatus.Early) { + base = `1 story so far. It starts counting at 2 stories, or right away if you follow it.`; + } else if (interest.origin === InterestOrigin.Declared) { + const boostKey = interest.strength + ? `interests.declared_boost.${interest.strength}` + : undefined; + base = + interest.strength && boostKey && interest.strength !== Strength.EarlySignal + ? `${params['interests.base_weight'].toFixed(2)} for following + ${( + params[boostKey as 'interests.declared_boost.strong'] ?? 0 + ).toFixed(2)} (${strengthLabel[interest.strength].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}` + : `${params['interests.base_weight'].toFixed(2)} for following. Reading about it adds more.`; + } else { + const weightKey = `interests.inferred_weight.${interest.strength}` as + | 'interests.inferred_weight.strong' + | 'interests.inferred_weight.moderate'; + base = `${(params[weightKey] ?? 0).toFixed(2)} (${strengthLabel[ + interest.strength ?? Strength.Moderate + ].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}`; + } + const { control } = interest; + let adjustment: string | undefined; + if (control?.weight !== undefined) { + adjustment = `You set it to ${control.weight.toFixed(2)}.`; + } else if (control?.mode === TopicMode.Boosted) { + adjustment = `× ${params['interests.control_boost'].toFixed(2)} for More = ${interest.weight.toFixed(2)}`; + } else if (control?.mode === TopicMode.Demoted) { + adjustment = `× ${params['interests.control_demote'].toFixed(2)} for Less = ${interest.weight.toFixed(2)}`; + } else if (control?.mode === TopicMode.Muted) { + adjustment = 'Muted, so it counts as 0.'; + } + + return ( +

        + {base} + {adjustment && {adjustment}} +

        + ); +}; + +const RowDetail = ({ row }: { row: ProfileRow }): ReactElement => { + const store = usePersonalization(); + const { interest } = row; + const isFollowed = row.status === RowStatus.Followed; + const isBlocked = row.status === RowStatus.Blocked; + + return ( +
        + + {interest && !isBlocked && ( +
        + + + Exact weight + + + {interest.weight.toFixed(2)} + + + store.setTopicWeight(row.tag, value)} + /> + + A post whose main topic is #{row.tag} gets × + {(1 + interest.weight).toFixed(2)}; as a side topic, × + {( + 1 + + interest.weight * store.state.params['relevance.side_topic_weight'] + ).toFixed(2)} + . + +
        + )} +
        + {!isBlocked && ( + + )} + + {interest?.control && ( + + )} +
        +
        + ); +}; + +const ProfileRowItem = ({ + row, + onScreen, + isCompact, +}: { + row: ProfileRow; + onScreen: number; + isCompact?: boolean; +}): ReactElement => { + const store = usePersonalization(); + const [isOpen, setIsOpen] = useState(false); + const { interest, evidence } = row; + const badge = statusBadge[row.status]; + const isOff = row.status === RowStatus.Blocked; + const mode = store.state.controls[row.tag]?.mode; + const facts = [ + evidence + ? `${evidence.stories} ${evidence.stories === 1 ? 'story' : 'stories'}` + : 'No reading yet', + evidence?.strongStories + ? `${evidence.strongStories} upvoted, saved or shared` + : undefined, + evidence ? `last ${formatDays(evidence.daysSinceLastAction)}` : undefined, + interest?.evidence ? `${Math.round(interest.recency * 100)}% fresh` : undefined, + onScreen ? `in ${onScreen} of your next 12` : undefined, + ].filter(Boolean); + + return ( +
      • +
        + +
        + + {!isOff && ( + store.setTopicMode(row.tag, value)} + options={[ + ...(row.status === RowStatus.Followed + ? [] + : [ + { + value: TopicMode.Muted, + label: 'Mute', + activeClassName: 'text-accent-ketchup-default', + }, + ]), + { + value: TopicMode.Demoted, + label: 'Less', + activeClassName: 'text-accent-bun-default', + }, + { value: undefined, label: 'Auto' }, + { + value: TopicMode.Boosted, + label: 'More', + activeClassName: 'text-accent-avocado-default', + }, + ]} + /> + )} +
        +
        + {isOpen && } +
      • + ); +}; + +const filterLabels: Record = { + [Filter.All]: 'All', + [Filter.Followed]: 'Followed', + [Filter.Learned]: 'Learned', + [Filter.Early]: 'Not counted yet', + [Filter.Off]: 'Muted and blocked', +}; + +export const InterestsSection = ({ + isCompact, +}: { + isCompact?: boolean; +}): ReactElement => { + const store = usePersonalization(); + const { state, next, pool } = store; + const [filter, setFilter] = useState(Filter.All); + const rows = useMemo((): ProfileRow[] => { + const fromInterests = next.interests.map((interest): ProfileRow => { + let status = RowStatus.Learned; + if (interest.control?.mode === TopicMode.Muted) { + status = RowStatus.Muted; + } else if (interest.origin === InterestOrigin.Declared) { + status = RowStatus.Followed; + } else if (interest.strength === Strength.EarlySignal) { + status = RowStatus.Early; + } + return { + tag: interest.tag, + status, + interest, + evidence: state.evidence.find(({ topic }) => topic === interest.tag), + }; + }); + const blocked = state.blockedTags.map( + (tag): ProfileRow => ({ + tag, + status: RowStatus.Blocked, + evidence: state.evidence.find(({ topic }) => topic === tag), + }), + ); + const order = Object.values(RowStatus); + return [...fromInterests, ...blocked].sort( + (a, b) => + order.indexOf(a.status) - order.indexOf(b.status) || + (b.interest?.weight ?? 0) - (a.interest?.weight ?? 0), + ); + }, [next.interests, state.blockedTags, state.evidence]); + const onScreen = (tag: string) => + next.served.filter(({ post }) => post.tags?.includes(tag)).length; + const suggestions = useMemo(() => { + const known = new Set(rows.map(({ tag }) => tag)); + const counts = new Map(); + pool.forEach(({ tags }) => + (tags ?? []).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1)), + ); + return [...counts.entries()] + .filter(([tag]) => !known.has(tag)) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([tag]) => tag); + }, [pool, rows]); + const visible = rows.filter(({ status }) => + filterStatuses[filter].includes(status), + ); + const countFor = (value: Filter) => + rows.filter(({ status }) => filterStatuses[value].includes(status)).length; + + return ( + + {!state.isLearning && ( +

        + Learning from reading is off, so only followed topics count right now. +

        + )} +
        + {Object.values(Filter).map((value) => ( + + ))} +
        +
          + {visible.map((row) => ( + + ))} + {!visible.length && ( +
        • + Nothing here. +
        • + )} +
        + + + Follow a topic + + + Topics in posts you could see, most common first. + +
        + {suggestions.map((tag) => ( + + ))} +
        +
        +
        + {Object.values(Strength).map((strength) => ( +
        +
        + {strengthLabel[strength]} +
        +
        {strengthRule[strength]}
        +
        + ))} +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx new file mode 100644 index 0000000000..1168361a85 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx @@ -0,0 +1,323 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; +import type { Decision } from '../../policy/rank'; +import { Exclusion, getMainTopic } from '../../policy/rank'; +import { + exclusionLabel, + getCategory, + getCategoryDot, + getFactorChips, +} from '../../policy/describe'; +import { defaultParams } from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { useFeedScreen } from '../FeedScreenContext'; +import { Badge } from './Section'; + +const Movement = ({ decision }: { decision: Decision }): ReactElement | null => { + const { served } = usePersonalization(); + const before = served.find(({ post }) => post.id === decision.post.id); + if (!before) { + return ( + + New + + ); + } + const delta = (before.position ?? 0) - (decision.position ?? 0); + if (!delta) { + return null; + } + return ( + 0 ? 'text-accent-avocado-default' : 'text-accent-bun-default', + )} + > + 0 ? undefined : 'rotate-180'} + /> + {Math.abs(delta)} + + ); +}; + +const PostLine = ({ + decision, + meta, + aside, + action, +}: { + decision: Decision; + meta: ReactNode; + aside?: ReactNode; + action?: ReactNode; +}): ReactElement => ( +
      • + + + + {decision.post.title} + + + {meta} + + {action} + + {aside} +
      • +); + +export const NextPageList = ({ + limit, +}: { + limit?: number; +}): ReactElement => { + const { next, served } = usePersonalization(); + const { onOpenDetails } = useFeedScreen(); + const servedIds = new Set(served.map(({ post }) => post.id)); + const incoming = next.served.filter(({ post }) => !servedIds.has(post.id)); + const decisions = limit ? next.served.slice(0, limit) : next.served; + + return ( +
        +

        + {incoming.length + ? `If you refreshed now, ${incoming.length} new ${ + incoming.length === 1 ? 'post comes' : 'posts come' + } in.` + : 'If you refreshed now, the same posts would show.'} +

        +
          + {decisions.map((decision) => { + const chip = getFactorChips(decision)[0]; + return ( + + + #{decision.position} + + + + {chip?.label} + + {onOpenDetails && ( + + )} + + } + aside={} + /> + ); + })} +
        +
        + ); +}; + +const exclusionOrder = [ + Exclusion.BelowThePage, + Exclusion.NoInterestOrFollow, + Exclusion.SideTopicUnproven, + Exclusion.SideTopicsOff, + Exclusion.BlockedTag, + Exclusion.BlockedSource, + Exclusion.BlockedAuthor, + Exclusion.Hidden, + Exclusion.ShownTooOften, + Exclusion.TooOld, +]; + +const exclusionHelp: Partial> = { + [Exclusion.BelowThePage]: + 'Eligible, but other posts scored higher. Freshness and variety decide most of these.', + [Exclusion.NoInterestOrFollow]: + "A post needs a topic you follow or read, or a source you follow. These don't have one.", + [Exclusion.SideTopicUnproven]: + 'Your topic is only a side topic here, and readers have not responded to it enough yet.', + [Exclusion.ShownTooOften]: 'On your screen too many times without a click.', +}; + +const LeftOutFix = ({ decision }: { decision: Decision }): ReactElement | null => { + const store = usePersonalization(); + const { post } = decision; + const source = { id: post.source?.id ?? '', name: post.source?.name ?? '' }; + const mainTopic = getMainTopic(post); + const link = (label: string, run: () => void) => ( + + ); + + switch (decision.exclusion) { + case Exclusion.NoInterestOrFollow: + case Exclusion.SideTopicUnproven: + return mainTopic + ? link(`Follow #${mainTopic}`, () => + store.setTagFollowed(mainTopic, true), + ) + : null; + case Exclusion.BlockedTag: + return link(`Unblock #${decision.exclusionTag}`, () => + store.setTagBlocked(decision.exclusionTag ?? '', false), + ); + case Exclusion.BlockedSource: + return link(`Unblock ${source.name}`, () => + store.setSourceBlocked(source, false), + ); + case Exclusion.Hidden: + return link('Show it again', () => store.setHidden(post.id, false)); + case Exclusion.SideTopicsOff: + return link('Turn side topics back on', () => + store.setParam( + 'relevance.side_topic_weight', + defaultParams['relevance.side_topic_weight'], + ), + ); + default: + return null; + } +}; + +const LeftOutGroup = ({ + exclusion, + decisions, +}: { + exclusion: Exclusion; + decisions: Decision[]; +}): ReactElement => { + const store = usePersonalization(); + const [isOpen, setIsOpen] = useState(exclusion !== Exclusion.BelowThePage); + const shown = isOpen ? decisions.slice(0, 6) : []; + + return ( +
      • + + {isOpen && ( +
          + {shown.map((decision) => ( + + {exclusion === Exclusion.BelowThePage + ? `Ranked #${decision.eligibleRank} before page variety` + : (decision.post.tags ?? []) + .slice(0, 3) + .map((tag) => `#${tag}`) + .join(' ')} + + } + action={} + /> + ))} + {decisions.length > shown.length && ( +
        • + And {decisions.length - shown.length} more +
        • + )} + {exclusion === Exclusion.ShownTooOften && ( +
        • + +
        • + )} +
        + )} +
      • + ); +}; + +export const LeftOutList = (): ReactElement => { + const { next } = usePersonalization(); + const groups = exclusionOrder + .map((exclusion) => ({ + exclusion, + decisions: next.leftOut.filter((decision) => decision.exclusion === exclusion), + })) + .filter(({ decisions }) => decisions.length); + + return ( +
        +

        + {next.leftOut.length} posts were considered and left out. Each group + says why. +

        +
          + {groups.map(({ exclusion, decisions }) => ( + + ))} +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx new file mode 100644 index 0000000000..e94d23a2d3 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx @@ -0,0 +1,209 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import { CopyIcon } from '@dailydotdev/shared/src/components/icons/Copy'; +import type { ParamKey } from '../../policy/params'; +import { + defaultParams, + ParamGroup, + paramGroups, + paramMeta, + policyVersion, + toRetrieverConfig, +} from '../../policy/params'; +import { challengerRetrieverConfig } from '../../policy/challengerConfig'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { ParamSlider, ToggleRow } from '../controls'; +import { PanelCard, PanelSection } from './Section'; + +const fixedKnobs = [ + { + title: 'Placement coin', + text: 'Two nearly tied cards swap places on a fair coin, so the team can measure how much position alone matters. It only reorders cards already picked.', + }, + { + title: 'Reader response math', + text: "How a post's upvotes, comments, saves and shares per view become its base score, and when a post is disliked enough to drop. It is about other readers, not you.", + }, + { + title: 'Title rules', + text: 'Posts with unusable titles, like "Untitled" or cookie banners, never show.', + }, +]; + +const ConfigView = (): ReactElement => { + const { state } = usePersonalization(); + const changed = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ); + const json = JSON.stringify( + toRetrieverConfig(state.params, challengerRetrieverConfig), + null, + 2, + ); + + return ( +
        +
        + + {changed.length + ? `Changed: ${changed.join(', ')}` + : 'Everything is at the default.'} + + +
        +
        +        {json}
        +      
        +
        + ); +}; + +export const RankingSection = (): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const [isAdvanced, setIsAdvanced] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + const changedCount = + (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length + + (state.isLearning ? 0 : 1) + + (state.isFillingWithPopular ? 0 : 1); + + return ( + + The knobs of {policyVersion}, the For You policy. A + post's score is reader response × topic match × follow × + freshness × seen before; each page then mixes topics and sources and + gives waiting interests a turn. + + } + actions={ +
        + setIsAdvanced(!isAdvanced)} + > + Advanced + + {changedCount > 0 && ( + + )} +
        + } + > +
        + {paramGroups.map(({ group, title, description }) => { + const knobs = paramMeta.filter( + (meta) => meta.group === group && (isAdvanced || !meta.isAdvanced), + ); + return ( + +
        +

        + {title} +

        +

        {description}

        +
        + {group === ParamGroup.Learning && ( + store.setLearning(!state.isLearning)} + /> + )} + {group === ParamGroup.Scope && ( + store.setFilling(!state.isFillingWithPopular)} + /> + )} + {knobs.map(({ key }) => ( + + ))} +
        + ); + })} +
        + + + {isConfigOpen && } + +
        +

        + Not adjustable +

        +
          + {fixedKnobs.map(({ title, text }) => ( +
        • + + {title} + + {text} +
        • + ))} +
        +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx new file mode 100644 index 0000000000..ceff3ddffa --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx @@ -0,0 +1,81 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ControlSection } from '../FeedScreenContext'; + +export const sectionId = (section: ControlSection): string => + `personalization-${section}`; + +export const PanelSection = ({ + section, + title, + description, + actions, + children, + className, +}: { + section: ControlSection; + title: string; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; + className?: string; +}): ReactElement => ( +
        +
        +
        +

        + {title} +

        + {description && ( +

        + {description} +

        + )} +
        + {actions} +
        + {children} +
        +); + +export const PanelCard = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( +
        + {children} +
        +); + +export const Badge = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( + + {children} + +); diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx new file mode 100644 index 0000000000..e4007d69ee --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx @@ -0,0 +1,240 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { settingsUrl, webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import type { ParamKey } from '../../policy/params'; +import { defaultParams } from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { PanelCard, PanelSection } from './Section'; + +const LinkRow = ({ + title, + summary, + href, +}: { + title: string; + summary: ReactNode; + href: string; +}): ReactElement => ( +
      • + + + {title} + + {summary} + + + + +
      • +); + +const list = (items: string[], empty: string): string => + items.length ? items.join(', ') : empty; + +/** Where the explicit choices already live; this page only links to them. */ +export const SettingsSection = (): ReactElement => { + const { state } = usePersonalization(); + const blockedCount = + state.blockedTags.length + + state.blockedSources.length + + state.blockedAuthors.length; + + return ( + + +
          + `#${tag}`), + 'None yet', + )} + href={`${settingsUrl}/feed/tags`} + /> + + + `#${tag}`), + ...state.blockedSources, + ], + 'Topics, sources, people and title words you blocked', + )} + href={`${settingsUrl}/feed/blocked`} + /> + + + +
        +
        +
        + ); +}; + +const ResetRow = ({ + title, + description, + isDisabled, + onReset, + isDanger, +}: { + title: string; + description: string; + isDisabled?: boolean; + onReset: () => void; + isDanger?: boolean; +}): ReactElement => { + const [isConfirming, setIsConfirming] = useState(false); + + return ( +
      • + + {title} + {description} + + {isConfirming ? ( + + + + + ) : ( + + )} +
      • + ); +}; + +export const ResetSection = (): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const learned = state.evidence.filter( + ({ topic }) => !state.followedTags.includes(topic), + ).length; + const adjustments = + Object.keys(state.controls).length + Object.keys(state.sourceControls).length; + const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length; + const seen = Object.keys(state.impressions).length; + + return ( + + +
          + + + + + +
        +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx new file mode 100644 index 0000000000..0407f983c7 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx @@ -0,0 +1,203 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SourceMode } from '../../policy/rank'; +import { formatMultiplier } from '../FactorChips'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { Badge, PanelCard, PanelSection } from './Section'; + +const SourceRow = ({ source }: { source: Source }): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const entity = { id: source.id, name: source.name ?? source.id }; + const isBlocked = state.blockedSources.includes(source.id); + const isDemoted = state.sourceControls[source.id] === SourceMode.Demoted; + const onScreen = store.next.served.filter( + ({ post }) => post.source?.id === source.id, + ).length; + let effect = formatMultiplier(state.params['follows.source_multiplier']); + if (isDemoted) { + effect = `${effect} × ${state.params['interests.control_demote'].toFixed(2)} for Less`; + } + + return ( +
      • + + + + + {source.name} + + {isBlocked ? ( + + Blocked + + ) : ( + + {effect} + + )} + + + {isBlocked + ? 'None of its posts show.' + : `Every post from it comes in. ${ + onScreen ? `${onScreen} in your next 12.` : '' + }`} + + + + {isBlocked ? ( + + ) : ( + <> + + + + + )} + +
      • + ); +}; + +export const SourcesSection = (): ReactElement => { + const store = usePersonalization(); + const { state, pool } = store; + const sourceById = useMemo(() => { + const map = new Map(); + pool.forEach(({ source }) => { + if (source?.id) { + map.set(source.id, source as Source); + } + }); + return map; + }, [pool]); + const followed = state.followedSources + .map((id) => sourceById.get(id)) + .filter((source): source is Source => !!source); + const blocked = state.blockedSources + .map((id) => sourceById.get(id)) + .filter((source): source is Source => !!source); + const suggestions = useMemo(() => { + const counts = new Map(); + pool.forEach(({ source }) => { + if (source?.id) { + counts.set(source.id, (counts.get(source.id) ?? 0) + 1); + } + }); + return [...counts.entries()] + .filter( + ([id]) => + !state.followedSources.includes(id) && + !state.blockedSources.includes(id), + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([id]) => sourceById.get(id)) + .filter((source): source is Source => !!source); + }, [pool, sourceById, state.followedSources, state.blockedSources]); + + return ( + + +
          + {[...followed, ...blocked].map((source) => ( + + ))} + {!followed.length && !blocked.length && ( +
        • + You don't follow or block any source yet. +
        • + )} +
        • + {state.followedAuthors.length + ? `${state.followedAuthors.length} people you follow, ${formatMultiplier( + state.params['follows.author_multiplier'], + )} each.` + : `No people followed. Following a writer multiplies their posts by ${formatMultiplier( + state.params['follows.author_multiplier'], + )}.`} +
        • +
        +
        + {suggestions.length > 0 && ( +
        + + Sources in posts you could see, most common first. + +
        + {suggestions.map((source) => ( + + ))} +
        +
        + )} +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts b/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts deleted file mode 100644 index b036e4354f..0000000000 --- a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { fullPost } from '../../history/posts'; -import type { ExplainedPost, ViewerProfile } from './components/feedExplain'; -import { explainPost } from './components/feedExplain'; - -/** - * A viewer a few days in: three topics picked at sign-up, two more inferred - * from what they opened, and one source followed. The posts are real ones - * from the /history story, so every title, source and tag is what the API - * returned. - */ -export const newViewer: ViewerProfile = { - pickedTags: ['webdev', 'react', 'ai'], - readingTags: ['rust', 'docker'], - followedSources: ['fireship'], -}; - -const explain = (ids: string[]): ExplainedPost[] => - ids.map((id) => explainPost(fullPost(id), newViewer)); - -export const forYouFeed = explain([ - '3wLIGsLJr', - 'Hd2eBXFkz', - 'XCmmeq8ZC', - 'BM2onQaux', - 'VNv8IMYss', - 'oBDUfGZ7N', - 'j4duNN9S2', - 'RFRxDV6yP', - 'DVZBShH7e', - 'DFJTpz2Tw', - 'IGmufOLb8', - 'AzxnBHN3V', - '9tLNItvh5', - 'CrehqCUVO', - '8YmexVcM0', - 'PFMO5JfPF', -]); - -/** What a refresh can bring in, best ranked first. */ -export const reserveFeed = explain([ - 'BjYAz50EA', - '4I7VllOCg', - 'Rvo54Shm1', - '7p9nRlTLf', - 'hsSp9jc9T', - 'Lfny6jDqe', - 'jvlaswdix', - '8eOv3hbFq', - 'B2K4Imgfa', - 'tbbNjYj52', -]); - -const byId = new Map( - [...forYouFeed, ...reserveFeed].map((item) => [item.post.id, item]), -); - -export const feedItem = (id: string): ExplainedPost => { - const item = byId.get(id); - if (!item) { - throw new Error(`No feed item ${id}`); - } - return item; -}; diff --git a/packages/storybook/stories/features/feed-explain/personas.ts b/packages/storybook/stories/features/feed-explain/personas.ts new file mode 100644 index 0000000000..768fdb4761 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/personas.ts @@ -0,0 +1,107 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { fullPost } from '../../history/posts'; +import postPages from '../../history/postPages.json'; +import { defaultParams } from './policy/params'; +import type { PersonalizationState } from './policy/rank'; + +/** + * Every article and video from the /history story: real titles, sources, + * tags, reactions and views as the API returned them. The policy picks the + * For You page from these. + */ +export const candidatePool: Post[] = Object.keys(postPages) + .map(fullPost) + .filter(({ type }) => type === 'article' || type === 'video:youtube'); + +export enum PersonaId { + TwoWeeksIn = 'two_weeks_in', + JustSignedUp = 'just_signed_up', +} + +export interface Persona { + id: PersonaId; + label: string; + description: string; + state: PersonalizationState; +} + +const baseState: PersonalizationState = { + followedTags: [], + blockedTags: [], + followedSources: [], + blockedSources: [], + followedAuthors: [], + blockedAuthors: [], + evidence: [], + isLearning: true, + controls: {}, + sourceControls: {}, + params: defaultParams, + isFillingWithPopular: true, + hidden: [], + impressions: {}, + tagShownDays: {}, +}; + +export const personas: Record = { + [PersonaId.TwoWeeksIn]: { + id: PersonaId.TwoWeeksIn, + label: 'Two weeks in', + description: + 'Followed #webdev, #react and #ai at sign-up, then read a lot about careers and some Rust and Docker.', + state: { + ...baseState, + followedTags: ['webdev', 'react', 'ai'], + followedSources: ['fireship'], + evidence: [ + { topic: 'ai', stories: 7, strongStories: 2, daysSinceLastAction: 1 }, + { + topic: 'career', + stories: 6, + strongStories: 1, + daysSinceLastAction: 2, + }, + { + topic: 'ai-coding', + stories: 4, + strongStories: 0, + daysSinceLastAction: 3, + }, + { + topic: 'webdev', + stories: 3, + strongStories: 0, + daysSinceLastAction: 4, + }, + { topic: 'rust', stories: 3, strongStories: 1, daysSinceLastAction: 6 }, + { + topic: 'docker', + stories: 2, + strongStories: 0, + daysSinceLastAction: 12, + }, + { + topic: 'productivity', + stories: 2, + strongStories: 0, + daysSinceLastAction: 30, + }, + { topic: 'react', stories: 1, strongStories: 0, daysSinceLastAction: 9 }, + { topic: 'linux', stories: 1, strongStories: 0, daysSinceLastAction: 5 }, + { topic: 'css', stories: 1, strongStories: 0, daysSinceLastAction: 20 }, + ], + impressions: { DVZBShH7e: 2, KfPEpQdOl: 1, mGfmauFls: 6 }, + tagShownDays: { ai: 0.3, career: 0.5, webdev: 1 }, + }, + }, + [PersonaId.JustSignedUp]: { + id: PersonaId.JustSignedUp, + label: 'Just signed up', + description: + 'Picked #react and #rust in onboarding and has not read anything yet.', + state: { + ...baseState, + followedTags: ['react', 'rust'], + }, + }, +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts new file mode 100644 index 0000000000..89dec53fca --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts @@ -0,0 +1,82 @@ +/** `retriever_config` from daily-feed's `feed_configs/challenger_v2.json`. */ +export const challengerRetrieverConfig = { + policy_version: 'challenger/5.0', + feed: { + page_size: 12, + pages: 2, + first_screen_posts: 8, + max_candidate_age_days: 60, + }, + interests: { + base_weight: 0.3, + declared_boost: { + confirmed: 0.25, + early_signal: 0, + moderate: 0.4, + strong: 1, + }, + inferred_weight: { + confirmed: 0.4, + early_signal: 0, + moderate: 0.5, + strong: 0.85, + }, + inferred_recency_half_life_days: 25, + inferred_recency_floor: 0.2, + control_boost: 1.15, + control_demote: 0.8, + }, + relevance: { + main_topic_weight: 1, + side_topic_weight: 0.56, + }, + engagement: { + proof_viewers: 200, + caution_sd: 2, + lift_floor: 0.02, + disliked_below: 0.75, + reaction_weights: { + comment: 2, + downvote: -1.5, + open: 1, + report: -2, + save: 2, + share: 2, + upvote: 2, + }, + prior: 'source', + source_prior_viewers: 2000, + evidence: { + proof_viewers: 2000, + caution_sd: 2, + lift_floor: 0.2, + }, + }, + age: { + half_life_days: 7, + max_stretch: 2, + }, + follows: { + source_multiplier: 1.7, + author_multiplier: 1.6, + }, + history: { + max_impressions_without_action: 6, + seen_factor_per_show: 0.69, + }, + diversity: { + topic_repeat_discount: 0.15, + topic_similarity_floor: 0.35, + source_repeat_discount: 0.09, + same_headline_discount: 0.175, + same_headline_jaccard: 0.35, + same_headline_min_tokens: 3, + waiting_interest_lift: 1, + waiting_half_life_days: 3, + }, + placement: { + share: 1, + near_tie_ratio: 0.95, + swap_probability: 0.5, + }, +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts new file mode 100644 index 0000000000..85e12b7e94 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts @@ -0,0 +1,279 @@ +import type { Decision, PersonalizationState } from './rank'; +import { Exclusion, FollowKind, Lane, TopicRole } from './rank'; +import { InterestOrigin, Strength, TopicMode } from './profile'; +import type { Interest } from './profile'; + +/** What put a card on the page, for the mix bar and the reason dot. */ +export enum ReasonCategory { + Followed = 'followed', + Learned = 'learned', + Following = 'following', + Popular = 'popular', +} + +export const reasonCategories: Array<{ + category: ReasonCategory; + label: string; + dotClassName: string; +}> = [ + { + category: ReasonCategory.Followed, + label: 'Topics you follow', + dotClassName: 'bg-accent-cabbage-default', + }, + { + category: ReasonCategory.Learned, + label: 'Learned from reading', + dotClassName: 'bg-accent-blueCheese-default', + }, + { + category: ReasonCategory.Following, + label: 'Sources you follow', + dotClassName: 'bg-accent-water-default', + }, + { + category: ReasonCategory.Popular, + label: 'Popular fill-ins', + dotClassName: 'bg-accent-cheese-default', + }, +]; + +export const getCategory = (decision: Decision): ReasonCategory => { + if (decision.lane === Lane.Popular) { + return ReasonCategory.Popular; + } + if (decision.followedBy) { + return ReasonCategory.Following; + } + return decision.best?.origin === InterestOrigin.Inferred + ? ReasonCategory.Learned + : ReasonCategory.Followed; +}; + +export const getCategoryDot = (category: ReasonCategory): string => + reasonCategories.find((item) => item.category === category)?.dotClassName ?? + ''; + +export enum FactorId { + Match = 'match', + Follow = 'follow', + Base = 'base', + Age = 'age', + Seen = 'seen', + Waiting = 'waiting', + SourceRepeat = 'source_repeat', + TopicRepeat = 'topic_repeat', + SameHeadline = 'same_headline', + Popular = 'popular', +} + +export interface FactorChip { + id: FactorId; + label: string; + value: number; +} + +const notable = (value: number): boolean => Math.abs(Math.log(value)) >= 0.05; + +const formatAge = (days: number): string => { + if (days < 1) { + return 'Today'; + } + const rounded = Math.round(days); + return `${rounded} ${rounded === 1 ? 'day' : 'days'} old`; +}; + +/** The multipliers that moved this card, biggest first. */ +export const getFactorChips = (decision: Decision): FactorChip[] => { + if (decision.lane === Lane.Popular) { + return [ + { id: FactorId.Popular, label: 'Feed ran short', value: 1 }, + { + id: FactorId.Base, + label: + decision.factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet', + value: decision.factors.base, + }, + { + id: FactorId.Age, + label: formatAge(decision.ageDays), + value: decision.factors.age, + }, + ]; + } + const { factors, slot, best } = decision; + const sourceName = decision.post.source?.name ?? 'this source'; + const chips: FactorChip[] = [ + ...(best + ? [ + { + id: FactorId.Match, + label: `#${best.tag}${best.role === TopicRole.Side ? ' (side)' : ''}`, + value: factors.match, + }, + ] + : []), + { + id: FactorId.Follow, + label: + decision.followedBy === FollowKind.Author + ? 'Author you follow' + : `Following ${sourceName}`, + value: factors.follow, + }, + { + id: FactorId.Base, + label: factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet', + value: factors.base, + }, + { id: FactorId.Age, label: formatAge(decision.ageDays), value: factors.age }, + { + id: FactorId.Seen, + label: `Seen ${decision.impressions}×`, + value: factors.seen, + }, + ...(slot + ? [ + { + id: FactorId.Waiting, + label: `#${slot.waitingTag}'s turn`, + value: slot.waiting, + }, + { + id: FactorId.SourceRepeat, + label: `More from ${sourceName}`, + value: slot.sourceRepeat, + }, + { + id: FactorId.TopicRepeat, + label: 'Similar above', + value: slot.topicRepeat, + }, + { + id: FactorId.SameHeadline, + label: 'Same story above', + value: slot.sameHeadline, + }, + ] + : []), + ]; + + return chips + .filter((chip) => chip.id === FactorId.Match || notable(chip.value)) + .sort((a, b) => Math.abs(Math.log(b.value)) - Math.abs(Math.log(a.value))); +}; + +const strengthPhrase: Partial> = { + [Strength.Strong]: 'and read about it often', + [Strength.Moderate]: 'and read about it sometimes', + [Strength.Confirmed]: 'and opened a story on it', +}; + +export interface ReasonParts { + lead: string; + name?: string; + tail: string; +} + +/** The one-sentence reason, split so the name can be bold. */ +export const getReason = ( + decision: Decision, + interests: Interest[], +): ReasonParts => { + if (decision.lane === Lane.Popular) { + return { + lead: 'Popular this week.', + tail: " Your interests didn't fill the page, so this tops it up.", + }; + } + const { best } = decision; + const interest = interests.find(({ tag }) => tag === best?.tag); + if (decision.followedBy === FollowKind.Source) { + return { + lead: 'You follow ', + name: decision.post.source?.name, + tail: best ? `. It's also about #${best.tag}.` : '.', + }; + } + if (decision.followedBy === FollowKind.Author) { + return { lead: 'Written by someone you follow', tail: '.' }; + } + if (!best) { + return { lead: 'Picked for you', tail: '.' }; + } + const side = best.role === TopicRole.Side ? ', here a side topic' : ''; + if (best.origin === InterestOrigin.Declared) { + const phrase = interest?.strength ? strengthPhrase[interest.strength] : ''; + return { + lead: 'You follow ', + name: `#${best.tag}`, + tail: `${phrase ? ` ${phrase}` : ''}${side}.`, + }; + } + return interest?.strength === Strength.Strong + ? { lead: "You've read a lot about ", name: `#${best.tag}`, tail: `${side}.` } + : { + lead: "You've read a few posts about ", + name: `#${best.tag}`, + tail: `${side}.`, + }; +}; + +export const exclusionLabel: Record = { + [Exclusion.Hidden]: 'You asked for fewer like it', + [Exclusion.BlockedSource]: 'Source you blocked', + [Exclusion.BlockedAuthor]: 'Author you blocked', + [Exclusion.BlockedTag]: 'Has a topic you blocked', + [Exclusion.TooOld]: 'Older than your limit', + [Exclusion.ShownTooOften]: 'Scrolled past too many times', + [Exclusion.NoInterestOrFollow]: 'Nothing you follow or read', + [Exclusion.SideTopicUnproven]: 'Side topic, not proven yet', + [Exclusion.SideTopicsOff]: 'Side topics are off', + [Exclusion.PopularFillOff]: 'Popular fill-ins are off', + [Exclusion.BelowThePage]: "Ranked lower, didn't make the page", +}; + +/** Why a card on screen would leave, in the viewer's terms. */ +export const getLeaveMessage = ( + served: Decision, + now: Decision, + state: PersonalizationState, +): string => { + const sourceName = served.post.source?.name ?? 'this source'; + switch (now.exclusion) { + case Exclusion.Hidden: + return 'You asked for fewer posts like this.'; + case Exclusion.BlockedSource: + return `You blocked ${sourceName}.`; + case Exclusion.BlockedAuthor: + return 'You blocked its author.'; + case Exclusion.BlockedTag: + return `You blocked #${now.exclusionTag}.`; + case Exclusion.TooOld: + return `Older than your ${state.params['feed.max_candidate_age_days']}-day limit.`; + case Exclusion.ShownTooOften: + return `You scrolled past it ${now.impressions} times.`; + case Exclusion.SideTopicsOff: + return 'You turned off side-topic matches.'; + case Exclusion.SideTopicUnproven: + return 'It only matches a side topic and readers have not proven it yet.'; + case Exclusion.PopularFillOff: + return 'You turned off popular fill-ins.'; + default: { + const tag = served.best?.tag; + if (tag && state.controls[tag]?.mode === TopicMode.Muted) { + return `You muted #${tag}.`; + } + if (tag && !state.isLearning) { + return 'You stopped learning from reading.'; + } + if (served.followedBy && !now.followedBy) { + return `You unfollowed ${sourceName}.`; + } + if (tag && served.best?.origin === InterestOrigin.Declared) { + return `You unfollowed #${tag}.`; + } + return 'None of its topics are in your interests anymore.'; + } + } +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/params.ts b/packages/storybook/stories/features/feed-explain/policy/params.ts new file mode 100644 index 0000000000..c25ed78a43 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/params.ts @@ -0,0 +1,419 @@ +/** + * The For You ranking knobs, named after `retriever_config` in daily-feed's + * `feed_configs/challenger_v2.json` (policy `challenger/5.0`). Defaults are + * that config's values; ranges stay inside what `policy.Parse` accepts. + */ + +export const policyVersion = 'challenger/5.0'; + +export enum ParamGroup { + Relevance = 'relevance', + Learning = 'learning', + Freshness = 'freshness', + Follows = 'follows', + Variety = 'variety', + Repeats = 'repeats', + Adjustments = 'adjustments', + Scope = 'scope', +} + +export type ParamKey = + | 'interests.base_weight' + | 'interests.declared_boost.strong' + | 'interests.declared_boost.moderate' + | 'interests.declared_boost.confirmed' + | 'interests.inferred_weight.strong' + | 'interests.inferred_weight.moderate' + | 'interests.inferred_recency_half_life_days' + | 'interests.inferred_recency_floor' + | 'interests.control_boost' + | 'interests.control_demote' + | 'relevance.side_topic_weight' + | 'age.half_life_days' + | 'age.max_stretch' + | 'follows.source_multiplier' + | 'follows.author_multiplier' + | 'history.max_impressions_without_action' + | 'history.seen_factor_per_show' + | 'diversity.topic_repeat_discount' + | 'diversity.source_repeat_discount' + | 'diversity.same_headline_discount' + | 'diversity.waiting_interest_lift' + | 'diversity.waiting_half_life_days' + | 'feed.max_candidate_age_days'; + +export type ParamValues = Record; + +export enum ParamFormat { + Weight = 'weight', + Multiplier = 'multiplier', + Percent = 'percent', + Days = 'days', + Count = 'count', +} + +export interface ParamMeta { + key: ParamKey; + group: ParamGroup; + label: string; + help: string; + min: number; + max: number; + step: number; + format: ParamFormat; + /** Shown only with advanced settings on. */ + isAdvanced?: boolean; +} + +export const defaultParams: ParamValues = { + 'interests.base_weight': 0.3, + 'interests.declared_boost.strong': 1, + 'interests.declared_boost.moderate': 0.4, + 'interests.declared_boost.confirmed': 0.25, + 'interests.inferred_weight.strong': 0.85, + 'interests.inferred_weight.moderate': 0.5, + 'interests.inferred_recency_half_life_days': 25, + 'interests.inferred_recency_floor': 0.2, + 'interests.control_boost': 1.15, + 'interests.control_demote': 0.8, + 'relevance.side_topic_weight': 0.56, + 'age.half_life_days': 7, + 'age.max_stretch': 2, + 'follows.source_multiplier': 1.7, + 'follows.author_multiplier': 1.6, + 'history.max_impressions_without_action': 6, + 'history.seen_factor_per_show': 0.69, + 'diversity.topic_repeat_discount': 0.15, + 'diversity.source_repeat_discount': 0.09, + 'diversity.same_headline_discount': 0.175, + 'diversity.waiting_interest_lift': 1, + 'diversity.waiting_half_life_days': 3, + 'feed.max_candidate_age_days': 60, +}; + +export const paramMeta: ParamMeta[] = [ + { + key: 'interests.base_weight', + group: ParamGroup.Relevance, + label: 'Followed topic, before any reading', + help: 'What a topic you follow is worth on its own. Reading about it adds to this.', + min: 0.05, + max: 1, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'relevance.side_topic_weight', + group: ParamGroup.Relevance, + label: 'Side topics', + help: 'How much a match counts when your topic is only a side topic of the post. At 0, side-topic matches never bring a post in.', + min: 0, + max: 1, + step: 0.02, + format: ParamFormat.Percent, + }, + { + key: 'interests.inferred_weight.strong', + group: ParamGroup.Learning, + label: 'Topics you read a lot (6+ stories)', + help: "Weight of a topic you haven't followed but keep reading.", + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'interests.inferred_weight.moderate', + group: ParamGroup.Learning, + label: 'Topics you read sometimes (2 to 5)', + help: "Weight of a topic you haven't followed and opened a few times.", + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'interests.inferred_recency_half_life_days', + group: ParamGroup.Learning, + label: 'How fast reading fades', + help: 'Reading evidence loses half its pull after this many days without new activity on the topic.', + min: 3, + max: 90, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'interests.inferred_recency_floor', + group: ParamGroup.Learning, + label: 'Fading stops at', + help: 'Old reading never drops below this share of its weight.', + min: 0, + max: 1, + step: 0.05, + format: ParamFormat.Percent, + }, + { + key: 'interests.declared_boost.strong', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read a lot', + help: 'Added to a followed topic once you have read 6 or more stories on it.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.moderate', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read sometimes', + help: 'Added to a followed topic after 2 to 5 stories.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.confirmed', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read once', + help: 'Added to a followed topic after its first story.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'age.half_life_days', + group: ParamGroup.Freshness, + label: 'Freshness', + help: 'A post loses half its pull every this many days. Lower means newer posts first.', + min: 1, + max: 30, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'age.max_stretch', + group: ParamGroup.Freshness, + label: 'Well-liked posts age slower, up to', + help: 'Posts readers respond well to stretch the freshness window by up to this much.', + min: 1, + max: 4, + step: 0.1, + format: ParamFormat.Multiplier, + }, + { + key: 'follows.source_multiplier', + group: ParamGroup.Follows, + label: 'Sources and squads you follow', + help: 'Multiplies every post from a source or squad you follow.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'follows.author_multiplier', + group: ParamGroup.Follows, + label: 'People you follow', + help: 'Multiplies every post written by someone you follow.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'diversity.topic_repeat_discount', + group: ParamGroup.Variety, + label: 'Spread out similar topics', + help: 'Each similar post already on the page lowers the next one by this much.', + min: 0, + max: 0.6, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.source_repeat_discount', + group: ParamGroup.Variety, + label: 'Spread out sources', + help: 'Each post from the same source already on the page lowers the next one by this much.', + min: 0, + max: 0.5, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.same_headline_discount', + group: ParamGroup.Variety, + label: 'Same story, different headline', + help: 'Lowers a post whose headline overlaps one already on the page.', + min: 0, + max: 0.6, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.waiting_interest_lift', + group: ParamGroup.Variety, + label: 'Rotate my interests', + help: "Lifts the first post on a topic that hasn't been on your first screen lately. At 0, topics don't take turns.", + min: 0, + max: 3, + step: 0.1, + format: ParamFormat.Weight, + }, + { + key: 'diversity.waiting_half_life_days', + group: ParamGroup.Variety, + label: 'A topic is due again after', + help: "Days until a topic's turn is half due again.", + min: 0.5, + max: 14, + step: 0.5, + format: ParamFormat.Days, + isAdvanced: true, + }, + { + key: 'history.seen_factor_per_show', + group: ParamGroup.Repeats, + label: 'Posts I scrolled past', + help: 'Each time a post was on your screen without you acting on it, it keeps this much of its pull. 100% means no penalty.', + min: 0.2, + max: 1, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'history.max_impressions_without_action', + group: ParamGroup.Repeats, + label: 'Stop showing a post after', + help: 'Views without any action before a post leaves your feed for good.', + min: 1, + max: 12, + step: 1, + format: ParamFormat.Count, + }, + { + key: 'interests.control_boost', + group: ParamGroup.Adjustments, + label: 'What More does', + help: 'Multiplies a topic you pressed More on.', + min: 1, + max: 2, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'interests.control_demote', + group: ParamGroup.Adjustments, + label: 'What Less does', + help: 'Multiplies a topic you pressed Less on.', + min: 0.1, + max: 1, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'feed.max_candidate_age_days', + group: ParamGroup.Scope, + label: 'Oldest post I want to see', + help: 'Posts older than this never make it in. The index keeps 60 days.', + min: 3, + max: 60, + step: 1, + format: ParamFormat.Days, + }, +]; + +export const paramGroups: Array<{ + group: ParamGroup; + title: string; + description: string; +}> = [ + { + group: ParamGroup.Relevance, + title: 'Relevance', + description: + "A post's best matching topic multiplies its score by 1 + that topic's weight.", + }, + { + group: ParamGroup.Learning, + title: 'Learning from your reading', + description: + 'Topics you open, upvote, save, comment on or share in the last 90 days.', + }, + { + group: ParamGroup.Freshness, + title: 'Freshness', + description: 'How quickly a post loses its place as it gets older.', + }, + { + group: ParamGroup.Follows, + title: 'Follows', + description: 'A follow brings every post in and multiplies its score.', + }, + { + group: ParamGroup.Variety, + title: 'Variety', + description: 'How much each page mixes topics, sources and stories.', + }, + { + group: ParamGroup.Repeats, + title: 'Repeats', + description: 'What happens to posts you keep scrolling past.', + }, + { + group: ParamGroup.Adjustments, + title: 'Your More and Less', + description: 'How hard the More and Less buttons push a topic.', + }, + { + group: ParamGroup.Scope, + title: 'Scope', + description: 'Which posts are considered at all.', + }, +]; + +export const getParamMeta = (key: ParamKey): ParamMeta => { + const meta = paramMeta.find((item) => item.key === key); + if (!meta) { + throw new Error(`Unknown param ${key}`); + } + return meta; +}; + +export const formatParam = (format: ParamFormat, value: number): string => { + switch (format) { + case ParamFormat.Percent: + return `${Math.round(value * 100)}%`; + case ParamFormat.Multiplier: + return `×${value.toFixed(2)}`; + case ParamFormat.Days: + return `${value} ${value === 1 ? 'day' : 'days'}`; + case ParamFormat.Count: + return `${value} ${value === 1 ? 'view' : 'views'}`; + default: + return value.toFixed(2); + } +}; + +/** Writes changed values into the config the policy reads. */ +export const toRetrieverConfig = ( + params: ParamValues, + base: Record, +): Record => { + const config = structuredClone(base); + (Object.keys(params) as ParamKey[]).forEach((key) => { + const path = key.split('.'); + let node = config as Record; + path.slice(0, -1).forEach((part) => { + node[part] = { ...(node[part] as Record) }; + node = node[part] as Record; + }); + node[path[path.length - 1]] = params[key]; + }); + return config; +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/profile.ts b/packages/storybook/stories/features/feed-explain/policy/profile.ts new file mode 100644 index 0000000000..f95349571e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/profile.ts @@ -0,0 +1,200 @@ +import type { ParamValues } from './params'; + +/** + * The inferred profile as daily-feed stores it (`services/inferred_interests`): + * per topic, the distinct stories with a positive action in the last 90 days, + * how many of them were upvoted, saved, commented on or shared, and when the + * latest action was. Labels and weights are computed per request. + */ +export interface TopicEvidence { + topic: string; + stories: number; + strongStories: number; + daysSinceLastAction: number; +} + +export enum Strength { + Strong = 'strong', + Moderate = 'moderate', + Confirmed = 'confirmed', + EarlySignal = 'early_signal', +} + +export enum InterestOrigin { + Declared = 'declared', + Inferred = 'inferred', +} + +/** Proposed per-topic user controls; the policy has the knobs, not the UI. */ +export enum TopicMode { + Boosted = 'boosted', + Demoted = 'demoted', + Muted = 'muted', +} + +export interface TopicControl { + mode?: TopicMode; + /** An exact weight the user set, replacing the computed one. */ + weight?: number; +} + +export interface Interest { + tag: string; + origin: InterestOrigin; + strength?: Strength; + evidence?: TopicEvidence; + recency: number; + /** The policy's weight before the user's control. */ + computedWeight: number; + weight: number; + control?: TopicControl; + /** Has reading behind it, so it can take a rotation turn. */ + isBacked: boolean; +} + +const strongMinStories = 6; +const moderateMinStories = 2; + +export const getStrength = ( + evidence: TopicEvidence | undefined, + isDeclared: boolean, +): Strength | undefined => { + if (!evidence || evidence.stories < 1) { + return undefined; + } + if (evidence.stories >= strongMinStories) { + return Strength.Strong; + } + if (evidence.stories >= moderateMinStories) { + return Strength.Moderate; + } + return isDeclared ? Strength.Confirmed : Strength.EarlySignal; +}; + +export const getRecency = (days: number, params: ParamValues): number => + Math.max( + params['interests.inferred_recency_floor'], + Math.min( + 1, + 0.5 ** (days / params['interests.inferred_recency_half_life_days']), + ), + ); + +export const strengthLabel: Record = { + [Strength.Strong]: 'Strong', + [Strength.Moderate]: 'Moderate', + [Strength.Confirmed]: 'Confirmed', + [Strength.EarlySignal]: 'Early signal', +}; + +export const strengthRule: Record = { + [Strength.Strong]: '6 or more stories', + [Strength.Moderate]: '2 to 5 stories', + [Strength.Confirmed]: '1 story on a topic you follow', + [Strength.EarlySignal]: '1 story; counts once there are 2', +}; + +interface BuildInterestsInput { + followedTags: string[]; + blockedTags: string[]; + evidence: TopicEvidence[]; + controls: Record; + isLearning: boolean; + params: ParamValues; +} + +const declaredBoost = (strength: Strength, params: ParamValues): number => { + switch (strength) { + case Strength.Strong: + return params['interests.declared_boost.strong']; + case Strength.Moderate: + return params['interests.declared_boost.moderate']; + case Strength.Confirmed: + return params['interests.declared_boost.confirmed']; + default: + return 0; + } +}; + +const inferredWeight = (strength: Strength, params: ParamValues): number => { + switch (strength) { + case Strength.Strong: + return params['interests.inferred_weight.strong']; + case Strength.Moderate: + return params['interests.inferred_weight.moderate']; + default: + return 0; + } +}; + +const applyControl = ( + weight: number, + control: TopicControl | undefined, + params: ParamValues, +): number => { + if (control?.weight !== undefined) { + return control.weight; + } + switch (control?.mode) { + case TopicMode.Boosted: + return weight * params['interests.control_boost']; + case TopicMode.Demoted: + return weight * params['interests.control_demote']; + case TopicMode.Muted: + return 0; + default: + return weight; + } +}; + +/** + * `BuildInterests` from daily-feed's challenger policy, plus the controls its + * proof of concept defined: boosted and demoted multiply the weight, muted + * drops it. + */ +export const buildInterests = ({ + followedTags, + blockedTags, + evidence, + controls, + isLearning, + params, +}: BuildInterestsInput): Interest[] => { + const evidenceByTopic = new Map( + (isLearning ? evidence : []).map((item) => [item.topic, item]), + ); + const tags = [ + ...new Set([...followedTags, ...evidenceByTopic.keys()]), + ].filter((tag) => !blockedTags.includes(tag)); + + return tags.map((tag): Interest => { + const isDeclared = followedTags.includes(tag); + const topicEvidence = evidenceByTopic.get(tag); + const strength = getStrength(topicEvidence, isDeclared); + const recency = topicEvidence + ? getRecency(topicEvidence.daysSinceLastAction, params) + : 1; + const computedWeight = isDeclared + ? params['interests.base_weight'] + + (strength ? declaredBoost(strength, params) * recency : 0) + : (strength ? inferredWeight(strength, params) : 0) * recency; + const control = controls[tag]; + + return { + tag, + origin: isDeclared ? InterestOrigin.Declared : InterestOrigin.Inferred, + strength, + evidence: topicEvidence, + recency, + computedWeight, + weight: Math.max(0, applyControl(computedWeight, control, params)), + control, + isBacked: + !!strength && strength !== Strength.EarlySignal && !!topicEvidence, + }; + }); +}; + +/** The interests the policy actually ranks with. */ +export const getActiveInterests = (interests: Interest[]): Interest[] => + interests.filter(({ weight }) => weight > 0); diff --git a/packages/storybook/stories/features/feed-explain/policy/rank.ts b/packages/storybook/stories/features/feed-explain/policy/rank.ts new file mode 100644 index 0000000000..4f4e924ade --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/rank.ts @@ -0,0 +1,512 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { ParamValues } from './params'; +import type { Interest, TopicControl, TopicEvidence } from './profile'; +import { buildInterests, getActiveInterests, InterestOrigin } from './profile'; + +/** + * A client-side copy of daily-feed's challenger/5.0 policy (`policy/gate.go`, + * `score.go`, `pick.go`), run over the posts in the story so every number on + * a card comes from the same math the feed uses. Simplified where the real + * inputs aren't in the story: reader response compares each post's + * reactions per view with the other posts here, the first tag is the main + * topic, topic similarity is tag overlap instead of tag embeddings, and + * placement swaps are left out. + */ + +export enum SourceMode { + Demoted = 'demoted', +} + +export interface PersonalizationState { + followedTags: string[]; + blockedTags: string[]; + followedSources: string[]; + blockedSources: string[]; + followedAuthors: string[]; + blockedAuthors: string[]; + evidence: TopicEvidence[]; + isLearning: boolean; + controls: Record; + sourceControls: Record; + params: ParamValues; + isFillingWithPopular: boolean; + hidden: string[]; + /** Views without an action, per post. */ + impressions: Record; + /** Days since each topic was the main topic on your first screen. */ + tagShownDays: Record; +} + +export enum Lane { + Challenger = 'chal', + Popular = 'po', +} + +export enum Exclusion { + Hidden = 'hidden', + BlockedSource = 'blocked_source', + BlockedAuthor = 'blocked_author', + BlockedTag = 'blocked_tag', + TooOld = 'older_than_max_age', + ShownTooOften = 'shown_too_often_without_action', + NoInterestOrFollow = 'no_interest_or_follow', + SideTopicUnproven = 'side_topic_on_an_unproven_post', + SideTopicsOff = 'side_topics_off', + PopularFillOff = 'popular_fill_off', + BelowThePage = 'below_the_page', +} + +export enum TopicRole { + Main = 'main', + Side = 'side', +} + +export interface Match { + tag: string; + origin: InterestOrigin; + role: TopicRole; + weight: number; + roleWeight: number; + contribution: number; +} + +export enum FollowKind { + Source = 'source', + Author = 'author', +} + +export interface ScoreFactors { + base: number; + match: number; + follow: number; + age: number; + seen: number; +} + +export interface SlotFactors { + sourceRepeat: number; + sourceCount: number; + topicRepeat: number; + similarAbove: number; + sameHeadline: number; + waiting: number; + waitingTag?: string; +} + +export interface Decision { + post: Post; + lane: Lane; + exclusion?: Exclusion; + /** The tag behind a blocked_tag exclusion. */ + exclusionTag?: string; + followedBy?: FollowKind; + sourceMode?: SourceMode; + best?: Match; + matches: Match[]; + ageDays: number; + impressions: number; + earned: number; + factors: ScoreFactors; + score: number; + slot?: SlotFactors; + pickScore?: number; + /** 1-based place on the page. */ + position?: number; + /** 1-based place among every eligible post, before page variety. */ + eligibleRank?: number; +} + +export interface RankResult { + served: Decision[]; + leftOut: Decision[]; + interests: Interest[]; +} + +export const pageSize = 12; +const firstScreenSize = 8; +const topicSimilarityFloor = 0.35; +const sameHeadlineJaccard = 0.35; +const proofFloor = 0.2; +const dayMs = 86_400_000; +/** The day the posts in the story were captured. */ +export const storyNow = Date.parse('2026-10-15T12:00:00Z'); + +const stopWords = new Set([ + 'the', + 'and', + 'for', + 'you', + 'your', + 'with', + 'how', + 'why', + 'this', + 'that', + 'are', + 'was', + 'not', + 'just', +]); + +const titleTokens = (title = ''): Set => + new Set( + title + .toLowerCase() + .split(/[^a-z0-9+#]+/) + .filter((word) => word.length >= 3 && !stopWords.has(word)), + ); + +const jaccard = (a: Set, b: Set): number => { + const shared = [...a].filter((word) => b.has(word)).length; + const union = new Set([...a, ...b]).size; + return union ? shared / union : 0; +}; + +const tagSimilarity = (a: string[], b: string[]): number => { + if (!a.length || !b.length) { + return 0; + } + const shared = a.filter((tag) => b.includes(tag)).length; + return shared / Math.sqrt(a.length * b.length); +}; + +const reactionRate = (post: Post): number => + (2 * (post.numUpvotes ?? 0) + 2 * (post.numComments ?? 0)) / + Math.max(1, post.analytics?.impressions ?? 1); + +const medianRates = new WeakMap(); + +const getMedianRate = (pool: Post[]): number => { + const cached = medianRates.get(pool); + if (cached !== undefined) { + return cached; + } + const rates = pool.map(reactionRate).sort((a, b) => a - b); + const median = rates[Math.floor(rates.length / 2)] || 1; + medianRates.set(pool, median); + return median; +}; + +export const getMainTopic = (post: Post): string | undefined => post.tags?.[0]; + +interface Context { + state: PersonalizationState; + interests: Map; + medianRate: number; + now: number; +} + +const assess = (post: Post, context: Context): Decision => { + const { state, interests, medianRate, now } = context; + const { params } = state; + const tags = post.tags ?? []; + const sourceId = post.source?.id ?? ''; + const authorId = post.author?.id; + const base = Math.max(0.02, reactionRate(post) / medianRate); + const earned = Math.max(0, base - 0.5); + const ageDays = Math.max( + 0, + (now - Date.parse(post.createdAt ?? '')) / dayMs || 0, + ); + const impressions = state.impressions[post.id] ?? 0; + const matches = tags + .map((tag, index): Match | undefined => { + const interest = interests.get(tag); + if (!interest) { + return undefined; + } + const role = index === 0 ? TopicRole.Main : TopicRole.Side; + const roleWeight = + role === TopicRole.Main ? 1 : params['relevance.side_topic_weight']; + return { + tag, + origin: interest.origin, + role, + weight: interest.weight, + roleWeight, + contribution: interest.weight * roleWeight, + }; + }) + .filter((match): match is Match => !!match && match.roleWeight > 0) + .sort((a, b) => b.contribution - a.contribution); + const best = matches[0]; + const isSourceFollowed = state.followedSources.includes(sourceId); + const isAuthorFollowed = + !!authorId && state.followedAuthors.includes(authorId); + const sourceFollow = isSourceFollowed + ? params['follows.source_multiplier'] + : 1; + const authorFollow = isAuthorFollowed + ? params['follows.author_multiplier'] + : 1; + let followedBy: FollowKind | undefined; + if (isSourceFollowed || isAuthorFollowed) { + followedBy = + sourceFollow >= authorFollow ? FollowKind.Source : FollowKind.Author; + } + const sourceMode = state.sourceControls[sourceId]; + const factors: ScoreFactors = { + base, + match: 1 + (best?.contribution ?? 0), + follow: + Math.max(sourceFollow, authorFollow) * + (sourceMode === SourceMode.Demoted + ? params['interests.control_demote'] + : 1), + age: + 0.5 ** + (ageDays / + (params['age.half_life_days'] * + Math.min(params['age.max_stretch'], Math.max(1, base)))), + seen: params['history.seen_factor_per_show'] ** impressions, + }; + const decision: Decision = { + post, + lane: Lane.Challenger, + followedBy, + sourceMode, + best, + matches, + ageDays, + impressions, + earned, + factors, + score: + factors.base * factors.match * factors.follow * factors.age * factors.seen, + }; + const exclude = (exclusion: Exclusion, exclusionTag?: string): Decision => ({ + ...decision, + exclusion, + exclusionTag, + }); + const blockedTag = tags.find((tag) => state.blockedTags.includes(tag)); + + if (state.hidden.includes(post.id)) { + return exclude(Exclusion.Hidden); + } + if (state.blockedSources.includes(sourceId)) { + return exclude(Exclusion.BlockedSource); + } + if (authorId && state.blockedAuthors.includes(authorId)) { + return exclude(Exclusion.BlockedAuthor); + } + if (blockedTag) { + return exclude(Exclusion.BlockedTag, blockedTag); + } + if (ageDays > params['feed.max_candidate_age_days']) { + return exclude(Exclusion.TooOld); + } + if (impressions >= params['history.max_impressions_without_action']) { + return exclude(Exclusion.ShownTooOften); + } + if (!followedBy && !matches.length) { + const hasSideOnly = + params['relevance.side_topic_weight'] === 0 && + tags.slice(1).some((tag) => interests.has(tag)); + return exclude( + hasSideOnly ? Exclusion.SideTopicsOff : Exclusion.NoInterestOrFollow, + ); + } + if ( + !followedBy && + !matches.some(({ role }) => role === TopicRole.Main) && + earned <= proofFloor + ) { + return exclude(Exclusion.SideTopicUnproven); + } + return decision; +}; + +const createContext = ( + pool: Post[], + state: PersonalizationState, + now: number, +): Context & { allInterests: Interest[] } => { + const allInterests = buildInterests({ + followedTags: state.followedTags, + blockedTags: state.blockedTags, + evidence: state.evidence, + controls: state.controls, + isLearning: state.isLearning, + params: state.params, + }); + + return { + state, + allInterests, + interests: new Map( + getActiveInterests(allInterests).map((interest) => [ + interest.tag, + interest, + ]), + ), + medianRate: getMedianRate(pool), + now, + }; +}; + +const fillableExclusions = [ + Exclusion.NoInterestOrFollow, + Exclusion.SideTopicUnproven, + Exclusion.SideTopicsOff, +]; + +/** Why a post on screen would not come back, under the current state. */ +export const getExclusion = ( + served: Decision, + pool: Post[], + state: PersonalizationState, + now = storyNow, +): Decision | undefined => { + const decision = assess(served.post, createContext(pool, state, now)); + if (served.lane === Lane.Popular) { + if (decision.exclusion && fillableExclusions.includes(decision.exclusion)) { + return state.isFillingWithPopular + ? undefined + : { ...decision, exclusion: Exclusion.PopularFillOff }; + } + } + return decision.exclusion ? decision : undefined; +}; + +export const rankFeed = ( + pool: Post[], + state: PersonalizationState, + now = storyNow, +): RankResult => { + const context = createContext(pool, state, now); + const { params } = state; + const assessed = pool.map((post) => assess(post, context)); + const eligible = assessed + .filter(({ exclusion }) => !exclusion) + .sort((a, b) => b.score - a.score) + .map((decision, index) => ({ ...decision, eligibleRank: index + 1 })); + const rotation = getActiveInterests(context.allInterests) + .filter(({ isBacked }) => isBacked) + .map(({ tag, weight }) => ({ + tag, + evidence: Math.min(1, weight - params['interests.base_weight']), + })) + .filter(({ evidence }) => evidence > 0); + const maxEvidence = Math.max(0, ...rotation.map(({ evidence }) => evidence)); + const evidenceByTag = new Map( + rotation.map(({ tag, evidence }) => [tag, evidence / maxEvidence]), + ); + const tookTurn = new Set(); + const sourceCounts = new Map(); + const served: Decision[] = []; + let remaining = eligible; + + const slotFor = (decision: Decision): SlotFactors => { + const { post } = decision; + const sourceCount = sourceCounts.get(post.source?.id ?? '') ?? 0; + const similarity = served.slice(-50).reduce((sum, picked) => { + const cos = tagSimilarity(post.tags ?? [], picked.post.tags ?? []); + return ( + sum + + Math.min( + 1, + Math.max(0, (cos - topicSimilarityFloor) / (1 - topicSimilarityFloor)), + ) + ); + }, 0); + const tokens = titleTokens(post.title); + const isSameHeadline = served.some( + (picked) => + jaccard(tokens, titleTokens(picked.post.title)) >= sameHeadlineJaccard, + ); + const mainTopic = getMainTopic(post); + const evidence = + mainTopic && !tookTurn.has(mainTopic) + ? evidenceByTag.get(mainTopic) + : undefined; + let waiting = 1; + if (evidence !== undefined) { + const shownDays = state.tagShownDays[mainTopic ?? '']; + const wait = + shownDays === undefined + ? 1 + : 1 - 0.5 ** (shownDays / params['diversity.waiting_half_life_days']); + waiting = + 1 + + params['diversity.waiting_interest_lift'] * + wait * + Math.max(evidence, Math.min(1, decision.factors.base)); + } + return { + sourceCount, + sourceRepeat: + (1 - params['diversity.source_repeat_discount']) ** sourceCount, + similarAbove: similarity, + topicRepeat: (1 - params['diversity.topic_repeat_discount']) ** similarity, + sameHeadline: isSameHeadline + ? 1 - params['diversity.same_headline_discount'] + : 1, + waiting, + waitingTag: waiting > 1 ? mainTopic : undefined, + }; + }; + + while (served.length < pageSize && remaining.length) { + const scored = remaining.map((decision) => { + const slot = slotFor(decision); + return { + ...decision, + slot, + pickScore: + decision.score * + slot.sourceRepeat * + slot.topicRepeat * + slot.sameHeadline * + slot.waiting, + }; + }); + const next = scored.reduce((top, decision) => + (decision.pickScore ?? 0) > (top.pickScore ?? 0) ? decision : top, + ); + served.push({ ...next, position: served.length + 1 }); + const sourceId = next.post.source?.id ?? ''; + sourceCounts.set(sourceId, (sourceCounts.get(sourceId) ?? 0) + 1); + if (next.slot?.waitingTag) { + tookTurn.add(next.slot.waitingTag); + } + remaining = remaining.filter(({ post }) => post.id !== next.post.id); + } + + // The challenger only fills what it can; the popular lane tops up the rest. + const fillable = assessed.filter( + ({ exclusion }) => exclusion && fillableExclusions.includes(exclusion), + ); + const fill = + state.isFillingWithPopular && served.length < pageSize + ? [...fillable] + .sort( + (a, b) => b.factors.base * b.factors.age - a.factors.base * a.factors.age, + ) + .slice(0, pageSize - served.length) + : []; + fill.forEach((decision) => + served.push({ + ...decision, + exclusion: undefined, + lane: Lane.Popular, + position: served.length + 1, + }), + ); + + const servedIds = new Set(served.map(({ post }) => post.id)); + const leftOut = [ + ...remaining.map((decision) => ({ + ...decision, + exclusion: Exclusion.BelowThePage, + })), + ...assessed.filter( + ({ exclusion, post }) => exclusion && !servedIds.has(post.id), + ), + ]; + + return { served, leftOut, interests: context.allInterests }; +}; + +export const firstScreen = (decisions: Decision[]): Decision[] => + decisions.slice(0, firstScreenSize); From f9bab71196cf8bdb6e6a7a0469084dce102efe8a Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:51:22 +0300 Subject: [PATCH 2/2] feat(storybook): simplify the personalization center Every topic and source is now one flat row with an exact boost slider and Follow or Unfollow, replacing the collapsible rows, the weight formula and More, Less and Mute. A topic at 0 shows as Off, and a reset icon returns a boost the user set to automatic. Sources get a per-source boost (an optional sourceWeights on the state) so they work like topics. Copy uses one vocabulary across rows and ranking settings: boost (shown as the multiplier it gives), topics you follow, topics from your reading, posts you read. Raw retriever_config keys, the config JSON, the Not adjustable list, the More and Less card and the at-a-glance tiles are gone; story 10 now maps each setting label to its config key. "Your explicit choices" becomes "Other feed settings" link cards, and a "Connect your agent" button opens a dialog to add the daily.dev MCP server with a starter prompt. Story 10 lists what the MCP still needs. --- .../feed-explain/FeedExplain.stories.tsx | 94 ++- .../components/PersonalizationContext.tsx | 62 +- .../components/PersonalizationPanel.tsx | 145 +---- .../feed-explain/components/controls.tsx | 106 ++- .../components/panel/AgentSetup.tsx | 154 +++++ .../components/panel/InterestsSection.tsx | 606 +++++------------- .../feed-explain/components/panel/Preview.tsx | 36 +- .../components/panel/RankingSection.tsx | 143 +---- .../feed-explain/components/panel/Section.tsx | 88 ++- .../components/panel/SettingsSection.tsx | 187 +++--- .../components/panel/SourcesSection.tsx | 265 ++++---- .../feed-explain/policy/challengerConfig.ts | 82 --- .../features/feed-explain/policy/params.ts | 272 ++++---- .../features/feed-explain/policy/rank.ts | 19 +- 14 files changed, 1068 insertions(+), 1191 deletions(-) create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 1a71b2916d..4886a5f163 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -8,7 +8,14 @@ import { providerHandlers } from '../why-recommended/whyRecommended.mocks'; import type { Decision, PersonalizationState } from './policy/rank'; import { getExclusion } from './policy/rank'; import { TopicMode } from './policy/profile'; -import { policyVersion } from './policy/params'; +import { + defaultParams, + formatParam, + ParamGroup, + paramGroups, + paramMeta, + policyVersion, +} from './policy/params'; import { PersonalizationProvider, usePersonalization, @@ -391,29 +398,26 @@ const specRows: Array<{ needs: 'Per-user, per-topic controls stored and applied in BuildInterests', }, { - control: 'Mute a learned topic', - status: Status.Missing, - today: 'Only in the proof-of-concept notebook', - needs: 'Same storage as More and Less; drop the inferred interest', - }, - { - control: 'Exact topic weight', + control: 'Exact topic boost, down to Off', status: Status.Missing, today: 'Weights are computed per request', - needs: 'A per-topic override that replaces the computed weight', + needs: + 'A per-topic override that replaces the computed weight; 0 drops the topic', }, { - control: 'Less from a source', + control: 'Exact source boost', status: Status.Missing, - today: 'Sources only have follow and block', - needs: 'A per-source demote on the follow factor', + today: + 'Sources only have follow and block; the follow boost is one global value', + needs: 'A per-source multiplier that replaces the follow factor', }, { control: 'Ranking knobs', status: Status.Config, today: 'retriever_config is global per cohort; it travels inline on every request and is part of the cache key', - needs: 'daily-api or lofn merges the user’s overrides into retriever_config, clamped to policy.Parse ranges', + needs: + 'daily-api or lofn merges the user’s overrides into retriever_config, clamped to policy.Parse ranges', }, { control: 'Learn from my reading', @@ -459,6 +463,14 @@ const specRows: Array<{ today: 'Stored per user in Redis and Postgres; no API returns it', needs: 'An endpoint that returns the labelled, weighted profile', }, + { + control: 'Connect your agent', + status: Status.Missing, + today: + 'The MCP server at api.daily.dev/mcp can already follow, unfollow and block topics and sources', + needs: + 'MCP tools for the rest of this page: read the profile and the left-out list, set boosts and ranking settings, run the resets', + }, ]; export const Spec: Story = { @@ -474,9 +486,9 @@ export const Spec: Story = { policy For You uses, over real posts. Every reason, chip, weight and left-out group comes from that copy, so changing a control re-ranks the page the same way the feed would. It simplifies four things: - reader response compares posts here instead of using the source - prior, the first tag is the main topic, topic similarity is tag - overlap instead of tag embeddings, and placement swaps are left out. + reader response compares posts here instead of using the source prior, + the first tag is the main topic, topic similarity is tag overlap + instead of tag embeddings, and placement swaps are left out.

        @@ -514,6 +526,56 @@ export const Spec: Story = {
        +
        +
        +

        + Ranking settings and their config keys +

        +

        + Each setting in the panel writes one retriever_config{' '} + value. Topic and per-source boosts show as the multiplier they give, + so a topic weight of 0.30 reads as ×1.30. +

        +
        +
        + + + + + + + + + + + {paramMeta + .filter(({ group }) => group !== ParamGroup.Adjustments) + .map((meta) => ( + + + + + + + ))} + +
        GroupSettingKeyDefault
        + { + paramGroups.find(({ group }) => group === meta.group) + ?.title + } + + {meta.label} + + {meta.key} + + {formatParam(meta.format, defaultParams[meta.key])} +
        +
        +
        ), }; diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx index 42307e5dde..945752ce89 100644 --- a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -2,7 +2,12 @@ import type { PropsWithChildren, ReactElement } from 'react'; import React, { createContext, useContext, useMemo, useState } from 'react'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import type { ParamKey } from '../policy/params'; -import { defaultParams, formatParam, getParamMeta } from '../policy/params'; +import { + defaultParams, + formatParam, + getParamMeta, + ParamFormat, +} from '../policy/params'; import type { TopicControl } from '../policy/profile'; import { TopicMode } from '../policy/profile'; import type { @@ -53,6 +58,7 @@ interface PersonalizationValue { setTagBlocked: (tag: string, isBlocked: boolean) => void; setSourceFollowed: (source: NamedEntity, isFollowed: boolean) => void; setSourceMode: (source: NamedEntity, mode?: SourceMode) => void; + setSourceWeight: (source: NamedEntity, weight?: number) => void; setSourceBlocked: (source: NamedEntity, isBlocked: boolean) => void; setAuthorFollowed: (author: NamedEntity, isFollowed: boolean) => void; setAuthorBlocked: (author: NamedEntity, isBlocked: boolean) => void; @@ -72,6 +78,18 @@ const PersonalizationContext = createContext( null, ); +const withoutKey = ( + record: Record | undefined, + key: string, +): Record | undefined => { + if (!record || !(key in record)) { + return record; + } + const next = { ...record }; + delete next[key]; + return next; +}; + const toggle = (list: string[], value: string, isOn: boolean): string[] => isOn ? [...new Set([...list, value])] @@ -208,8 +226,11 @@ export const PersonalizationProvider = ({ setTopicWeight: (tag, weight) => apply( weight === undefined - ? `${tagName(tag)} uses its learned weight again.` - : `${tagName(tag)} weight set to ${weight.toFixed(2)}.`, + ? `${tagName(tag)} is automatic again.` + : `${tagName(tag)} boost set to ${formatParam( + ParamFormat.Boost, + weight, + )}.`, (current) => ({ ...current, controls: setControl(current.controls, tag, { weight }), @@ -254,6 +275,9 @@ export const PersonalizationProvider = ({ source.id, isFollowed, ), + sourceWeights: isFollowed + ? current.sourceWeights + : withoutKey(current.sourceWeights, source.id), }), ), setSourceMode: (source, mode) => @@ -266,8 +290,34 @@ export const PersonalizationProvider = ({ } else { delete sourceControls[source.id]; } - return { ...current, sourceControls }; + return { + ...current, + sourceControls, + sourceWeights: withoutKey(current.sourceWeights, source.id), + }; + }, + ), + setSourceWeight: (source, weight) => + apply( + weight === undefined + ? `${source.name} is automatic again.` + : `${source.name} boost set to ${formatParam( + ParamFormat.Multiplier, + weight, + )}.`, + (current) => { + const sourceControls = { ...current.sourceControls }; + delete sourceControls[source.id]; + return { + ...current, + sourceControls, + sourceWeights: + weight === undefined + ? withoutKey(current.sourceWeights, source.id) + : { ...current.sourceWeights, [source.id]: weight }, + }; }, + `source:${source.id}`, ), setSourceBlocked: (source, isBlocked) => apply( @@ -353,10 +403,11 @@ export const PersonalizationProvider = ({ (current) => ({ ...current, evidence: [], tagShownDays: {} }), ), resetControls: () => - apply('Cleared your More, Less, Mute and exact weights.', (current) => ({ + apply('Every boost you set is automatic again.', (current) => ({ ...current, controls: {}, sourceControls: {}, + sourceWeights: {}, })), resetParams: () => apply('Ranking is back to the defaults.', (current) => ({ @@ -379,6 +430,7 @@ export const PersonalizationProvider = ({ tagShownDays: {}, controls: {}, sourceControls: {}, + sourceWeights: {}, params: defaultParams, isFillingWithPopular: true, isLearning: true, diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx index a8312d4bab..41332f72b9 100644 --- a/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx @@ -8,9 +8,6 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; -import type { ParamKey } from '../policy/params'; -import { defaultParams, policyVersion } from '../policy/params'; -import { InterestOrigin, Strength } from '../policy/profile'; import { usePersonalization } from './PersonalizationContext'; import { ControlSection } from './FeedScreenContext'; import { ChangesDock } from './ChangesDock'; @@ -20,14 +17,14 @@ import { SourcesSection } from './panel/SourcesSection'; import { RankingSection } from './panel/RankingSection'; import { ResetSection, SettingsSection } from './panel/SettingsSection'; import { LeftOutList, NextPageList } from './panel/Preview'; +import { AgentSetupButton } from './panel/AgentSetup'; const navItems: Array<{ section: ControlSection; label: string }> = [ - { section: ControlSection.Overview, label: 'Overview' }, - { section: ControlSection.Interests, label: 'Interests' }, + { section: ControlSection.Interests, label: 'Topics' }, { section: ControlSection.Sources, label: 'Sources and people' }, { section: ControlSection.Ranking, label: 'Ranking' }, - { section: ControlSection.LeftOut, label: 'Left out' }, - { section: ControlSection.Settings, label: 'Your choices' }, + { section: ControlSection.LeftOut, label: 'Your next page' }, + { section: ControlSection.Settings, label: 'Other settings' }, { section: ControlSection.Reset, label: 'Start over' }, ]; @@ -43,30 +40,6 @@ const scrollTo = (section: ControlSection) => .getElementById(sectionId(section)) ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); -const Tile = ({ - label, - value, - detail, - onClick, -}: { - label: string; - value: string | number; - detail: string; - onClick: () => void; -}): ReactElement => ( - -); - const Rail = ({ tab, onTabChange, @@ -115,27 +88,14 @@ export const PersonalizationPanel = ({ onBack: () => void; }): ReactElement => { const store = usePersonalization(); - const { state, next } = store; + const { state } = store; const [railTab, setRailTab] = useState( section === ControlSection.LeftOut ? RailTab.LeftOut : RailTab.Next, ); const hasRail = () => !isCompact && window.matchMedia(railQuery).matches; - const followedCount = next.interests.filter( - ({ origin }) => origin === InterestOrigin.Declared, - ).length; - const learnedCount = next.interests.filter( - ({ origin, weight }) => origin === InterestOrigin.Inferred && weight > 0, - ).length; - const earlyCount = next.interests.filter( - ({ strength }) => strength === Strength.EarlySignal, - ).length; - const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( - (key) => state.params[key] !== defaultParams[key], - ).length; - const goTo = (target: ControlSection) => { if (target === ControlSection.LeftOut && hasRail()) { - setRailTab(RailTab.LeftOut); + setRailTab(RailTab.Next); window.scrollTo({ top: 0, behavior: 'smooth' }); return; } @@ -172,25 +132,27 @@ export const PersonalizationPanel = ({ For You
        -
        +

        Personalization

        - Everything that decides what For You shows you, and every knob - behind it. Ranked by {policyVersion}; your profile - updates within minutes of what you read. + Everything that shapes your For You feed, and how much each thing + counts. Change anything here and your feed follows.

        - store.setLearning(!state.isLearning)} - compact={false} - > - Learn from my reading - +
        + store.setLearning(!state.isLearning)} + compact={false} + > + Learn from my reading + + +
        {meta.help} )} - - {paramKey} - {isDefault ? ( - Default - ) : ( - - )} -
        ); }; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx b/packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx new file mode 100644 index 0000000000..349418e257 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx @@ -0,0 +1,154 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { CopyIcon } from '@dailydotdev/shared/src/components/icons/Copy'; +import { TerminalIcon } from '@dailydotdev/shared/src/components/icons/Terminal'; +import { VIcon } from '@dailydotdev/shared/src/components/icons/V'; +import { mcpServerDocs } from '@dailydotdev/shared/src/lib/constants'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; + +// The app reads this from `oauthEndpoints.mcpResource`; Storybook has no API URL. +const mcpServerUrl = 'https://api.daily.dev/mcp'; +const claudeCodeCommand = `claude mcp add --transport http daily.dev ${mcpServerUrl}`; +const starterPrompt = + 'Review my daily.dev feed settings and suggest changes. Ask me before you apply any.'; + +const CopyBlock = ({ + label, + text, + isCode, +}: { + label: string; + text: string; + isCode?: boolean; +}): ReactElement => { + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (!isCopied) { + return undefined; + } + const timeout = setTimeout(() => setIsCopied(false), 2000); + return () => clearTimeout(timeout); + }, [isCopied]); + + return ( +
        + {label} +
        + + {text} + +
        +
        + ); +}; + +const Step = ({ + number, + title, + children, +}: { + number: number; + title: string; + children: ReactNode; +}): ReactElement => ( +
        + + {number} + +
        +

        {title}

        + {children} +
        +
        +); + +const AgentSetupModal = ({ + onClose, +}: { + onClose: () => void; +}): ReactElement => ( + + + +

        + Add the daily.dev MCP server to Claude, Cursor, Codex or any MCP client. + Your agent can then read and change everything on this page. +

        + + + + + + + +

        + Every change it makes shows up here, and you can undo any of it.{' '} + + Setup guide + +

        +
        +
        +); + +export const AgentSetupButton = (): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + {isOpen && setIsOpen(false)} />} + + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx index 2442291a23..58eefccd3a 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx @@ -1,518 +1,212 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useState } from 'react'; -import classNames from 'classnames'; +import React, { useMemo } from 'react'; import { Button, ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; -import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag'; import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import type { Interest, TopicEvidence } from '../../policy/profile'; -import { - InterestOrigin, - Strength, - strengthLabel, - strengthRule, - TopicMode, -} from '../../policy/profile'; +import { InterestOrigin, Strength } from '../../policy/profile'; +import { formatParam, ParamFormat } from '../../policy/params'; import { usePersonalization } from '../PersonalizationContext'; import { ControlSection } from '../FeedScreenContext'; -import { Segmented } from '../controls'; -import { Badge, PanelCard, PanelSection } from './Section'; - -enum RowStatus { - Followed = 'followed', - Learned = 'learned', - Early = 'early', - Muted = 'muted', - Blocked = 'blocked', -} - -enum Filter { - All = 'all', - Followed = 'followed', - Learned = 'learned', - Early = 'early', - Off = 'off', -} - -interface ProfileRow { - tag: string; - status: RowStatus; - interest?: Interest; - evidence?: TopicEvidence; -} - -const statusBadge: Record = { - [RowStatus.Followed]: { - label: 'You follow', - className: 'bg-accent-cabbage-flat text-accent-cabbage-default', - }, - [RowStatus.Learned]: { - label: 'Learned', - className: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', - }, - [RowStatus.Early]: { - label: 'Not counted yet', - className: 'bg-surface-float text-text-tertiary', - }, - [RowStatus.Muted]: { - label: 'Muted', - className: 'bg-accent-ketchup-flat text-accent-ketchup-default', - }, - [RowStatus.Blocked]: { - label: 'Blocked', - className: 'bg-accent-ketchup-flat text-accent-ketchup-default', - }, -}; - -const filterStatuses: Record = { - [Filter.All]: Object.values(RowStatus), - [Filter.Followed]: [RowStatus.Followed], - [Filter.Learned]: [RowStatus.Learned], - [Filter.Early]: [RowStatus.Early], - [Filter.Off]: [RowStatus.Muted, RowStatus.Blocked], -}; +import { BoostSlider } from '../controls'; +import { EntityRow, PanelList, PanelListEmpty, PanelSection } from './Section'; const maxWeight = 1.5; -const formatDays = (days: number): string => { +const formatBoost = (weight: number): string => + weight > 0 ? formatParam(ParamFormat.Boost, weight) : 'Off'; + +const formatWhen = (days: number): string => { if (days < 1) { return 'today'; } return days === 1 ? 'yesterday' : `${days} days ago`; }; -const WeightBar = ({ interest }: { interest?: Interest }): ReactElement => { - const weight = interest?.weight ?? 0; - const computed = interest?.computedWeight ?? 0; - const isAdjusted = !!interest?.control && weight !== computed; - - return ( - - - - {isAdjusted && ( - - )} - - - {weight.toFixed(2)} - - - ); -}; - -const Formula = ({ row }: { row: ProfileRow }): ReactElement => { - const { state } = usePersonalization(); - const { params } = state; - const { interest } = row; - if (!interest || row.status === RowStatus.Blocked) { - return ( -

        - Blocked topics are not interests. Posts with this topic never show. -

        - ); +const describeReading = (evidence: TopicEvidence): string => + evidence.stories === 1 + ? `You read 1 post about it ${formatWhen(evidence.daysSinceLastAction)}.` + : `You read ${evidence.stories} posts about it, the last one ${formatWhen( + evidence.daysSinceLastAction, + )}.`; + +const describeInterest = (interest: Interest): string => { + const { evidence } = interest; + const isFollowed = interest.origin === InterestOrigin.Declared; + if (!evidence) { + return isFollowed ? "You follow this. You haven't read about it yet." : ''; } - const recency = interest.recency.toFixed(2); - let base: string; - if (row.status === RowStatus.Early) { - base = `1 story so far. It starts counting at 2 stories, or right away if you follow it.`; - } else if (interest.origin === InterestOrigin.Declared) { - const boostKey = interest.strength - ? `interests.declared_boost.${interest.strength}` - : undefined; - base = - interest.strength && boostKey && interest.strength !== Strength.EarlySignal - ? `${params['interests.base_weight'].toFixed(2)} for following + ${( - params[boostKey as 'interests.declared_boost.strong'] ?? 0 - ).toFixed(2)} (${strengthLabel[interest.strength].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}` - : `${params['interests.base_weight'].toFixed(2)} for following. Reading about it adds more.`; - } else { - const weightKey = `interests.inferred_weight.${interest.strength}` as - | 'interests.inferred_weight.strong' - | 'interests.inferred_weight.moderate'; - base = `${(params[weightKey] ?? 0).toFixed(2)} (${strengthLabel[ - interest.strength ?? Strength.Moderate - ].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}`; + const reading = describeReading(evidence); + if (isFollowed) { + return `You follow this. ${reading}`; } - const { control } = interest; - let adjustment: string | undefined; - if (control?.weight !== undefined) { - adjustment = `You set it to ${control.weight.toFixed(2)}.`; - } else if (control?.mode === TopicMode.Boosted) { - adjustment = `× ${params['interests.control_boost'].toFixed(2)} for More = ${interest.weight.toFixed(2)}`; - } else if (control?.mode === TopicMode.Demoted) { - adjustment = `× ${params['interests.control_demote'].toFixed(2)} for Less = ${interest.weight.toFixed(2)}`; - } else if (control?.mode === TopicMode.Muted) { - adjustment = 'Muted, so it counts as 0.'; - } - - return ( -

        - {base} - {adjustment && {adjustment}} -

        - ); + return interest.strength === Strength.EarlySignal && !interest.control + ? `${reading} It counts once you read 2.` + : reading; }; -const RowDetail = ({ row }: { row: ProfileRow }): ReactElement => { +const topicIcon = ( + + + +); + +const InterestRow = ({ interest }: { interest: Interest }): ReactElement => { const store = usePersonalization(); - const { interest } = row; - const isFollowed = row.status === RowStatus.Followed; - const isBlocked = row.status === RowStatus.Blocked; + const isFollowed = interest.origin === InterestOrigin.Declared; return ( -
        - - {interest && !isBlocked && ( -
        - - - Exact weight - - - {interest.weight.toFixed(2)} - - - store.setTopicWeight(row.tag, value)} - /> - - A post whose main topic is #{row.tag} gets × - {(1 + interest.weight).toFixed(2)}; as a side topic, × - {( - 1 + - interest.weight * store.state.params['relevance.side_topic_weight'] - ).toFixed(2)} - . - -
        - )} -
        - {!isBlocked && ( - - )} + store.setTopicWeight(interest.tag, value)} + onReset={() => store.setTopicMode(interest.tag, undefined)} + /> + } + action={ - {interest?.control && ( - - )} -
        -
        - ); -}; - -const ProfileRowItem = ({ - row, - onScreen, - isCompact, -}: { - row: ProfileRow; - onScreen: number; - isCompact?: boolean; -}): ReactElement => { - const store = usePersonalization(); - const [isOpen, setIsOpen] = useState(false); - const { interest, evidence } = row; - const badge = statusBadge[row.status]; - const isOff = row.status === RowStatus.Blocked; - const mode = store.state.controls[row.tag]?.mode; - const facts = [ - evidence - ? `${evidence.stories} ${evidence.stories === 1 ? 'story' : 'stories'}` - : 'No reading yet', - evidence?.strongStories - ? `${evidence.strongStories} upvoted, saved or shared` - : undefined, - evidence ? `last ${formatDays(evidence.daysSinceLastAction)}` : undefined, - interest?.evidence ? `${Math.round(interest.recency * 100)}% fresh` : undefined, - onScreen ? `in ${onScreen} of your next 12` : undefined, - ].filter(Boolean); - - return ( -
      • -
        - -
        - - {!isOff && ( - store.setTopicMode(row.tag, value)} - options={[ - ...(row.status === RowStatus.Followed - ? [] - : [ - { - value: TopicMode.Muted, - label: 'Mute', - activeClassName: 'text-accent-ketchup-default', - }, - ]), - { - value: TopicMode.Demoted, - label: 'Less', - activeClassName: 'text-accent-bun-default', - }, - { value: undefined, label: 'Auto' }, - { - value: TopicMode.Boosted, - label: 'More', - activeClassName: 'text-accent-avocado-default', - }, - ]} - /> - )} -
        -
        - {isOpen && } -
      • + } + /> ); }; -const filterLabels: Record = { - [Filter.All]: 'All', - [Filter.Followed]: 'Followed', - [Filter.Learned]: 'Learned', - [Filter.Early]: 'Not counted yet', - [Filter.Off]: 'Muted and blocked', -}; +const byWeight = (a: Interest, b: Interest): number => + b.weight - a.weight || a.tag.localeCompare(b.tag); -export const InterestsSection = ({ - isCompact, -}: { - isCompact?: boolean; -}): ReactElement => { +export const InterestsSection = (): ReactElement => { const store = usePersonalization(); const { state, next, pool } = store; - const [filter, setFilter] = useState(Filter.All); - const rows = useMemo((): ProfileRow[] => { - const fromInterests = next.interests.map((interest): ProfileRow => { - let status = RowStatus.Learned; - if (interest.control?.mode === TopicMode.Muted) { - status = RowStatus.Muted; - } else if (interest.origin === InterestOrigin.Declared) { - status = RowStatus.Followed; - } else if (interest.strength === Strength.EarlySignal) { - status = RowStatus.Early; - } - return { - tag: interest.tag, - status, - interest, - evidence: state.evidence.find(({ topic }) => topic === interest.tag), - }; - }); - const blocked = state.blockedTags.map( - (tag): ProfileRow => ({ - tag, - status: RowStatus.Blocked, - evidence: state.evidence.find(({ topic }) => topic === tag), - }), - ); - const order = Object.values(RowStatus); - return [...fromInterests, ...blocked].sort( - (a, b) => - order.indexOf(a.status) - order.indexOf(b.status) || - (b.interest?.weight ?? 0) - (a.interest?.weight ?? 0), - ); - }, [next.interests, state.blockedTags, state.evidence]); - const onScreen = (tag: string) => - next.served.filter(({ post }) => post.tags?.includes(tag)).length; + const followed = next.interests + .filter(({ origin }) => origin === InterestOrigin.Declared) + .sort(byWeight); + const learned = next.interests + .filter(({ origin }) => origin === InterestOrigin.Inferred) + .sort(byWeight); const suggestions = useMemo(() => { - const known = new Set(rows.map(({ tag }) => tag)); + const known = new Set([ + ...next.interests.map(({ tag }) => tag), + ...state.blockedTags, + ]); const counts = new Map(); pool.forEach(({ tags }) => - (tags ?? []).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1)), + (tags ?? []).forEach((tag) => + counts.set(tag, (counts.get(tag) ?? 0) + 1), + ), ); return [...counts.entries()] .filter(([tag]) => !known.has(tag)) .sort((a, b) => b[1] - a[1]) .slice(0, 8) .map(([tag]) => tag); - }, [pool, rows]); - const visible = rows.filter(({ status }) => - filterStatuses[filter].includes(status), - ); - const countFor = (value: Filter) => - rows.filter(({ status }) => filterStatuses[value].includes(status)).length; + }, [pool, next.interests, state.blockedTags]); return ( - {!state.isLearning && ( -

        - Learning from reading is off, so only followed topics count right now. -

        - )} -
        - {Object.values(Filter).map((value) => ( - + + {followed.map((interest) => ( + ))} -
        -
          - {visible.map((row) => ( - + {!followed.length && ( + You don't follow any topics yet. + )} + + + {learned.map((interest) => ( + ))} - {!visible.length && ( -
        • - Nothing here. -
        • + {!learned.length && ( + + {state.isLearning + ? 'Nothing yet. Topics show up here as you read.' + : 'Learning from your reading is off, so only topics you follow count.'} + )} -
        - - - Follow a topic - - - Topics in posts you could see, most common first. - -
        - {suggestions.map((tag) => ( - + media={topicIcon} + name={tag} + isStruck + action={ + + } + /> ))} -
        -
        -
        - {Object.values(Strength).map((strength) => ( -
        -
        - {strengthLabel[strength]} -
        -
        {strengthRule[strength]}
        + + )} + {suggestions.length > 0 && ( +
        +

        + Follow a topic +

        +
        + {suggestions.map((tag) => ( + + ))}
        - ))} -
        +
    + )} ); }; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx index 1168361a85..a9d228df72 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx @@ -23,7 +23,11 @@ import { usePersonalization } from '../PersonalizationContext'; import { useFeedScreen } from '../FeedScreenContext'; import { Badge } from './Section'; -const Movement = ({ decision }: { decision: Decision }): ReactElement | null => { +const Movement = ({ + decision, +}: { + decision: Decision; +}): ReactElement | null => { const { served } = usePersonalization(); const before = served.find(({ post }) => post.id === decision.post.id); if (!before) { @@ -83,11 +87,7 @@ const PostLine = ({ ); -export const NextPageList = ({ - limit, -}: { - limit?: number; -}): ReactElement => { +export const NextPageList = ({ limit }: { limit?: number }): ReactElement => { const { next, served } = usePersonalization(); const { onOpenDetails } = useFeedScreen(); const servedIds = new Set(served.map(({ post }) => post.id)); @@ -121,9 +121,7 @@ export const NextPageList = ({ getCategoryDot(getCategory(decision)), )} /> - - {chip?.label} - + {chip?.label} {onOpenDetails && ( -
    -
    -        {json}
    -      
    - - ); -}; - export const RankingSection = (): ReactElement => { const store = usePersonalization(); const { state } = store; const [isAdvanced, setIsAdvanced] = useState(false); - const [isConfigOpen, setIsConfigOpen] = useState(false); const changedCount = (Object.keys(state.params) as ParamKey[]).filter( (key) => state.params[key] !== defaultParams[key], - ).length + - (state.isLearning ? 0 : 1) + - (state.isFillingWithPopular ? 0 : 1); + ).length + (state.isFillingWithPopular ? 0 : 1); return ( - The knobs of {policyVersion}, the For You policy. A - post's score is reader response × topic match × follow × - freshness × seen before; each page then mixes topics and sources and - gives waiting interests a turn. - - } + description="Each post starts from how other readers liked it. Your topics and follows boost it, and it drops as it gets older or as you scroll past it. These settings apply to every post." actions={
    { size={ButtonSize.Small} onClick={store.resetParams} > - Reset {changedCount} + Reset all )}
    } > -
    +
    {paramGroups.map(({ group, title, description }) => { - const knobs = paramMeta.filter( + const settings = paramMeta.filter( (meta) => meta.group === group && (isAdvanced || !meta.isAdvanced), ); return ( - +

    {title}

    -

    {description}

    +

    + {description} +

    - {group === ParamGroup.Learning && ( - store.setLearning(!state.isLearning)} - /> - )} {group === ParamGroup.Scope && ( store.setFilling(!state.isFillingWithPopular)} /> )} - {knobs.map(({ key }) => ( + {settings.map(({ key }) => ( ))}
    ); })}
    - - - {isConfigOpen && } - -
    -

    - Not adjustable -

    -
      - {fixedKnobs.map(({ title, text }) => ( -
    • - - {title} - - {text} -
    • - ))} -
    -
    ); }; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx index ceff3ddffa..e6179a57b5 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx @@ -27,7 +27,7 @@ export const PanelSection = ({ className={classNames('flex scroll-mt-24 flex-col gap-4', className)} >
    -
    +

    {description && ( -

    - {description} -

    +

    {description}

    )}

    {actions} @@ -79,3 +77,85 @@ export const Badge = ({ {children} ); + +/** A titled card of rows, like the topics you follow. */ +export const PanelList = ({ + title, + description, + children, +}: { + title: string; + description?: ReactNode; + children: ReactNode; +}): ReactElement => ( +
    +
    +

    {title}

    + {description && ( +

    {description}

    + )} +
    + +
      + {children} +
    +
    +
    +); + +export const PanelListEmpty = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( +
  • {children}
  • +); + +/** + * One topic, source or person: who it is, its boost and one action. The boost + * sits between them on a wide list and under them on a narrow one. + */ +export const EntityRow = ({ + media, + name, + description, + isStruck, + boost, + action, +}: { + media: ReactNode; + name: string; + description?: string; + isStruck?: boolean; + boost?: ReactNode; + action: ReactNode; +}): ReactElement => ( +
  • + + {media} + + + {name} + + {description && ( + + {description} + + )} + + + {boost && ( + + {boost} + + )} + + {action} + +
  • +); diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx index e4007d69ee..a0865e2599 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx @@ -6,7 +6,12 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { AiIcon } from '@dailydotdev/shared/src/components/icons/Ai'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; +import { EyeIcon } from '@dailydotdev/shared/src/components/icons/Eye'; +import { FilterIcon } from '@dailydotdev/shared/src/components/icons/Filter'; +import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { settingsUrl, webappUrl } from '@dailydotdev/shared/src/lib/constants'; import type { ParamKey } from '../../policy/params'; @@ -15,25 +20,30 @@ import { usePersonalization } from '../PersonalizationContext'; import { ControlSection } from '../FeedScreenContext'; import { PanelCard, PanelSection } from './Section'; -const LinkRow = ({ +const SettingLink = ({ + icon, title, summary, href, }: { + icon: ReactNode; title: string; - summary: ReactNode; + summary: string; href: string; }): ReactElement => (
  • + + {icon} + - {title} - - {summary} + + {title} + {summary} ); -const list = (items: string[], empty: string): string => - items.length ? items.join(', ') : empty; - -/** Where the explicit choices already live; this page only links to them. */ +/** Filters that live in settings; this page only links to them. */ export const SettingsSection = (): ReactElement => { const { state } = usePersonalization(); const blockedCount = @@ -57,59 +64,45 @@ export const SettingsSection = (): ReactElement => { return ( - -
      - `#${tag}`), - 'None yet', - )} - href={`${settingsUrl}/feed/tags`} - /> - - - `#${tag}`), - ...state.blockedSources, - ], - 'Topics, sources, people and title words you blocked', - )} - href={`${settingsUrl}/feed/blocked`} - /> - - - -
    -
    +
      + } + title="Blocked" + summary={ + blockedCount + ? `${blockedCount} topics, sources or people you blocked, plus words in titles` + : 'Topics, sources, people and words in titles you never want to see' + } + href={`${settingsUrl}/feed/blocked`} + /> + } + title="Content types" + summary="Which kinds of posts can show up" + href={`${settingsUrl}/feed/preferences`} + /> + } + title="AI filters" + summary="How much AI-generated content and clickbait to allow" + href={`${settingsUrl}/feed/ai`} + /> + } + title="Squads" + summary="Squads you join count as sources you follow" + href={`${webappUrl}squads`} + /> + } + title="Reading history" + summary="The posts your topics from reading come from" + href={`${webappUrl}history`} + /> +
    ); }; @@ -132,7 +125,9 @@ const ResetRow = ({ return (
  • - {title} + + {title} + {description} {isConfirming ? ( @@ -180,11 +175,15 @@ export const ResetSection = (): ReactElement => { const learned = state.evidence.filter( ({ topic }) => !state.followedTags.includes(topic), ).length; - const adjustments = - Object.keys(state.controls).length + Object.keys(state.sourceControls).length; - const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( - (key) => state.params[key] !== defaultParams[key], - ).length; + const adjusted = new Set([ + ...Object.keys(state.controls), + ...Object.keys(state.sourceControls), + ...Object.keys(state.sourceWeights ?? {}), + ]).size; + const changedSettings = + (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length + (state.isFillingWithPopular ? 0 : 1); const seen = Object.keys(state.impressions).length; return ( @@ -196,40 +195,60 @@ export const ResetSection = (): ReactElement => {
      diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx index 0407f983c7..4dde84eeb3 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx @@ -9,102 +9,96 @@ import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { formatParam, ParamFormat } from '../../policy/params'; import { SourceMode } from '../../policy/rank'; -import { formatMultiplier } from '../FactorChips'; import { usePersonalization } from '../PersonalizationContext'; import { ControlSection } from '../FeedScreenContext'; -import { Badge, PanelCard, PanelSection } from './Section'; +import { BoostSlider } from '../controls'; +import { EntityRow, PanelList, PanelListEmpty, PanelSection } from './Section'; -const SourceRow = ({ source }: { source: Source }): ReactElement => { +type ListedSource = Source & { id: string }; + +const formatBoost = (value: number): string => + formatParam(ParamFormat.Multiplier, value); + +const SourceRow = ({ source }: { source: ListedSource }): ReactElement => { const store = usePersonalization(); const { state } = store; + const { params } = state; const entity = { id: source.id, name: source.name ?? source.id }; + const isFollowed = state.followedSources.includes(source.id); const isBlocked = state.blockedSources.includes(source.id); - const isDemoted = state.sourceControls[source.id] === SourceMode.Demoted; - const onScreen = store.next.served.filter( - ({ post }) => post.source?.id === source.id, - ).length; - let effect = formatMultiplier(state.params['follows.source_multiplier']); - if (isDemoted) { - effect = `${effect} × ${state.params['interests.control_demote'].toFixed(2)} for Less`; - } + const media = ( + + ); - return ( -
    • - - - - - {source.name} - - {isBlocked ? ( - - Blocked - - ) : ( - - {effect} - - )} - - - {isBlocked - ? 'None of its posts show.' - : `Every post from it comes in. ${ - onScreen ? `${onScreen} in your next 12.` : '' - }`} - - - - {isBlocked ? ( + if (isBlocked) { + return ( + store.setSourceBlocked(entity, false)} > Unblock - ) : ( - <> - - - - - )} - -
    • + } + /> + ); + } + + const automatic = isFollowed ? params['follows.source_multiplier'] : 1; + const exact = state.sourceWeights?.[source.id]; + const isDemoted = state.sourceControls[source.id] === SourceMode.Demoted; + const value = + exact ?? automatic * (isDemoted ? params['interests.control_demote'] : 1); + + return ( + store.setSourceWeight(entity, next)} + onReset={() => store.setSourceWeight(entity, undefined)} + /> + } + action={ + + } + /> ); }; @@ -112,20 +106,38 @@ export const SourcesSection = (): ReactElement => { const store = usePersonalization(); const { state, pool } = store; const sourceById = useMemo(() => { - const map = new Map(); + const map = new Map(); pool.forEach(({ source }) => { if (source?.id) { - map.set(source.id, source as Source); + map.set(source.id, source as ListedSource); } }); return map; }, [pool]); - const followed = state.followedSources - .map((id) => sourceById.get(id)) - .filter((source): source is Source => !!source); - const blocked = state.blockedSources - .map((id) => sourceById.get(id)) - .filter((source): source is Source => !!source); + const authorById = useMemo(() => { + const map = new Map(); + pool.forEach(({ author }) => { + if (author?.id) { + map.set(author.id, author.name ?? author.id); + } + }); + return map; + }, [pool]); + const toSources = (ids: string[]) => + ids + .map((id) => sourceById.get(id)) + .filter((source): source is ListedSource => !!source); + const adjusted = [ + ...new Set([ + ...Object.keys(state.sourceControls), + ...Object.keys(state.sourceWeights ?? {}), + ]), + ].filter( + (id) => + !state.followedSources.includes(id) && !state.blockedSources.includes(id), + ); + const listed = toSources([...state.followedSources, ...adjusted]); + const blocked = toSources(state.blockedSources); const suggestions = useMemo(() => { const counts = new Map(); pool.forEach(({ source }) => { @@ -142,41 +154,72 @@ export const SourcesSection = (): ReactElement => { .sort((a, b) => b[1] - a[1]) .slice(0, 6) .map(([id]) => sourceById.get(id)) - .filter((source): source is Source => !!source); + .filter((source): source is ListedSource => !!source); }, [pool, sourceById, state.followedSources, state.blockedSources]); return ( - -
        - {[...followed, ...blocked].map((source) => ( + + {listed.map((source) => ( + + ))} + {!listed.length && ( + + You don't follow any sources yet. + + )} + + + {state.followedAuthors.map((id) => { + const author = { id, name: authorById.get(id) ?? id }; + return ( + store.setAuthorFollowed(author, false)} + > + Unfollow + + } + /> + ); + })} + {!state.followedAuthors.length && ( + + You don't follow anyone yet. Following a writer shows you every + post they write. + + )} + + {blocked.length > 0 && ( + + {blocked.map((source) => ( ))} - {!followed.length && !blocked.length && ( -
      • - You don't follow or block any source yet. -
      • - )} -
      • - {state.followedAuthors.length - ? `${state.followedAuthors.length} people you follow, ${formatMultiplier( - state.params['follows.author_multiplier'], - )} each.` - : `No people followed. Following a writer multiplies their posts by ${formatMultiplier( - state.params['follows.author_multiplier'], - )}.`} -
      • -
      -
      + + )} {suggestions.length > 0 && (
      - - Sources in posts you could see, most common first. - +

      + Follow a source +

      {suggestions.map((source) => (