From dad7cfdc41019585da227e7a1d47d51bbfd6841c Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 8 May 2024 14:01:57 +0800 Subject: [PATCH 1/4] 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) + + + )} + + ) +} From 976f5e5a6b1709dfd02657cacfe96099eec600a4 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 8 May 2024 16:09:02 +0800 Subject: [PATCH 2/4] Better handling of user personalization errors --- .../networking/mutations/scheduleDigest.tsx | 7 +++-- packages/web/lib/networking/networkHelpers.ts | 9 ++---- .../queries/useGetUserPersonalization.tsx | 6 ---- packages/web/pages/settings/account.tsx | 28 ++++++++++--------- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/packages/web/lib/networking/mutations/scheduleDigest.tsx b/packages/web/lib/networking/mutations/scheduleDigest.tsx index 936d2d994..96f233048 100644 --- a/packages/web/lib/networking/mutations/scheduleDigest.tsx +++ b/packages/web/lib/networking/mutations/scheduleDigest.tsx @@ -9,9 +9,10 @@ export const scheduleDigest = async ( request: DigestRequest ): Promise => { try { - const result = await apiPoster(`/api/digest/v1/`, request) - console.log('RESULT: ', result) - return true + 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 diff --git a/packages/web/lib/networking/networkHelpers.ts b/packages/web/lib/networking/networkHelpers.ts index 04cd0b6b6..0cf2d3bf9 100644 --- a/packages/web/lib/networking/networkHelpers.ts +++ b/packages/web/lib/networking/networkHelpers.ts @@ -68,19 +68,14 @@ export function apiFetcher(path: string): Promise { }) } -export function apiPoster(path: string, body: any): 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', - // }, + headers: requestHeaders(), body: JSON.stringify(body), - }).then((result) => { - return result.json() }) } diff --git a/packages/web/lib/networking/queries/useGetUserPersonalization.tsx b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx index 557f4956b..3d4fc0eae 100644 --- a/packages/web/lib/networking/queries/useGetUserPersonalization.tsx +++ b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx @@ -70,12 +70,6 @@ export function useGetUserPersonalization(): UserPersonalizationResult { const { data, error, mutate } = useSWR(query, publicGqlFetcher) const response = data as Response | undefined - console.log( - 'useGetUserPersonalization:data: ', - response?.getUserPersonalization?.userPersonalization, - 'data', - data - ) if ( !response || diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 97a831fa5..ca707a35d 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -569,19 +569,21 @@ const DigestSection = (): JSX.Element => { 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') - } + // 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') } From 7e7ca4333b6a6a5a11017fb8caa1b38d1a507e49 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 8 May 2024 16:24:13 +0800 Subject: [PATCH 3/4] linting --- packages/web/pages/settings/account.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index ca707a35d..9115815a7 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -548,11 +548,11 @@ const DigestSection = (): JSX.Element => { const handleDigestCheckboxChange = useCallback( (name: DigestChannel, checked: boolean) => { ;(async () => { - let selectedChannels = channelState + const selectedChannels = channelState channelState[name] = checked setChannelState({ ...selectedChannels }) - let updatedChannels: DigestChannel[] = [] + const updatedChannels: DigestChannel[] = [] if (channelState.push) { updatedChannels.push('push') } From 0d41e046405e6b7094d5b702e6a490879876ba4b Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 8 May 2024 16:43:01 +0800 Subject: [PATCH 4/4] Add enable Digest button --- .../queries/useGetUserPersonalization.tsx | 1 - packages/web/pages/settings/account.tsx | 53 ++++++++++++++++--- packages/web/pages/settings/features/beta.tsx | 2 +- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/web/lib/networking/queries/useGetUserPersonalization.tsx b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx index 3d4fc0eae..bbcf1f958 100644 --- a/packages/web/lib/networking/queries/useGetUserPersonalization.tsx +++ b/packages/web/lib/networking/queries/useGetUserPersonalization.tsx @@ -80,7 +80,6 @@ export function useGetUserPersonalization(): UserPersonalizationResult { response.getUserPersonalization?.userPersonalization.digestConfig ) ) { - console.log('invalid digest config') return { mutate, isLoading: false, diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 9115815a7..bf5640818 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -34,6 +34,7 @@ import { } 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 @@ -524,6 +525,7 @@ const BetaFeaturesSection = (): JSX.Element => { } const DigestSection = (): JSX.Element => { + const { viewerData, isLoading, mutate } = useGetViewerQuery() const [channelState, setChannelState] = useState({ push: false, email: false, @@ -532,7 +534,7 @@ const DigestSection = (): JSX.Element => { const { userPersonalization, isLoading: isDigestConfigLoading, - mutate, + mutate: mutatePersonalization, } = useGetUserPersonalization() useEffect(() => { @@ -545,6 +547,10 @@ const DigestSection = (): JSX.Element => { 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 () => { @@ -588,11 +594,23 @@ const DigestSection = (): JSX.Element => { console.log('deleting daily digest job') } - mutate() + mutatePersonalization() })() }, [channelState] ) + + const requestDigestAccess = useCallback(() => { + ;(async () => { + const result = await optInFeature({ name: 'ai-digest' }) + if (!result) { + showErrorToast('Error enabling digest') + return + } + mutate() + })() + }, []) + return ( { > 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, + 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. + + + 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 && ( + {hasDigest && ( <> { )} + {!hasDigest && ( + + )} ) } diff --git a/packages/web/pages/settings/features/beta.tsx b/packages/web/pages/settings/features/beta.tsx index 720a48c2d..c88fec98a 100644 --- a/packages/web/pages/settings/features/beta.tsx +++ b/packages/web/pages/settings/features/beta.tsx @@ -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)