Skip to content

Commit

Permalink
feat: onboarding follow sources (#3867)
Browse files Browse the repository at this point in the history
  • Loading branch information
rebelchris authored Nov 22, 2024
1 parent 3314b30 commit 8abe6a2
Show file tree
Hide file tree
Showing 12 changed files with 355 additions and 9 deletions.
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) && (
<CreateFeedButton
onClick={onClickCreateFeed}
customActionName={customActionName}
Expand Down
223 changes: 223 additions & 0 deletions packages/shared/src/components/onboarding/Sources/Sources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import React, { ReactElement, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyTag,
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,
SourceType,
} 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';
import { Separator } from '../../cards/common/common';

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}
{source.type === SourceType.Squad && (
<>
<Separator />
<Typography
tag={TypographyTag.Span}
type={TypographyType.Body}
color={TypographyColor.Brand}
>
Squad
</Typography>
</>
)}
</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"
>
Content sources you may want 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
18 changes: 16 additions & 2 deletions 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 @@ -123,7 +128,7 @@ export function TagSelection({
const [searchQuery, setSearchQuery] = React.useState<string>();
const [onSearch] = useDebounceFn(setSearchQuery, 200);

const { data: searchResult, isLoading: isSearchLoading } = useTagSearch({
const { data: searchResult } = useTagSearch({
value: searchQuery,
origin: searchOrigin,
});
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
1 change: 1 addition & 0 deletions packages/shared/src/components/typography/Typography.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export enum TypographyColor {
Link = 'text-text-link',
StatusSuccess = 'text-status-success',
Plus = 'text-action-plus-default',
Brand = 'text-brand-default',
}

export type AllowedTags = keyof Pick<JSX.IntrinsicElements, TypographyTag>;
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,
};
};
Loading

0 comments on commit 8abe6a2

Please sign in to comment.