From f7dfc946a7fdcd571c2c724479ded201b6ee9e21 Mon Sep 17 00:00:00 2001 From: Mykhailo Danilenko Date: Mon, 6 Oct 2025 23:45:52 +0300 Subject: [PATCH 1/5] refactor axios interceptors for improved session handling and error diagnostics; enhance error capturing with detailed diagnostics data. --- OwnTube.tv/api/axiosInstance.ts | 51 +++++++++++++++---- OwnTube.tv/api/errorHandler.ts | 11 +++- OwnTube.tv/api/helpers.ts | 14 +++++ OwnTube.tv/components/CategoryView.tsx | 3 +- OwnTube.tv/components/ChannelView.tsx | 2 +- .../diagnostics/useCustomDiagnosticEvents.ts | 2 +- OwnTube.tv/hooks/useAppStateDiagnostics.ts | 2 +- OwnTube.tv/hooks/useAuthSessionSync.tsx | 11 ++-- OwnTube.tv/public/featured-instances.json5 | 1 + OwnTube.tv/screens/CategoriesScreen/index.tsx | 3 +- OwnTube.tv/screens/CategoryScreen/index.tsx | 13 ++++- .../screens/ChannelCategoryScreen/index.tsx | 13 ++++- OwnTube.tv/screens/ChannelsScreen/index.tsx | 29 ++++++----- OwnTube.tv/screens/Playlist/index.tsx | 14 +++-- .../components/PlaylistVideosView.tsx | 2 +- OwnTube.tv/screens/Playlists/index.tsx | 3 +- 16 files changed, 130 insertions(+), 44 deletions(-) diff --git a/OwnTube.tv/api/axiosInstance.ts b/OwnTube.tv/api/axiosInstance.ts index e55cd6b9..6a2e1686 100644 --- a/OwnTube.tv/api/axiosInstance.ts +++ b/OwnTube.tv/api/axiosInstance.ts @@ -60,7 +60,7 @@ axiosInstance.interceptors.request.use(async (config) => { const { session, updateSession } = useAuthSessionStore.getState(); - if (!backend || !session) return config; + if (!backend) return config; const { basePath, @@ -72,16 +72,21 @@ axiosInstance.interceptors.request.use(async (config) => { refreshTokenIssuedAt, refreshTokenExpiresIn, sessionExpired, - } = session; + } = session || {}; const now = Math.floor(Date.now() / 1000); - const accessIssued = parseISOToEpoch(accessTokenIssuedAt); - const accessValidUntil = accessIssued + accessTokenExpiresIn - 10; - const accessTokenValid = accessIssued <= now && now < accessValidUntil; - const refreshIssued = parseISOToEpoch(refreshTokenIssuedAt); - const refreshValidUntil = refreshIssued + refreshTokenExpiresIn - 10; - const refreshTokenValid = refreshIssued <= now && now < refreshValidUntil; + // Normalize issuedAt timestamps and expiresIn values to safe numbers. + const accessIssued = accessTokenIssuedAt ? parseISOToEpoch(accessTokenIssuedAt) : 0; + const accessExpiresInNum = Number(accessTokenExpiresIn ?? 0); + const accessValidUntil = accessIssued && accessExpiresInNum ? accessIssued + accessExpiresInNum - 10 : 0; + const accessTokenValid = accessIssued > 0 && accessExpiresInNum > 0 && accessIssued <= now && now < accessValidUntil; + + const refreshIssued = refreshTokenIssuedAt ? parseISOToEpoch(refreshTokenIssuedAt) : 0; + const refreshExpiresInNum = Number(refreshTokenExpiresIn ?? 0); + const refreshValidUntil = refreshIssued && refreshExpiresInNum ? refreshIssued + refreshExpiresInNum - 10 : 0; + const refreshTokenValid = + refreshIssued > 0 && refreshExpiresInNum > 0 && refreshIssued <= now && now < refreshValidUntil; const shouldAttachAccessToken = Boolean( session && @@ -95,9 +100,10 @@ axiosInstance.interceptors.request.use(async (config) => { config.headers.Authorization = `${tokenType} ${accessToken}`; } - const halfway = accessIssued + accessTokenExpiresIn * 0.5; + const halfway = accessIssued && accessExpiresInNum ? accessIssued + accessExpiresInNum * 0.5 : 0; if ((now > halfway && refreshToken && shouldAttachAccessToken) || (!accessTokenValid && refreshTokenValid)) { try { + if (!refreshToken) throw new Error("Missing refresh token"); const refreshed = await refreshAccessToken(backend, refreshToken); if (refreshed) { const parsed = parseAuthSessionData(refreshed, backend); @@ -111,7 +117,7 @@ axiosInstance.interceptors.request.use(async (config) => { } } - if (!accessTokenValid && !refreshTokenValid) { + if (!accessTokenValid && !refreshTokenValid && session) { await useAuthSessionStore.getState().updateSession(backend, { sessionExpired: true }); controller.abort("Session expired, aborting request"); postHogInstance.capture(CustomPostHogEvents.SessionExpired); @@ -121,6 +127,31 @@ axiosInstance.interceptors.request.use(async (config) => { return config; }); +// Capture 401 and prompt the user to login again +axiosInstance.interceptors.response.use( + (resp) => resp, + async (error) => { + try { + const status = error?.response?.status; + if (status === 401) { + const config = error?.config || {}; + const backend = config.baseURL?.replace("/api/v1", "").replace("https://", ""); + + if (typeof backend === "string" && backend.length > 0) { + console.info("Session expired, aborting request"); + await useAuthSessionStore.getState().updateSession(backend, { sessionExpired: true }); + } + + postHogInstance.capture(CustomPostHogEvents.SessionExpired); + } + } catch (e) { + console.error("Stale token handling frontend error:", e); + } + + return Promise.reject(error); + }, +); + export abstract class AxiosInstanceBasedApi { protected constructor(debugLogging: boolean = false) { this.attachToAxiosInstance(); diff --git a/OwnTube.tv/api/errorHandler.ts b/OwnTube.tv/api/errorHandler.ts index 828aebb0..591f5df6 100644 --- a/OwnTube.tv/api/errorHandler.ts +++ b/OwnTube.tv/api/errorHandler.ts @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import { OwnTubeError } from "./models"; import { postHogInstance } from "../diagnostics"; import { CustomPostHogExceptions } from "../diagnostics/constants"; +import { parseAxiosErrorDiagnosticsData } from "./helpers"; export function handleAxiosErrorWithRetry(error: unknown, target: string): Promise { const { message, response } = error as AxiosError; @@ -9,9 +10,15 @@ export function handleAxiosErrorWithRetry(error: unknown, target: string): Promi if (retryAfter) { console.info(`Too many requests. Retrying to fetch ${target} in ${retryAfter} seconds...`); - postHogInstance.captureException(error, { errorType: `${CustomPostHogExceptions.RateLimitError} (${target})` }); + postHogInstance.captureException(error, { + errorType: `${CustomPostHogExceptions.RateLimitError} (${target})`, + originalError: parseAxiosErrorDiagnosticsData(error as AxiosError), + }); } else { - postHogInstance.captureException(error, { errorType: `${CustomPostHogExceptions.HttpRequestError} (${target})` }); + postHogInstance.captureException(error, { + errorType: `${CustomPostHogExceptions.HttpRequestError} (${target})`, + originalError: parseAxiosErrorDiagnosticsData(error as AxiosError), + }); } return new Promise((_, reject) => { diff --git a/OwnTube.tv/api/helpers.ts b/OwnTube.tv/api/helpers.ts index ceceab7b..0a36ac94 100644 --- a/OwnTube.tv/api/helpers.ts +++ b/OwnTube.tv/api/helpers.ts @@ -1,6 +1,8 @@ import { GetVideosVideo, OwnTubeError } from "./models"; import { QUERY_KEYS } from "./constants"; import { UseQueryResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { JsonType } from "posthog-react-native/lib/posthog-core/src"; import { Video } from "@peertube/peertube-types"; const jsonPaths: Record = { @@ -37,6 +39,18 @@ export const combineCollectionQueryResults = ( data: result.filter((item) => item?.data?.isError || Number(item?.data?.total) > 0), isLoading: result.filter(({ isLoading }) => isLoading).length > 1, isError: result.length > 0 && result.every(({ data }) => data?.isError), + error: (result.filter(({ data }) => data?.isError)?.map(({ data }) => data?.error) as OwnTubeError[]) || null, + }; +}; + +export const parseAxiosErrorDiagnosticsData = (error?: AxiosError): JsonType => { + return { + code: error?.code || null, + requestUrl: `${error?.config?.baseURL}/${error?.config?.url}`, + method: error?.config?.method || null, + params: error?.config?.params || null, + timeout: error?.config?.timeout || null, + status: error?.status || null, }; }; diff --git a/OwnTube.tv/components/CategoryView.tsx b/OwnTube.tv/components/CategoryView.tsx index 4a544061..807bb581 100644 --- a/OwnTube.tv/components/CategoryView.tsx +++ b/OwnTube.tv/components/CategoryView.tsx @@ -26,13 +26,14 @@ export const CategoryView = ({ category }: CategoryViewProps) => { const { currentInstanceConfig } = useAppConfigContext(); const showHorizontalScrollableLists = currentInstanceConfig?.customizations?.homeUseHorizontalListsForMobilePortrait; - if (!data?.data?.length && !isLoading) { + if (!data?.data?.length && !isLoading && !isError) { return null; } return ( <> { const { currentInstanceConfig } = useAppConfigContext(); const showHorizontalScrollableLists = currentInstanceConfig?.customizations?.homeUseHorizontalListsForMobilePortrait; - if (!data?.data?.length && !isLoading) { + if (!data?.data?.length && !isLoading && !isError) { return null; } diff --git a/OwnTube.tv/diagnostics/useCustomDiagnosticEvents.ts b/OwnTube.tv/diagnostics/useCustomDiagnosticEvents.ts index 79bab0d5..9b44c30a 100644 --- a/OwnTube.tv/diagnostics/useCustomDiagnosticEvents.ts +++ b/OwnTube.tv/diagnostics/useCustomDiagnosticEvents.ts @@ -15,7 +15,7 @@ export const useCustomDiagnosticsEvents = () => { return; } - posthog.capture(event, properties as any); + posthog.capture(event, properties); }; const captureError = (error: unknown, errorType: CustomPostHogExceptions) => { diff --git a/OwnTube.tv/hooks/useAppStateDiagnostics.ts b/OwnTube.tv/hooks/useAppStateDiagnostics.ts index b73b785b..1af75188 100644 --- a/OwnTube.tv/hooks/useAppStateDiagnostics.ts +++ b/OwnTube.tv/hooks/useAppStateDiagnostics.ts @@ -38,7 +38,7 @@ export const useAppStateDiagnostics = () => { window?.removeEventListener("blur", handleBlur); } appStateSubscription.remove(); - outOfMemorySubscription.remove(); + outOfMemorySubscription?.remove(); }; }, []); }; diff --git a/OwnTube.tv/hooks/useAuthSessionSync.tsx b/OwnTube.tv/hooks/useAuthSessionSync.tsx index 78d7b9bf..5ccb242b 100644 --- a/OwnTube.tv/hooks/useAuthSessionSync.tsx +++ b/OwnTube.tv/hooks/useAuthSessionSync.tsx @@ -25,13 +25,18 @@ export const useAuthSessionSync = () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.myChannelSubscription] }); }, [session]); - useEffect(() => { + const handleCheckSessionExistence = async () => { if (backend) { - selectSession(backend); + await selectSession(backend); } else { clearSession(); } - setIsSessionDataLoaded(true); + }; + + useEffect(() => { + handleCheckSessionExistence().then(() => { + setIsSessionDataLoaded(true); + }); }, [backend]); return { isSessionDataLoaded }; diff --git a/OwnTube.tv/public/featured-instances.json5 b/OwnTube.tv/public/featured-instances.json5 index e6f43ec3..2db52484 100644 --- a/OwnTube.tv/public/featured-instances.json5 +++ b/OwnTube.tv/public/featured-instances.json5 @@ -129,6 +129,7 @@ hostname: "peertube2.cpy.re", logoUrl: "https://owntube-tv.github.io/web-client/logos/peertube2.cpy.re.png", customizations: { + loginWithUsernameAndPassword: true, menuExternalLinks: [ { label: "PeerTube Website", diff --git a/OwnTube.tv/screens/CategoriesScreen/index.tsx b/OwnTube.tv/screens/CategoriesScreen/index.tsx index 6564e24b..72bab74c 100644 --- a/OwnTube.tv/screens/CategoriesScreen/index.tsx +++ b/OwnTube.tv/screens/CategoriesScreen/index.tsx @@ -21,6 +21,7 @@ export const CategoriesScreen = () => { data, isLoading: isLoadingCategoriesCollection, isError: isCollectionError, + error: collectionError, } = useGetCategoriesCollectionQuery(categories); const { backend } = useLocalSearchParams(); const { t } = useTranslation(); @@ -39,7 +40,7 @@ export const CategoriesScreen = () => { } if (isError) { - const { title, description } = getErrorTextKeys(categoriesError); + const { title, description } = getErrorTextKeys(categoriesError || collectionError[0] || null); return ( { const queryClient = useQueryClient(); const { currentInstanceConfig } = useAppConfigContext(); const { category } = useLocalSearchParams(); - const { data: categories, isLoading: isLoadingCategories } = useGetCategoriesQuery({}); + const { data: categories, isLoading: isLoadingCategories, isError } = useGetCategoriesQuery({}); const { t } = useTranslation(); const { top } = usePageContentTopPadding(); useCustomFocusManager(); @@ -23,7 +23,14 @@ export const CategoryScreen = () => { return categories?.find(({ id }) => String(id) === category)?.name; }, [categories, category]); - const { fetchNextPage, data, hasNextPage, isLoading, isFetchingNextPage } = useInfiniteVideosQuery({ + const { + fetchNextPage, + data, + hasNextPage, + isLoading, + isFetchingNextPage, + isError: isVideosError, + } = useInfiniteVideosQuery({ uniqueQueryKey: QUERY_KEYS.categoryVideosView, queryParams: { categoryOneOf: [Number(category)] }, pageSize: currentInstanceConfig?.customizations?.showMoreSize, @@ -44,6 +51,8 @@ export const CategoryScreen = () => { return ( { const { isMobile } = useBreakpoints(); const { channel, category } = useLocalSearchParams(); const { data: channelInfo } = useGetChannelInfoQuery(channel); - const { data: categories } = useGetCategoriesQuery({}); - const { fetchNextPage, data, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteGetChannelVideosQuery({ + const { data: categories, isError } = useGetCategoriesQuery({}); + const { + fetchNextPage, + data, + hasNextPage, + isFetchingNextPage, + isLoading, + isError: isVideosError, + } = useInfiniteGetChannelVideosQuery({ channelHandle: channel, category: Number(category), uniqueQueryKey: "categoryView", @@ -56,6 +63,8 @@ export const ChannelCategoryScreen = () => { {categoryTitle} { data: channelSections, isLoading: isLoadingChannelsCollection, isError: isChannelsCollectionError, + error: channelsCollectionError, } = useGetChannelsCollectionQuery(channels?.map(({ name }) => name)); const isError = isChannelsError || isChannelsCollectionError; const isLoading = isLoadingChannels || isLoadingChannelsCollection; @@ -41,19 +42,6 @@ export const ChannelsScreen = () => { return ; } - if (isError) { - const { title, description } = getErrorTextKeys(channelsError); - - return ( - } - button={{ text: t("tryAgain"), action: refetchPageData }} - /> - ); - } - return channelSections?.map(({ data, isLoading, refetch }) => { const channelInfoSection = channels?.find(({ name }) => name === data?.id); @@ -73,7 +61,20 @@ export const ChannelsScreen = () => { /> ); }); - }, [isLoading, isLoadingChannels, channelSections, channels, backend]); + }, [isLoading, isLoadingChannels, channelSections, channels, backend, channelsError]); + + if (isError) { + const { title, description } = getErrorTextKeys(channelsError || channelsCollectionError[0]); + + return ( + } + button={{ text: t("tryAgain"), action: refetchPageData }} + /> + ); + } if (!channelSections.length) { return ; diff --git a/OwnTube.tv/screens/Playlist/index.tsx b/OwnTube.tv/screens/Playlist/index.tsx index f0bf41e9..84f8ae53 100644 --- a/OwnTube.tv/screens/Playlist/index.tsx +++ b/OwnTube.tv/screens/Playlist/index.tsx @@ -21,10 +21,14 @@ export const Playlist = () => { const { backend, playlist, channel } = useLocalSearchParams< RootStackParams[ROUTES.CHANNEL_PLAYLIST] & RootStackParams[ROUTES.PLAYLIST] >(); - const { fetchNextPage, data, hasNextPage, isLoading, isFetchingNextPage } = useInfiniteGetPlaylistVideosQuery( - Number(playlist), - currentInstanceConfig?.customizations?.showMoreSize, - ); + const { + fetchNextPage, + data, + hasNextPage, + isLoading, + isFetchingNextPage, + isError: isVideosError, + } = useInfiniteGetPlaylistVideosQuery(Number(playlist), currentInstanceConfig?.customizations?.showMoreSize); const { data: channelInfo } = useGetChannelInfoQuery(channel); const { data: playlistInfo, isLoading: isLoadingPlaylistInfo } = useGetPlaylistInfoQuery(Number(playlist)); const videos = useMemo(() => { @@ -54,6 +58,8 @@ export const Playlist = () => { linkHref={`https://${backend}/w/p/${playlistInfo?.uuid}`} /> { data: playlistSections, isLoading: isLoadingPlaylistVideos, isError: isCollectionError, + error: collectionError, } = useGetPlaylistsCollectionQuery(playlists?.data); const isShowAllButtonVisible = currentInstanceConfig?.customizations?.playlistsShowHiddenButton && !showHiddenPlaylists; @@ -50,7 +51,7 @@ export const Playlists = () => { } if (isError) { - const { title, description } = getErrorTextKeys(playlistsError); + const { title, description } = getErrorTextKeys(playlistsError || collectionError[0] || null); return ( Date: Tue, 7 Oct 2025 00:33:35 +0300 Subject: [PATCH 2/5] add small copy button test for error boundary --- OwnTube.tv/app/_layout.tsx | 125 ++++++++-------- .../DeviceCapabilities/DeviceCapabilities.tsx | 68 +++++++-- OwnTube.tv/components/ErrorBoundary.tsx | 7 +- OwnTube.tv/components/ErrorPage.tsx | 10 +- OwnTube.tv/components/shared/Button.tsx | 7 +- OwnTube.tv/locales/en.json | 4 +- OwnTube.tv/locales/ru.json | 4 +- OwnTube.tv/locales/sv.json | 4 +- OwnTube.tv/locales/uk.json | 4 +- OwnTube.tv/package-lock.json | 135 +++++++----------- OwnTube.tv/screens/ChannelsScreen/index.tsx | 2 +- 11 files changed, 198 insertions(+), 172 deletions(-) diff --git a/OwnTube.tv/app/_layout.tsx b/OwnTube.tv/app/_layout.tsx index 18d16c69..c61902ff 100644 --- a/OwnTube.tv/app/_layout.tsx +++ b/OwnTube.tv/app/_layout.tsx @@ -104,58 +104,55 @@ const RootStack = () => { <> - - renderAppHeader(props), - }} - backBehavior="history" - drawerContent={(props) => } - > - <> }} - /> - - <> }} - /> - - - - - - - - - - - - - , - }} + renderAppHeader(props), + }} + backBehavior="history" + drawerContent={(props) => } + > + <> }} /> - { - handleModalClose?.(); - toggleModal?.(false); - }} - isVisible={isModalOpen} - > - {modalContent} - - + + <> }} + /> + + + + + + + + + + + + + , + }} + /> + { + handleModalClose?.(); + toggleModal?.(false); + }} + isVisible={isModalOpen} + > + {modalContent} + ); @@ -192,18 +189,20 @@ export default function RootLayout() { return ( - - - - {isWeb && } - - - - - - - - + + + + + {isWeb && } + + + + + + + + + ); diff --git a/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx b/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx index 4f43ab90..34893c96 100644 --- a/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx +++ b/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx @@ -1,17 +1,17 @@ -import { Pressable, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { Typography } from "../Typography"; import * as Clipboard from "expo-clipboard"; import { useAppConfigContext } from "../../contexts"; import { useTheme } from "@react-navigation/native"; import { useTranslation } from "react-i18next"; -import { IcoMoonIcon } from "../IcoMoonIcon"; -import { borderRadius } from "../../theme"; +import { spacing } from "../../theme"; import { BuildInfo } from "../BuildInfo"; import build_info from "../../build-info.json"; import { useAuthSessionStore } from "../../store"; import { format } from "date-fns"; -import { useMemo } from "react"; +import { useMemo, useRef, useState, useEffect } from "react"; import { useGlobalSearchParams } from "expo-router"; +import { Button } from "../shared"; const CapabilityKeyValuePair = ({ label, value }: { label: string; value: string }) => { const { colors } = useTheme(); @@ -54,7 +54,38 @@ const DeviceCapabilities = () => { [session], ); + const timeoutRef = useRef | null>(null); + const [copyButtonText, setCopyButtonText] = useState(undefined); + const pressCountRef = useRef(0); + const pressResetRef = useRef | null>(null); + + const [shouldThrow, setShouldThrow] = useState(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (pressResetRef.current) clearTimeout(pressResetRef.current); + }; + }, []); + const handleCopyToClipboard = async () => { + pressCountRef.current = (pressCountRef.current || 0) + 1; + if (pressCountRef.current >= 5) { + pressCountRef.current = 0; + if (pressResetRef.current) { + clearTimeout(pressResetRef.current); + pressResetRef.current = null; + } + + setShouldThrow(new Error(t("pressedTooManyTimesError"))); + return; + } + if (pressResetRef.current) clearTimeout(pressResetRef.current); + pressResetRef.current = setTimeout(() => { + pressCountRef.current = 0; + pressResetRef.current = null; + }, 2000); + const buildInfo = process.env.EXPO_PUBLIC_HIDE_GIT_DETAILS ? { BUILD_TIMESTAMP: build_info.BUILD_TIMESTAMP } : build_info; @@ -70,6 +101,11 @@ const DeviceCapabilities = () => { }, }), ); + setCopyButtonText(t("copied")); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setCopyButtonText(undefined); + }, 3_000); }; const currentAuthText = useMemo(() => { @@ -86,15 +122,25 @@ const DeviceCapabilities = () => { }); }, [authInfo, t]); + if (shouldThrow) { + // This throw happens during render and will be caught by your ErrorBoundary + throw shouldThrow; + } + return ( {t("settingsPageDeviceCapabilityInfoHeading")} - - - +