From 0f0b0f9164fc6dc060be93ea45a724bf2d539564 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Fri, 6 Mar 2026 14:32:41 +0100 Subject: [PATCH] feat: navigation and folder listing in share extension --- src/navigation/RootNavigator.tsx | 4 +- .../hooks/useFolderNavigation.ts | 174 ++++++++++++++++++ .../hooks/useShareAuth.android.ts | 39 +++- .../hooks/useShareExtension.android.ts | 15 ++ .../hooks/useShareExtension.ios.ts | 42 +++++ .../services/shareDriveService.ts | 59 ++++++ src/shareExtension/utils.ts | 23 +++ 7 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 src/shareExtension/hooks/useFolderNavigation.ts create mode 100644 src/shareExtension/hooks/useShareExtension.android.ts create mode 100644 src/shareExtension/hooks/useShareExtension.ios.ts create mode 100644 src/shareExtension/services/shareDriveService.ts create mode 100644 src/shareExtension/utils.ts diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index ac893a37..78ec42c7 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -19,7 +19,7 @@ import { DeactivatedAccountScreen } from '../screens/DeactivatedAccountScreen'; import DebugScreen from '../screens/DebugScreen'; import { TrashScreen } from '../screens/common/TrashScreen'; import { DrivePreviewScreen } from '../screens/drive/DrivePreviewScreen'; -import AndroidShareScreen from '../shareExtension/AndroidShareScreen'; +import ShareExtensionView from '../shareExtension/ShareExtensionView.android'; import { useAndroidShareIntent } from '../shareExtension/useAndroidShareIntent'; const Stack = createNativeStackNavigator(); @@ -90,7 +90,7 @@ function AppNavigator({ navigationContainerRef }: Readonly): JSX.Element {Platform.OS === 'android' && ( )} diff --git a/src/shareExtension/hooks/useFolderNavigation.ts b/src/shareExtension/hooks/useFolderNavigation.ts new file mode 100644 index 00000000..cf220f09 --- /dev/null +++ b/src/shareExtension/hooks/useFolderNavigation.ts @@ -0,0 +1,174 @@ +import { DriveListViewMode } from '@internxt-mobile/types/drive/ui'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { shareDriveService } from '../services/shareDriveService'; +import { ShareFileItem, ShareFolderItem } from '../types'; + +interface FolderNavEntry { + uuid: string; + name: string; +} + +interface UseFolderNavigationResult { + currentFolder: FolderNavEntry; + folders: ShareFolderItem[]; + files: ShareFileItem[]; + loading: boolean; + loadingMore: boolean; + loadMore: () => void; + searchQuery: string; + setSearchQuery: (value: string) => void; + viewMode: DriveListViewMode; + setViewMode: (viewMode: DriveListViewMode) => void; + breadcrumb: FolderNavEntry[]; + navigate: (uuid: string, name: string) => void; + goBack: () => void; + refresh: () => Promise; + createFolder: (name: string) => Promise; +} + +const filterByName = (items: T[], query: string): T[] => { + if (!query) return items; + const queryLowerCase = query.toLowerCase(); + return items.filter((item) => item.plainName.toLowerCase().includes(queryLowerCase)); +}; + +export const useFolderNavigation = (rootFolderUuid: string, rootFolderName = 'Drive'): UseFolderNavigationResult => { + const [folderStack, setFolderStack] = useState([{ uuid: rootFolderUuid, name: rootFolderName }]); + const [allFolders, setAllFolders] = useState([]); + const [allFiles, setAllFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState(DriveListViewMode.List); + + const folderOffsetRef = useRef(0); + const fileOffsetRef = useRef(0); + const foldersExhaustedRef = useRef(false); + const filesExhaustedRef = useRef(false); + const isLoadingMoreRef = useRef(false); + const latestUuidRef = useRef(rootFolderUuid); + const loadSequentialRef = useRef(0); + + const currentFolder = folderStack[folderStack.length - 1]; + + const loadFolder = useCallback(async (folderUuid: string) => { + const seq = ++loadSequentialRef.current; + latestUuidRef.current = folderUuid; + isLoadingMoreRef.current = false; + folderOffsetRef.current = 0; + fileOffsetRef.current = 0; + foldersExhaustedRef.current = false; + filesExhaustedRef.current = false; + setLoading(true); + setLoadingMore(false); + setAllFolders([]); + setAllFiles([]); + + try { + const foldersPage = await shareDriveService.getFolderFolders(folderUuid, 0); + if (loadSequentialRef.current !== seq) return; + + setAllFolders(foldersPage.items); + folderOffsetRef.current = foldersPage.items.length; + + if (!foldersPage.hasMore) { + foldersExhaustedRef.current = true; + const filesPage = await shareDriveService.getFolderFiles(folderUuid, 0); + if (loadSequentialRef.current !== seq) return; + + setAllFiles(filesPage.items); + fileOffsetRef.current = filesPage.items.length; + if (!filesPage.hasMore) filesExhaustedRef.current = true; + } + } finally { + if (loadSequentialRef.current === seq) setLoading(false); + } + }, []); + + useEffect(() => { + loadFolder(currentFolder.uuid); + }, [currentFolder.uuid, loadFolder]); + + const loadMore = useCallback(async () => { + if (loading) return; + if (isLoadingMoreRef.current) return; + if (searchQuery) return; + if (foldersExhaustedRef.current && filesExhaustedRef.current) return; + + isLoadingMoreRef.current = true; + setLoadingMore(true); + const uuid = latestUuidRef.current; + const seq = loadSequentialRef.current; + + try { + if (!foldersExhaustedRef.current) { + const foldersPage = await shareDriveService.getFolderFolders(uuid, folderOffsetRef.current); + if (loadSequentialRef.current !== seq) return; + + setAllFolders((prev) => [...prev, ...foldersPage.items]); + folderOffsetRef.current += foldersPage.items.length; + + if (!foldersPage.hasMore) { + foldersExhaustedRef.current = true; + const filesPage = await shareDriveService.getFolderFiles(uuid, 0); + if (loadSequentialRef.current !== seq) return; + + setAllFiles((prev) => [...prev, ...filesPage.items]); + fileOffsetRef.current = filesPage.items.length; + if (!filesPage.hasMore) filesExhaustedRef.current = true; + } + } else { + const filesPage = await shareDriveService.getFolderFiles(uuid, fileOffsetRef.current); + if (loadSequentialRef.current !== seq) return; + + setAllFiles((prev) => [...prev, ...filesPage.items]); + fileOffsetRef.current += filesPage.items.length; + if (!filesPage.hasMore) filesExhaustedRef.current = true; + } + } finally { + if (loadSequentialRef.current === seq) setLoadingMore(false); + isLoadingMoreRef.current = false; + } + }, [loading, searchQuery]); + + const navigateToFolder = useCallback((uuid: string, name: string) => { + setSearchQuery(''); + setFolderStack((prev) => [...prev, { uuid, name }]); + }, []); + + const goBack = useCallback(() => { + setSearchQuery(''); + setFolderStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)); + }, []); + + const refresh = useCallback(() => loadFolder(currentFolder.uuid), [currentFolder.uuid, loadFolder]); + + const createFolder = useCallback( + async (name: string) => { + await shareDriveService.createFolder(currentFolder.uuid, name); + await loadFolder(currentFolder.uuid); + }, + [currentFolder.uuid, loadFolder], + ); + + const folders = useMemo(() => filterByName(allFolders, searchQuery), [allFolders, searchQuery]); + const files = useMemo(() => filterByName(allFiles, searchQuery), [allFiles, searchQuery]); + + return { + currentFolder, + folders, + files, + loading, + loadingMore, + loadMore, + searchQuery, + setSearchQuery, + viewMode, + setViewMode, + breadcrumb: folderStack, + navigate: navigateToFolder, + goBack, + refresh, + createFolder, + }; +}; diff --git a/src/shareExtension/hooks/useShareAuth.android.ts b/src/shareExtension/hooks/useShareAuth.android.ts index faffed47..70d2a874 100644 --- a/src/shareExtension/hooks/useShareAuth.android.ts +++ b/src/shareExtension/hooks/useShareAuth.android.ts @@ -4,15 +4,40 @@ import { AsyncStorageKey } from '../../types'; export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; -export const useShareAuth = (): AuthStatus => { - const [status, setStatus] = useState('loading'); +export type ShareAuthData = { + status: AuthStatus; + photosToken: string | null; + mnemonic: string | null; + rootFolderUuid: string | null; +}; + +export const useShareAuth = (): ShareAuthData => { + const [data, setData] = useState({ + status: 'loading', + photosToken: null, + mnemonic: null, + rootFolderUuid: null, + }); useEffect(() => { - asyncStorageService - .getItem(AsyncStorageKey.PhotosToken) - .then((token) => setStatus(token ? 'authenticated' : 'unauthenticated')) - .catch(() => setStatus('unauthenticated')); + Promise.all([asyncStorageService.getItem(AsyncStorageKey.PhotosToken), asyncStorageService.getUser()]) + .then(([photosToken, user]) => { + setData({ + status: photosToken ? 'authenticated' : 'unauthenticated', + photosToken, + mnemonic: user?.mnemonic ?? null, + rootFolderUuid: user?.rootFolderId ?? null, + }); + }) + .catch(() => + setData({ + status: 'unauthenticated', + photosToken: null, + mnemonic: null, + rootFolderUuid: null, + }), + ); }, []); - return status; + return data; }; diff --git a/src/shareExtension/hooks/useShareExtension.android.ts b/src/shareExtension/hooks/useShareExtension.android.ts new file mode 100644 index 00000000..cf43575b --- /dev/null +++ b/src/shareExtension/hooks/useShareExtension.android.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { SharedFile } from '../types'; +import { readSize } from '../utils'; +import { useShareAuth } from './useShareAuth.android'; + +export const useShareExtension = (rawFiles: SharedFile[]) => { + const { status, rootFolderUuid } = useShareAuth(); + + const sharedFiles = useMemo( + () => rawFiles.map((file) => ({ ...file, size: file.size ?? readSize(file.uri) })), + [rawFiles], + ); + + return { status, rootFolderUuid, sharedFiles }; +}; diff --git a/src/shareExtension/hooks/useShareExtension.ios.ts b/src/shareExtension/hooks/useShareExtension.ios.ts new file mode 100644 index 00000000..593ec6eb --- /dev/null +++ b/src/shareExtension/hooks/useShareExtension.ios.ts @@ -0,0 +1,42 @@ +import { useEffect, useMemo, useState } from 'react'; +import { SdkManager } from '../../services/common/sdk/SdkManager'; +import { SharedFile } from '../types'; +import { readSize } from '../utils'; + +interface ShareExtensionInput { + photosToken?: string; + files?: string[]; + images?: string[]; + videos?: string[]; +} + +export const useShareExtension = ({ photosToken, files, images, videos }: ShareExtensionInput) => { + const [sdkReady, setSdkReady] = useState(false); + + useEffect(() => { + if (!photosToken) return; + SdkManager.init({ token: photosToken, newToken: photosToken }); + setSdkReady(true); + }, [photosToken]); + + const sharedFiles = useMemo( + () => [ + ...(files ?? []).map((uri) => ({ uri, mimeType: null, fileName: uri.split('/').pop() ?? null, size: readSize(uri) })), + ...(images ?? []).map((uri) => ({ + uri, + mimeType: 'image/jpeg', + fileName: uri.split('/').pop() ?? null, + size: readSize(uri), + })), + ...(videos ?? []).map((uri) => ({ + uri, + mimeType: 'video/mp4', + fileName: uri.split('/').pop() ?? null, + size: readSize(uri), + })), + ], + [files, images, videos], + ); + + return { sdkReady, sharedFiles }; +}; diff --git a/src/shareExtension/services/shareDriveService.ts b/src/shareExtension/services/shareDriveService.ts new file mode 100644 index 00000000..e943414b --- /dev/null +++ b/src/shareExtension/services/shareDriveService.ts @@ -0,0 +1,59 @@ +import { SdkManager } from '../../services/common/sdk/SdkManager'; +import { ShareFileItem, ShareFolderItem } from '../types'; + +const PAGE_SIZE = 50; + +const mapFolder = (folder: { uuid: string; plainName: string; updatedAt: string }): ShareFolderItem => ({ + uuid: folder.uuid, + plainName: folder.plainName, + updatedAt: folder.updatedAt, +}); + +const mapFile = (file: { + uuid: string; + plainName: string; + size: string; + type?: string | null; + updatedAt: string; +}): ShareFileItem => ({ + uuid: file.uuid, + plainName: file.plainName, + size: file.size, + type: file.type ?? '', + updatedAt: file.updatedAt, +}); + +const getFolderFolders = async ( + folderUuid: string, + offset: number, +): Promise<{ items: ShareFolderItem[]; hasMore: boolean }> => { + const sdk = SdkManager.getInstance(); + const [promise] = sdk.storageV2.getFolderFoldersByUuid(folderUuid, offset, PAGE_SIZE, 'plainName', 'ASC'); + const result = await promise; + return { + items: result.folders.map(mapFolder), + hasMore: result.folders.length >= PAGE_SIZE, + }; +}; + +const getFolderFiles = async ( + folderUuid: string, + offset: number, +): Promise<{ items: ShareFileItem[]; hasMore: boolean }> => { + const sdk = SdkManager.getInstance(); + const [promise] = sdk.storageV2.getFolderFilesByUuid(folderUuid, offset, PAGE_SIZE, 'plainName', 'ASC'); + const result = await promise; + return { + items: result.files.map(mapFile), + hasMore: result.files.length >= PAGE_SIZE, + }; +}; + +const createFolder = async (parentFolderUuid: string, name: string): Promise => { + const sdk = SdkManager.getInstance(); + const result = sdk.storageV2.createFolderByUuid({ parentFolderUuid, plainName: name }); + if (!result) throw new Error('createFolder failed'); + await result[0]; +}; + +export const shareDriveService = { getFolderFolders, getFolderFiles, createFolder }; diff --git a/src/shareExtension/utils.ts b/src/shareExtension/utils.ts new file mode 100644 index 00000000..c26a2788 --- /dev/null +++ b/src/shareExtension/utils.ts @@ -0,0 +1,23 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { File as FSFile } from 'expo-file-system'; +import prettysize from 'prettysize'; + +dayjs.extend(relativeTime); + +export const formatDate = (dateStr: string): string => { + const date = dayjs(dateStr); + const weekDays = 7; + return dayjs().diff(date, 'day') < weekDays ? date.fromNow() : date.toDate().toLocaleDateString(); +}; + +export const readSize = (uri: string): number | null => { + try { + const file = new FSFile(uri); + return file.exists ? file.size : null; + } catch { + return null; + } +}; + +export const formatBytes = (bytes: number): string => prettysize(bytes);