diff --git a/app.config.ts b/app.config.ts
index 6005db89a..8009a16c0 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -151,8 +151,7 @@ const appConfig: ExpoConfig & { extra: AppEnv & { NODE_ENV: AppStage; RELEASE_ID
'react-native-create-thumbnail',
'jail-monkey',
],
- backgroundColor: { red: 255, green: 255, blue: 255, alpha: 1 },
- height: 700,
+ backgroundColor: { red: 0, green: 0, blue: 0, alpha: 0 },
},
],
],
diff --git a/assets/icons/stacked-files.svg b/assets/icons/stacked-files.svg
new file mode 100644
index 000000000..06ab0c64d
--- /dev/null
+++ b/assets/icons/stacked-files.svg
@@ -0,0 +1,14 @@
+
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index 6962db664..2d2593951 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -329,6 +329,19 @@ const translations = {
subtitle: 'Log in to upload files securely',
openLogin: 'Open Internxt login',
},
+ multipleFormats: 'Multiple formats',
+ fileNameFallback: 'File',
+ rename: 'Rename',
+ itemsSelected: '{0} items selected',
+ searchPlaceholder: 'Search in Drive',
+ rootFolderName: 'Drive',
+ noResults: 'No results found',
+ emptyFolder: 'This folder is empty',
+ sortByName: 'Name ↑',
+ folderNameLabel: 'Name',
+ folderNamePlaceholder: 'Folder name',
+ folderNameEmpty: 'Folder name cannot be empty',
+ folderCreateError: 'Failed to create folder. Please try again.',
},
},
buttons: {
@@ -1141,6 +1154,19 @@ const translations = {
subtitle: 'Inicia sesión para subir archivos de forma segura',
openLogin: 'Abrir login de Internxt',
},
+ multipleFormats: 'Múltiples formatos',
+ fileNameFallback: 'Archivo',
+ rename: 'Renombrar',
+ itemsSelected: '{0} elementos seleccionados',
+ searchPlaceholder: 'Buscar en Drive',
+ rootFolderName: 'Drive',
+ noResults: 'Sin resultados',
+ emptyFolder: 'Esta carpeta está vacía',
+ sortByName: 'Nombre ↑',
+ folderNameLabel: 'Nombre',
+ folderNamePlaceholder: 'Nombre de carpeta',
+ folderNameEmpty: 'El nombre no puede estar vacío',
+ folderCreateError: 'Error al crear la carpeta. Inténtalo de nuevo.',
},
},
buttons: {
diff --git a/index.share.js b/index.share.js
index 301ebbfec..4b7880070 100644
--- a/index.share.js
+++ b/index.share.js
@@ -1,4 +1,10 @@
+import { createElement } from 'react';
import { AppRegistry } from 'react-native';
-import ShareExtensionApp from './src/shareExtension/ShareExtensionApp';
+import { TailwindProvider } from 'tailwind-rn';
+import ShareExtensionView from './src/shareExtension/ShareExtensionView.ios';
+import utilities from './src/styles/tailwind.json';
-AppRegistry.registerComponent('shareExtension', () => ShareExtensionApp);
+AppRegistry.registerComponent(
+ 'shareExtension',
+ () => (props) => createElement(TailwindProvider, { utilities }, createElement(ShareExtensionView, props)),
+);
diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift
index 0ec1e60c0..6bd807c8b 100644
--- a/ios/Internxt/AppDelegate.swift
+++ b/ios/Internxt/AppDelegate.swift
@@ -44,37 +44,24 @@ public class AppDelegate: ExpoAppDelegate {
// MARK: - App Group auth sync
private func syncAuthStatusToAppGroup() {
- guard let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String,
- let defaults = UserDefaults(suiteName: appGroup),
- let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String
+ guard let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String
else { return }
let isAuthenticated = privateKeychainItemExists(key: "photosToken")
- defaults.set(isAuthenticated, forKey: "isAuthenticated")
if isAuthenticated {
- copyToSharedKeychain(privateKey: "photosToken", sharedKey: "shared_photosToken", accessGroup: sharedGroup)
- copyToSharedKeychain(privateKey: "xUser_mnemonic", sharedKey: "shared_mnemonic", accessGroup: sharedGroup)
- defaults.set(readEmailFromKeychain(), forKey: "userEmail")
+ copyToSharedKeychain(privateKey: "photosToken", sharedKey: "shared_photosToken", accessGroup: sharedGroup)
+ copyToSharedKeychain(privateKey: "xUser_mnemonic", sharedKey: "shared_mnemonic", accessGroup: sharedGroup)
+ copyToSharedKeychain(privateKey: "xUser_rootFolderId", sharedKey: "shared_rootFolderId", accessGroup: sharedGroup)
+ copyToSharedKeychain(privateKey: "xUser_bucket", sharedKey: "shared_bucket", accessGroup: sharedGroup)
} else {
- deleteFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
- deleteFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup)
- defaults.removeObject(forKey: "userEmail")
+ deleteFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
+ deleteFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup)
+ deleteFromSharedKeychain(key: "shared_rootFolderId", accessGroup: sharedGroup)
+ deleteFromSharedKeychain(key: "shared_bucket", accessGroup: sharedGroup)
}
}
- private func readEmailFromKeychain() -> String? {
- guard let data = readFromPrivateKeychain(key: "xUser_data"),
- var raw = String(data: data, encoding: .utf8) else { return nil }
- if raw.hasPrefix("\"") && raw.hasSuffix("\"") {
- raw = String(raw.dropFirst().dropLast())
- }
- guard let jsonData = raw.data(using: .utf8),
- let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
- let email = json["email"] as? String else { return nil }
- return email
- }
-
private func privateKeychainItemExists(key: String) -> Bool {
return readFromPrivateKeychain(key: key) != nil
}
diff --git a/ios/InternxtShareExtension/ShareExtensionViewController.swift b/ios/InternxtShareExtension/ShareExtensionViewController.swift
index 4958ed645..ad235979a 100644
--- a/ios/InternxtShareExtension/ShareExtensionViewController.swift
+++ b/ios/InternxtShareExtension/ShareExtensionViewController.swift
@@ -64,6 +64,7 @@ class ShareExtensionViewController: UIViewController {
super.viewDidLoad()
setupLoadingIndicator()
isCleanedUp = false
+ self.view.backgroundColor = .clear
self.view.contentScaleFactor = UIScreen.main.scale
#if canImport(FirebaseCore)
@@ -96,21 +97,12 @@ class ShareExtensionViewController: UIViewController {
reactNativeFactory = RCTReactNativeFactory(delegate: reactNativeFactoryDelegate!)
var initialProps = sharedData ?? [:]
- // ── Internxt: inject auth state from UserDefaults and Keychain ──────────
- if let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String,
- let defaults = UserDefaults(suiteName: appGroup) {
- initialProps["isAuthenticated"] = defaults.bool(forKey: "isAuthenticated")
- initialProps["userEmail"] = defaults.string(forKey: "userEmail")
- }
-
+ // ── Internxt: inject auth state from Keychain ───────────────────────────
if let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String {
- initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
- if let raw = readFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup) {
- // Strip JSON string encoding added by expo-secure-store: '"words"' → 'words'
- initialProps["mnemonic"] = (raw.hasPrefix("\"") && raw.hasSuffix("\""))
- ? String(raw.dropFirst().dropLast())
- : raw
- }
+ initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
+ initialProps["mnemonic"] = readFromSharedKeychainStripped(key: "shared_mnemonic", accessGroup: sharedGroup)
+ initialProps["rootFolderId"] = readFromSharedKeychainStripped(key: "shared_rootFolderId", accessGroup: sharedGroup)
+ initialProps["bucket"] = readFromSharedKeychainStripped(key: "shared_bucket", accessGroup: sharedGroup)
}
// ── From expo-share-extension library ──────────────────────────────────
let currentBounds = self.view.bounds
@@ -275,6 +267,15 @@ class ShareExtensionViewController: UIViewController {
let value = String(data: data, encoding: .utf8) else { return nil }
return value
}
+
+ /// Reads a shared-keychain entry and strips surrounding JSON quotes if present.
+ private func readFromSharedKeychainStripped(key: String, accessGroup: String) -> String? {
+ guard let raw = readFromSharedKeychain(key: key, accessGroup: accessGroup) else { return nil }
+ if raw.hasPrefix("\"") && raw.hasSuffix("\"") {
+ return String(raw.dropFirst().dropLast())
+ }
+ return raw
+ }
// ─────────────────────────────────────────────────────────────────────────
// ── From expo-share-extension library ─────────────────────────────────────
diff --git a/package.json b/package.json
index 8ad3bd39e..be0aa0110 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"axios": "^1.13.5",
"base-64": "^1.0.0",
"crypto-js": "=3.1.9-1",
+ "dayjs": "^1.11.19",
"events": "^3.3.0",
"expo": "^54",
"expo-asset": "~12.0.12",
diff --git a/src/App.tsx b/src/App.tsx
index f0bbb4214..5a3fa42ef 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -125,6 +125,7 @@ function AppContent(): JSX.Element {
if (needsMigration) {
await asyncStorageService.migrateToSecureStorage();
}
+ await asyncStorageService.migrateShareExtensionCriticalFields();
};
useEffect(() => {
diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts
index 1883ba9b1..00bdfd846 100644
--- a/src/services/AsyncStorageService.ts
+++ b/src/services/AsyncStorageService.ts
@@ -185,6 +185,17 @@ class AsyncStorageService {
}
}
+ async migrateShareExtensionCriticalFields(): Promise {
+ const alreadyMigrated = await secureStorageService.hasItem('xUser_rootFolderId');
+ if (alreadyMigrated) return;
+
+ const user = await this.getUser();
+ if (!user?.rootFolderId) return;
+
+ await this.saveItem(AsyncStorageKey.User, JSON.stringify(user));
+ logger.info('Share extension critical fields migrated (rootFolderId, bucket)');
+ }
+
async checkNeedsMigration(): Promise<{ needsMigration: boolean; itemsToMigrate: string[] }> {
const itemsToMigrate: string[] = [];
diff --git a/src/services/SecureStorageService.ts b/src/services/SecureStorageService.ts
index 2ff6fb9af..b4f8d6927 100644
--- a/src/services/SecureStorageService.ts
+++ b/src/services/SecureStorageService.ts
@@ -3,7 +3,16 @@ import * as SecureStore from 'expo-secure-store';
import { AsyncStorageKey } from '../types';
import { UserData, UserKeysHandler } from './UserKeysHandler';
-const CRITICAL_USER_FIELDS = ['mnemonic', 'privateKey', 'publicKey', 'keys', 'revocationKey', 'revocateKey'];
+const CRITICAL_USER_FIELDS = [
+ 'mnemonic',
+ 'privateKey',
+ 'publicKey',
+ 'keys',
+ 'revocationKey',
+ 'revocateKey',
+ 'rootFolderId',
+ 'bucket',
+];
class SecureStorageService {
private readonly MAX_CHUNK_SIZE = 1800;
diff --git a/src/shareExtension/AndroidShareScreen.tsx b/src/shareExtension/AndroidShareScreen.tsx
deleted file mode 100644
index 117c30863..000000000
--- a/src/shareExtension/AndroidShareScreen.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { useEffect, useState } from 'react';
-import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
-import { useTailwind } from 'tailwind-rn';
-import strings from '../../assets/lang/strings';
-import asyncStorageService from '../services/AsyncStorageService';
-import { AsyncStorageKey } from '../types';
-import { RootStackScreenProps } from '../types/navigation';
-import { useShareAuth } from './hooks/useShareAuth.android';
-import { NotSignedInScreen } from './screens/NotSignedInScreen';
-
-interface DebugInfo {
- userEmail: string | null;
- photosToken: string | null;
- mnemonic: string | null;
-}
-
-const AndroidShareScreen = ({ navigation, route }: RootStackScreenProps<'AndroidShare'>) => {
- const tailwind = useTailwind();
- const authStatus = useShareAuth();
- const [debug, setDebug] = useState({ userEmail: null, photosToken: null, mnemonic: null });
-
- useEffect(() => {
- if (authStatus !== 'authenticated') return;
- Promise.all([asyncStorageService.getUser(), asyncStorageService.getItem(AsyncStorageKey.PhotosToken)]).then(
- ([user, photosToken]) => {
- setDebug({
- userEmail: user?.email ?? null,
- photosToken,
- mnemonic: user?.mnemonic ?? null,
- });
- },
- );
- }, [authStatus]);
-
- if (authStatus === 'loading') {
- return (
-
-
-
- );
- }
-
- if (authStatus === 'unauthenticated') {
- return navigation.goBack()} onOpenLogin={() => navigation.navigate('SignIn')} />;
- }
-
- const translations = strings.screens.ShareExtension;
- const files = route.params?.files ?? [];
-
- return (
-
-
- navigation.goBack()} style={tailwind('w-8 h-8 items-center justify-center')}>
- ✕
-
- {translations.title}
-
-
-
- {debug.userEmail ? (
-
- Signed in as
- {debug.userEmail}
-
- ) : null}
- {debug.photosToken ? (
-
- Signed new token
- {debug.photosToken}
-
- ) : null}
- {debug.mnemonic ? (
-
- Signed in with mnemonic
- {debug.mnemonic}
-
- ) : null}
-
-
- {files.map((file) => {
- const name = file.fileName ?? file.uri.split('/').pop() ?? file.uri;
- return (
-
-
- {name}
-
-
- );
- })}
-
-
- );
-};
-
-const styles = StyleSheet.create({
- semibold: { fontWeight: '600' },
- blueSection: {
- backgroundColor: 'rgba(0,102,255,0.05)',
- borderBottomWidth: 1,
- borderBottomColor: 'rgba(0,102,255,0.2)',
- },
-});
-
-export default AndroidShareScreen;
diff --git a/src/shareExtension/ShareExtensionApp.tsx b/src/shareExtension/ShareExtensionApp.tsx
deleted file mode 100644
index f8c6c0c74..000000000
--- a/src/shareExtension/ShareExtensionApp.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { close, openHostApp } from 'expo-share-extension';
-import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
-import strings from '../../assets/lang/strings';
-import { NotSignedInScreen } from './screens/NotSignedInScreen';
-
-interface ShareExtensionProps {
- isAuthenticated?: boolean;
- userEmail?: string;
- photosToken?: string;
- mnemonic?: string;
- files?: string[];
- images?: string[];
- videos?: string[];
- url?: string;
- text?: string;
-}
-
-const ShareExtensionApp = (props: ShareExtensionProps) => {
- const translations = strings.screens.ShareExtension;
-
- if (!props.isAuthenticated) {
- return openHostApp('sign-in')} />;
- }
-
- const allFiles = [...(props.files ?? []), ...(props.images ?? []), ...(props.videos ?? [])];
-
- return (
-
-
-
- ✕
-
- {translations.title}
-
-
- {props.userEmail ? (
-
- Signed in as
- {props.userEmail}
-
- ) : null}
- {props.photosToken ? (
-
- Signed new token
- {props.photosToken}
-
- ) : null}
- {props.mnemonic ? (
-
- Signed in with mnemonic
- {props.mnemonic}
-
- ) : null}
-
- {allFiles.map((filePath) => {
- const name = filePath.split('/').pop() ?? filePath;
- return (
-
-
- {name}
-
-
- );
- })}
- {props.url ? (
-
-
- {props.url}
-
-
- ) : null}
- {props.text ? (
-
-
- {props.text}
-
-
- ) : null}
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#ffffff',
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 16,
- paddingTop: 16,
- paddingBottom: 12,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#e5e7eb',
- },
- closeBtn: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- },
- closeText: {
- fontSize: 18,
- color: '#6b7280',
- },
- title: {
- fontSize: 16,
- fontWeight: '600',
- color: '#1a1a1a',
- },
- content: {
- padding: 16,
- gap: 8,
- },
- fileRow: {
- backgroundColor: '#f9fafb',
- borderRadius: 8,
- padding: 12,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: '#e5e7eb',
- },
- fileName: {
- fontSize: 14,
- color: '#374151',
- },
- sessionBanner: {
- backgroundColor: '#f0f7ff',
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#bfdbfe',
- paddingHorizontal: 16,
- paddingVertical: 10,
- },
- sessionLabel: {
- fontSize: 11,
- color: '#6b7280',
- marginBottom: 2,
- },
- sessionEmail: {
- fontSize: 13,
- fontWeight: '600',
- color: '#0066FF',
- },
-});
-
-export default ShareExtensionApp;
diff --git a/src/shareExtension/ShareExtensionView.android.tsx b/src/shareExtension/ShareExtensionView.android.tsx
new file mode 100644
index 000000000..c0c59c72a
--- /dev/null
+++ b/src/shareExtension/ShareExtensionView.android.tsx
@@ -0,0 +1,51 @@
+import { useCallback } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useTailwind } from 'tailwind-rn';
+import { RootStackScreenProps } from '../types/navigation';
+import { useShareExtension } from './hooks/useShareExtension.android';
+import { DriveScreen } from './screens/DriveScreen';
+import { NotSignedInScreen } from './screens/NotSignedInScreen';
+import { colors } from './theme';
+
+const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'AndroidShare'>) => {
+ const tailwind = useTailwind();
+ const { status, rootFolderUuid, sharedFiles } = useShareExtension(route.params?.files ?? []);
+
+ const handleClose = useCallback(() => navigation.goBack(), [navigation]);
+ const handleOpenLogin = useCallback(() => navigation.navigate('SignIn'), [navigation]);
+ const handleSave = useCallback(() => {
+ //upload logic
+ }, []);
+
+ if (status === 'unauthenticated') {
+ return (
+
+
+
+ );
+ }
+
+ if (status === 'loading' || !rootFolderUuid) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default ShareExtensionView;
diff --git a/src/shareExtension/ShareExtensionView.ios.tsx b/src/shareExtension/ShareExtensionView.ios.tsx
new file mode 100644
index 000000000..1dbc7ed36
--- /dev/null
+++ b/src/shareExtension/ShareExtensionView.ios.tsx
@@ -0,0 +1,51 @@
+import { close, openHostApp } from 'expo-share-extension';
+import { ActivityIndicator, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import { useShareExtension } from './hooks/useShareExtension.ios';
+import { DriveScreen } from './screens/DriveScreen';
+import { NotSignedInScreen } from './screens/NotSignedInScreen';
+import { colors } from './theme';
+
+interface ShareExtensionProps {
+ photosToken?: string;
+ mnemonic?: string;
+ rootFolderId?: string;
+ bucket?: string;
+ files?: string[];
+ images?: string[];
+ videos?: string[];
+ url?: string;
+ text?: string;
+}
+
+const ShareExtensionView = (props: ShareExtensionProps) => {
+ const tailwind = useTailwind();
+ const { sdkReady, sharedFiles } = useShareExtension(props);
+
+ const handleSave = () => {
+ // Upload logic
+ };
+
+ if (!props.photosToken) {
+ return openHostApp('sign-in')} />;
+ }
+
+ if (!sdkReady || !props.rootFolderId) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ShareExtensionView;
diff --git a/src/shareExtension/components/DriveHeader.tsx b/src/shareExtension/components/DriveHeader.tsx
new file mode 100644
index 000000000..0ddd89277
--- /dev/null
+++ b/src/shareExtension/components/DriveHeader.tsx
@@ -0,0 +1,39 @@
+import { XIcon } from 'phosphor-react-native';
+import { Text, TouchableOpacity, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../assets/lang/strings';
+import { colors, fontStyles } from '../theme';
+
+interface DriveHeaderProps {
+ onClose: () => void;
+ onSave: () => void;
+ saveEnabled: boolean;
+}
+
+export const DriveHeader = ({ onClose, onSave, saveEnabled }: DriveHeaderProps) => {
+ const tailwind = useTailwind();
+
+ return (
+
+
+
+
+
+
+ {strings.screens.ShareExtension.title}
+
+
+
+ {strings.buttons.save}
+
+
+ );
+};
diff --git a/src/shareExtension/components/DriveScreen/DriveList.tsx b/src/shareExtension/components/DriveScreen/DriveList.tsx
new file mode 100644
index 000000000..c423ecf63
--- /dev/null
+++ b/src/shareExtension/components/DriveScreen/DriveList.tsx
@@ -0,0 +1,85 @@
+import { DriveListViewMode } from '@internxt-mobile/types/drive/ui';
+import { useCallback } from 'react';
+import { FlatList, Keyboard, Text, View } from 'react-native';
+import DriveItemSkinSkeleton from 'src/components/DriveItemSkinSkeleton';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../../assets/lang/strings';
+import { fontStyles } from '../../theme';
+import { ShareFileItem, ShareFolderItem } from '../../types';
+import { FileListItem } from '../FileListItem';
+
+export type DriveListItem = { type: 'folder'; data: ShareFolderItem } | { type: 'file'; data: ShareFileItem };
+
+const keyExtractor = (item: DriveListItem) => item.data.uuid;
+
+interface DriveListProps {
+ listData: DriveListItem[];
+ viewMode: DriveListViewMode;
+ loading: boolean;
+ loadingMore: boolean;
+ searchQuery: string;
+ onNavigate: (uuid: string, name: string) => void;
+ onLoadMore: () => void;
+}
+
+export const DriveList = ({
+ listData,
+ viewMode,
+ loading,
+ loadingMore,
+ searchQuery,
+ onNavigate,
+ onLoadMore,
+}: DriveListProps) => {
+ const tailwind = useTailwind();
+ const numColumns = viewMode === DriveListViewMode.Grid ? 3 : 1;
+
+ const renderItem = useCallback(
+ ({ item: listItem }: { item: DriveListItem }) => {
+ const isFolder = listItem.type === 'folder';
+ const handleItemPress = isFolder ? () => onNavigate(listItem.data.uuid, listItem.data.plainName) : undefined;
+ return ;
+ },
+ [onNavigate, viewMode],
+ );
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+ {searchQuery ? strings.screens.ShareExtension.noResults : strings.screens.ShareExtension.emptyFolder}
+
+
+ }
+ ListFooterComponent={
+ loadingMore ? (
+
+
+
+ ) : null
+ }
+ onEndReached={onLoadMore}
+ onEndReachedThreshold={0.5}
+ onScrollBeginDrag={Keyboard.dismiss}
+ />
+ );
+};
diff --git a/src/shareExtension/components/DriveScreen/RootHeader.tsx b/src/shareExtension/components/DriveScreen/RootHeader.tsx
new file mode 100644
index 000000000..ed7574c35
--- /dev/null
+++ b/src/shareExtension/components/DriveScreen/RootHeader.tsx
@@ -0,0 +1,40 @@
+import { MagnifyingGlassIcon } from 'phosphor-react-native';
+import { Text, TextInput, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../../assets/lang/strings';
+import { colors, fontStyles } from '../../theme';
+import { TextButton } from '../TextButton';
+
+interface RootHeaderProps {
+ searchQuery: string;
+ onChangeSearch: (value: string) => void;
+ onNewFolder: () => void;
+}
+
+export const RootHeader = ({ searchQuery, onChangeSearch, onNewFolder }: RootHeaderProps) => {
+ const tailwind = useTailwind();
+
+ return (
+ <>
+
+
+ {strings.screens.ShareExtension.rootFolderName}
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/shareExtension/components/DriveScreen/SortRow.tsx b/src/shareExtension/components/DriveScreen/SortRow.tsx
new file mode 100644
index 000000000..3b6f2967f
--- /dev/null
+++ b/src/shareExtension/components/DriveScreen/SortRow.tsx
@@ -0,0 +1,35 @@
+import { DriveListViewMode } from '@internxt-mobile/types/drive/ui';
+import { RowsIcon, SquaresFourIcon } from 'phosphor-react-native';
+import { Text, TouchableOpacity, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../../assets/lang/strings';
+import { colors, fontStyles } from '../../theme';
+
+interface SortRowProps {
+ viewMode: DriveListViewMode;
+ onToggleViewMode: () => void;
+}
+
+export const SortRow = ({ viewMode, onToggleViewMode }: SortRowProps) => {
+ const tailwind = useTailwind();
+
+ return (
+
+
+ {strings.screens.ShareExtension.sortByName}
+
+
+ {viewMode === DriveListViewMode.List ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx b/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx
new file mode 100644
index 000000000..cb0c13ad0
--- /dev/null
+++ b/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx
@@ -0,0 +1,88 @@
+import { CaretLeftIcon, MagnifyingGlassIcon } from 'phosphor-react-native';
+import { Animated, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../../assets/lang/strings';
+import { UseSearchAnimationResult } from '../../hooks/useSearchAnimation';
+import { colors, fontStyles } from '../../theme';
+import { TextButton } from '../TextButton';
+
+interface SubfolderHeaderProps {
+ folderName: string;
+ parentName: string;
+ onBack: () => void;
+ searchQuery: string;
+ onChangeSearch: (q: string) => void;
+ onClearSearch: () => void;
+ onNewFolder: () => void;
+ searchAnim: UseSearchAnimationResult;
+}
+
+export const SubfolderHeader = ({
+ folderName,
+ parentName,
+ onBack,
+ searchQuery,
+ onChangeSearch,
+ onClearSearch,
+ onNewFolder,
+ searchAnim,
+}: SubfolderHeaderProps) => {
+ const tailwind = useTailwind();
+ const { searchHeight, searchOpacity, isSearchOpen, searchRef, toggleSearch } = searchAnim;
+
+ const handleSearchPress = () => toggleSearch(onClearSearch);
+
+ return (
+ <>
+
+
+
+
+ {parentName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {folderName}
+
+
+
+ >
+ );
+};
diff --git a/src/shareExtension/components/FileListItem.tsx b/src/shareExtension/components/FileListItem.tsx
new file mode 100644
index 000000000..700ce2ecd
--- /dev/null
+++ b/src/shareExtension/components/FileListItem.tsx
@@ -0,0 +1,82 @@
+import { DriveListViewMode } from '@internxt-mobile/types/drive/ui';
+import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import { FolderIcon, getFileTypeIcon } from '../../helpers/filetypes';
+import { colors, fontStyles } from '../theme';
+import { ShareFileItem, ShareFolderItem } from '../types';
+import { formatBytes, formatDate } from '../utils';
+
+interface FileListItemProps {
+ item: ShareFolderItem | ShareFileItem;
+ isFolder: boolean;
+ viewMode: DriveListViewMode;
+ onPress?: () => void;
+}
+
+export const FileListItem = ({ item, isFolder, viewMode, onPress }: FileListItemProps) => {
+ const tailwind = useTailwind();
+ const IconComponent = isFolder ? FolderIcon : getFileTypeIcon((item as ShareFileItem).type ?? '');
+ const fileItem = isFolder ? null : (item as ShareFileItem);
+ const fileSize = fileItem ? Number.parseInt(fileItem.size, 10) : Number.NaN;
+ const fileSizeText = Number.isNaN(fileSize) ? '' : formatBytes(fileSize);
+
+ if (viewMode === DriveListViewMode.Grid) {
+ return (
+
+
+
+
+
+ {item.plainName}
+
+
+ {formatDate(item.updatedAt)}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {item.plainName}
+
+
+ {fileItem ? `${fileSizeText} · ${formatDate(item.updatedAt)}` : formatDate(item.updatedAt)}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ rowItem: {
+ paddingRight: 16,
+ },
+ disabled: {
+ opacity: 0.4,
+ },
+ separator: {
+ position: 'absolute',
+ bottom: 0,
+ left: 16,
+ right: 16,
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.gray10,
+ },
+});
diff --git a/src/shareExtension/components/TextButton.tsx b/src/shareExtension/components/TextButton.tsx
new file mode 100644
index 000000000..05b33835b
--- /dev/null
+++ b/src/shareExtension/components/TextButton.tsx
@@ -0,0 +1,27 @@
+import { StyleProp, Text, TouchableOpacity, ViewStyle } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import { colors, fontStyles } from '../theme';
+
+interface TextButtonProps {
+ title: string;
+ onPress: () => void;
+ disabled?: boolean;
+ style?: StyleProp;
+}
+
+export const TextButton = ({ title, onPress, disabled, style }: TextButtonProps) => {
+ const tailwind = useTailwind();
+ return (
+
+
+ {title}
+
+
+ );
+};
diff --git a/src/shareExtension/hooks/useNavAnimation.ts b/src/shareExtension/hooks/useNavAnimation.ts
new file mode 100644
index 000000000..fcd4e01c2
--- /dev/null
+++ b/src/shareExtension/hooks/useNavAnimation.ts
@@ -0,0 +1,50 @@
+import { useEffect, useRef } from 'react';
+import { Animated, Easing } from 'react-native';
+
+const NAV_SLIDE_OFFSET = 40;
+const NAV_SLIDE_DURATION_MS = 220;
+const NAV_FADE_DURATION_MS = 200;
+
+interface UseNavAnimationResult {
+ translateX: Animated.Value;
+ contentOpacity: Animated.Value;
+}
+
+export const useNavAnimation = (currentFolderUuid: string, depth: number): UseNavAnimationResult => {
+ const translateX = useRef(new Animated.Value(0)).current;
+ const contentOpacity = useRef(new Animated.Value(1)).current;
+ const prevDepthRef = useRef(1);
+ const isFirstNavRef = useRef(true);
+
+ useEffect(() => {
+ if (isFirstNavRef.current) {
+ isFirstNavRef.current = false;
+ return;
+ }
+
+ const goingForward = depth > prevDepthRef.current;
+ prevDepthRef.current = depth;
+
+ translateX.setValue(goingForward ? NAV_SLIDE_OFFSET : -NAV_SLIDE_OFFSET);
+ contentOpacity.setValue(0);
+
+ const anim = Animated.parallel([
+ Animated.timing(translateX, {
+ toValue: 0,
+ duration: NAV_SLIDE_DURATION_MS,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(contentOpacity, {
+ toValue: 1,
+ duration: NAV_FADE_DURATION_MS,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]);
+ anim.start();
+ return () => anim.stop();
+ }, [currentFolderUuid]);
+
+ return { translateX, contentOpacity };
+};
diff --git a/src/shareExtension/hooks/useSearchAnimation.ts b/src/shareExtension/hooks/useSearchAnimation.ts
new file mode 100644
index 000000000..f60dd5b08
--- /dev/null
+++ b/src/shareExtension/hooks/useSearchAnimation.ts
@@ -0,0 +1,66 @@
+import { useCallback, useRef, useState } from 'react';
+import { Animated, Keyboard, TextInput } from 'react-native';
+
+const SEARCH_BAR_EXPANDED_HEIGHT = 46;
+const SEARCH_OPEN_DURATION_MS = 200;
+const SEARCH_OPEN_OPACITY_DURATION_MS = 180;
+const SEARCH_CLOSE_DURATION_MS = 180;
+const SEARCH_CLOSE_OPACITY_DURATION_MS = 150;
+
+export interface UseSearchAnimationResult {
+ searchHeight: Animated.Value;
+ searchOpacity: Animated.Value;
+ isSearchOpen: boolean;
+ searchRef: React.RefObject;
+ toggleSearch: (onClose?: () => void) => void;
+ resetSearch: () => void;
+}
+
+export const useSearchAnimation = (): UseSearchAnimationResult => {
+ const searchHeight = useRef(new Animated.Value(0)).current;
+ const searchOpacity = useRef(new Animated.Value(0)).current;
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const searchRef = useRef(null);
+
+ const toggleSearch = useCallback(
+ (onClose?: () => void) => {
+ if (isSearchOpen) {
+ Keyboard.dismiss();
+ Animated.parallel([
+ Animated.timing(searchHeight, { toValue: 0, duration: SEARCH_CLOSE_DURATION_MS, useNativeDriver: false }),
+ Animated.timing(searchOpacity, {
+ toValue: 0,
+ duration: SEARCH_CLOSE_OPACITY_DURATION_MS,
+ useNativeDriver: false,
+ }),
+ ]).start(() => {
+ setIsSearchOpen(false);
+ onClose?.();
+ });
+ } else {
+ setIsSearchOpen(true);
+ Animated.parallel([
+ Animated.timing(searchHeight, {
+ toValue: SEARCH_BAR_EXPANDED_HEIGHT,
+ duration: SEARCH_OPEN_DURATION_MS,
+ useNativeDriver: false,
+ }),
+ Animated.timing(searchOpacity, {
+ toValue: 1,
+ duration: SEARCH_OPEN_OPACITY_DURATION_MS,
+ useNativeDriver: false,
+ }),
+ ]).start(() => searchRef.current?.focus());
+ }
+ },
+ [isSearchOpen, searchHeight, searchOpacity],
+ );
+
+ const resetSearch = useCallback(() => {
+ searchHeight.setValue(0);
+ searchOpacity.setValue(0);
+ setIsSearchOpen(false);
+ }, [searchHeight, searchOpacity]);
+
+ return { searchHeight, searchOpacity, isSearchOpen, searchRef, toggleSearch, resetSearch };
+};
diff --git a/src/shareExtension/screens/DriveScreen.tsx b/src/shareExtension/screens/DriveScreen.tsx
new file mode 100644
index 000000000..39abc7b18
--- /dev/null
+++ b/src/shareExtension/screens/DriveScreen.tsx
@@ -0,0 +1,141 @@
+import { DriveListViewMode } from '@internxt-mobile/types/drive/ui';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Animated, Keyboard, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../assets/lang/strings';
+import { BottomFilePanel } from '../components/BottomFilePanel';
+import { DriveHeader } from '../components/DriveHeader';
+import { DriveList, DriveListItem } from '../components/DriveScreen/DriveList';
+import { RootHeader } from '../components/DriveScreen/RootHeader';
+import { SortRow } from '../components/DriveScreen/SortRow';
+import { SubfolderHeader } from '../components/DriveScreen/SubfolderHeader';
+import { NewFolderModal } from '../components/NewFolderModal';
+import { useFolderNavigation } from '../hooks/useFolderNavigation';
+import { useNavAnimation } from '../hooks/useNavAnimation';
+import { useSearchAnimation } from '../hooks/useSearchAnimation';
+import { SharedFile } from '../types';
+
+interface DriveScreenProps {
+ sharedFiles: SharedFile[];
+ rootFolderUuid: string;
+ onClose: () => void;
+ onSave: (destinationFolderUuid: string, finalFileName?: string) => void;
+}
+
+export const DriveScreen = ({ sharedFiles, rootFolderUuid, onClose, onSave }: DriveScreenProps) => {
+ const tailwind = useTailwind();
+ const {
+ currentFolder,
+ folders,
+ files,
+ loading,
+ loadingMore,
+ searchQuery,
+ setSearchQuery,
+ viewMode,
+ setViewMode,
+ breadcrumb,
+ navigate,
+ goBack,
+ loadMore,
+ createFolder,
+ } = useFolderNavigation(rootFolderUuid);
+ const [showNewFolderModal, setShowNewFolderModal] = useState(false);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [editingName, setEditingName] = useState(sharedFiles[0]?.fileName ?? '');
+
+ const isRoot = breadcrumb.length === 1;
+ const parentFolderIndex = breadcrumb.length - 2;
+ const parentFolder = breadcrumb[parentFolderIndex];
+
+ const { translateX, contentOpacity } = useNavAnimation(currentFolder.uuid, breadcrumb.length);
+ const searchAnimation = useSearchAnimation();
+
+ useEffect(() => {
+ searchAnimation.resetSearch();
+ }, [currentFolder.uuid]);
+
+ const listItems = useMemo(
+ () => [
+ ...folders.map((folder): DriveListItem => ({ type: 'folder', data: folder })),
+ ...files.map((file): DriveListItem => ({ type: 'file', data: file })),
+ ],
+ [folders, files],
+ );
+
+ const handleSave = useCallback(() => {
+ Keyboard.dismiss();
+ onSave(currentFolder.uuid, isRenaming ? editingName : undefined);
+ }, [onSave, currentFolder.uuid, isRenaming, editingName]);
+
+ const handleCreateFolder = useCallback(
+ async (name: string) => {
+ await createFolder(name);
+ setShowNewFolderModal(false);
+ },
+ [createFolder],
+ );
+
+ const handleOpenNewFolderModal = useCallback(() => setShowNewFolderModal(true), []);
+ const handleCloseNewFolderModal = useCallback(() => setShowNewFolderModal(false), []);
+ const handleClearSearch = useCallback(() => setSearchQuery(''), []);
+ const handleToggleViewMode = useCallback(
+ () => setViewMode(viewMode === DriveListViewMode.List ? DriveListViewMode.Grid : DriveListViewMode.List),
+ [viewMode, setViewMode],
+ );
+ const handleStartRename = useCallback(() => setIsRenaming(true), []);
+ const handleEndRename = useCallback(() => setIsRenaming(false), []);
+
+ return (
+
+ {/* saveEnabled will be blocked when files are uploading (next tasks) */}
+
+
+
+ {isRoot ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/shareExtension/screens/NotSignedInScreen.tsx b/src/shareExtension/screens/NotSignedInScreen.tsx
index 008bec8fa..5f6720431 100644
--- a/src/shareExtension/screens/NotSignedInScreen.tsx
+++ b/src/shareExtension/screens/NotSignedInScreen.tsx
@@ -1,6 +1,8 @@
-import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
+import { Platform, TouchableOpacity, StyleSheet, Text, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
+import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
+import { colors, fontStyles } from '../theme';
interface NotSignedInScreenProps {
onClose: () => void;
@@ -12,7 +14,7 @@ function LoginIcon() {