From dad7cfdc41019585da227e7a1d47d51bbfd6841c Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 8 May 2024 14:01:57 +0800 Subject: [PATCH] Add digest config to the account page --- .../networking/mutations/scheduleDigest.tsx | 19 ++ .../mutations/updateDigestConfigMutation.ts | 56 ++++ packages/web/lib/networking/networkHelpers.ts | 16 ++ .../queries/useGetUserPersonalization.tsx | 102 +++++++ packages/web/pages/settings/account.tsx | 251 ++++++++++++++---- 5 files changed, 397 insertions(+), 47 deletions(-) create mode 100644 packages/web/lib/networking/mutations/scheduleDigest.tsx create mode 100644 packages/web/lib/networking/mutations/updateDigestConfigMutation.ts create mode 100644 packages/web/lib/networking/queries/useGetUserPersonalization.tsx diff --git a/packages/web/lib/networking/mutations/scheduleDigest.tsx b/packages/web/lib/networking/mutations/scheduleDigest.tsx new file mode 100644 index 000000000..936d2d994 --- /dev/null +++ b/packages/web/lib/networking/mutations/scheduleDigest.tsx @@ -0,0 +1,19 @@ +import { apiPoster } from '../networkHelpers' + +export interface DigestRequest { + schedule: string + voices: string[] +} + +export const scheduleDigest = async ( + request: DigestRequest +): Promise => { + 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 + } +} diff --git a/packages/web/lib/networking/mutations/updateDigestConfigMutation.ts b/packages/web/lib/networking/mutations/updateDigestConfigMutation.ts new file mode 100644 index 000000000..87983ebc7 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateDigestConfigMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/networkHelpers.ts b/packages/web/lib/networking/networkHelpers.ts index b70bd0015..04cd0b6b6 100644 --- a/packages/web/lib/networking/networkHelpers.ts +++ b/packages/web/lib/networking/networkHelpers.ts @@ -68,6 +68,22 @@ export function apiFetcher(path: string): Promise { }) } +export function apiPoster(path: string, body: any): Promise { + 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 { diff --git a/packages/web/lib/networking/queries/useGetUserPersonalization.tsx b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx new file mode 100644 index 000000000..557f4956b --- /dev/null +++ b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx @@ -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, + } +} diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 9ea8b774d..97a831fa5 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -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 { + + Upgrade */} - - Beta features - {!isValidating && ( - <> - {viewerData?.me?.featureList.map((feature) => { - return ( - - - {`${feature.name}${ - userHasFeature(viewerData?.me, feature.name) - ? '' - : ' - Requested' - }`} - - ) - })} - - To learn more about beta features available,{' '} - - join the Omnivore Discord - - - - )} - {/* */} - + ) } + +const BetaFeaturesSection = (): JSX.Element => { + const { viewerData } = useGetViewerQuery() + return ( + + Beta features + {viewerData?.me?.featureList.map((feature) => { + return ( + + + {`${feature.name}${ + userHasFeature(viewerData?.me, feature.name) ? '' : ' - Requested' + }`} + + ) + })} + + To learn more about beta features available,{' '} + join the Omnivore Discord + + + ) +} + +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 ( + + Digest + + 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 + + {!isDigestConfigLoading && ( + <> + + + handleDigestCheckboxChange('library', event.target.checked) + } + > + Deliver to library (added to your library each morning) + + + + handleDigestCheckboxChange('email', event.target.checked) + } + > + Deliver to email (daily email sent each morning) + + + + handleDigestCheckboxChange('push', event.target.checked) + } + > + Deliver to iOS (daily podcast available in the iOS app) + + + )} + + ) +}