Add digest config to the account page

This commit is contained in:
Jackson Harper
2024-05-08 14:01:57 +08:00
parent e56c5aa91b
commit dad7cfdc41
5 changed files with 397 additions and 47 deletions

View File

@ -0,0 +1,19 @@
import { apiPoster } from '../networkHelpers'
export interface DigestRequest {
schedule: string
voices: string[]
}
export const scheduleDigest = async (
request: DigestRequest
): Promise<boolean> => {
try {
const result = await apiPoster(`/api/digest/v1/`, request)
console.log('RESULT: ', result)
return true
} catch (error) {
console.log('error scheduling job: ', error)
return false
}
}

View File

@ -0,0 +1,56 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import {
DigestChannel,
DigestConfig,
UserPersonalization,
isDigestConfig,
} from '../queries/useGetUserPersonalization'
type EmptyTrashResult = {
updatedUserPersonalization?: UserPersonalization
errorCodes?: string[]
}
type SetUserPersonalizationResponse = {
setUserPersonalization: EmptyTrashResult
}
export async function updateDigestConfigMutation(
channels: DigestChannel[]
): Promise<DigestConfig | undefined> {
const mutation = gql`
mutation SetUserPersonalization($input: SetUserPersonalizationInput!) {
setUserPersonalization(input: $input) {
... on SetUserPersonalizationError {
errorCodes
}
... on SetUserPersonalizationSuccess {
updatedUserPersonalization {
digestConfig
}
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
input: {
digestConfig: {
channels: channels,
},
},
})) as SetUserPersonalizationResponse
const digestConfig =
data.setUserPersonalization.updatedUserPersonalization?.digestConfig
if (isDigestConfig(digestConfig)) {
return digestConfig
}
return undefined
} catch (err) {
console.log('error updating user config', err)
return undefined
}
}

View File

@ -68,6 +68,22 @@ export function apiFetcher(path: string): Promise<unknown> {
})
}
export function apiPoster(path: string, body: any): Promise<unknown> {
const url = new URL(path, fetchEndpoint)
return fetch(url.toString(), {
method: 'POST',
credentials: 'include',
mode: 'cors',
// headers: {
// Accept: 'application/json',
// 'X-OmnivoreClient': 'web',
// },
body: JSON.stringify(body),
}).then((result) => {
return result.json()
})
}
export function makePublicGqlFetcher(
variables?: unknown
): (query: string) => Promise<unknown> {

View File

@ -0,0 +1,102 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
export type DigestChannel = 'push' | 'email' | 'library'
export type UserPersonalizationResult = {
mutate: () => void
isLoading: boolean
userPersonalization?: UserPersonalization
}
type Response = {
getUserPersonalization?: Result
}
type Result = {
userPersonalization?: UserPersonalization
errorCodes?: string[]
}
export type UserPersonalization = {
digestConfig?: DigestConfig
}
export type DigestConfig = {
channels?: DigestChannel[]
}
export function isDigestConfig(obj: any): obj is DigestConfig {
const validChannels = ['push', 'email', 'library'] as const
function isValidChannel(channel: any): channel is DigestChannel {
return validChannels.includes(channel)
}
if (typeof obj !== 'object' || obj === null) {
return false
}
if ('channels' in obj) {
const { channels } = obj
if (!Array.isArray(channels)) {
return false
}
for (const channel of channels) {
if (!isValidChannel(channel)) {
return false
}
}
}
return true
}
export function useGetUserPersonalization(): UserPersonalizationResult {
const query = gql`
query UserPersonalization {
getUserPersonalization {
... on GetUserPersonalizationSuccess {
userPersonalization {
digestConfig
}
}
... on GetUserPersonalizationError {
errorCodes
}
}
}
`
const { data, error, mutate } = useSWR(query, publicGqlFetcher)
const response = data as Response | undefined
console.log(
'useGetUserPersonalization:data: ',
response?.getUserPersonalization?.userPersonalization,
'data',
data
)
if (
!response ||
!response.getUserPersonalization ||
response.getUserPersonalization?.errorCodes ||
!response.getUserPersonalization?.userPersonalization ||
!isDigestConfig(
response.getUserPersonalization?.userPersonalization.digestConfig
)
) {
console.log('invalid digest config')
return {
mutate,
isLoading: false,
userPersonalization: undefined,
}
}
return {
mutate,
userPersonalization: response.getUserPersonalization?.userPersonalization,
isLoading: !error && !data,
}
}

View File

@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Toaster } from 'react-hot-toast'
import { Button } from '../../components/elements/Button'
import {
@ -21,6 +28,12 @@ import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuer
import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import {
DigestChannel,
useGetUserPersonalization,
} from '../../lib/networking/queries/useGetUserPersonalization'
import { updateDigestConfigMutation } from '../../lib/networking/mutations/updateDigestConfigMutation'
import { scheduleDigest } from '../../lib/networking/mutations/scheduleDigest'
const ACCOUNT_LIMIT = 50_000
@ -388,6 +401,8 @@ export default function Account(): JSX.Element {
</form>
</VStack>
<DigestSection />
<VStack
css={{
padding: '24px',
@ -429,52 +444,7 @@ export default function Account(): JSX.Element {
{/* <Button style="ctaDarkYellow">Upgrade</Button> */}
</VStack>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '10px',
borderRadius: '5px',
}}
>
<StyledLabel>Beta features</StyledLabel>
{!isValidating && (
<>
{viewerData?.me?.featureList.map((feature) => {
return (
<StyledText
key={`feature-${feature.name}`}
style="footnote"
css={{ display: 'flex', gap: '5px' }}
>
<input
type="checkbox"
checked={userHasFeature(viewerData?.me, feature.name)}
disabled={true}
></input>
{`${feature.name}${
userHasFeature(viewerData?.me, feature.name)
? ''
: ' - Requested'
}`}
</StyledText>
)
})}
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px' }}
>
To learn more about beta features available,{' '}
<a href="https://discord.gg/h2z5rppzz9">
join the Omnivore Discord
</a>
</StyledText>
</>
)}
{/* <Button style="ctaDarkYellow">Upgrade</Button> */}
</VStack>
<BetaFeaturesSection />
<VStack
css={{
@ -512,3 +482,190 @@ export default function Account(): JSX.Element {
</SettingsLayout>
)
}
const BetaFeaturesSection = (): JSX.Element => {
const { viewerData } = useGetViewerQuery()
return (
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '10px',
borderRadius: '5px',
}}
>
<StyledLabel>Beta features</StyledLabel>
{viewerData?.me?.featureList.map((feature) => {
return (
<StyledText
key={`feature-${feature.name}`}
style="footnote"
css={{ display: 'flex', gap: '5px', m: '0px' }}
>
<input
type="checkbox"
checked={userHasFeature(viewerData?.me, feature.name)}
disabled={true}
></input>
{`${feature.name}${
userHasFeature(viewerData?.me, feature.name) ? '' : ' - Requested'
}`}
</StyledText>
)
})}
<StyledText style="footnote" css={{ display: 'flex', gap: '5px' }}>
To learn more about beta features available,{' '}
<a href="https://discord.gg/h2z5rppzz9">join the Omnivore Discord</a>
</StyledText>
</VStack>
)
}
const DigestSection = (): JSX.Element => {
const [channelState, setChannelState] = useState({
push: false,
email: false,
library: false,
})
const {
userPersonalization,
isLoading: isDigestConfigLoading,
mutate,
} = useGetUserPersonalization()
useEffect(() => {
const channels = userPersonalization?.digestConfig?.channels ?? []
const initialState = {
push: channels.indexOf('push') !== -1,
email: channels.indexOf('email') !== -1,
library: channels.indexOf('library') !== -1,
}
setChannelState({ ...initialState })
}, [userPersonalization])
const handleDigestCheckboxChange = useCallback(
(name: DigestChannel, checked: boolean) => {
;(async () => {
let selectedChannels = channelState
channelState[name] = checked
setChannelState({ ...selectedChannels })
let updatedChannels: DigestChannel[] = []
if (channelState.push) {
updatedChannels.push('push')
}
if (channelState.email) {
updatedChannels.push('email')
}
if (channelState.library) {
updatedChannels.push('library')
}
const result = await updateDigestConfigMutation(updatedChannels)
if (result) {
showSuccessToast('Updated digest config')
} else {
showErrorToast('Error updating digest config')
}
if (updatedChannels.length) {
// Queue the daily job
console.log('queueing daily digest job')
const scheduled = await scheduleDigest({
schedule: 'daily',
voices: ['openai-nova'],
})
if (scheduled) {
showSuccessToast(
'Your daily digest is scheduled to start tomorrow.'
)
} else {
showErrorToast('Error scheduling your daily digest')
}
} else {
console.log('deleting daily digest job')
}
mutate()
})()
},
[channelState]
)
return (
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '10px',
borderRadius: '5px',
}}
>
<StyledLabel>Digest</StyledLabel>
<StyledText
style="footnote"
css={{
display: 'flex',
gap: '5px',
lineHeight: '22px',
mt: '0px',
}}
>
Omnivore Digest is a free daily digest of some of your best recent
library items. Omnivore filters and ranks all the items recently added
them to your library, uses AI to summarize them, and creates a short
email for you to review, or a daily podcast you can listen to in our iOS
app. Note that if you sign up for Digest, your recent library items will
be processed by an AI service (Anthropic, or OpenAI). Your highlights,
notes, and labels will not be sent to the AI service
</StyledText>
{!isDigestConfigLoading && (
<>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px', m: '0px' }}
>
<input
type="checkbox"
name="digest-library"
checked={channelState.library}
onChange={(event) =>
handleDigestCheckboxChange('library', event.target.checked)
}
></input>
Deliver to library (added to your library each morning)
</StyledText>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px', m: '0px' }}
>
<input
type="checkbox"
name="digest-email"
checked={channelState.email}
onChange={(event) =>
handleDigestCheckboxChange('email', event.target.checked)
}
></input>
Deliver to email (daily email sent each morning)
</StyledText>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px', m: '0px' }}
>
<input
type="checkbox"
name="digest-ios"
checked={channelState.push}
onChange={(event) =>
handleDigestCheckboxChange('push', event.target.checked)
}
></input>
Deliver to iOS (daily podcast available in the iOS app)
</StyledText>
</>
)}
</VStack>
)
}