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',
- },
- ]}
- />
-
- store.setTagFollowed(best.tag, !isFollowed)}
- >
- {isFollowed ? 'Unfollow' : 'Follow'}
-
- store.setTagBlocked(best.tag, true)}
- >
- Block #{best.tag}
-
-
- >
- );
- break;
- }
- case FactorId.Follow:
- body = (
- <>
-
- You follow {sourceName} , so every post from it is
- in and multiplied by {formatMultiplier(decision.factors.follow)}.
-
-
-
- store.setSourceFollowed(
- { id: decision.post.source?.id ?? '', name: sourceName },
- false,
- )
- }
- >
- Unfollow {sourceName}
-
- >
- );
- 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.
-
-
-
- Forget what I scrolled past
-
- >
- );
- 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 && (
- onOpenControls(section)}
- >
- All controls
-
- )}
-
- >
- );
-};
-
-const Chip = ({
- chip,
- decision,
-}: {
- chip: FactorChip;
- decision: Decision;
-}): ReactElement => {
- const [anchor, setAnchor] = useState(null);
- const tone = getTone(chip);
-
- return (
- <>
-
- setAnchor(anchor ? null : (event.currentTarget as HTMLButtonElement))
- }
- className={classNames(
- 'flex h-6 min-w-0 max-w-full items-center gap-1 rounded-8 px-1.5 font-bold typo-caption1 transition-colors',
- anchor ? 'bg-surface-hover' : 'bg-surface-float hover:bg-surface-hover',
- )}
- >
- {tone !== 'neutral' && (
-
- )}
-
- {chip.label}
-
-
- {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 => {