From 7f77ce5509ef8e5e75c78dbf85f8527069e06b99 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 17 Dec 2024 15:23:42 +0400 Subject: [PATCH 1/7] Attempt to fix offline mode --- app/gui/src/dashboard/providers/SessionProvider.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index edb984da3d92..a33c85b975fc 100644 --- a/app/gui/src/dashboard/providers/SessionProvider.tsx +++ b/app/gui/src/dashboard/providers/SessionProvider.tsx @@ -15,6 +15,7 @@ import * as errorModule from '#/utilities/error' import type * as cognito from '#/authentication/cognito' import * as listen from '#/authentication/listen' +import { useOffline } from '#/hooks/offlineHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks' // ====================== @@ -188,6 +189,8 @@ const SIX_HOURS_MS = 21_600_000 function SessionRefresher(props: SessionRefresherProps) { const { refreshUserSession, session } = props + const { isOffline } = useOffline() + reactQuery.useQuery({ queryKey: ['refreshUserSession', { refreshToken: session.refreshToken }] as const, queryFn: () => refreshUserSession(), @@ -199,6 +202,7 @@ function SessionRefresher(props: SessionRefresherProps) { refetchOnWindowFocus: 'always', refetchOnReconnect: 'always', refetchOnMount: 'always', + enabled: !isOffline, refetchInterval: () => { const expireAt = session.expireAt From e9bbe60bf9eb79513e86ee941af3d8ecd3f66dc4 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 18 Dec 2024 12:15:58 +0400 Subject: [PATCH 2/7] Fixes --- app/gui/src/dashboard/hooks/offlineHooks.ts | 2 +- app/gui/src/dashboard/providers/SessionProvider.tsx | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/gui/src/dashboard/hooks/offlineHooks.ts b/app/gui/src/dashboard/hooks/offlineHooks.ts index 0c2d509f57cd..c20f0c075891 100644 --- a/app/gui/src/dashboard/hooks/offlineHooks.ts +++ b/app/gui/src/dashboard/hooks/offlineHooks.ts @@ -10,7 +10,7 @@ export function useOffline() { const isOnline = React.useSyncExternalStore( reactQuery.onlineManager.subscribe.bind(reactQuery.onlineManager), () => reactQuery.onlineManager.isOnline(), - () => navigator.onLine, + () => false, ) return { isOffline: !isOnline } diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index a33c85b975fc..a3931a1c33e7 100644 --- a/app/gui/src/dashboard/providers/SessionProvider.tsx +++ b/app/gui/src/dashboard/providers/SessionProvider.tsx @@ -61,13 +61,7 @@ export interface SessionProviderProps { function createSessionQuery(userSession: (() => Promise) | null) { return reactQuery.queryOptions({ queryKey: ['userSession'], - queryFn: async () => { - const session = (await userSession?.().catch(() => null)) ?? null - return session - }, - refetchOnWindowFocus: 'always', - refetchOnMount: 'always', - refetchOnReconnect: 'always', + queryFn: () => userSession?.().catch(() => null) ?? null, }) } From 6e18508454277aa0343b3c2141eb10bdac5f9e4f Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 19 Dec 2024 17:57:57 +0400 Subject: [PATCH 3/7] Fix test --- .../dashboard/providers/__test__/SessionProvider.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx index 7869349cf630..6e4157c57da6 100644 --- a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx @@ -99,12 +99,14 @@ describe('SessionProvider', () => { , ) + expect(refreshUserSession).not.toBeCalled() + expect(userSession).toBeCalledTimes(2) + await waitFor(() => { expect(refreshUserSession).toBeCalledTimes(1) expect(screen.getByText(/Hello/)).toBeInTheDocument() - // 2 initial calls(fetching session and refreshing session), 1 mutation call, 1 re-fetch call - expect(userSession).toBeCalledTimes(4) + expect(userSession).toBeCalledTimes(3) }) }) From a9b9ab2b7ee72e96134a76db39790088b57c2b56 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 16:39:02 +0400 Subject: [PATCH 4/7] Improve Oflline mode support --- app/common/package.json | 1 + app/common/src/queryClient.ts | 5 +- app/common/src/text/english.json | 3 + app/common/src/utilities/errors.ts | 52 +++++++++++++ app/gui/src/dashboard/App.tsx | 23 ++++++ .../dashboard/components/ErrorBoundary.tsx | 65 ++++++++++------ app/gui/src/dashboard/components/Suspense.tsx | 60 +------------- app/gui/src/dashboard/hooks/backendHooks.tsx | 3 +- app/gui/src/dashboard/layouts/Drive.tsx | 78 +++++++++++++------ app/gui/src/dashboard/utilities/HttpClient.ts | 20 ++++- app/gui/src/dashboard/utilities/error.ts | 17 +++- 11 files changed, 221 insertions(+), 106 deletions(-) create mode 100644 app/common/src/utilities/errors.ts diff --git a/app/common/package.json b/app/common/package.json index a3bda304841a..b193595dc5b9 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -15,6 +15,7 @@ "./src/backendQuery": "./src/backendQuery.ts", "./src/queryClient": "./src/queryClient.ts", "./src/utilities/data/array": "./src/utilities/data/array.ts", + "./src/utilities/errors": "./src/utilities/errors.ts", "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", "./src/utilities/data/object": "./src/utilities/data/object.ts", diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index ab69795436d0..9c8829c8f736 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -83,7 +83,7 @@ export function createQueryClient( storage: persisterStorage, // Prefer online first and don't rely on the local cache if user is online // fallback to the local cache only if the user is offline - maxAge: queryCore.onlineManager.isOnline() ? -1 : DEFAULT_QUERY_PERSIST_TIME_MS, + maxAge: DEFAULT_QUERY_PERSIST_TIME_MS, buster: DEFAULT_BUSTER, filters: { predicate: query => query.meta?.persist !== false }, prefix: 'enso:query-persist:', @@ -130,6 +130,9 @@ export function createQueryClient( defaultOptions: { queries: { ...(persister != null ? { persister } : {}), + // Default set to 'always' to don't pause ongoing queries + // and make them fail. + networkMode: 'always', refetchOnReconnect: 'always', staleTime: DEFAULT_QUERY_STALE_TIME_MS, retry: (failureCount, error: unknown) => { diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index a89c0135bc24..ef6a7d214603 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -17,6 +17,9 @@ "deleteAssetError": "Could not delete '$0'", "restoreAssetError": "Could not restore '$0'", + "refetchQueriesPending": "Getting latest updates...", + "refetchQueriesError": "Could not get latest updates. Some information may be outdated", + "localBackendDatalinkError": "Cannot create Datalinks on the local drive", "localBackendSecretError": "Cannot create secrets on the local drive", "offlineUploadFilesError": "Cannot upload files when offline", diff --git a/app/common/src/utilities/errors.ts b/app/common/src/utilities/errors.ts new file mode 100644 index 000000000000..cba8c4b01f15 --- /dev/null +++ b/app/common/src/utilities/errors.ts @@ -0,0 +1,52 @@ +/** + * An error that occurs when a network request fails. + * + * This error is used to indicate that a network request failed due to a network error, + * such as a timeout or a connection error. + */ +export class NetworkError extends Error { + /** + * Create a new {@link NetworkError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'NetworkError' + } +} + +/** + * An error that occurs when the user is offline. + * + * This error is used to indicate that the user is offline, such as when they are + * not connected to the internet or when they are on an airplane. + */ +export class OfflineError extends Error { + /** + * Create a new {@link OfflineError} with the specified message. + * @param message - The message to display when the error is thrown. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'OfflineError' + } +} + +/** + * An error with a display message. + * + * This message can be shown to a user. + */ +export class ErrorWithDisplayMessage extends Error { + readonly displayMessage: string + /** + * Create a new {@link ErrorWithDisplayMessage} with the specified message and display message. + * @param message - The message to display when the error is thrown. + * @param options - The options to pass to the error. + */ + constructor(message: string, options: ErrorOptions & { displayMessage: string }) { + super(message, options) + this.name = 'ErrorWithDisplayMessage' + this.displayMessage = options.displayMessage + } +} diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index fe8d68ee04e0..1b7123965687 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -98,6 +98,8 @@ import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' +import { useMutation } from '@tanstack/react-query' +import { useOffline } from './hooks/offlineHooks' // ============================ // === Global configuration === @@ -215,6 +217,9 @@ export default function App(props: AppProps) { }, }) + const { isOffline } = useOffline() + const { getText } = textProvider.useText() + const queryClient = props.queryClient // Force all queries to be stale @@ -236,6 +241,24 @@ export default function App(props: AppProps) { refetchInterval: 2 * 60 * 1000, }) + const { mutate: executeBackgroundUpdate } = useMutation({ + mutationKey: ['refetch-queries', { isOffline }], + scope: { id: 'refetch-queries' }, + mutationFn: () => queryClient.refetchQueries({ type: 'all' }), + networkMode: 'online', + onError: () => { + toastify.toast.error(getText('refetchQueriesError'), { + position: 'bottom-right', + }) + }, + }) + + React.useEffect(() => { + if (!isOffline) { + executeBackgroundUpdate() + } + }, [executeBackgroundUpdate, isOffline]) + // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // will redirect the user between the login/register pages and the dashboard. diff --git a/app/gui/src/dashboard/components/ErrorBoundary.tsx b/app/gui/src/dashboard/components/ErrorBoundary.tsx index ee5dc324d4dc..f85fa6c1edb0 100644 --- a/app/gui/src/dashboard/components/ErrorBoundary.tsx +++ b/app/gui/src/dashboard/components/ErrorBoundary.tsx @@ -7,8 +7,6 @@ import * as errorBoundary from 'react-error-boundary' import * as detect from 'enso-common/src/detect' -import * as offlineHooks from '#/hooks/offlineHooks' - import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' @@ -16,6 +14,7 @@ import * as result from '#/components/Result' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as errorUtils from '#/utilities/error' +import { OfflineError } from '#/utilities/HttpClient' // ===================== // === ErrorBoundary === @@ -38,7 +37,9 @@ export interface ErrorBoundaryProps > > { /** Called before the fallback is shown. */ - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: ( + args: OnBeforeFallbackShownArgs, + ) => React.ReactNode | null | undefined readonly title?: string readonly subtitle?: string } @@ -53,7 +54,7 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { FallbackComponent = ErrorDisplay, onError = () => {}, onReset = () => {}, - onBeforeFallbackShown = () => {}, + onBeforeFallbackShown = () => null, title, subtitle, ...rest @@ -63,15 +64,19 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { {({ reset }) => ( ( - - )} + FallbackComponent={(fallbackProps) => { + const displayMessage = errorUtils.extractDisplayMessage(fallbackProps.error) + + return ( + + ) + }} onError={(error, info) => { sentry.captureException(error, { extra: { info } }) onError(error, info) @@ -90,39 +95,51 @@ export function ErrorBoundary(props: ErrorBoundaryProps) { /** Props for a {@link ErrorDisplay}. */ export interface ErrorDisplayProps extends errorBoundary.FallbackProps { readonly status?: result.ResultProps['status'] - readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void + readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => React.ReactNode | undefined readonly resetQueries?: () => void - readonly title?: string | undefined - readonly subtitle?: string | undefined + readonly title?: string | null | undefined + readonly subtitle?: string | null | undefined readonly error: unknown } /** Default fallback component to show when there is an error. */ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element { const { getText } = textProvider.useText() - const { isOffline } = offlineHooks.useOffline() const { error, resetErrorBoundary, - title = getText('somethingWentWrong'), - subtitle = isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'), - status = isOffline ? 'info' : 'error', + title, + subtitle, + status, onBeforeFallbackShown, resetQueries = () => {}, } = props + const isOfflineError = error instanceof OfflineError + const message = errorUtils.getMessageOrToString(error) const stack = errorUtils.tryGetStack(error) - onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) + const render = onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries }) const onReset = useEventCallback(() => { resetErrorBoundary() }) - return ( - + const finalTitle = title ?? getText('somethingWentWrong') + const finalSubtitle = + subtitle ?? + (isOfflineError ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle')) + const finalStatus = status ?? (isOfflineError ? 'info' : 'error') + + const defaultRender = ( + ) + + return <>{render ?? defaultRender} } export { useErrorBoundary, withErrorBoundary } from 'react-error-boundary' diff --git a/app/gui/src/dashboard/components/Suspense.tsx b/app/gui/src/dashboard/components/Suspense.tsx index bf4342246a34..fd833df7fdce 100644 --- a/app/gui/src/dashboard/components/Suspense.tsx +++ b/app/gui/src/dashboard/components/Suspense.tsx @@ -7,26 +7,13 @@ import * as React from 'react' -import * as reactQuery from '@tanstack/react-query' - -import * as debounceValue from '#/hooks/debounceValueHooks' -import * as offlineHooks from '#/hooks/offlineHooks' - -import * as textProvider from '#/providers/TextProvider' - -import * as result from '#/components/Result' - import * as loader from './Loader' /** Props for {@link Suspense} component. */ export interface SuspenseProps extends React.SuspenseProps { readonly loaderProps?: loader.LoaderProps - readonly offlineFallback?: React.ReactNode - readonly offlineFallbackProps?: result.ResultProps } -const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 - /** * Suspense is a component that allows you to wrap a part of your application that might suspend, * showing a fallback to the user while waiting for the data to load. @@ -35,19 +22,10 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250 * And handles offline scenarios. */ export function Suspense(props: SuspenseProps) { - const { children, loaderProps, fallback, offlineFallback, offlineFallbackProps } = props + const { children, loaderProps, fallback } = props return ( - - } - > + }> {children} ) @@ -58,8 +36,6 @@ export function Suspense(props: SuspenseProps) { */ interface LoaderProps extends loader.LoaderProps { readonly fallback?: SuspenseProps['fallback'] - readonly offlineFallback?: SuspenseProps['offlineFallback'] - readonly offlineFallbackProps?: SuspenseProps['offlineFallbackProps'] } /** @@ -74,35 +50,7 @@ interface LoaderProps extends loader.LoaderProps { * we want to know if there are ongoing requests once React renders the fallback in suspense */ export function Loader(props: LoaderProps) { - const { fallback, offlineFallbackProps, offlineFallback, ...loaderProps } = props - - const { getText } = textProvider.useText() - - const { isOffline } = offlineHooks.useOffline() - - const paused = reactQuery.useIsFetching({ fetchStatus: 'paused' }) - - const fetching = reactQuery.useIsFetching({ - predicate: (query) => - query.state.fetchStatus === 'fetching' || - query.state.status === 'pending' || - query.state.status === 'success', - }) - - // we use small debounce to avoid flickering when query is resolved, - // but fallback is still showing - const shouldDisplayOfflineMessage = debounceValue.useDebounceValue( - isOffline && paused >= 0 && fetching === 0, - OFFLINE_FETCHING_TOGGLE_DELAY_MS, - ) + const { fallback, ...loaderProps } = props - if (shouldDisplayOfflineMessage) { - return ( - offlineFallback ?? ( - - ) - ) - } else { - return fallback ?? - } + return fallback ?? } diff --git a/app/gui/src/dashboard/hooks/backendHooks.tsx b/app/gui/src/dashboard/hooks/backendHooks.tsx index fbed7fb15f63..786ff9415f8e 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.tsx +++ b/app/gui/src/dashboard/hooks/backendHooks.tsx @@ -335,6 +335,7 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { parentId, ) } catch (e) { + console.log(e) if (e instanceof Error) { throw Object.assign(e, { parentId }) } else { @@ -342,8 +343,6 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) { } } }, - - meta: { persist: false }, }) } diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index 87a10d4bb2ec..0f74045a4c3e 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -32,6 +32,7 @@ import { DirectoryDoesNotExistError, Plan } from '#/services/Backend' import AssetQuery from '#/utilities/AssetQuery' import * as download from '#/utilities/download' import * as github from '#/utilities/github' +import { OfflineError } from '#/utilities/HttpClient' import { tryFindSelfPermission } from '#/utilities/permissions' import * as tailwindMerge from '#/utilities/tailwindMerge' import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' @@ -58,7 +59,7 @@ const CATEGORIES_TO_DISPLAY_START_MODAL = ['cloud', 'local', 'local-directory'] /** Contains directory path and directory contents (projects, folders, secrets and files). */ function Drive(props: DriveProps) { - const { category, resetCategory } = props + const { category, resetCategory, setCategory } = props const { isOffline } = offlineHooks.useOffline() const toastAndLog = toastAndLogHooks.useToastAndLog() @@ -122,6 +123,18 @@ function Drive(props: DriveProps) { resetQueries() resetErrorBoundary() } + + if (error instanceof OfflineError) { + return ( + { + setCategory(nextCategory) + resetErrorBoundary() + }} + /> + ) + } }} > @@ -152,7 +165,6 @@ function DriveAssetsView(props: DriveProps) { const { user } = authProvider.useFullUserSession() const localBackend = backendProvider.useLocalBackend() const backend = backendProvider.useBackend(category) - const { getText } = textProvider.useText() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const [query, setQuery] = React.useState(() => AssetQuery.fromString('')) @@ -263,26 +275,7 @@ function DriveAssetsView(props: DriveProps) { {status === 'offline' ? - - {supportLocalBackend && ( - { - setCategory({ type: 'local' }) - }} - > - {getText('switchToLocal')} - - )} - + : + ) diff --git a/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx b/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx index b13dceac4314..e0abfddefacc 100644 --- a/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx +++ b/app/gui/src/dashboard/modules/payments/components/StripeProvider.tsx @@ -6,6 +6,7 @@ import * as React from 'react' +import { OfflineError } from '#/utilities/error' import * as stripeReact from '@stripe/react-stripe-js' import type * as stripeTypes from '@stripe/stripe-js' import * as stripe from '@stripe/stripe-js/pure' @@ -23,27 +24,37 @@ export interface StripeProviderRenderProps { readonly elements: stripeTypes.StripeElements } -export const stripeQuery = reactQuery.queryOptions({ - queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const, - staleTime: Infinity, - gcTime: Infinity, - meta: { persist: false }, - queryFn: async ({ queryKey }) => { - const stripeKey = queryKey[1] - - if (stripeKey == null) { - throw new Error('Stripe key not found') - } else { +/** + * Creates options for quering stripe instance + */ +export function stripeQueryOptions() { + return reactQuery.queryOptions({ + queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const, + staleTime: Infinity, + gcTime: Infinity, + meta: { persist: false }, + queryFn: async ({ queryKey }) => { + const isOnline = reactQuery.onlineManager.isOnline() + const stripeKey = queryKey[1] + + if (stripeKey == null) { + throw new Error('Stripe key not found') + } + + if (!isOnline) { + throw new OfflineError() + } + return stripe.loadStripe(stripeKey).then((maybeStripeInstance) => { if (maybeStripeInstance == null) { throw new Error('Stripe instance not found') - } else { - return maybeStripeInstance } + + return maybeStripeInstance }) - } - }, -}) + }, + }) +} /** A component that provides a Stripe context. */ export function StripeProvider(props: StripeProviderProps) { @@ -90,5 +101,5 @@ export function useStripe() { * @returns The Stripe instance. */ export function useStripeLoader() { - return reactQuery.useSuspenseQuery(stripeQuery) + return reactQuery.useSuspenseQuery(stripeQueryOptions()) } From 81ab291e5f439b7f651823254e4930295e9b15a5 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Sun, 22 Dec 2024 17:12:57 +0400 Subject: [PATCH 6/7] Improve messages --- app/common/src/text/english.json | 6 +- .../components/OfflineNotificationManager.tsx | 33 ++++---- .../layouts/Settings/SetupTwoFaForm.tsx | 77 +++++++++---------- app/gui/src/dashboard/layouts/UserBar.tsx | 26 ++++++- 4 files changed, 84 insertions(+), 58 deletions(-) diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index ef6a7d214603..47049202ad30 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -480,9 +480,9 @@ "hidePassword": "Hide password", "showPassword": "Show password", "copiedToClipboard": "Copied to clipboard", - "noResultsFound": "No results found.", - "youAreOffline": "You are offline.", - "cannotCreateAssetsHere": "You do not have the permissions to create assets here.", + "noResultsFound": "No results found", + "youAreOffline": "You are offline", + "cannotCreateAssetsHere": "You do not have the permissions to create assets here", "enableVersionChecker": "Enable Version Checker", "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "disableAnimations": "Disable animations", diff --git a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx index 5776459e34cc..3f2b4377aca6 100644 --- a/app/gui/src/dashboard/components/OfflineNotificationManager.tsx +++ b/app/gui/src/dashboard/components/OfflineNotificationManager.tsx @@ -32,21 +32,24 @@ export function OfflineNotificationManager(props: OfflineNotificationManagerProp const toastId = 'offline' const { getText } = textProvider.useText() - offlineHooks.useOfflineChange((isOffline) => { - toast.toast.dismiss(toastId) - - if (isOffline) { - toast.toast.info(getText('offlineToastMessage'), { - toastId, - hideProgressBar: true, - }) - } else { - toast.toast.info(getText('onlineToastMessage'), { - toastId, - hideProgressBar: true, - }) - } - }) + offlineHooks.useOfflineChange( + (isOffline) => { + toast.toast.dismiss(toastId) + + if (isOffline) { + toast.toast.info(getText('offlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } else { + toast.toast.info(getText('onlineToastMessage'), { + toastId, + hideProgressBar: true, + }) + } + }, + { triggerImmediate: false }, + ) return ( diff --git a/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx b/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx index 231afd5471ec..acea41d0e431 100644 --- a/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx +++ b/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx @@ -125,48 +125,47 @@ export function SetupTwoFaForm() { ) - } else { - return ( -
- z.object({ - enabled: z.boolean(), - display: z.string(), - /* eslint-disable-next-line @typescript-eslint/no-magic-numbers */ - otp: z.string().min(6).max(6), + } + return ( + + z.object({ + enabled: z.boolean(), + display: z.string(), + /* eslint-disable-next-line @typescript-eslint/no-magic-numbers */ + otp: z.string().min(6).max(6), + }) + } + defaultValues={{ enabled: false, display: 'QR' }} + onSubmit={async ({ enabled, otp }) => { + if (enabled) { + return cognito.verifyTotpToken(otp).then((res) => { + if (res.ok) { + return updateMFAPreferenceMutation.mutateAsync('TOTP') + } else { + throw res.val + } }) } - defaultValues={{ enabled: false, display: 'QR' }} - onSubmit={async ({ enabled, otp }) => { - if (enabled) { - return cognito.verifyTotpToken(otp).then((res) => { - if (res.ok) { - return updateMFAPreferenceMutation.mutateAsync('TOTP') - } else { - throw res.val - } - }) - } - }} - > - <> - + }} + > + <> + - - - - {(enabled) => enabled === true && } - - - - - - ) - } + + + + {(enabled) => enabled === true && } + + + + + + ) } /** Two Factor Authentication Setup Form. */ diff --git a/app/gui/src/dashboard/layouts/UserBar.tsx b/app/gui/src/dashboard/layouts/UserBar.tsx index 1fc32609c4ae..a41424396597 100644 --- a/app/gui/src/dashboard/layouts/UserBar.tsx +++ b/app/gui/src/dashboard/layouts/UserBar.tsx @@ -2,7 +2,8 @@ import { SUBSCRIBE_PATH } from '#/appUtils' import ChatIcon from '#/assets/chat.svg' import DefaultUserIcon from '#/assets/default_user.svg' -import { Button, DialogTrigger } from '#/components/AriaComponents' +import Offline from '#/assets/offline_filled.svg' +import { Button, DialogTrigger, Text } from '#/components/AriaComponents' import { PaywallDialogButton } from '#/components/Paywall' import FocusArea from '#/components/styled/FocusArea' import { usePaywall } from '#/hooks/billing' @@ -11,6 +12,9 @@ import InviteUsersModal from '#/modals/InviteUsersModal' import { useFullUserSession } from '#/providers/AuthProvider' import { useText } from '#/providers/TextProvider' import { Plan } from '#/services/Backend' +import { AnimatePresence, motion } from 'framer-motion' +import SvgMask from '../components/SvgMask' +import { useOffline } from '../hooks/offlineHooks' /** Whether the chat button should be visible. Temporarily disabled. */ const SHOULD_SHOW_CHAT_BUTTON: boolean = false @@ -35,6 +39,7 @@ export default function UserBar(props: UserBarProps) { const { user } = useFullUserSession() const { getText } = useText() const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan }) + const { isOffline } = useOffline() const shouldShowUpgradeButton = user.isOrganizationAdmin && user.plan !== Plan.enterprise && user.plan !== Plan.team @@ -55,6 +60,24 @@ export default function UserBar(props: UserBarProps) { className="flex h-[46px] shrink-0 cursor-default items-center gap-user-bar pl-icons-x pr-3" {...innerProps} > + + {isOffline && ( + + + + {getText('youAreOffline')} + + + )} + + {SHOULD_SHOW_CHAT_BUTTON && (