Merge pull request #3915 from omnivore-app/feat/web-digest-signup

Add digest config to the account page
This commit is contained in:
Jackson Harper
2024-05-09 14:53:05 +08:00
committed by GitHub
6 changed files with 428 additions and 48 deletions

View File

@ -0,0 +1,20 @@
import { apiPoster } from '../networkHelpers'
export interface DigestRequest {
schedule: string
voices: string[]
}
export const scheduleDigest = async (
request: DigestRequest
): Promise<boolean> => {
try {
const response = await apiPoster(`/api/digest/v1/`, request)
return (
response.status == 202 || response.status == 201 || response.status == 200
)
} 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,17 @@ export function apiFetcher(path: string): Promise<unknown> {
})
}
export function apiPoster(path: string, body: any): Promise<Response> {
const url = new URL(path, fetchEndpoint)
return fetch(url.toString(), {
method: 'POST',
credentials: 'include',
mode: 'cors',
headers: requestHeaders(),
body: JSON.stringify(body),
})
}
export function makePublicGqlFetcher(
variables?: unknown
): (query: string) => Promise<unknown> {

View File

@ -0,0 +1,95 @@
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
if (
!response ||
!response.getUserPersonalization ||
response.getUserPersonalization?.errorCodes ||
!response.getUserPersonalization?.userPersonalization ||
!isDigestConfig(
response.getUserPersonalization?.userPersonalization.digestConfig
)
) {
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,13 @@ 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'
import { optInFeature } from '../../lib/networking/mutations/optIntoFeatureMutation'
const ACCOUNT_LIMIT = 50_000
@ -388,6 +402,8 @@ export default function Account(): JSX.Element {
</form>
</VStack>
<DigestSection />
<VStack
css={{
padding: '24px',
@ -429,52 +445,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 +483,230 @@ 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 { viewerData, isLoading, mutate } = useGetViewerQuery()
const [channelState, setChannelState] = useState({
push: false,
email: false,
library: false,
})
const {
userPersonalization,
isLoading: isDigestConfigLoading,
mutate: mutatePersonalization,
} = 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 hasDigest = useMemo(() => {
return viewerData?.me?.featureList?.some((f) => f.name === 'ai-digest')
}, [viewerData])
const handleDigestCheckboxChange = useCallback(
(name: DigestChannel, checked: boolean) => {
;(async () => {
const selectedChannels = channelState
channelState[name] = checked
setChannelState({ ...selectedChannels })
const 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) {
// Schedule the job in a timeout so the user notifications
// make sense
setTimeout(async () => {
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')
}
}, 500)
} else {
console.log('deleting daily digest job')
}
mutatePersonalization()
})()
},
[channelState]
)
const requestDigestAccess = useCallback(() => {
;(async () => {
const result = await optInFeature({ name: 'ai-digest' })
if (!result) {
showErrorToast('Error enabling digest')
return
}
mutate()
})()
}, [])
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
to your library, uses AI to summarize them, and creates a short library
item, email, or a daily podcast you can listen to in our iOS app.
</StyledText>
<StyledText
style="footnote"
css={{
display: 'flex',
gap: '5px',
lineHeight: '22px',
mt: '0px',
}}
>
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>
{hasDigest && (
<>
<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>
</>
)}
{!hasDigest && (
<Button
style="ctaDarkYellow"
onClick={(event) => {
requestDigestAccess()
event.preventDefault()
}}
>
Enable Digest
</Button>
)}
</VStack>
)
}

View File

@ -18,7 +18,7 @@ const StyledLabel = styled('label', {
marginBottom: '5px',
})
export default function Account(): JSX.Element {
export default function BetaFeatures(): JSX.Element {
const { viewerData, isLoading, mutate } = useGetViewerQuery()
const [pageLoading, setPageLoading] = useState(false)