Merge pull request #4012 from omnivore-app/feature/just-added-section

add just added section to home
This commit is contained in:
Hongbo Wu
2024-05-31 12:24:08 +08:00
committed by GitHub
8 changed files with 386 additions and 79 deletions

View File

@ -1324,7 +1324,7 @@ export type HomeItemSource = {
__typename?: 'HomeItemSource';
icon?: Maybe<Scalars['String']>;
id?: Maybe<Scalars['ID']>;
name: Scalars['String'];
name?: Maybe<Scalars['String']>;
type: HomeItemSourceType;
url?: Maybe<Scalars['String']>;
};
@ -6117,7 +6117,7 @@ export type HomeItemResolvers<ContextType = ResolverContext, ParentType extends
export type HomeItemSourceResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeItemSource'] = ResolversParentTypes['HomeItemSource']> = {
icon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
name?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
type?: Resolver<ResolversTypes['HomeItemSourceType'], ParentType, ContextType>;
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;

View File

@ -1192,7 +1192,7 @@ type HomeItem {
type HomeItemSource {
icon: String
id: ID
name: String!
name: String
type: HomeItemSourceType!
url: String
}

View File

@ -3,6 +3,7 @@ import {
updateLibraryItem,
} from '../services/library_item'
import { Feature, getScores } from '../services/score'
import { enqueueUpdateHomeJob } from '../utils/createTask'
import { lanaugeToCode } from '../utils/helpers'
import { logger } from '../utils/logger'
@ -81,4 +82,12 @@ export const scoreLibraryItem = async (
true
)
logger.info('Library item scored', data)
try {
await enqueueUpdateHomeJob({
userId,
})
} catch (error) {
logger.error('Failed to enqueue update home job', error)
}
}

View File

@ -104,19 +104,57 @@ const publicItemToCandidate = (item: PublicItem): Candidate => ({
score: 0,
})
const selectCandidates = async (user: User): Promise<Array<Candidate>> => {
const userId = user.id
// get last 100 library items saved and not seen by user
const getJustAddedCandidates = async (
userId: string,
limit = 5 // limit to 5 just added candidates
): Promise<Array<Candidate>> => {
const libraryItems = await searchLibraryItems(
{
size: 100,
size: limit,
includeContent: false,
query: `-is:seen wordsCount:>0`,
useFolders: true, // only show items in inbox folder
query: `in:inbox saved:"this week"`,
},
userId
)
logger.info(`Found ${libraryItems.length} library items`)
logger.info(`Found ${libraryItems.length} just added library items`)
// get subscriptions for the library items
const subscriptionNames = libraryItems
.filter((item) => !!item.subscription)
.map((item) => item.subscription as string)
const subscriptions = await findSubscriptionsByNames(
userId,
subscriptionNames
)
// map library items to candidates
const justAddedCandidates: Array<Candidate> = libraryItems.map((item) =>
libraryItemToCandidate(item, subscriptions)
)
return justAddedCandidates
}
const selectCandidates = async (
user: User,
excludes: Array<string> = [],
limit = 100
): Promise<Array<Candidate>> => {
const userId = user.id
// get last 100 library items saved and not seen by user
const libraryItems = await searchLibraryItems(
{
size: limit,
includeContent: false,
query: `in:inbox -is:seen -includes:${excludes.join(',')}`,
},
userId
)
logger.info(`Found ${libraryItems.length} not just added library items`)
// get subscriptions for the library items
const subscriptionNames = libraryItems
@ -202,7 +240,6 @@ const rankCandidates = async (
}
const redisKey = (userId: string) => `home:${userId}`
const MAX_FEED_ITEMS = 500
export const getHomeSections = async (
userId: string,
@ -283,34 +320,17 @@ const appendSectionsToHome = async (
const ttl = 86_400_000
pipeline.zremrangebyscore(key, '-inf', Date.now() - ttl)
// keep only the top MAX_FEED_ITEMS items
pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1))
// keep only the new sections and remove the oldest ones
pipeline.zremrangebyrank(key, 0, -(sections.length + 1))
logger.info('Adding home sections to redis')
await pipeline.exec()
}
const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
// 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)]
// 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) {
shortItems.push(item)
} else {
longItems.push(item)
}
}
// initialize empty batches
const batches: Array<Array<Candidate>> = Array.from(
{ length: Math.floor(rankedHomeItems.length / 10) },
() => []
)
const mixHomeItems = (
justAddedCandidates: Array<Candidate>,
rankedHomeItems: Array<Candidate>
): Array<Section> => {
const checkConstraints = (batch: Array<Candidate>, item: Candidate) => {
const titleCount = batch.filter((i) => i.title === item.title).length
const authorCount = batch.filter((i) => i.author === item.author).length
@ -328,14 +348,22 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
)
}
const candidateToItem = (candidate: Candidate): Item => ({
id: candidate.id,
type: candidate.type,
score: candidate.score,
})
const distributeItems = (
items: Array<Candidate>,
batches: Array<Array<Candidate>>
) => {
const batchSize = Math.ceil(items.length / batches.length)
for (const item of items) {
let added = false
for (const batch of batches) {
if (batch.length < 5 && checkConstraints(batch, item)) {
if (batch.length < batchSize && checkConstraints(batch, item)) {
batch.push(item)
added = true
break
@ -344,7 +372,7 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
if (!added) {
for (const batch of batches) {
if (batch.length < 10) {
if (batch.length < batchSize) {
batch.push(item)
break
}
@ -353,31 +381,51 @@ const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
}
}
// distribute quick link items first
distributeItems(shortItems, batches)
distributeItems(longItems, batches)
// 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)]
// 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) {
shortItems.push(item)
} else {
longItems.push(item)
}
}
// initialize empty batches
const numOfBatches = 10
const batches = {
short: Array.from({ length: numOfBatches }, () => []) as Array<
Array<Candidate>
>,
long: Array.from({ length: numOfBatches }, () => []) as Array<
Array<Candidate>
>,
}
distributeItems(shortItems, batches.short)
distributeItems(longItems, batches.long)
// convert batches to sections
const sections = []
for (const batch of batches) {
// create a section for all quick links
sections.push({
items: batch.slice(0, 5).map((item) => ({
id: item.id,
type: item.type,
score: item.score,
})),
layout: 'quick links',
})
sections.push({
items: batches.short.flat().map(candidateToItem),
layout: 'quick_links',
})
// create a section for each long item
sections.push(
...batch.slice(5).map((item) => ({
items: [{ id: item.id, type: item.type, score: item.score }],
layout: 'long',
}))
)
}
sections.push({
items: batches.long.flat().map(candidateToItem),
layout: 'top_picks',
})
sections.push({
items: justAddedCandidates.map(candidateToItem),
layout: 'just_added',
})
return sections
}
@ -395,14 +443,24 @@ export const updateHome = async (data: UpdateHomeJobData) => {
logger.info(`Updating home for user ${userId}`)
logger.profile('justAdded')
const justAddedCandidates = await getJustAddedCandidates(userId)
logger.profile('justAdded', {
level: 'info',
message: `Found ${justAddedCandidates.length} just added candidates`,
})
logger.profile('selecting')
const candidates = await selectCandidates(user)
const candidates = await selectCandidates(
user,
justAddedCandidates.map((c) => c.id)
)
logger.profile('selecting', {
level: 'info',
message: `Found ${candidates.length} candidates`,
})
if (candidates.length === 0) {
if (!justAddedCandidates.length && !candidates.length) {
logger.info('No candidates found')
return
}
@ -419,7 +477,7 @@ export const updateHome = async (data: UpdateHomeJobData) => {
// TODO: filter candidates
logger.profile('mixing')
const rankedSections = mixHomeItems(rankedCandidates)
const rankedSections = mixHomeItems(justAddedCandidates, rankedCandidates)
logger.profile('mixing', {
level: 'info',
message: `Created ${rankedSections.length} sections`,

View File

@ -635,6 +635,20 @@ export const functionResolvers = {
},
},
HomeSection: {
title: (section: { title?: string; layout: string }) => {
if (section.title) return section.title
switch (section.layout) {
case 'just_added':
return 'Just Added'
case 'top_picks':
return 'Top Picks'
case 'quick_links':
return 'Quick Links'
default:
return ''
}
},
async items(
section: {
items: Array<{

View File

@ -3121,7 +3121,7 @@ const schema = gql`
type HomeItemSource {
id: ID
name: String!
name: String
url: String
icon: String
type: HomeItemSourceType!

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react'
import { Button } from './Button'
import { HStack, VStack } from './LayoutPrimitives'
type PaginationProps<T> = {
items: T[]
itemsPerPage: number
render: (item: T) => React.ReactNode
}
const Pagination = <T,>({
items,
itemsPerPage,
render,
}: PaginationProps<T>) => {
const [currentPage, setCurrentPage] = useState(1)
const maxPage = Math.ceil(items.length / itemsPerPage)
function createChangePageHandler(page: number) {
return function handlePageChange() {
setCurrentPage(page)
}
}
const itemsToShow = items.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
return (
<VStack>
{itemsToShow.map(render)}
<HStack>
{Array.from({ length: maxPage }, (_, i) => i + 1).map((pageNum) => (
<Button
key={pageNum}
onClick={createChangePageHandler(pageNum)}
disabled={pageNum === currentPage}
>
{pageNum}
</Button>
))}
</HStack>
</VStack>
)
}
export default Pagination

View File

@ -1,18 +1,17 @@
import * as HoverCard from '@radix-ui/react-hover-card'
import { styled } from '@stitches/react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { Button } from '../../components/elements/Button'
import { AddToLibraryActionIcon } from '../../components/elements/icons/home/AddToLibraryActionIcon'
import { ArchiveActionIcon } from '../../components/elements/icons/home/ArchiveActionIcon'
import { CommentActionIcon } from '../../components/elements/icons/home/CommentActionIcon'
import { RemoveActionIcon } from '../../components/elements/icons/home/RemoveActionIcon'
import { ShareActionIcon } from '../../components/elements/icons/home/ShareActionIcon'
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 {
HStack,
SpanBox,
VStack,
} from './../../components/elements/LayoutPrimitives'
import * as HoverCard from '@radix-ui/react-hover-card'
import { Button } from '../../components/elements/Button'
import {
HomeItem,
HomeItemSource,
@ -20,14 +19,15 @@ import {
HomeSection,
useGetHomeItems,
} from '../../lib/networking/queries/useGetHome'
import { timeAgo } from '../../components/patterns/LibraryCards/LibraryCardStyles'
import { theme } from '../../components/tokens/stitches.config'
import { useRouter } from 'next/router'
import {
SubscriptionType,
useGetSubscriptionsQuery,
} from '../../lib/networking/queries/useGetSubscriptionsQuery'
import { useMemo } from 'react'
import {
HStack,
SpanBox,
VStack,
} from './../../components/elements/LayoutPrimitives'
export default function Home(): JSX.Element {
const homeData = useGetHomeItems()
@ -58,9 +58,23 @@ export default function Home(): JSX.Element {
>
{homeData.sections?.map((homeSection, idx) => {
switch (homeSection.layout) {
case 'long':
case 'just_added':
return (
<LongHomeSection
<JustReadHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
)
case 'top_picks':
return (
<TopPicksHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
)
case 'quick_links':
return (
<QuickLinksHomeSection
key={`section-${idx}`}
homeSection={homeSection}
/>
@ -76,13 +90,92 @@ type HomeSectionProps = {
homeSection: HomeSection
}
const LongHomeSection = (props: HomeSectionProps): JSX.Element => {
const JustReadHomeSection = (props: HomeSectionProps): JSX.Element => {
return (
<SpanBox css={{ width: '100%' }}>
<VStack
distribution="start"
css={{
width: '100%',
gap: '20px',
}}
>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '16px',
fontWeight: '600',
color: '$readerText',
}}
>
{props.homeSection.title}
</SpanBox>
{props.homeSection.items.map((homeItem) => {
return <HomeItemView key={homeItem.id} homeItem={homeItem} />
return <JustReadItemView key={homeItem.id} homeItem={homeItem} />
})}
</SpanBox>
</VStack>
)
}
const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => {
return (
<VStack
distribution="start"
css={{
width: '100%',
gap: '20px',
}}
>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '16px',
fontWeight: '600',
color: '$readerText',
}}
>
{props.homeSection.title}
</SpanBox>
<Pagination
items={props.homeSection.items}
itemsPerPage={10}
render={(homeItem) => (
<LongHomeItemView key={homeItem.id} homeItem={homeItem} />
)}
/>
</VStack>
)
}
const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => {
return (
<VStack
distribution="start"
css={{
width: '100%',
gap: '20px',
}}
>
<SpanBox
css={{
fontFamily: '$inter',
fontSize: '16px',
fontWeight: '600',
color: '$readerText',
}}
>
{props.homeSection.title}
</SpanBox>
<Pagination
items={props.homeSection.items}
itemsPerPage={15}
render={(homeItem) => (
<QuickLinkHomeItemView key={homeItem.id} homeItem={homeItem} />
)}
/>
</VStack>
)
}
@ -129,7 +222,59 @@ const Title = (props: HomeItemViewProps): JSX.Element => {
)
}
const HomeItemView = (props: HomeItemViewProps): JSX.Element => {
const JustReadItemView = (props: HomeItemViewProps): JSX.Element => {
const router = useRouter()
return (
<VStack
css={{
width: '100%',
padding: '20px',
borderRadius: '5px',
'&:hover': {
bg: '$thBackground',
borderRadius: '0px',
},
}}
onClick={(event) => {
if (event.metaKey || event.ctrlKey) {
window.open(props.homeItem.url, '_blank')
} else {
router.push(props.homeItem.url)
}
}}
>
<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>
</VStack>
)
}
const LongHomeItemView = (props: HomeItemViewProps): JSX.Element => {
const router = useRouter()
return (
@ -203,6 +348,39 @@ const HomeItemView = (props: HomeItemViewProps): JSX.Element => {
)
}
const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => {
const router = useRouter()
return (
<VStack
css={{
width: '100%',
padding: '10px',
borderRadius: '5px',
'&:hover': {
bg: '$thBackground',
borderRadius: '0px',
},
}}
onClick={(event) => {
if (event.metaKey || event.ctrlKey) {
window.open(props.homeItem.url, '_blank')
} else {
router.push(props.homeItem.url)
}
}}
>
<TimeAgo homeItem={props.homeItem} />
<Title homeItem={props.homeItem} />
<SpanBox
css={{ fontFamily: '$inter', fontSize: '13px', lineHeight: '23px' }}
>
{props.homeItem.previewContent}
</SpanBox>
</VStack>
)
}
const SiteIconSmall = styled('img', {
width: '16px',
height: '16px',