Merge pull request #3915 from omnivore-app/feat/web-digest-signup
Add digest config to the account page
This commit is contained in:
20
packages/web/lib/networking/mutations/scheduleDigest.tsx
Normal file
20
packages/web/lib/networking/mutations/scheduleDigest.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user