Skip to content
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

feat: onboarding follow sources #3867

Merged
merged 11 commits into from
Nov 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const CreateFeedButton = ({
const { selectedSettings, checkSourceBlocked } = useAdvancedSettings();

const contentTypeStep = activeScreen === OnboardingStep.ContentTypes;
const sourceStep = activeScreen === OnboardingStep.Sources;

const contentTypeNotEmpty =
!!getContentTypeNotEmpty({
Expand All @@ -39,7 +40,7 @@ export const CreateFeedButton = ({
tagsCount >= REQUIRED_TAGS_THRESHOLD &&
activeScreen === OnboardingStep.EditTag;

const canCreateFeed = tagsCountMatch || contentTypeNotEmpty;
const canCreateFeed = tagsCountMatch || contentTypeNotEmpty || sourceStep;
const { sidebarRendered } = useSidebarRendered();
const buttonName =
customActionName ??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export const OnboardingHeader = ({
return isLaptop ? cloudinaryFeedBgLaptop : cloudinaryFeedBgTablet;
};

const showButtonOnScreens: Partial<OnboardingStep[]> = [
OnboardingStep.EditTag,
OnboardingStep.ContentTypes,
OnboardingStep.Sources,
];

if (activeScreen !== OnboardingStep.Intro) {
return (
<header className="sticky top-0 z-3 mb-10 flex w-full justify-center backdrop-blur-sm">
Expand All @@ -53,8 +59,7 @@ export const OnboardingHeader = ({
position={LogoPosition.Relative}
linkDisabled
/>
{(activeScreen === OnboardingStep.EditTag ||
activeScreen === OnboardingStep.ContentTypes) && (
{showButtonOnScreens.includes(activeScreen) && (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More readable in my opinion

<CreateFeedButton
onClick={onClickCreateFeed}
customActionName={customActionName}
Expand Down
205 changes: 205 additions & 0 deletions packages/shared/src/components/onboarding/Sources/Sources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React, { ReactElement, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyType,
} from '../../typography/Typography';
import { SearchField } from '../../fields/SearchField';
import { useViewSize, ViewSize } from '../../../hooks';
import useDebounceFn from '../../../hooks/useDebounceFn';
import { useSourceSearch } from '../../../hooks/useSourceSearch';
import { Origin } from '../../../lib/log';
import { gqlClient } from '../../../graphql/common';
import { disabledRefetch } from '../../../lib/func';
import { generateQueryKey, RequestKey } from '../../../lib/query';
import { ONBOARDING_SOURCES_QUERY, Source } from '../../../graphql/sources';
import { ElementPlaceholder } from '../../ElementPlaceholder';
import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture';
import { PlusIcon, VIcon } from '../../icons';
import { IconSize } from '../../Icon';
import useFeedSettings from '../../../hooks/useFeedSettings';
import useTagAndSource from '../../../hooks/useTagAndSource';

const placeholderSources = new Array(5).fill(null).map((_, index) => index);

export const Sources = (): ReactElement => {
const isMobile = useViewSize(ViewSize.MobileL);

const [searchQuery, setSearchQuery] = React.useState<string>();
const [onSearch] = useDebounceFn(setSearchQuery, 200);

const { feedSettings } = useFeedSettings();

const { onFollowSource, onUnfollowSource } = useTagAndSource({
origin: Origin.Onboarding,
});

const selectedTags = feedSettings?.includeTags || [];

const selectedSources = useMemo(() => {
return feedSettings?.includeSources.map(({ id }) => id);
}, [feedSettings?.includeSources]);

const onToggleSource = useCallback(
(source) => {
if (selectedSources?.includes(source.id)) {
onUnfollowSource({ source });
} else {
onFollowSource({ source });
}
},
[onFollowSource, onUnfollowSource, selectedSources],
);

const { data: searchResult } = useSourceSearch({
value: searchQuery,
});

const { data: onboardingSources, isPending } = useQuery({
queryKey: generateQueryKey(RequestKey.OnboardingSources),

queryFn: async () => {
const result = await gqlClient.request<{
sourceRecommendationByTags: Source[];
}>(ONBOARDING_SOURCES_QUERY, { tags: selectedTags });

return result.sourceRecommendationByTags;
},
...disabledRefetch,
staleTime: Infinity,
});

const sources = searchQuery ? searchResult : onboardingSources;

const SourceTag = ({ source }: { source: Source }): ReactElement => {
const checked = selectedSources?.includes(source.id);
return (
<div
key={source.id}
className={classNames(
'flex w-full rounded-12 p-px',
checked
? 'bg-gradient-to-b from-accent-onion-subtlest to-accent-cabbage-default'
: 'bg-transparent',
)}
>
<div className="w-full rounded-11 bg-background-default">
<button
type="button"
className={classNames(
'flex w-full gap-2 rounded-11 bg-surface-float px-3 py-2',
checked
? undefined
: 'hover:bg-surface-hover active:bg-surface-active',
)}
onClick={() => onToggleSource(source)}
>
<ProfilePicture
size={ProfileImageSize.Medium}
rounded="full"
className="mt-2"
user={{
id: source.id,
image: source.image,
username: source.handle,
}}
nativeLazyLoading
/>
<div className="flex-1 text-left">
<Typography
type={TypographyType.Title3}
bold
color={TypographyColor.Primary}
>
{source.name}
</Typography>
<Typography
type={TypographyType.Body}
color={TypographyColor.Secondary}
>
{source.handle}
</Typography>
<Typography
type={TypographyType.Body}
color={TypographyColor.Tertiary}
className="multi-truncate line-clamp-2"
>
{source.description}
</Typography>
</div>
<div className="mr-2 flex size-8 items-center justify-center self-center">
{checked ? (
<VIcon
className="text-brand-default"
size={IconSize.Small}
secondary
/>
) : (
<PlusIcon size={IconSize.Small} />
)}
</div>
</button>
</div>
</div>
);
};

return (
<div className="flex w-full max-w-screen-laptop flex-col items-center tablet:px-10">
<Typography
type={TypographyType.LargeTitle}
bold
className="mb-10 text-center"
>
Things you would care to follow
</Typography>
<div className="w-full max-w-[35rem]">
<SearchField
aria-label="Pick tags that are relevant to you"
autoFocus={!isMobile}
className="mb-10 w-full"
inputId="search-filters"
placeholder="TechCrunch, Hacker News, GitHub, etc"
valueChanged={onSearch}
/>
<div
role="list"
aria-busy={isPending}
className="flex flex-row flex-wrap justify-center gap-4"
>
{isPending &&
placeholderSources.map((item) => (
<ElementPlaceholder key={item} className="h-16 w-full rounded-12">
<span className="invisible">{item}</span>
</ElementPlaceholder>
))}
{!isPending && !sources?.length && (
<Typography
type={TypographyType.Body}
color={TypographyColor.Secondary}
className="text-center"
>
No sources found
</Typography>
)}
{!isPending &&
sources?.map((source) => (
<SourceTag source={source} key={source.id} />
))}
{/* render leftover tags not rendered in initial recommendations but selected */}
{!isPending &&
!searchQuery &&
feedSettings?.includeSources?.map((source) => {
if (sources.find(({ id }) => id === source.id)) {
return null;
}

return <SourceTag source={source} key={source.id} />;
})}
</div>
</div>
</div>
);
};
1 change: 1 addition & 0 deletions packages/shared/src/components/onboarding/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum OnboardingStep {
EditTag = 'edit_tag',
ContentTypes = 'content_types',
ReadingReminder = 'reading_reminder',
Sources = 'sources',
}

export const OnboardingTitle = classed(
Expand Down
16 changes: 15 additions & 1 deletion packages/shared/src/components/tags/TagSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { ElementPlaceholder } from '../ElementPlaceholder';
import { TagElement } from './TagElement';
import { gqlClient } from '../../graphql/common';
import { OnSelectTagProps } from './common';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';

const tagsSelector = (data: TagsData) => data?.tags || [];

Expand Down Expand Up @@ -192,7 +197,7 @@ export function TagSelection({
refetchFeed();
};

const tags = searchQuery && !isSearchLoading ? searchTags : onboardingTags;
const tags = searchQuery ? searchTags : onboardingTags;
const renderedTags = {};

return (
Expand All @@ -219,6 +224,15 @@ export function TagSelection({
<span className="invisible">{item}</span>
</ElementPlaceholder>
))}
{!isPending && !tags?.length && (
<Typography
type={TypographyType.Body}
color={TypographyColor.Secondary}
className="text-center"
>
No tags found
</Typography>
)}
{!isPending &&
tags?.map((tag) => {
const isSelected = selectedTags.has(tag.name);
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/src/graphql/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Connection } from './common';
import {
SOURCE_CATEGORY_FRAGMENT,
SOURCE_DIRECTORY_INFO_FRAGMENT,
SOURCE_SHORT_INFO_FRAGMENT,
} from './fragments';

export enum SourceMemberRole {
Expand Down Expand Up @@ -114,6 +115,24 @@ export const SOURCE_QUERY = gql`
${SOURCE_DIRECTORY_INFO_FRAGMENT}
`;

export const SEARCH_SOURCES_QUERY = gql`
query SearchSources($query: String!, $limit: Int) {
searchSources(query: $query, limit: $limit) {
...SourceShortInfo
}
}
${SOURCE_SHORT_INFO_FRAGMENT}
`;

export const ONBOARDING_SOURCES_QUERY = gql`
query SourceRecommendationByTags($tags: [String]!) {
sourceRecommendationByTags(tags: $tags) {
...SourceShortInfo
}
}
${SOURCE_SHORT_INFO_FRAGMENT}
`;

export const SOURCE_DIRECTORY_QUERY = gql`
query SourceDirectory {
trendingSources {
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/hooks/useSourceSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { gqlClient } from '../graphql/common';
import { SEARCH_SOURCES_QUERY, Source } from '../graphql/sources';
import { generateQueryKey, RequestKey } from '../lib/query';

export type UseSourceSearchProps = {
value: string;
};

export type UseSourceSearch = {
data?: Source[];
isPending: boolean;
};

export const MIN_SEARCH_QUERY_LENGTH = 2;

export const useSourceSearch = ({
value,
}: UseSourceSearchProps): UseSourceSearch => {
const { data, isPending } = useQuery({
queryKey: generateQueryKey(RequestKey.SearchSources, null, value),
queryFn: async () => {
const result = await gqlClient.request<{
searchSources: Source[];
}>(SEARCH_SOURCES_QUERY, {
query: value,
});

return result.searchSources;
},
enabled: value?.length >= MIN_SEARCH_QUERY_LENGTH,
});

return {
data,
isPending,
};
};
4 changes: 4 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,9 @@ const feature = {
};

export const featureAutorotateAds = new Feature('autorotate_ads', 0);
export const featureOnboardingSources = new Feature(
'onboarding_sources',
false,
);

export { feature };
2 changes: 2 additions & 0 deletions packages/shared/src/lib/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export enum RequestKey {
ContentPreferenceSubscribe = 'content_preference_subscribe',
ContentPreferenceUnsubscribe = 'content_preference_unsubscribe',
TopReaderBadge = 'top_reader_badge',
SearchSources = 'search_sources',
OnboardingSources = 'onboarding_sources',
}

export type HasConnection<
Expand Down
1 change: 1 addition & 0 deletions packages/shared/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export default {
6: '0.375rem',
8: '0.5rem',
10: '0.625rem',
11: '0.6875rem',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed for inner border, else @omBratteng would get angry

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😂

12: '0.75rem',
14: '0.875rem',
16: '1rem',
Expand Down
Loading