Skip to content

feat: brief #4584

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 78 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from 75 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
ad38faa
feat: briefing
capJavert Jun 12, 2025
4c515c2
feat: plus upsell
capJavert Jun 12, 2025
4cbca84
feat: single page navigation
capJavert Jun 12, 2025
086f5d8
feat: post modal navigation
capJavert Jun 13, 2025
5de2c8d
Merge branch 'main' into feat-brief
capJavert Jun 13, 2025
b7072b9
feat: suspense animation size during lazy load
capJavert Jun 13, 2025
95537c4
chore: add brief card example
capJavert Jun 13, 2025
9933a67
Merge branch 'main' into feat-brief
capJavert Jun 13, 2025
11478bb
feat: brief post modal and content
capJavert Jun 13, 2025
99d2c30
feat: adjust modal size
capJavert Jun 13, 2025
49b79ee
feat: plus offering for post content
capJavert Jun 13, 2025
013cd3a
fix: types
capJavert Jun 13, 2025
f848e7d
feat: post settings
capJavert Jun 13, 2025
948bdd2
Merge branch 'main' into feat-brief
capJavert Jun 17, 2025
0c49ff8
feat: brief card states
capJavert Jun 17, 2025
5c00aab
Merge branch 'main' into feat-brief
capJavert Jun 26, 2025
a0fb8be
feat: post navigation
capJavert Jun 26, 2025
bdb741d
fix: types
capJavert Jun 26, 2025
977903b
feat: list briefs
capJavert Jun 27, 2025
8d042b4
feat: generate brief on demand and basic queries
capJavert Jun 27, 2025
0c81341
Merge branch 'main' into feat-brief
capJavert Jun 27, 2025
b7466c8
feat: remove brief type for base post types
capJavert Jul 1, 2025
8a47d46
Merge branch 'main' into feat-brief
capJavert Jul 1, 2025
40b6cd9
fix: link
capJavert Jul 1, 2025
1f64b88
feat: stats
capJavert Jul 2, 2025
6670f68
Merge branch 'main' into feat-brief
capJavert Jul 2, 2025
0376f3f
fix: blinking plus cta on briefing pages
capJavert Jul 2, 2025
478d158
feat: notification
capJavert Jul 2, 2025
7262730
Merge branch 'main' into feat-brief
capJavert Jul 7, 2025
9f9dfe4
feat: serif font for brief content
capJavert Jul 7, 2025
a42f786
fix: brief refetch interval
capJavert Jul 7, 2025
915c4c6
feat: add brief to notification settings
capJavert Jul 7, 2025
868e145
feat: adjust input name
capJavert Jul 7, 2025
4a3fd60
feat: receive via section
capJavert Jul 8, 2025
3545efc
feat: default email value
capJavert Jul 8, 2025
52ff285
fix: adjust font
capJavert Jul 8, 2025
cb28745
fix: digest tests
capJavert Jul 8, 2025
323105c
feat: brief content width
capJavert Jul 8, 2025
7cbbb65
Revert "feat: serif font for brief content"
capJavert Jul 8, 2025
157a144
Merge branch 'main' into feat-brief
capJavert Jul 8, 2025
5e34b10
feat: slack settings
capJavert Jul 8, 2025
5a077ce
feat: slack integration settings
capJavert Jul 8, 2025
9cc96a4
feat: digest settings links, remove send email since not in spec
capJavert Jul 8, 2025
28da66a
feat: allow workdays for all digest types
capJavert Jul 9, 2025
aaff7cf
chore: remove single brief since we use post page
capJavert Jul 9, 2025
aa93d2a
feat: remove briefing feed on generation
capJavert Jul 9, 2025
99cf833
feat: mobile brief card adjustments
capJavert Jul 9, 2025
3c0c586
feat: connect brief listing settings
capJavert Jul 9, 2025
37d5673
fix: receive settings reset time and vice versa
capJavert Jul 9, 2025
ffa072e
feat: implement brief settings on brief page
capJavert Jul 9, 2025
a5e3527
feat: slack popup on brief page
capJavert Jul 9, 2025
82f0d0b
fix: optional chaining
capJavert Jul 9, 2025
5f174db
feat: copy updates
capJavert Jul 10, 2025
e7190c7
feat: tweak loading animation
capJavert Jul 10, 2025
8ef690c
feat: float robot loading
capJavert Jul 10, 2025
6feac8a
fix: brief ready description overflow
capJavert Jul 10, 2025
0d09668
fix: preloading for animation assets, loading optimizations
capJavert Jul 10, 2025
d389f99
feat: seo description
capJavert Jul 10, 2025
8c21bcd
feat: show brief hard only if did not generated brief yet
capJavert Jul 10, 2025
b10a19c
Merge branch 'main' into feat-brief
capJavert Jul 10, 2025
e0d8024
refactor: notification settings event
capJavert Jul 10, 2025
d1de10c
fix: lint and links
capJavert Jul 10, 2025
ab6d82f
fix: tests
capJavert Jul 10, 2025
4e71be2
feat: empty states, loading reset and small fixes
capJavert Jul 10, 2025
eb55121
feat: hide from team members as well
capJavert Jul 11, 2025
cbaf7c6
feat: additional analytics
capJavert Jul 11, 2025
0fe0b5b
feat: adjust event name
capJavert Jul 11, 2025
c4c23a5
fix: fromCDN in extension
capJavert Jul 11, 2025
159b92c
Merge branch 'main' into feat-brief
capJavert Jul 11, 2025
8ae25f5
feat: lazy load brief card
capJavert Jul 11, 2025
6682a7d
fix: blink of card when actions fetching
capJavert Jul 11, 2025
d458818
feat: toggle brief email with global notification config
capJavert Jul 11, 2025
8d51bc1
Merge branch 'main' into feat-brief
capJavert Jul 11, 2025
af1a5b4
fix: test import
capJavert Jul 11, 2025
3da3480
Merge branch 'main' into feat-brief
capJavert Jul 11, 2025
8595fa1
feat: add feed card behind feature flag
capJavert Jul 11, 2025
7816293
Merge branch 'main' into feat-brief
capJavert Jul 14, 2025
42e44c4
feat: brief ui feature flag
capJavert Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import LogContext from '../contexts/LogContext';
import { adLogEvent, feedLogExtra, postLogEvent } from '../lib/feed';
import { usePostModalNavigation } from '../hooks/usePostModalNavigation';
import { useSharePost } from '../hooks/useSharePost';
import { Origin } from '../lib/log';
import { Origin, TargetId } from '../lib/log';
import { SharedFeedPage } from './utilities';
import type { FeedContainerProps } from './feeds/FeedContainer';
import { FeedContainer } from './feeds/FeedContainer';
Expand Down Expand Up @@ -96,18 +96,31 @@ const CollectionPostModal = dynamic(
),
);

const BriefPostModal = dynamic(
() =>
import(/* webpackChunkName: "briefPostModal" */ './modals/BriefPostModal'),
);

const BriefCardFeed = dynamic(
() =>
import(
/* webpackChunkName: "briefCardFeed" */ './cards/brief/BriefCard/BriefCardFeed'
),
);

const calculateRow = (index: number, numCards: number): number =>
Math.floor(index / numCards);
const calculateColumn = (index: number, numCards: number): number =>
index % numCards;

const PostModalMap: Record<PostType, typeof ArticlePostModal> = {
export const PostModalMap: Record<PostType, typeof ArticlePostModal> = {
[PostType.Article]: ArticlePostModal,
[PostType.Share]: SharePostModal,
[PostType.Welcome]: SharePostModal,
[PostType.Freeform]: SharePostModal,
[PostType.VideoYouTube]: ArticlePostModal,
[PostType.Collection]: CollectionPostModal,
[PostType.Brief]: BriefPostModal,
};

export default function Feed<T>({
Expand Down Expand Up @@ -431,6 +444,9 @@ export default function Feed<T>({
<>{emptyScreen}</>
) : (
<>
{feedName === SharedFeedPage.MyFeed && (
<BriefCardFeed targetId={TargetId.Feed} />
)}
{items.map((item, index) => (
<FeedItemComponent
item={item}
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { CollectionGrid } from './cards/collection';
import type { UseBookmarkPost } from '../hooks/useBookmarkPost';
import { AdActions } from '../lib/ads';
import PlusGrid from './cards/plus/PlusGrid';
import { BriefCard } from './cards/brief/BriefCard/BriefCard';

const CommentPopup = dynamic(
() =>
Expand Down Expand Up @@ -110,6 +111,7 @@ const PostTypeToTagCard: Record<PostType, FunctionComponent> = {
[PostType.Freeform]: FreeformGrid,
[PostType.VideoYouTube]: ArticleGrid,
[PostType.Collection]: CollectionGrid,
[PostType.Brief]: BriefCard,
};

const PostTypeToTagList: Record<PostType, FunctionComponent> = {
Expand All @@ -119,6 +121,7 @@ const PostTypeToTagList: Record<PostType, FunctionComponent> = {
[PostType.Freeform]: FreeformList,
[PostType.VideoYouTube]: ArticleList,
[PostType.Collection]: CollectionList,
[PostType.Brief]: BriefCard,
};

type GetTagsProps = {
Expand Down
36 changes: 8 additions & 28 deletions packages/shared/src/components/LottieAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import React, { lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { LottieComponentProps } from 'lottie-react';
import type { ReactElement } from 'react';
import { RequestKey } from '../lib/query';
import { disabledRefetch } from '../lib/func';
import { fromCDN } from '../lib';
import {
lottieAnimationQueryOptions,
lottieAssetsBasePath,
} from '../lib/lottie';

export type LottieAnimationProps = {
className?: string;
Expand All @@ -19,35 +20,14 @@ const Lottie = lazy(
export const LottieAnimation = ({
className,
src,
basePath = '/assets/lottie',
basePath = lottieAssetsBasePath,
loop = true,
autoplay = true,
...lottieProps
}: LottieAnimationProps): ReactElement => {
const { data: animationData } = useQuery({
queryKey: [RequestKey.LottieAnimations, basePath, src],
queryFn: async () => {
const animationPath = `${basePath}${src}`;

const headers = new Headers();
headers.set('Accept', 'application/json');

const response = await fetch(fromCDN(animationPath), {
headers,
});

if (!response.ok) {
throw new Error(`Failed to load animation from ${animationPath}`);
}

const result = await response.json();

return result;
},
...disabledRefetch,
staleTime: Infinity,
gcTime: Infinity,
});
const { data: animationData } = useQuery(
lottieAnimationQueryOptions({ src, basePath }),
);

return (
<Suspense fallback={<div className={className} />}>
Expand Down
10 changes: 5 additions & 5 deletions packages/shared/src/components/Pill.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';

Expand All @@ -12,13 +12,13 @@ const pillSizeToClassName: Record<PillSize, string> = {
[PillSize.Medium]: 'font-bold typo-caption1 rounded-10 p-2',
};

interface Props {
label: string;
export type PillProps = {
label: ReactNode;
tag?: keyof Pick<JSX.IntrinsicElements, 'a' | 'div'>;
size?: PillSize;
className?: string;
alignment?: string;
}
};

export const Pill = ({
label,
Expand All @@ -27,7 +27,7 @@ export const Pill = ({
alignment = 'self-start',
className,
...props
}: Props): ReactElement => {
}: PillProps): ReactElement => {
return (
<Tag
{...props}
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/components/brief/BriefListHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import type { ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';

export type BriefListHeadingProps = {
className?: string;
title: ReactNode;
};

export const BriefListHeading = ({
className,
title,
}: BriefListHeadingProps): ReactElement => {
return (
<article
className={classNames(
'flex w-full items-center gap-4 px-4 pb-2 pt-6',
className,
)}
>
<Typography
type={TypographyType.Title3}
bold
color={TypographyColor.Quaternary}
>
{title}
</Typography>
</article>
);
};
157 changes: 157 additions & 0 deletions packages/shared/src/components/brief/BriefListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { Fragment } from 'react';
import type { MouseEvent, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../typography/Typography';
import type { PillProps } from '../Pill';
import { Pill } from '../Pill';
import { IconSize } from '../Icon';
import { BriefGradientIcon, LockIcon } from '../icons';
import type { Origin, TargetId } from '../../lib/log';
import { LogEvent } from '../../lib/log';
import useOnPostClick from '../../hooks/useOnPostClick';
import type { Post } from '../../graphql/posts';
import { isNullOrUndefined } from '../../lib/func';
import CardOverlay from '../cards/common/CardOverlay';
import { CardLink } from '../cards/common/Card';
import { webappUrl } from '../../lib/constants';
import { anchorDefaultRel } from '../../lib/strings';
import { combinedClicks } from '../../lib/click';
import Link from '../utilities/Link';
import { useLogContext } from '../../contexts/LogContext';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';

export type BriefListItemProps = {
className?: string;
title: ReactNode;
pill?: Omit<PillProps, 'className'>;
readTime?: number;
postsCount?: number;
sourcesCount?: number;
isRead?: boolean;
isLocked?: boolean;
onClick?: (post: Post, event: MouseEvent<HTMLAnchorElement>) => void;
origin: Origin;
post: Post;
targetId: TargetId;
};

export const BriefListItem = ({
className,
title,
pill,
readTime,
postsCount,
sourcesCount,
isRead,
isLocked,
onClick,
origin,
post,
targetId,
}: BriefListItemProps): ReactElement => {
const { isPlus } = usePlusSubscription();
const { logEvent } = useLogContext();
const onPostClick = useOnPostClick({ origin });

const onCombinedClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (typeof onClick === 'function') {
onClick(post, event);
}

onPostClick({ post });

logEvent({
event_name: LogEvent.ClickBrief,
target_id: targetId,
extra: JSON.stringify({
is_demo: !isPlus,
brief_date: post.createdAt,
}),
});
};

return (
<article
className={classNames(
'relative flex w-full items-center gap-4 rounded-16 border border-border-subtlest-tertiary p-3',
className,
)}
>
<CardOverlay
post={post}
onPostCardClick={onCombinedClick}
onPostCardAuxClick={onCombinedClick}
/>
<Link href={`${webappUrl}posts/${post.slug ?? post.id}`} passHref>
<CardLink
title={post.title}
rel={anchorDefaultRel}
{...combinedClicks(onCombinedClick)}
/>
</Link>
<div className="hidden items-center mobileXL:flex">
<BriefGradientIcon secondary={!isRead} size={IconSize.Size48} />
</div>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center gap-2">
<Typography
type={TypographyType.Title3}
bold
color={
isRead ? TypographyColor.Quaternary : TypographyColor.Primary
}
>
{title}
</Typography>
{!!pill && (
<Pill
{...pill}
className="invert !self-auto bg-accent-bacon-default py-0.5 text-text-primary"
/>
)}
{isLocked && (
<LockIcon className="text-text-quaternary" size={IconSize.Small} />
)}
</div>
<div className="flex">
<Typography
className="gap-1"
type={TypographyType.Subhead}
color={TypographyColor.Tertiary}
truncate
>
{[
!isNullOrUndefined(readTime) && (
<Typography
tag={TypographyTag.Span}
key="read-time"
color={TypographyColor.Primary}
>
{readTime}m read time
</Typography>
),
`Based on ${postsCount ?? 0} posts from ${
sourcesCount ?? 0
} sources`,
]
.filter(Boolean)
.map((item, index) => {
return (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={index}>
{index > 0 ? ' • ' : undefined}
{item}
</Fragment>
);
})}
</Typography>
</div>
</div>
</article>
);
};
6 changes: 6 additions & 0 deletions packages/shared/src/components/brief/BriefListSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import classed from '../../lib/classed';

export const BriefListSection = classed(
'section',
'flex w-full flex-col gap-4',
);
Loading