diff --git a/apps/mobile/app/(main)/chat/list.tsx b/apps/mobile/app/(main)/chat/list.tsx index b8ab24d1..d6a6e8dd 100644 --- a/apps/mobile/app/(main)/chat/list.tsx +++ b/apps/mobile/app/(main)/chat/list.tsx @@ -11,6 +11,7 @@ import { UpsertFolderSheet, UpsertFolderSheetMethods, } from '@open-webui-react-native/mobile/folder/features/upsert-folder-sheet'; +import { useFoldersEnabled } from '@open-webui-react-native/mobile/shared/features/use-folders-enabled'; import { ScreenWrapper } from '@open-webui-react-native/mobile/shared/ui/screen-wrapper'; import { AppHeader, AppPressable, Avatar, IconButton, View } from '@open-webui-react-native/mobile/shared/ui/ui-kit'; import { navigationConfig } from '@open-webui-react-native/mobile/shared/utils/navigation'; @@ -29,15 +30,13 @@ export default function ChatListScreen(): ReactElement { const upsertFolderSheetRef = useRef(null); const shareFolderSheetRef = useRef(null); const { data: profile } = authApi.useGetProfile(); + const canUseFolders = useFoldersEnabled(); const handleChatPress = (id: string): void => navigateOnce(navigationConfig.main.chat.view({ id })); const handleNewChatPress = (): void => navigateOnce(`${navigationConfig.main.chat.index}/${navigationConfig.main.chat.create}`); - const handleArchivedChatsPress = (): void => - navigateOnce(`${navigationConfig.main.chat.index}/${navigationConfig.main.chat.archivedChats}`); - const handleSettingsPress = (): void => navigateOnce(navigationConfig.main.settings); const handleFolderPress = (id: string, title: string): void => @@ -68,7 +67,7 @@ export default function ChatListScreen(): ReactElement { } accessoryRight={ - {isFeatureEnabled(FeatureID.CHAT_FOLDERS) && ( + {isFeatureEnabled(FeatureID.CHAT_FOLDERS) && canUseFolders && ( ( )} - } - selectedItemId={selectedItemId} - renderTrigger={renderTrigger} - searchPlaceholder={translate('TEXT_SEARCH_FOLDER')} - {...props} - /> + {canUseFolders && ( + } + selectedItemId={selectedItemId} + renderTrigger={renderTrigger} + searchPlaceholder={translate('TEXT_SEARCH_FOLDER')} + {...props} + /> + )} ); } diff --git a/libs/mobile/chat/features/menu-list/src/lib/component.tsx b/libs/mobile/chat/features/menu-list/src/lib/component.tsx index 5dd64c52..1fe35e47 100644 --- a/libs/mobile/chat/features/menu-list/src/lib/component.tsx +++ b/libs/mobile/chat/features/menu-list/src/lib/component.tsx @@ -5,6 +5,7 @@ import { ChatActionsMenuSheet, ChatActionsMenuSheetMethods, } from '@open-webui-react-native/mobile/shared/features/chat-actions-menu-sheet'; +import { useFoldersEnabled } from '@open-webui-react-native/mobile/shared/features/use-folders-enabled'; import { ChatListRow } from '@open-webui-react-native/mobile/shared/ui/chat-list-row'; import { DateSectionList } from '@open-webui-react-native/mobile/shared/ui/date-section-list'; import { @@ -44,6 +45,8 @@ export function ChatMenuList({ const [isFirstLoading, setIsFirstLoading] = useState(true); + const canUseFolders = useFoldersEnabled(); + const { data: chats, isFetchingNextPage, @@ -63,20 +66,26 @@ export function ChatMenuList({ isLoading: isFoldersLoading, isRefetching: isFoldersRefetching, refetch: refetchFolders, - } = foldersApi.useGetFolders(); + } = foldersApi.useGetFolders({ enabled: canUseFolders }); const { data: sharedFolders, isLoading: isSharedFoldersLoading, isRefetching: isSharedFoldersRefetching, refetch: refetchSharedFolders, - } = foldersApi.useGetSharedFolders(); + } = foldersApi.useGetSharedFolders({ enabled: canUseFolders }); const isLoading = isChatsLoading || isPinnedChatsLoading || isFoldersLoading || isSharedFoldersLoading || isFirstLoading; const isRefetching = isChatsRefetching || isPinnedChatsRefetching || isFoldersRefetching || isSharedFoldersRefetching; const refetch = (): void => { - Promise.all([refetchChats(), refetchPinnedChats(), refetchFolders(), refetchSharedFolders()]); + // NOTE: react-query's refetch() runs even for a disabled query, so the folders feature gate has + // to be re-checked here too, or a pull-to-refresh would still hit the forbidden endpoint. + Promise.all([ + refetchChats(), + refetchPinnedChats(), + ...(canUseFolders ? [refetchFolders(), refetchSharedFolders()] : []), + ]); }; useEffect(() => { @@ -113,7 +122,7 @@ export function ChatMenuList({ refreshControl={} ListHeaderComponent={ - {isFeatureEnabled(FeatureID.CHAT_FOLDERS) && ( + {isFeatureEnabled(FeatureID.CHAT_FOLDERS) && canUseFolders && ( {/* NOTE: A folder owned by somebody else cannot be renamed or deleted by the recipient, so its row offers no actions on long press. */} diff --git a/libs/mobile/chat/utils/use-folder-search-list/src/lib/hook.ts b/libs/mobile/chat/utils/use-folder-search-list/src/lib/hook.ts index d6da6e07..7ceada1d 100644 --- a/libs/mobile/chat/utils/use-folder-search-list/src/lib/hook.ts +++ b/libs/mobile/chat/utils/use-folder-search-list/src/lib/hook.ts @@ -11,6 +11,7 @@ interface UseFolderSearchListParams { noFolderText: string; createFolderText: string; onCreateFolderPress: () => void; + canCreateFolder?: boolean; } interface UseFolderSearchListResult { @@ -22,6 +23,7 @@ export function useFolderSearchList({ noFolderText, createFolderText, onCreateFolderPress, + canCreateFolder = true, }: UseFolderSearchListParams): UseFolderSearchListResult { const { isDarkColorScheme } = useColorScheme(); @@ -32,17 +34,21 @@ export function useFolderSearchList({ name: noFolderText, iconName: isDarkColorScheme ? ('logoSmallDark' as IconName) : ('logoSmallLight' as IconName), }, - { - id: MockFolderItemIds.CREATE_FOLDER_ID, - name: createFolderText, - onPress: onCreateFolderPress, - iconName: 'folderPlus' as IconName, - containerClassName: 'mb-24', - textClassName: 'text-brand-primary', - iconClassName: 'color-brand-primary', - }, + ...(canCreateFolder + ? [ + { + id: MockFolderItemIds.CREATE_FOLDER_ID, + name: createFolderText, + onPress: onCreateFolderPress, + iconName: 'folderPlus' as IconName, + containerClassName: 'mb-24', + textClassName: 'text-brand-primary', + iconClassName: 'color-brand-primary', + }, + ] + : []), ], - [isDarkColorScheme, noFolderText, createFolderText, onCreateFolderPress], + [isDarkColorScheme, canCreateFolder, noFolderText, createFolderText, onCreateFolderPress], ); return { emptyFolders, createFolderId: MockFolderItemIds.CREATE_FOLDER_ID }; diff --git a/libs/mobile/folder/features/folder-actions-sheet/src/lib/component.tsx b/libs/mobile/folder/features/folder-actions-sheet/src/lib/component.tsx index 2d162a95..3e051c6d 100644 --- a/libs/mobile/folder/features/folder-actions-sheet/src/lib/component.tsx +++ b/libs/mobile/folder/features/folder-actions-sheet/src/lib/component.tsx @@ -2,6 +2,7 @@ import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useTranslation } from '@ronas-it/react-native-common-modules/i18n'; import { ForwardedRef, ReactElement, useImperativeHandle, useRef, useState } from 'react'; import { fileSystemService } from '@open-webui-react-native/mobile/shared/data-access/file-system-service'; +import { useFoldersEnabled } from '@open-webui-react-native/mobile/shared/features/use-folders-enabled'; import { ActionsBottomSheet, ActionsBottomSheetProps, @@ -39,7 +40,8 @@ export function FolderActionsSheet({ onEditPress, onSharePress, ref }: FolderAct const [isExportLoading, setIsExportLoading] = useState(false); const { data: profile } = authApi.useGetProfile(); - const { data: sharedFolders } = foldersApi.useGetSharedFolders(); + const canUseFolders = useFoldersEnabled(); + const { data: sharedFolders } = foldersApi.useGetSharedFolders({ enabled: canUseFolders }); const { mutateAsync: deleteFolder, isPending: isDeleting } = foldersApi.useDeleteFolder(); // NOTE: The shared list holds exactly the folders owned by somebody else, so a folder missing from diff --git a/libs/mobile/shared/features/chat-actions-menu-sheet/src/lib/component.tsx b/libs/mobile/shared/features/chat-actions-menu-sheet/src/lib/component.tsx index 17250173..953aed03 100644 --- a/libs/mobile/shared/features/chat-actions-menu-sheet/src/lib/component.tsx +++ b/libs/mobile/shared/features/chat-actions-menu-sheet/src/lib/component.tsx @@ -12,6 +12,7 @@ import { UpsertFolderSheetMethods, } from '@open-webui-react-native/mobile/folder/features/upsert-folder-sheet'; import { DownloadChatOptionsSheet } from '@open-webui-react-native/mobile/shared/features/download-chat-options-sheet'; +import { useFoldersEnabled } from '@open-webui-react-native/mobile/shared/features/use-folders-enabled'; import { ActionButtonsModal, ActionButtonsModalMethods, @@ -64,7 +65,8 @@ export function ChatActionsMenuSheet({ goToChat, isPinned, ref, isInChat }: Chat const { mutateAsync: cloneChat, isPending: isCloning } = chatApi.useCloneChat(); const { mutateAsync: archiveChat, isPending: isArchiving } = chatApi.useArchiveChat(); const { mutateAsync: unarchiveChat, isPending: isUnarchiving } = chatApi.useUnarchiveChat(); - const { data: folders } = foldersApi.useGetFolders(); + const canUseFolders = useFoldersEnabled(); + const { data: folders } = foldersApi.useGetFolders({ enabled: canUseFolders }); const [activeChat, setActiveChat] = useState(null); const [folderId, setFolderId] = useState(undefined); @@ -105,6 +107,7 @@ export function ChatActionsMenuSheet({ goToChat, isPinned, ref, isInChat }: Chat noFolderText: translate('MOVE_CHAT_TO_FOLDER_MODAL.TEXT_NO_FOLDER'), createFolderText: translate('MOVE_CHAT_TO_FOLDER_MODAL.TEXT_CREATE_NEW_FOLDER'), onCreateFolderPress: openCreateFolderModal, + canCreateFolder: canUseFolders, }); const handleAction = useMemo( @@ -236,7 +239,7 @@ export function ChatActionsMenuSheet({ goToChat, isPinned, ref, isInChat }: Chat }; const actions: Array = compact([ - { + canUseFolders && { title: translate('TEXT_MOVE_TO_FOLDER'), iconName: 'folderPlus', onPress: () => handleAction(ChatAction.MOVE_TO_FOLDER), diff --git a/libs/mobile/shared/features/use-folders-enabled/.babelrc b/libs/mobile/shared/features/use-folders-enabled/.babelrc new file mode 100644 index 00000000..1ea870ea --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/mobile/shared/features/use-folders-enabled/eslint.config.cjs b/libs/mobile/shared/features/use-folders-enabled/eslint.config.cjs new file mode 100644 index 00000000..6f47c1a7 --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/eslint.config.cjs @@ -0,0 +1,12 @@ +const nx = require('@nx/eslint-plugin'); +const baseConfig = require('../../../../../eslint.config.cjs'); + +module.exports = [ + ...baseConfig, + ...nx.configs['flat/react'], + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/mobile/shared/features/use-folders-enabled/project.json b/libs/mobile/shared/features/use-folders-enabled/project.json new file mode 100644 index 00000000..a00a162d --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/project.json @@ -0,0 +1,9 @@ +{ + "name": "mobile/shared/features/use-folders-enabled", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/mobile/shared/features/use-folders-enabled/src", + "projectType": "library", + "tags": ["app:mobile", "scope:shared", "type:features"], + "// targets": "to see all targets run: nx show project mobile/shared/features/use-folders-enabled --web", + "targets": {} +} diff --git a/libs/mobile/shared/features/use-folders-enabled/src/hook.ts b/libs/mobile/shared/features/use-folders-enabled/src/hook.ts new file mode 100644 index 00000000..388aa80d --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/src/hook.ts @@ -0,0 +1,15 @@ +import { appConfigurationApi, authApi } from '@open-webui-react-native/shared/data-access/api'; +import { UserRole } from '@open-webui-react-native/shared/data-access/common'; + +export function useFoldersEnabled(): boolean { + const { data: config } = appConfigurationApi.useGetAppConfiguration(); + const { data: profile } = authApi.useGetProfile(); + + // NOTE: matches the web app's gate (Sidebar.svelte) and the backend's check_folders_permission, + // which guards every folders route (read and write alike) — the instance-wide toggle must be on, + // and a non-admin additionally needs the per-user permission, defaulting to allowed when unset. + return Boolean( + config?.features.enableFolders && + (profile?.role === UserRole.ADMIN || (profile?.permissions.features.folders ?? true)), + ); +} diff --git a/libs/mobile/shared/features/use-folders-enabled/src/index.ts b/libs/mobile/shared/features/use-folders-enabled/src/index.ts new file mode 100644 index 00000000..1146a6d5 --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/src/index.ts @@ -0,0 +1 @@ +export * from './hook'; diff --git a/libs/mobile/shared/features/use-folders-enabled/tsconfig.json b/libs/mobile/shared/features/use-folders-enabled/tsconfig.json new file mode 100644 index 00000000..ec74bfc3 --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../../../../tsconfig.base.json" +} diff --git a/libs/mobile/shared/features/use-folders-enabled/tsconfig.lib.json b/libs/mobile/shared/features/use-folders-enabled/tsconfig.lib.json new file mode 100644 index 00000000..27f2b917 --- /dev/null +++ b/libs/mobile/shared/features/use-folders-enabled/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/shared/data-access/api/src/lib/app-configuration/models/features.ts b/libs/shared/data-access/api/src/lib/app-configuration/models/features.ts index b84eb3e4..a0880e83 100644 --- a/libs/shared/data-access/api/src/lib/app-configuration/models/features.ts +++ b/libs/shared/data-access/api/src/lib/app-configuration/models/features.ts @@ -25,6 +25,9 @@ export class Features { @Expose({ name: 'enable_direct_connections' }) public enableDirectConnections: boolean; + @Expose({ name: 'enable_folders' }) + public enableFolders: boolean; + @Expose({ name: 'enable_channels' }) public enableChannels: boolean; diff --git a/libs/shared/data-access/api/src/lib/auth/models/permissions/features-permissions.ts b/libs/shared/data-access/api/src/lib/auth/models/permissions/features-permissions.ts index 57e5f207..ba2eba0c 100644 --- a/libs/shared/data-access/api/src/lib/auth/models/permissions/features-permissions.ts +++ b/libs/shared/data-access/api/src/lib/auth/models/permissions/features-permissions.ts @@ -16,6 +16,9 @@ export class FeaturesPermissions { @Expose() public notes: boolean; + @Expose() + public folders: boolean; + constructor(model: Partial) { Object.assign(this, model); } diff --git a/libs/shared/data-access/api/src/lib/folders/api.ts b/libs/shared/data-access/api/src/lib/folders/api.ts index ff036e2d..22fe17c3 100644 --- a/libs/shared/data-access/api/src/lib/folders/api.ts +++ b/libs/shared/data-access/api/src/lib/folders/api.ts @@ -122,7 +122,7 @@ function useDeleteFolder( } function useGetFolders( - props?: UseQueryOptions, AxiosError>, + props?: Omit, AxiosError>, 'queryKey' | 'queryFn'>, ): UseQueryResult, AxiosError> { return useQuery, AxiosError>({ queryFn: foldersService.getFolders, diff --git a/tsconfig.base.json b/tsconfig.base.json index 24470c31..ea272e1a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -212,6 +212,9 @@ "@open-webui-react-native/mobile/shared/features/use-audio-recorder": [ "libs/mobile/shared/features/use-audio-recorder/src/index.ts" ], + "@open-webui-react-native/mobile/shared/features/use-folders-enabled": [ + "libs/mobile/shared/features/use-folders-enabled/src/index.ts" + ], "@open-webui-react-native/mobile/shared/features/use-dictate-mode": [ "libs/mobile/shared/features/use-dictate-mode/src/index.ts" ],