Merge pull request #4016 from omnivore-app/feature/hidden-section-in-home

feat: add a hidden section in home feed and load on demand
This commit is contained in:
Hongbo Wu
2024-06-03 13:00:50 +08:00
committed by GitHub
8 changed files with 358 additions and 64 deletions

View File

@ -1232,6 +1232,24 @@ export type GroupsSuccess = {
groups: Array<RecommendationGroup>;
};
export type HiddenHomeSectionError = {
__typename?: 'HiddenHomeSectionError';
errorCodes: Array<HiddenHomeSectionErrorCode>;
};
export enum HiddenHomeSectionErrorCode {
BadRequest = 'BAD_REQUEST',
Pending = 'PENDING',
Unauthorized = 'UNAUTHORIZED'
}
export type HiddenHomeSectionResult = HiddenHomeSectionError | HiddenHomeSectionSuccess;
export type HiddenHomeSectionSuccess = {
__typename?: 'HiddenHomeSectionSuccess';
section?: Maybe<HomeSection>;
};
export type Highlight = {
__typename?: 'Highlight';
annotation?: Maybe<Scalars['String']>;
@ -2242,6 +2260,7 @@ export type Query = {
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
hiddenHomeSection: HiddenHomeSectionResult;
home: HomeResult;
integration: IntegrationResult;
integrations: IntegrationsResult;
@ -4307,6 +4326,10 @@ export type ResolversTypes = {
GroupsErrorCode: GroupsErrorCode;
GroupsResult: ResolversTypes['GroupsError'] | ResolversTypes['GroupsSuccess'];
GroupsSuccess: ResolverTypeWrapper<GroupsSuccess>;
HiddenHomeSectionError: ResolverTypeWrapper<HiddenHomeSectionError>;
HiddenHomeSectionErrorCode: HiddenHomeSectionErrorCode;
HiddenHomeSectionResult: ResolversTypes['HiddenHomeSectionError'] | ResolversTypes['HiddenHomeSectionSuccess'];
HiddenHomeSectionSuccess: ResolverTypeWrapper<HiddenHomeSectionSuccess>;
Highlight: ResolverTypeWrapper<Highlight>;
HighlightReply: ResolverTypeWrapper<HighlightReply>;
HighlightStats: ResolverTypeWrapper<HighlightStats>;
@ -4873,6 +4896,9 @@ export type ResolversParentTypes = {
GroupsError: GroupsError;
GroupsResult: ResolversParentTypes['GroupsError'] | ResolversParentTypes['GroupsSuccess'];
GroupsSuccess: GroupsSuccess;
HiddenHomeSectionError: HiddenHomeSectionError;
HiddenHomeSectionResult: ResolversParentTypes['HiddenHomeSectionError'] | ResolversParentTypes['HiddenHomeSectionSuccess'];
HiddenHomeSectionSuccess: HiddenHomeSectionSuccess;
Highlight: Highlight;
HighlightReply: HighlightReply;
HighlightStats: HighlightStats;
@ -6038,6 +6064,20 @@ export type GroupsSuccessResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HiddenHomeSectionErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HiddenHomeSectionError'] = ResolversParentTypes['HiddenHomeSectionError']> = {
errorCodes?: Resolver<Array<ResolversTypes['HiddenHomeSectionErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HiddenHomeSectionResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HiddenHomeSectionResult'] = ResolversParentTypes['HiddenHomeSectionResult']> = {
__resolveType: TypeResolveFn<'HiddenHomeSectionError' | 'HiddenHomeSectionSuccess', ParentType, ContextType>;
};
export type HiddenHomeSectionSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HiddenHomeSectionSuccess'] = ResolversParentTypes['HiddenHomeSectionSuccess']> = {
section?: Resolver<Maybe<ResolversTypes['HomeSection']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Highlight'] = ResolversParentTypes['Highlight']> = {
annotation?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
color?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6540,6 +6580,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
hiddenHomeSection?: Resolver<ResolversTypes['HiddenHomeSectionResult'], ParentType, ContextType>;
home?: Resolver<ResolversTypes['HomeResult'], ParentType, ContextType, Partial<QueryHomeArgs>>;
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'name'>>;
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
@ -7752,6 +7793,9 @@ export type Resolvers<ContextType = ResolverContext> = {
GroupsError?: GroupsErrorResolvers<ContextType>;
GroupsResult?: GroupsResultResolvers<ContextType>;
GroupsSuccess?: GroupsSuccessResolvers<ContextType>;
HiddenHomeSectionError?: HiddenHomeSectionErrorResolvers<ContextType>;
HiddenHomeSectionResult?: HiddenHomeSectionResultResolvers<ContextType>;
HiddenHomeSectionSuccess?: HiddenHomeSectionSuccessResolvers<ContextType>;
Highlight?: HighlightResolvers<ContextType>;
HighlightReply?: HighlightReplyResolvers<ContextType>;
HighlightStats?: HighlightStatsResolvers<ContextType>;

View File

@ -1107,6 +1107,22 @@ type GroupsSuccess {
groups: [RecommendationGroup!]!
}
type HiddenHomeSectionError {
errorCodes: [HiddenHomeSectionErrorCode!]!
}
enum HiddenHomeSectionErrorCode {
BAD_REQUEST
PENDING
UNAUTHORIZED
}
union HiddenHomeSectionResult = HiddenHomeSectionError | HiddenHomeSectionSuccess
type HiddenHomeSectionSuccess {
section: HomeSection
}
type Highlight {
annotation: String
color: String
@ -1721,6 +1737,7 @@ type Query {
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
hello: String
hiddenHomeSection: HiddenHomeSectionResult!
home(after: String, first: Int): HomeResult!
integration(name: String!): IntegrationResult!
integrations: IntegrationsResult!

View File

@ -166,10 +166,10 @@ const selectCandidates = async (
subscriptionNames
)
// map library items to candidates and limit to 70
const privateCandidates: Array<Candidate> = libraryItems
.map((item) => libraryItemToCandidate(item, subscriptions))
.slice(0, 70)
// map library items to candidates
const privateCandidates: Array<Candidate> = libraryItems.map((item) =>
libraryItemToCandidate(item, subscriptions)
)
const privateCandidatesSize = privateCandidates.length
logger.info(`Found ${privateCandidatesSize} private candidates`)
@ -241,7 +241,7 @@ const redisKey = (userId: string) => `home:${userId}`
export const getHomeSections = async (
userId: string,
limit: number,
limit = 100,
maxScore?: number
): Promise<Array<{ member: Section; score: number }>> => {
const redisClient = redisDataSource.redisClient
@ -311,13 +311,12 @@ const appendSectionsToHome = async (
JSON.stringify(section),
])
// sections expire in 24 hours
pipeline.expire(key, 24 * 60 * 60)
// add section to the sorted set
pipeline.zadd(key, ...scoreMembers)
// remove expired sections and sections expire in 24 hours
const ttl = 86_400_000
pipeline.zremrangebyscore(key, '-inf', Date.now() - ttl)
// keep only the new sections and remove the oldest ones
pipeline.zremrangebyrank(key, 0, -(sections.length + 1))
@ -379,15 +378,15 @@ const mixHomeItems = (
}
}
const topCandidates = rankedHomeItems.slice(0, 50)
// find the median word count
const wordCounts = rankedHomeItems.map((item) => item.wordCount)
wordCounts.sort((a, b) => a - b)
const medianWordCount = wordCounts[Math.floor(wordCounts.length / 2)]
const wordCountThreshold = 500
// separate items into two groups based on word count
const shortItems: Array<Candidate> = []
const longItems: Array<Candidate> = []
for (const item of rankedHomeItems) {
if (item.wordCount < medianWordCount) {
for (const item of topCandidates) {
if (item.wordCount < wordCountThreshold) {
shortItems.push(item)
} else {
longItems.push(item)
@ -410,6 +409,13 @@ const mixHomeItems = (
// convert batches to sections
const sections = []
const hiddenCandidates = rankedHomeItems.slice(50)
sections.push({
items: hiddenCandidates.map(candidateToItem),
layout: 'hidden',
})
sections.push({
items: batches.short.flat().map(candidateToItem),
layout: 'quick_links',
@ -472,17 +478,15 @@ export const updateHome = async (data: UpdateHomeJobData) => {
message: `Ranked ${rankedCandidates.length} candidates`,
})
// TODO: filter candidates
logger.profile('mixing')
const rankedSections = mixHomeItems(justAddedCandidates, rankedCandidates)
const sections = mixHomeItems(justAddedCandidates, rankedCandidates)
logger.profile('mixing', {
level: 'info',
message: `Created ${rankedSections.length} sections`,
message: `Created ${sections.length} sections`,
})
logger.profile('saving')
await appendSectionsToHome(userId, rankedSections, cursor)
await appendSectionsToHome(userId, sections, cursor)
logger.profile('saving', {
level: 'info',
message: 'Sections appended to home',

View File

@ -60,7 +60,11 @@ import {
saveDiscoverArticleResolver,
} from './discover_feeds'
import { optInFeatureResolver } from './features'
import { homeResolver, refreshHomeResolver } from './home'
import {
hiddenHomeSectionResolver,
homeResolver,
refreshHomeResolver,
} from './home'
import { uploadImportFileResolver } from './importers/uploadImportFileResolver'
import {
addPopularReadResolver,
@ -371,6 +375,7 @@ export const functionResolvers = {
integration: integrationResolver,
home: homeResolver,
subscription: subscriptionResolver,
hiddenHomeSection: hiddenHomeSectionResolver,
},
User: {
async intercomHash(
@ -645,6 +650,8 @@ export const functionResolvers = {
return 'Top Picks'
case 'quick_links':
return 'Quick Links'
case 'hidden':
return 'Hidden Gems'
default:
return ''
}
@ -878,4 +885,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('Home'),
...resultResolveTypeResolver('Subscription'),
...resultResolveTypeResolver('RefreshHome'),
...resultResolveTypeResolver('HiddenHomeSection'),
}

View File

@ -1,4 +1,6 @@
import {
HiddenHomeSectionError,
HiddenHomeSectionSuccess,
HomeError,
HomeErrorCode,
HomeItem,
@ -63,10 +65,16 @@ export const homeResolver = authorized<
const endCursor = sections[sections.length - 1].score.toString()
const edges = sections.map((section) => ({
cursor: section.score.toString(),
node: section.member,
}))
const edges = sections.map((section) => {
if (section.member.layout === 'hidden') {
section.member.items = []
}
return {
cursor: section.score.toString(),
node: section.member,
}
})
return {
edges,
@ -105,3 +113,31 @@ export const refreshHomeResolver = authorized<
success: true,
}
})
type PartialHiddenHomeSectionSuccess = Merge<
HiddenHomeSectionSuccess,
{
section?: PartialHomeSection
}
>
export const hiddenHomeSectionResolver = authorized<
PartialHiddenHomeSectionSuccess,
HiddenHomeSectionError
>(async (_, __, { uid, log }) => {
const sections = await getHomeSections(uid)
log.info('Home sections fetched')
if (sections.length === 0) {
return {
errorCodes: [HomeErrorCode.Pending],
}
}
const hiddenSection = sections.find(
(section) => section.member.layout === 'hidden'
)
return {
section: hiddenSection?.member,
}
})

View File

@ -3208,6 +3208,24 @@ const schema = gql`
union RefreshHomeResult = RefreshHomeSuccess | RefreshHomeError
union HiddenHomeSectionResult =
HiddenHomeSectionSuccess
| HiddenHomeSectionError
type HiddenHomeSectionSuccess {
section: HomeSection
}
type HiddenHomeSectionError {
errorCodes: [HiddenHomeSectionErrorCode!]!
}
enum HiddenHomeSectionErrorCode {
UNAUTHORIZED
BAD_REQUEST
PENDING
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -3406,6 +3424,7 @@ const schema = gql`
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
home(first: Int, after: String): HomeResult!
subscription(id: ID!): SubscriptionResult!
hiddenHomeSection: HiddenHomeSectionResult!
}
schema {

View File

@ -0,0 +1,103 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
import { HomeSection } from './useGetHome'
type HiddenHomeSectionResult = {
hiddenHomeSection: {
section?: HomeSection
errorCodes?: string[]
}
}
export type HiddenHomeSectionResponse = {
error: boolean
isValidating: boolean
errorMessage?: string
section?: HomeSection
mutate?: () => void
}
export function useGetHiddenHomeSection(): HiddenHomeSectionResponse {
const query = gql`
query HiddenHomeSection {
hiddenHomeSection {
... on HiddenHomeSectionSuccess {
section {
title
layout
thumbnail
items {
id
title
url
slug
score
thumbnail
previewContent
saveCount
likeCount
broadcastCount
date
author
dir
seen_at
wordCount
source {
id
name
url
icon
type
}
canSave
canComment
canShare
canArchive
canDelete
}
}
}
... on HiddenHomeSectionError {
errorCodes
}
}
}
`
const { data, error, isValidating, mutate } = useSWR(query, publicGqlFetcher)
console.log('HiddenHomeSection data', data)
if (error) {
return {
error: true,
isValidating,
errorMessage: error.toString(),
}
}
const result = data as HiddenHomeSectionResult
if (result && result.hiddenHomeSection.errorCodes) {
const errorCodes = result.hiddenHomeSection.errorCodes
return {
error: true,
isValidating,
errorMessage: errorCodes.length > 0 ? errorCodes[0] : undefined,
}
}
if (result && result.hiddenHomeSection && result.hiddenHomeSection.section) {
return {
mutate,
error: false,
isValidating,
section: result.hiddenHomeSection.section,
}
}
return {
isValidating,
error: !!error,
}
}

View File

@ -1,7 +1,7 @@
import * as HoverCard from '@radix-ui/react-hover-card'
import { styled } from '@stitches/react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '../../components/elements/Button'
import { AddToLibraryActionIcon } from '../../components/elements/icons/home/AddToLibraryActionIcon'
import { ArchiveActionIcon } from '../../components/elements/icons/home/ArchiveActionIcon'
@ -12,6 +12,7 @@ import Pagination from '../../components/elements/Pagination'
import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles'
import { theme } from '../../components/tokens/stitches.config'
import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme'
import { useGetHiddenHomeSection } from '../../lib/networking/queries/useGetHiddenHomeSection'
import {
HomeItem,
HomeItemSource,
@ -60,7 +61,7 @@ export default function Home(): JSX.Element {
switch (homeSection.layout) {
case 'just_added':
return (
<JustReadHomeSection
<JustAddedHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
@ -79,6 +80,15 @@ export default function Home(): JSX.Element {
homeSection={homeSection}
/>
)
case 'hidden':
return (
<HiddenHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
)
default:
return <></>
}
})}
</VStack>
@ -90,7 +100,7 @@ type HomeSectionProps = {
homeSection: HomeSection
}
const JustReadHomeSection = (props: HomeSectionProps): JSX.Element => {
const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => {
return (
<VStack
distribution="start"
@ -111,7 +121,7 @@ const JustReadHomeSection = (props: HomeSectionProps): JSX.Element => {
</SpanBox>
{props.homeSection.items.map((homeItem) => {
return <JustReadItemView key={homeItem.id} homeItem={homeItem} />
return <JustAddedItemView key={homeItem.id} homeItem={homeItem} />
})}
</VStack>
)
@ -139,9 +149,9 @@ const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => {
<Pagination
items={props.homeSection.items}
itemsPerPage={10}
itemsPerPage={4}
render={(homeItem) => (
<LongHomeItemView key={homeItem.id} homeItem={homeItem} />
<TopicPickHomeItemView key={homeItem.id} homeItem={homeItem} />
)}
/>
</VStack>
@ -170,7 +180,7 @@ const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => {
<Pagination
items={props.homeSection.items}
itemsPerPage={15}
itemsPerPage={8}
render={(homeItem) => (
<QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} />
)}
@ -179,6 +189,81 @@ const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => {
)
}
const HiddenHomeSection = (props: HomeSectionProps): JSX.Element => {
const [isHidden, setIsHidden] = useState(true)
return (
<VStack
distribution="start"
css={{
width: '100%',
gap: '20px',
marginBottom: '40px',
}}
>
<HStack
distribution="start"
alignment="center"
css={{
gap: '10px',
cursor: 'pointer',
}}
onClick={() => setIsHidden(!isHidden)}
>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '16px',
fontWeight: '600',
color: '$readerText',
}}
>
{props.homeSection.title}
</SpanBox>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '13px',
color: '$readerFont',
}}
>
{isHidden ? 'Show' : 'Hide'}
</SpanBox>
</HStack>
{isHidden ? <></> : <HiddenHomeSectionView />}
</VStack>
)
}
const HiddenHomeSectionView = (): JSX.Element => {
const hiddenSectionData = useGetHiddenHomeSection()
if (hiddenSectionData.error) {
return <SpanBox>Error loading hidden section</SpanBox>
}
if (hiddenSectionData.isValidating) {
return <SpanBox>Loading...</SpanBox>
}
if (!hiddenSectionData.section) {
return <SpanBox>No hidden section data</SpanBox>
}
return (
<VStack
distribution="start"
css={{
width: '100%',
}}
>
{hiddenSectionData.section.items.map((homeItem) => {
return <QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} />
})}
</VStack>
)
}
const CoverImage = styled('img', {
objectFit: 'cover',
})
@ -222,14 +307,14 @@ const Title = (props: HomeItemViewProps): JSX.Element => {
)
}
const JustReadItemView = (props: HomeItemViewProps): JSX.Element => {
const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => {
const router = useRouter()
return (
<VStack
css={{
width: '100%',
padding: '20px',
padding: '5px',
borderRadius: '5px',
'&:hover': {
bg: '$thBackground',
@ -244,37 +329,20 @@ const JustReadItemView = (props: HomeItemViewProps): JSX.Element => {
}
}}
>
<HStack css={{ width: '100%', gap: '5px' }}>
<VStack css={{ gap: '15px' }}>
<HStack
distribution="start"
alignment="center"
css={{ gap: '5px', lineHeight: '1' }}
>
<SourceInfo homeItem={props.homeItem} />
<TimeAgo homeItem={props.homeItem} />
</HStack>
<Title homeItem={props.homeItem} />
</VStack>
<SpanBox css={{ ml: 'auto' }}>
{props.homeItem.thumbnail && (
<CoverImage
css={{
mt: '6px',
width: '120px',
height: '70px',
borderRadius: '4px',
}}
src={props.homeItem.thumbnail}
></CoverImage>
)}
</SpanBox>
<HStack
distribution="start"
alignment="center"
css={{ gap: '5px', lineHeight: '1' }}
>
<SourceInfo homeItem={props.homeItem} />
<TimeAgo homeItem={props.homeItem} />
</HStack>
<Title homeItem={props.homeItem} />
</VStack>
)
}
const LongHomeItemView = (props: HomeItemViewProps): JSX.Element => {
const TopicPickHomeItemView = (props: HomeItemViewProps): JSX.Element => {
const router = useRouter()
return (
@ -372,11 +440,6 @@ const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => {
>
<TimeAgo homeItem={props.homeItem} />
<Title homeItem={props.homeItem} />
<SpanBox
css={{ fontFamily: '$inter', fontSize: '13px', lineHeight: '23px' }}
>
{props.homeItem.previewContent}
</SpanBox>
</VStack>
)
}