diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 4886a5f163..46567fb467 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -7,7 +7,7 @@ import ExtensionProviders from '../../extension/_providers'; 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 type { TopicControl } from './policy/profile'; import { defaultParams, formatParam, @@ -23,7 +23,6 @@ import { import { ControlSection, FeedScreenContext, - getSignalCounts, } from './components/FeedScreenContext'; import { ExplainDetailsModal } from './components/ExplainDetails'; import { @@ -96,18 +95,18 @@ export const ExplainOn: Story = { render: () => , }; -export const ReasonControls: Story = { - name: '3 · Reason controls', +export const TopicBoost: Story = { + name: '3 · Topic boost', render: () => , play: async ({ canvasElement }) => { await waitFor(() => { - const chip = canvasElement.querySelector( - 'ul[aria-label="What moved this post"] button', + const value = canvasElement.querySelector( + 'button[aria-label^="Boost for #"]', ); - if (!chip) { - throw new Error('Waiting for reason chips'); + if (!value) { + throw new Error('Waiting for the topic rows'); } - chip.click(); + value.click(); }); }, }; @@ -154,12 +153,12 @@ interface CardState { layout?: FeedLayout; } -const withControl = (tag: string, mode: TopicMode) => ( - state: PersonalizationState, -): PersonalizationState => ({ - ...state, - controls: { ...state.controls, [tag]: { mode } }, -}); +const withControl = + (tag: string, control: TopicControl) => + (state: PersonalizationState): PersonalizationState => ({ + ...state, + controls: { ...state.controls, [tag]: control }, + }); const cardStates: CardState[] = [ { @@ -177,16 +176,6 @@ const cardStates: CardState[] = [ note: 'Never followed; six stories in two weeks made it strong.', id: 'MsFCKRbGk', }, - { - title: "A topic's turn", - note: 'Docker had not been on the first screen lately, so it jumped ahead.', - id: 'JKsEou23l', - }, - { - title: 'Variety pushed it down', - note: 'The third Fireship post on the page, so it gives way.', - id: '1XMQ9zlpA', - }, { title: 'Popular fill-in', note: 'A new viewer with two topics; the popular lane tops up the page.', @@ -194,16 +183,16 @@ const cardStates: CardState[] = [ persona: PersonaId.JustSignedUp, }, { - title: 'Showing more', - note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.', + title: 'Boosted a topic', + note: '#rust raised to ×2.20 from its boost on the card. It ranks higher next refresh.', id: 'BjYAz50EA', - change: withControl('rust', TopicMode.Boosted), + change: withControl('rust', { weight: 1.2 }), }, { - title: 'Muted a learned topic', - note: 'Reading about #career stops counting, so this card leaves.', + title: 'Turned a topic off', + note: '#career dragged all the way left, so this card leaves. Undo brings it back.', id: 'MsFCKRbGk', - change: withControl('career', TopicMode.Muted), + change: withControl('career', { weight: 0 }), }, { title: 'Blocked a source', @@ -250,8 +239,7 @@ const StateCardBody = ({ }): 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 screen = useMemo(() => ({ onOpenDetails: setDetails }), []); const decision = store.served.find(({ post }) => post.id === id); if (!decision) { return

Not on the page.

; @@ -267,7 +255,10 @@ const StateCardBody = ({ isExplain /> {details && ( - setDetails(null)} /> + setDetails(null)} + /> )} ); @@ -318,12 +309,11 @@ export const CardStates: Story = { Explain mode, card by card

- 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. + Each card keeps its source and title and says why it is here in one + sentence, then lists the post's topics and source with follow + and block. Each topic shows its boost; tap it to change how much it + counts. Full explanation opens the "Why am I seeing this?" + view with the score breakdown.

diff --git a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx index d469d0cbca..430df78392 100644 --- a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import { Button, ButtonSize, @@ -9,7 +10,7 @@ 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. */ +/** One floating line: the last change, Undo, and the screen's next step. */ export const ChangesDock = ({ primaryLabel, onPrimary, @@ -24,48 +25,64 @@ export const ChangesDock = ({ isCompact?: boolean; }): ReactElement => { const { lastMessage, notice, pendingCount, undo } = usePersonalization(); + const message = lastMessage ?? notice ?? defaultMessage; return (
- - {lastMessage ?? notice ?? defaultMessage} + + {message} - {pendingCount > 0 && ( - - )} - {onOpenControls && ( + + {pendingCount > 0 && ( + + )} + {onOpenControls && ( + + )} - )} - +
); diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index fb2d16ea0d..a6d26efdd6 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 } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { Button, @@ -12,17 +12,19 @@ import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; import { EyeCancelIcon } from '@dailydotdev/shared/src/components/icons/EyeCancel'; import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag'; -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 { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; 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 type { Decision } from '../policy/rank'; import { Exclusion, FollowKind, SourceMode } from '../policy/rank'; -import { InterestOrigin, TopicMode } from '../policy/profile'; +import type { Interest } from '../policy/profile'; +import { maxTopicWeight, TopicMode } from '../policy/profile'; import { + formatBoost, getCategory, getCategoryDot, getLeaveMessage, @@ -30,9 +32,9 @@ import { } from '../policy/describe'; import { defaultParams } from '../policy/params'; import { usePersonalization } from './PersonalizationContext'; -import { useFeedScreen } from './FeedScreenContext'; -import { FactorChips } from './FactorChips'; +import { ControlSection, useFeedScreen } from './FeedScreenContext'; import { Strong } from './controls'; +import { AnchoredPopover } from './AnchoredPopover'; export enum ExplainVariant { Grid = 'grid', @@ -132,6 +134,109 @@ const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement => ); +const TopicBoost = ({ + tag, + interest, +}: { + tag: string; + /** Unset when the topic is not in the profile yet. */ + interest?: Interest; +}): ReactElement => { + const store = usePersonalization(); + const { onOpenControls } = useFeedScreen(); + const [anchor, setAnchor] = useState(null); + const weight = interest?.weight ?? 0; + const boost = formatBoost(weight); + const isSet = !!interest?.control; + const label = `Boost for #${tag}`; + const valueClassName = isSet + ? 'text-accent-cabbage-default' + : 'text-text-secondary'; + + return ( + <> + + {anchor && ( + setAnchor(null)} + > +
+ + {label} + + + {boost} + +
+ store.setTopicWeight(tag, value)} + /> +

+ {interest + ? `Posts about #${tag} get this boost. All the way left turns it off.` + : `Not in your profile yet. Drag right to get posts about #${tag}.`} +

+
+ {isSet ? ( + + ) : ( + Changes apply when you refresh. + )} + {onOpenControls && ( + + )} +
+
+ )} + + ); +}; + const SignalRow = ({ signal, size, @@ -140,20 +245,21 @@ const SignalRow = ({ size: ButtonSize; }): ReactElement => { const store = usePersonalization(); - const { state, next, setHoverKey } = store; - const { counts } = useFeedScreen(); - const count = counts[signal.key] ?? 0; + const { state, next } = store; 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 isFollowed = isTopic + ? state.followedTags.includes(signal.id) + : state.followedSources.includes(signal.id); const isDemotedSource = !isTopic && state.sourceControls[signal.id] === SourceMode.Demoted; - const interest = next.interests.find(({ tag }) => tag === signal.id); + const interest = isTopic + ? next.interests.find(({ tag }) => tag === signal.id) + : undefined; const evidence = state.evidence.find(({ topic }) => topic === signal.id); let meta: string; let metaClassName = 'text-text-quaternary'; @@ -170,47 +276,58 @@ const SignalRow = ({ } 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'; + } else if (isTopic) { + const stories = interest?.evidence?.stories ?? evidence?.stories ?? 0; + if (stories) { + meta = `You've read ${stories} ${stories === 1 ? 'post' : 'posts'} on it`; + } else if (isFollowed) { + meta = 'You follow it'; + } else if (interest?.control?.weight !== undefined) { + meta = 'You set its boost'; + } else { + meta = 'Not one of your topics'; } - meta = [origin, count > 1 && `in ${count} posts`].filter(Boolean).join(' · '); + } else { + meta = isFollowed ? 'You follow it' : "You don't follow it"; } const onBlock = () => isTopic ? store.setTagBlocked(signal.id, !isBlocked) : store.setSourceBlocked(entity, !isBlocked); + const followLabel = `${isFollowed ? 'Unfollow' : 'Follow'} ${signal.label}`; + const onFollow = () => + isTopic + ? store.setTagFollowed(signal.id, !isFollowed) + : store.setSourceFollowed(entity, !isFollowed); return (
  • setHoverKey(signal.key)} - onMouseLeave={() => setHoverKey(null)} > - - + + + {signal.label} + + {isTopic && !isBlocked && ( + )} - > - {signal.label} - + {meta} @@ -227,44 +344,16 @@ const SignalRow = ({ ) : ( - - - - - ); - 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 88b4d34cc4..928059d6ff 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx +++ b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx @@ -23,11 +23,7 @@ import { usePersonalization, } from './PersonalizationContext'; import type { ControlSection } from './FeedScreenContext'; -import { - FeedScreenContext, - getSignalCounts, - useFeedScreen, -} from './FeedScreenContext'; +import { FeedScreenContext, useFeedScreen } from './FeedScreenContext'; import { ExplainPanel, ExplainVariant } from './ExplainPanel'; import { ExplainDetailsModal } from './ExplainDetails'; import { PersonalizationPanel } from './PersonalizationPanel'; @@ -155,31 +151,17 @@ export const CardFrame = ({ layout, isExplain, }: CardFrameProps): ReactElement => { - 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 && - keys.includes(hoverKey); // The real card stays underneath so the feed keeps every size and gap; the // overlay covers it with the explanation. return (
    @@ -302,6 +284,10 @@ const FeedExplainScreen = ({ const { served, pool, state, pendingCount, refresh } = usePersonalization(); const { onOpenControls } = useFeedScreen(); const isList = layout === FeedLayout.List; + let refreshLabel = 'Done'; + if (pendingCount) { + refreshLabel = isList ? 'Refresh' : 'Refresh feed'; + } const leaving = useMemo( () => new Map( @@ -346,8 +332,8 @@ const FeedExplainScreen = ({
    {isExplain && ( onOpenControls())} isCompact={isList} @@ -382,13 +368,10 @@ const App = ({ const [section, setSection] = useState(initialSection); const [isExplain, setIsExplain] = useState(initialExplain); const [details, setDetails] = useState( - () => - store.served.find(({ post }) => post.id === initialDetailsId) ?? null, + () => 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); @@ -396,14 +379,13 @@ const App = ({ window.scrollTo({ top: 0 }); }, }), - [counts], + [], ); const onToggleExplain = () => { if (isExplain && store.pendingCount) { store.refresh(); } - store.setHoverKey(null); setIsExplain(!isExplain); }; diff --git a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts index 7d86e5d24e..42a4620d14 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts +++ b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts @@ -12,32 +12,13 @@ export enum ControlSection { } 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 FeedScreenContext = createContext({}); 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/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx index 945752ce89..717dac85fe 100644 --- a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -50,8 +50,6 @@ interface PersonalizationValue { 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; @@ -74,9 +72,7 @@ interface PersonalizationValue { resetAll: () => void; } -const PersonalizationContext = createContext( - null, -); +const PersonalizationContext = createContext(null); const withoutKey = ( record: Record | undefined, @@ -91,9 +87,7 @@ const withoutKey = ( }; const toggle = (list: string[], value: string, isOn: boolean): string[] => - isOn - ? [...new Set([...list, value])] - : list.filter((item) => item !== value); + isOn ? [...new Set([...list, value])] : list.filter((item) => item !== value); const setControl = ( controls: Record, @@ -112,7 +106,8 @@ const setControl = ( 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.`, + [TopicMode.Muted]: (name) => + `Muted ${name}. Reading about it no longer counts.`, }; export const PersonalizationProvider = ({ @@ -132,7 +127,6 @@ export const PersonalizationProvider = ({ 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]); @@ -213,11 +207,11 @@ export const PersonalizationProvider = ({ : 'Feed refreshed with your changes.', }; }), - hoverKey, - setHoverKey, setTopicMode: (tag, mode) => apply( - mode ? modeMessage[mode](tagName(tag)) : `${tagName(tag)} is back to normal.`, + mode + ? modeMessage[mode](tagName(tag)) + : `${tagName(tag)} is back to normal.`, (current) => ({ ...current, controls: setControl(current.controls, tag, { mode }), @@ -239,7 +233,9 @@ export const PersonalizationProvider = ({ ), setTagFollowed: (tag, isFollowed) => apply( - isFollowed ? `Following ${tagName(tag)}.` : `Unfollowed ${tagName(tag)}.`, + isFollowed + ? `Following ${tagName(tag)}.` + : `Unfollowed ${tagName(tag)}.`, (current) => ({ ...current, followedTags: toggle(current.followedTags, tag, isFollowed), @@ -267,7 +263,9 @@ export const PersonalizationProvider = ({ ), setSourceFollowed: (source, isFollowed) => apply( - isFollowed ? `Following ${source.name}.` : `Unfollowed ${source.name}.`, + isFollowed + ? `Following ${source.name}.` + : `Unfollowed ${source.name}.`, (current) => ({ ...current, followedSources: toggle( @@ -282,7 +280,9 @@ export const PersonalizationProvider = ({ ), setSourceMode: (source, mode) => apply( - mode ? `You'll see less from ${source.name}.` : `${source.name} is back to normal.`, + mode + ? `You'll see less from ${source.name}.` + : `${source.name} is back to normal.`, (current) => { const sourceControls = { ...current.sourceControls }; if (mode) { @@ -336,7 +336,9 @@ export const PersonalizationProvider = ({ ), setAuthorFollowed: (author, isFollowed) => apply( - isFollowed ? `Following ${author.name}.` : `Unfollowed ${author.name}.`, + isFollowed + ? `Following ${author.name}.` + : `Unfollowed ${author.name}.`, (current) => ({ ...current, followedAuthors: toggle( @@ -391,7 +393,7 @@ export const PersonalizationProvider = ({ ), setHidden: (postId, isHidden) => apply( - isHidden ? 'Got it, fewer posts like this one.' : 'Post is back in your feed.', + isHidden ? 'Got it, fewer like this.' : 'Post is back in your feed.', (current) => ({ ...current, hidden: toggle(current.hidden, postId, isHidden), @@ -439,7 +441,7 @@ export const PersonalizationProvider = ({ }), ), }; - }, [state, pool, served, next, changes, refreshedAt, notice, hoverKey]); + }, [state, pool, served, next, changes, refreshedAt, notice]); return ( 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 58eefccd3a..185de105db 100644 --- a/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx +++ b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx @@ -9,18 +9,13 @@ 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 } from '../../policy/profile'; -import { formatParam, ParamFormat } from '../../policy/params'; +import { InterestOrigin, maxTopicWeight, Strength } from '../../policy/profile'; +import { formatBoost } from '../../policy/describe'; import { usePersonalization } from '../PersonalizationContext'; import { ControlSection } from '../FeedScreenContext'; import { BoostSlider } from '../controls'; import { EntityRow, PanelList, PanelListEmpty, PanelSection } from './Section'; -const maxWeight = 1.5; - -const formatBoost = (weight: number): string => - weight > 0 ? formatParam(ParamFormat.Boost, weight) : 'Off'; - const formatWhen = (days: number): string => { if (days < 1) { return 'today'; @@ -39,7 +34,9 @@ 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." : ''; + return isFollowed + ? "You follow this. You haven't read about it yet." + : 'You added this with a boost.'; } const reading = describeReading(evidence); if (isFollowed) { @@ -71,7 +68,7 @@ const InterestRow = ({ interest }: { interest: Interest }): ReactElement => { label={`Boost for #${interest.tag}`} value={interest.weight} min={0} - max={maxWeight} + max={maxTopicWeight} step={0.05} display={formatBoost(interest.weight)} isSet={!!interest.control} diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts index 85e12b7e94..7a96beab0b 100644 --- a/packages/storybook/stories/features/feed-explain/policy/describe.ts +++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts @@ -2,6 +2,7 @@ import type { Decision, PersonalizationState } from './rank'; import { Exclusion, FollowKind, Lane, TopicRole } from './rank'; import { InterestOrigin, Strength, TopicMode } from './profile'; import type { Interest } from './profile'; +import { formatParam, ParamFormat } from './params'; /** What put a card on the page, for the mix bar and the reason dot. */ export enum ReasonCategory { @@ -50,6 +51,10 @@ export const getCategory = (decision: Decision): ReasonCategory => { : ReasonCategory.Followed; }; +/** A topic's weight as the boost it gives a post, or Off. */ +export const formatBoost = (weight: number): string => + weight > 0 ? formatParam(ParamFormat.Boost, weight) : 'Off'; + export const getCategoryDot = (category: ReasonCategory): string => reasonCategories.find((item) => item.category === category)?.dotClassName ?? ''; @@ -108,7 +113,9 @@ export const getFactorChips = (decision: Decision): FactorChip[] => { ? [ { id: FactorId.Match, - label: `#${best.tag}${best.role === TopicRole.Side ? ' (side)' : ''}`, + label: `#${best.tag}${ + best.role === TopicRole.Side ? ' (side)' : '' + }`, value: factors.match, }, ] @@ -126,7 +133,11 @@ export const getFactorChips = (decision: Decision): FactorChip[] => { 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.Age, + label: formatAge(decision.ageDays), + value: factors.age, + }, { id: FactorId.Seen, label: `Seen ${decision.impressions}×`, @@ -166,7 +177,7 @@ export const getFactorChips = (decision: Decision): FactorChip[] => { 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', + [Strength.Confirmed]: 'and read a post on it', }; export interface ReasonParts { @@ -183,7 +194,7 @@ export const getReason = ( if (decision.lane === Lane.Popular) { return { lead: 'Popular this week.', - tail: " Your interests didn't fill the page, so this tops it up.", + tail: " Your topics didn't have enough new posts, so we added it.", }; } const { best } = decision; @@ -201,22 +212,37 @@ export const getReason = ( if (!best) { return { lead: 'Picked for you', tail: '.' }; } - const side = best.role === TopicRole.Side ? ', here a side topic' : ''; + const name = `#${best.tag}`; + const isStrong = interest?.strength === Strength.Strong; + if ( + best.origin === InterestOrigin.Inferred && + !interest?.evidence && + interest?.control?.weight !== undefined + ) { + const boost = formatBoost(interest.weight); + return best.role === TopicRole.Side + ? { lead: 'Partly about ', name, tail: `. You gave it a ${boost} boost.` } + : { lead: 'You gave ', name, tail: ` a ${boost} boost.` }; + } if (best.origin === InterestOrigin.Declared) { const phrase = interest?.strength ? strengthPhrase[interest.strength] : ''; + const follow = `${phrase ? ` ${phrase}` : ''}.`; + return best.role === TopicRole.Side + ? { lead: 'Partly about ', name, tail: `. You follow it${follow}` } + : { lead: 'You follow ', name, tail: follow }; + } + if (best.role === TopicRole.Side) { return { - lead: 'You follow ', - name: `#${best.tag}`, - tail: `${phrase ? ` ${phrase}` : ''}${side}.`, + lead: 'Partly about ', + name, + tail: isStrong + ? ". You've read a lot about it." + : ". You've read a few posts on it.", }; } - 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}.`, - }; + return isStrong + ? { lead: "You've read a lot about ", name, tail: '.' } + : { lead: "You've read a few posts about ", name, tail: '.' }; }; export const exclusionLabel: Record = { @@ -254,18 +280,21 @@ export const getLeaveMessage = ( case Exclusion.ShownTooOften: return `You scrolled past it ${now.impressions} times.`; case Exclusion.SideTopicsOff: - return 'You turned off side-topic matches.'; + return 'You turned off posts that are only partly about your topics.'; case Exclusion.SideTopicUnproven: - return 'It only matches a side topic and readers have not proven it yet.'; + return "It's only partly about your topics and hasn't caught on with readers yet."; case Exclusion.PopularFillOff: - return 'You turned off popular fill-ins.'; + return 'You turned off topping up your feed with popular posts.'; default: { const tag = served.best?.tag; + if (tag && state.controls[tag]?.weight === 0) { + return `You turned #${tag} off.`; + } if (tag && state.controls[tag]?.mode === TopicMode.Muted) { return `You muted #${tag}.`; } if (tag && !state.isLearning) { - return 'You stopped learning from reading.'; + return 'You turned off learning from your reading.'; } if (served.followedBy && !now.followedBy) { return `You unfollowed ${sourceName}.`; diff --git a/packages/storybook/stories/features/feed-explain/policy/profile.ts b/packages/storybook/stories/features/feed-explain/policy/profile.ts index f95349571e..ca704e2d63 100644 --- a/packages/storybook/stories/features/feed-explain/policy/profile.ts +++ b/packages/storybook/stories/features/feed-explain/policy/profile.ts @@ -52,6 +52,9 @@ export interface Interest { isBacked: boolean; } +/** The most an exact topic weight can be set to. */ +export const maxTopicWeight = 1.5; + const strongMinStories = 6; const moderateMinStories = 2; @@ -164,7 +167,13 @@ export const buildInterests = ({ (isLearning ? evidence : []).map((item) => [item.topic, item]), ); const tags = [ - ...new Set([...followedTags, ...evidenceByTopic.keys()]), + ...new Set([ + ...followedTags, + ...evidenceByTopic.keys(), + ...Object.keys(controls).filter( + (tag) => controls[tag].weight !== undefined, + ), + ]), ].filter((tag) => !blockedTags.includes(tag)); return tags.map((tag): Interest => {