snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import type { App } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useState, useEffect } from 'react';
import { useApps } from './useApps';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
import type { AppInfo } from '../definitions/AppInfo';
import type { ISettings } from '../definitions/ISettings';
const getBundledInApp = async (app: App): Promise<App['bundledIn']> => {
const { bundledIn = [] } = app;
return Promise.all(
bundledIn.map(async (bundle) => {
const apps = await AppClientOrchestratorInstance.getAppsOnBundle(bundle.bundleId);
bundle.apps = apps.slice(0, 4);
return bundle;
}),
);
};
export const useAppInfo = (appId: string, context: string): AppInfo | undefined => {
const { data: { installedApps, marketplaceApps, privateApps } = {} } = useApps();
const [appData, setAppData] = useState<AppInfo>();
const getSettings = useEndpoint('GET', '/apps/:id/settings', { id: appId });
const getScreenshots = useEndpoint('GET', '/apps/:id/screenshots', { id: appId });
const getApis = useEndpoint('GET', '/apps/:id/apis', { id: appId });
const getBundledIn = useEndpoint('GET', '/apps/:id', { id: appId });
useEffect(() => {
const fetchAppInfo = async (): Promise<void> => {
if ((!marketplaceApps?.length && !installedApps?.length && !privateApps?.length) || !appId) {
return;
}
let appResult: App | undefined;
const marketplaceAppsContexts = ['explore', 'premium', 'requested'];
if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps?.find((app) => app.id === appId);
if (context === 'private') appResult = privateApps?.find((app) => app.id === appId);
if (context === 'installed') appResult = installedApps?.find((app) => app.id === appId);
if (!appResult) return;
const [settings, apis, screenshots, bundledIn] = await Promise.all([
getSettings().catch(() => ({
settings: {},
})),
getApis().catch(() => ({
apis: [],
})),
getScreenshots().catch(() => ({
screenshots: [],
})),
appResult.marketplace === false
? []
: getBundledIn({
marketplace: 'true',
update: 'true',
appVersion: appId,
})
.then(({ app }: any) => {
appResult.tosLink = app.tosLink;
appResult.privacyLink = app.privacyLink;
return getBundledInApp(app);
})
.catch(() => ({
settings: {},
})),
]);
setAppData({
...appResult,
bundledIn: bundledIn as App['bundledIn'],
settings: settings.settings as ISettings,
apis: apis ? apis.apis : [],
screenshots: screenshots ? screenshots.screenshots : [],
});
};
fetchAppInfo();
}, [appId, context, getApis, getBundledIn, getScreenshots, getSettings, installedApps, marketplaceApps, privateApps]);
return appData;
};

View File

@@ -0,0 +1,159 @@
import type { App } from '@rocket.chat/core-typings';
import { useEndpoint, useRouteParameter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { isMarketplaceRouteContext, useAppsCountQuery } from './useAppsCountQuery';
import { useOpenAppPermissionsReviewModal } from './useOpenAppPermissionsReviewModal';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../../admin/subscription/hooks/useCheckoutUrl';
import IframeModal from '../IframeModal';
import AppInstallModal from '../components/AppInstallModal/AppInstallModal';
import type { Actions } from '../helpers';
import { useAppsOrchestration } from './useAppsOrchestration';
import { useOpenIncompatibleModal } from './useOpenIncompatibleModal';
import { handleAPIError } from '../helpers/handleAPIError';
export type AppInstallationHandlerParams = {
app: App;
action: Actions | '';
isAppPurchased?: boolean;
onDismiss: () => void;
onSuccess: (action: Actions | '', appPermissions?: App['permissions']) => void;
setIsPurchased: (purchased: boolean) => void;
};
export function useAppInstallationHandler({
app,
action,
isAppPurchased,
onDismiss,
onSuccess,
setIsPurchased,
}: AppInstallationHandlerParams) {
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const routeContext = String(useRouteParameter('context'));
const context = isMarketplaceRouteContext(routeContext) ? routeContext : 'explore';
const appCountQuery = useAppsCountQuery(context);
const notifyAdmins = useEndpoint('POST', `/apps/notify-admins`);
const openIncompatibleModal = useOpenIncompatibleModal();
const openExternalLink = useExternalLink();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' });
const closeModal = useCallback(() => {
setModal(null);
onDismiss();
}, [onDismiss, setModal]);
const success = useCallback(
(appPermissions?: App['permissions']) => {
setModal(null);
onSuccess(action, appPermissions);
},
[action, onSuccess, setModal],
);
const openPermissionModal = useOpenAppPermissionsReviewModal({ app, onCancel: closeModal, onConfirm: success });
const appsOrchestrator = useAppsOrchestration();
if (!appsOrchestrator) {
throw new Error('Apps orchestrator is not available');
}
const acquireApp = useCallback(async () => {
if (action === 'purchase' && !isAppPurchased) {
try {
const data = await appsOrchestrator.buildExternalUrl(app.id, app.purchaseType, false);
setModal(
<IframeModal
url={data.url}
cancel={onDismiss}
confirm={() => {
setIsPurchased(true);
openPermissionModal();
}}
/>,
);
} catch (error) {
handleAPIError(error);
}
return;
}
openPermissionModal();
}, [action, isAppPurchased, openPermissionModal, appsOrchestrator, app.id, app.purchaseType, setModal, onDismiss, setIsPurchased]);
return useCallback(async () => {
if (app?.versionIncompatible) {
openIncompatibleModal(app, action, closeModal);
return;
}
if (action === 'request') {
const requestConfirmAction = (postMessage: Record<string, unknown>) => {
setModal(null);
dispatchToastMessage({ type: 'success', message: 'App request submitted' });
notifyAdmins({
appId: app.id,
appName: app.name,
appVersion: app.marketplaceVersion,
message: typeof postMessage.message === 'string' ? postMessage.message : '',
});
success();
};
try {
const data = await appsOrchestrator.buildExternalAppRequest(app.id);
setModal(<IframeModal url={data.url} wrapperHeight='x460' cancel={onDismiss} confirm={requestConfirmAction} />);
} catch (error) {
handleAPIError(error);
}
return;
}
if (!appCountQuery.data) {
return;
}
if (appCountQuery.data.hasUnlimitedApps) {
return acquireApp();
}
setModal(
<AppInstallModal
enabled={appCountQuery.data.enabled}
limit={appCountQuery.data.limit}
appName={app.name}
handleClose={closeModal}
handleConfirm={acquireApp}
handleEnableUnlimitedApps={() => {
openExternalLink(manageSubscriptionUrl);
setModal(null);
}}
/>,
);
}, [
app,
action,
appCountQuery.data,
setModal,
closeModal,
acquireApp,
openIncompatibleModal,
dispatchToastMessage,
notifyAdmins,
success,
appsOrchestrator,
onDismiss,
openExternalLink,
manageSubscriptionUrl,
]);
}

View File

@@ -0,0 +1,15 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
export const useAppInstances = ({ appId }: { appId: string }): UseQueryResult<OperationResult<'GET', '/apps/:id/status'>> => {
const status = useEndpoint('GET', '/apps/:id/status', { id: appId });
return useQuery({
queryKey: marketplaceQueryKeys.appStatus(appId),
queryFn: () => status(),
});
};

View File

@@ -0,0 +1,485 @@
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { App, AppPermission } from '@rocket.chat/core-typings';
import { Box, Icon } from '@rocket.chat/fuselage';
import {
useSetModal,
useEndpoint,
useTranslation,
useRouteParameter,
useToastMessageDispatch,
usePermission,
useRouter,
} from '@rocket.chat/ui-contexts';
import type { MouseEvent, ReactNode } from 'react';
import { useMemo, useCallback, useState } from 'react';
import semver from 'semver';
import { useAppInstallationHandler } from './useAppInstallationHandler';
import type { MarketplaceRouteContext } from './useAppsCountQuery';
import { useAppsCountQuery } from './useAppsCountQuery';
import { useMarketplaceActions } from './useMarketplaceActions';
import { useOpenAppPermissionsReviewModal } from './useOpenAppPermissionsReviewModal';
import { useOpenIncompatibleModal } from './useOpenIncompatibleModal';
import WarningModal from '../../../components/WarningModal';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import type { AddonActionType } from '../AppsList/AddonRequiredModal';
import AddonRequiredModal from '../AppsList/AddonRequiredModal';
import IframeModal from '../IframeModal';
import UninstallGrandfatheredAppModal from '../components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal';
import type { Actions } from '../helpers';
import { appEnabledStatuses, appButtonProps } from '../helpers';
import { handleAPIError } from '../helpers/handleAPIError';
import { warnEnableDisableApp } from '../helpers/warnEnableDisableApp';
export type AppMenuOption = {
id: string;
section: number;
content: ReactNode;
disabled?: boolean;
onClick?: (e?: MouseEvent<HTMLElement>) => void;
};
type AppMenuSections = {
items: AppMenuOption[];
}[];
export const useAppMenu = (app: App, isAppDetailsPage: boolean) => {
const t = useTranslation();
const router = useRouter();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const openIncompatibleModal = useOpenIncompatibleModal();
const context = useRouteParameter('context') as MarketplaceRouteContext;
const currentTab = useRouteParameter('tab');
const appCountQuery = useAppsCountQuery(context);
const isAdminUser = usePermission('manage-apps');
const { data } = useIsEnterprise();
const isEnterpriseLicense = !!data?.isEnterprise;
const { data: workspaceHasMarketplaceAddon = false } = useHasLicenseModule(app.addon);
const { data: workspaceHasInstalledAddon = false } = useHasLicenseModule(app.installedAddon);
const [isLoading, setLoading] = useState(false);
const [requestedEndUser, setRequestedEndUser] = useState(app.requestedEndUser);
const [isAppPurchased, setPurchased] = useState(app?.isPurchased);
const button = appButtonProps({ ...app, isAdminUser, endUserRequested: false });
const buttonLabel = button?.label.replace(' ', '_') as
'Update' | 'Install' | 'Subscribe' | 'See_Pricing' | 'Try_now' | 'Buy' | 'Request' | 'Requested';
const action = button?.action || '';
const setAppStatus = useEndpoint<'POST', '/apps/:id/status'>('POST', '/apps/:id/status', { id: app.id });
const buildExternalUrl = useEndpoint('GET', '/apps/buildExternalUrl');
const syncApp = useEndpoint<'POST', '/apps/:id/sync'>('POST', '/apps/:id/sync', { id: app.id });
const uninstallApp = useEndpoint<'DELETE', '/apps/:id'>('DELETE', '/apps/:id', { id: app.id });
const canAppBeSubscribed = app.purchaseType === 'subscription';
const isSubscribed = app.subscriptionInfo && ['active', 'trialing'].includes(app.subscriptionInfo.status);
const isAppEnabled = app.status ? appEnabledStatuses.includes(app.status) : false;
const closeModal = useCallback(() => {
setModal(null);
setLoading(false);
}, [setModal, setLoading]);
const marketplaceActions = useMarketplaceActions();
const installationSuccess = useCallback(
async (action: Actions | '', permissionsGranted?: AppPermission[]) => {
if (action) {
if (action === 'request') {
setRequestedEndUser(true);
} else {
await marketplaceActions[action]({ ...app, permissionsGranted });
}
}
setLoading(false);
},
[app, marketplaceActions, setLoading],
);
const openPermissionModal = useOpenAppPermissionsReviewModal({
app,
onCancel: closeModal,
onConfirm: (permissionsGranted) => installationSuccess(action, permissionsGranted),
});
const appInstallationHandler = useAppInstallationHandler({
app,
isAppPurchased,
action,
onDismiss: closeModal,
onSuccess: installationSuccess,
setIsPurchased: setPurchased,
});
// TODO: There is no necessity of all these callbacks being out of the above useMemo.
// My propose here is to refactor the hook to make it clearer and with less unnecessary caching.
const missingAddonHandler = useCallback(
(actionType: AddonActionType) => {
setModal(<AddonRequiredModal actionType={actionType} onDismiss={closeModal} onInstallAnyway={appInstallationHandler} />);
},
[appInstallationHandler, closeModal, setModal],
);
const handleAddon = useCallback(
(actionType: AddonActionType, callback: () => void) => {
if (actionType === 'enable' && isAdminUser && app.installedAddon && !workspaceHasInstalledAddon) {
return missingAddonHandler(actionType);
}
if (actionType !== 'enable' && isAdminUser && app.addon && !workspaceHasMarketplaceAddon) {
return missingAddonHandler(actionType);
}
callback();
},
[app.addon, app.installedAddon, isAdminUser, missingAddonHandler, workspaceHasInstalledAddon, workspaceHasMarketplaceAddon],
);
const handleAcquireApp = useCallback(() => {
setLoading(true);
handleAddon('install', appInstallationHandler);
}, [appInstallationHandler, handleAddon]);
const handleSubscription = useCallback(async () => {
if (app?.versionIncompatible && !isSubscribed) {
openIncompatibleModal(app, 'subscribe', closeModal);
return;
}
let data;
try {
data = await buildExternalUrl({
appId: app.id,
purchaseType: app.purchaseType,
details: 'true',
});
} catch (error) {
handleAPIError(error);
return;
}
const confirm = async () => {
try {
await syncApp();
} catch (error) {
handleAPIError(error);
}
};
setModal(<IframeModal url={data.url} confirm={confirm} cancel={closeModal} />);
}, [app, isSubscribed, setModal, closeModal, openIncompatibleModal, buildExternalUrl, syncApp]);
const handleViewLogs = useCallback(() => {
router.navigate({
name: 'marketplace',
params: {
context,
page: 'info',
id: app.id,
version: app.version,
tab: 'logs',
},
});
}, [app.id, app.version, context, router]);
const handleDisable = useCallback(() => {
const confirm = async () => {
closeModal();
try {
const { status } = await setAppStatus({ status: AppStatus.MANUALLY_DISABLED });
warnEnableDisableApp(app.name, status, 'disable');
} catch (error) {
handleAPIError(error);
}
};
setModal(
<WarningModal close={closeModal} confirm={confirm} text={t('Apps_Marketplace_Deactivate_App_Prompt')} confirmText={t('Yes')} />,
);
}, [app.name, closeModal, setAppStatus, setModal, t]);
const handleEnable = useCallback(() => {
handleAddon('enable', async () => {
try {
const { status } = await setAppStatus({ status: AppStatus.MANUALLY_ENABLED });
warnEnableDisableApp(app.name, status, 'enable');
} catch (error) {
handleAPIError(error);
}
});
}, [app.name, handleAddon, setAppStatus]);
const handleUninstall = useCallback(() => {
const uninstall = async () => {
closeModal();
try {
const { success } = await uninstallApp();
if (success) {
dispatchToastMessage({ type: 'success', message: `${app.name} uninstalled` });
if (context === 'details' && currentTab !== 'details') {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), tab: 'details' },
},
{ replace: true },
);
}
}
} catch (error) {
handleAPIError(error);
}
};
if (isSubscribed) {
const confirm = async () => {
await handleSubscription();
};
setModal(
<WarningModal
close={closeModal}
cancel={uninstall}
confirm={confirm}
text={t('Apps_Marketplace_Uninstall_Subscribed_App_Prompt')}
confirmText={t('Apps_Marketplace_Modify_App_Subscription')}
cancelText={t('Apps_Marketplace_Uninstall_Subscribed_App_Anyway')}
/>,
);
}
if (!appCountQuery.data) {
return;
}
if (app.migrated) {
setModal(
<UninstallGrandfatheredAppModal
context={context}
appName={app.name}
limit={appCountQuery.data.limit}
handleUninstall={uninstall}
handleClose={closeModal}
/>,
);
return;
}
setModal(
<WarningModal close={closeModal} confirm={uninstall} text={t('Apps_Marketplace_Uninstall_App_Prompt')} confirmText={t('Yes')} />,
);
}, [
isSubscribed,
appCountQuery.data,
app.migrated,
app.name,
setModal,
closeModal,
t,
uninstallApp,
dispatchToastMessage,
context,
currentTab,
router,
handleSubscription,
]);
const incompatibleIconName = useCallback(
(app: App, action: 'subscribe' | 'install' | 'update') => {
if (!app.versionIncompatible) {
if (action === 'update') {
return 'refresh';
}
return 'card';
}
// Now we are handling an incompatible app
if (action === 'subscribe' && !isSubscribed) {
return 'warning';
}
if (action === 'install' || action === 'update') {
return 'warning';
}
return 'card';
},
[isSubscribed],
);
const handleUpdate = useCallback(async () => {
setLoading(true);
if (app?.versionIncompatible) {
openIncompatibleModal(app, 'update', closeModal);
return;
}
handleAddon('update', openPermissionModal);
}, [app, handleAddon, openPermissionModal, openIncompatibleModal, closeModal]);
const canUpdate = app.installed && app.version && app.marketplaceVersion && semver.lt(app.version, app.marketplaceVersion);
const menuSections = useMemo(() => {
const bothAppStatusOptions = [
canAppBeSubscribed &&
isSubscribed &&
isAdminUser && {
id: 'subscribe',
section: 0,
content: (
<>
<Icon name={incompatibleIconName(app, 'subscribe')} size='x16' marginInlineEnd={4} />
{t('Subscription')}
</>
),
onClick: handleSubscription,
},
];
const nonInstalledAppOptions = [
!app.installed &&
!!button && {
id: 'acquire',
section: 0,
disabled: requestedEndUser,
content: (
<>
{isAdminUser && <Icon name={incompatibleIconName(app, 'install')} size='x16' marginInlineEnd={4} />}
{t(buttonLabel)}
</>
),
onClick: handleAcquireApp,
},
];
const isPossibleToEnableApp =
app.installed &&
isAdminUser &&
!isAppEnabled &&
// If the app is migrated, it can be enabled regardless of other validations
// If not, and the app isEnterpriseOnly, we need to check the workspace's license
(app.migrated || !app.isEnterpriseOnly || isEnterpriseLicense);
const doesItReachedTheLimit =
!app.migrated &&
!appCountQuery?.data?.hasUnlimitedApps &&
appCountQuery?.data?.enabled !== undefined &&
appCountQuery?.data?.enabled >= appCountQuery?.data?.limit;
const installedAppOptions = [
context !== 'details' &&
isAdminUser &&
app.installed && {
id: 'viewLogs',
section: 0,
content: (
<>
<Icon name='desktop-text' size='x16' marginInlineEnd={4} />
{t('View_Logs')}
</>
),
onClick: handleViewLogs,
},
isAdminUser &&
!!canUpdate &&
!isAppDetailsPage && {
id: 'update',
section: 0,
content: (
<>
<Icon name={incompatibleIconName(app, 'update')} size='x16' marginInlineEnd={4} />
{t('Update')}
</>
),
onClick: handleUpdate,
},
app.installed &&
isAdminUser &&
isAppEnabled && {
id: 'disable',
section: 0,
content: (
<Box color='status-font-on-warning'>
<Icon name='ban' size='x16' marginInlineEnd={4} />
{t('Disable')}
</Box>
),
onClick: handleDisable,
},
isPossibleToEnableApp && {
id: 'enable',
section: 0,
disabled: doesItReachedTheLimit,
content: (
<>
<Icon name='check' size='x16' marginInlineEnd='x4' />
{t('Enable')}
</>
),
onClick: handleEnable,
},
app.installed &&
isAdminUser && {
id: 'uninstall',
section: 1,
content: (
<Box color='status-font-on-danger'>
<Icon name='trash' size='x16' marginInlineEnd={4} />
{t('Uninstall')}
</Box>
),
onClick: handleUninstall,
},
];
const filtered = [...bothAppStatusOptions, ...nonInstalledAppOptions, ...installedAppOptions].flatMap((value) =>
value && typeof value !== 'boolean' ? value : [],
);
const sections: AppMenuSections = [];
filtered.forEach((option) => {
if (typeof sections[option.section] === 'undefined') {
sections[option.section] = { items: [] };
}
sections[option.section].items.push(option);
});
return sections;
}, [
canAppBeSubscribed,
isSubscribed,
isAdminUser,
incompatibleIconName,
app,
t,
handleSubscription,
button,
requestedEndUser,
buttonLabel,
handleAcquireApp,
isEnterpriseLicense,
isAppEnabled,
appCountQuery?.data?.hasUnlimitedApps,
appCountQuery?.data?.enabled,
appCountQuery?.data?.limit,
context,
handleViewLogs,
canUpdate,
isAppDetailsPage,
handleUpdate,
handleDisable,
handleEnable,
handleUninstall,
]);
return { isLoading, isAdminUser, sections: menuSections };
};

View File

@@ -0,0 +1,17 @@
import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useAppRequestStats = () => {
const canManageApp = usePermission('manage-apps');
const fetchRequestStats = useEndpoint('GET', '/apps/app-request/stats');
return useQuery({
queryKey: ['app-requests-stats'],
queryFn: () => fetchRequestStats(),
select: ({ data }) => data,
refetchOnWindowFocus: false,
retry: false,
enabled: canManageApp,
});
};

View File

@@ -0,0 +1,13 @@
import type { AppRequestFilter } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useAppRequests = (appId: string, limit?: number, offset?: number, sort?: string, filter?: AppRequestFilter) => {
const fetchAppRequests = useEndpoint('GET', '/apps/app-request');
return useQuery({
queryKey: ['app-requests', appId, limit, offset],
queryFn: async () => fetchAppRequests({ appId, q: filter, sort, limit, offset }),
gcTime: 0,
});
};

View File

@@ -0,0 +1,44 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { Utilities } from '../../../../ee/lib/misc/Utilities';
type AppTranslationFunction = {
(key: string, ...replaces: unknown[]): string;
has: (key: string | undefined) => boolean;
};
export const useAppTranslation = (appId: string): AppTranslationFunction => {
const t = useTranslation();
const tApp = useCallback(
(key: string, ...args: unknown[]) => {
if (!key) {
return '';
}
const appKey = Utilities.getI18nKeyForApp(key, appId);
if (t.has(appKey)) {
return t(appKey, ...args);
}
if (t.has(key)) {
return t(key, ...args);
}
return key;
},
[t, appId],
);
return Object.assign(tApp, {
has: useCallback(
(key: string | undefined) => {
if (!key) {
return false;
}
return t.has(Utilities.getI18nKeyForApp(key, appId)) || t.has(key);
},
[t, appId],
),
});
};

View File

@@ -0,0 +1,39 @@
import type { App } from '@rocket.chat/core-typings';
import type { UseQueryResult } from '@tanstack/react-query';
import { storeQueryFunction } from './useApps';
import { createFakeApp } from '../../../../tests/mocks/data';
import { createFakeAppInstalledMarketplace, createFakeAppPrivate } from '../../../../tests/mocks/data/marketplace';
describe(`when an app installed from the Marketplace, but has since been unpublished`, () => {
it(`should still be present in the installed app data provided`, () => {
const marketplaceMockQuery = {
data: [createFakeApp({ id: 'marketplace-1' }), createFakeAppInstalledMarketplace({ id: 'marketplace-2' })],
isFetched: true,
} as unknown as UseQueryResult<App[], unknown>;
const instanceMockQuery = {
data: [
marketplaceMockQuery.data?.[1],
createFakeAppInstalledMarketplace({ id: 'marketplace-3' }), // This app has been installed via Marketplace but has been unpublished since
createFakeAppPrivate({ id: 'private-1' }),
],
isFetched: true,
} as unknown as UseQueryResult<App[], unknown>;
const [marketplaceList, installedList, privateList] = storeQueryFunction(marketplaceMockQuery, instanceMockQuery);
expect(marketplaceList.find((app) => app.id === 'marketplace-1')).toBeTruthy();
expect(marketplaceList.find((app) => app.id === 'marketplace-2')).toBeTruthy();
expect(marketplaceList.find((app) => app.id === 'marketplace-3')).toBeUndefined();
expect(marketplaceList).toHaveLength(2);
expect(installedList.find((app) => app.id === 'marketplace-1')).toBeUndefined();
expect(installedList.find((app) => app.id === 'marketplace-2')).toBeTruthy();
expect(installedList.find((app) => app.id === 'marketplace-3')).toBeTruthy();
expect(installedList).toHaveLength(2);
expect(privateList.find((app) => app.id === 'private-1')).toBeTruthy();
expect(privateList).toHaveLength(1);
});
});

View File

@@ -0,0 +1,180 @@
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useInvalidateLicense } from '@rocket.chat/ui-client';
import { usePermission, useStream } from '@rocket.chat/ui-contexts';
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
import { useContext, useEffect } from 'react';
import { useInvalidateAppsCountQueryCallback } from './useAppsCountQuery';
import { AppsContext } from '../../../contexts/AppsContext';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
import type { App } from '../types';
const sortByName = (apps: App[]): App[] => apps.toSorted((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1));
/**
* Aggregates result data from marketplace request and instance installed into their appropriate lists
*
* Exporting for better testing
*/
export const storeQueryFunction = (
marketplace: UseQueryResult<App[], unknown>,
instance: UseQueryResult<App[], unknown>,
): [App[], App[], App[]] => {
if (!marketplace.isFetched && !instance.isFetched) {
throw new Error('Apps not loaded');
}
const marketplaceApps: App[] = [];
const installedApps: App[] = [];
const privateApps: App[] = [];
const clonedData = [...(instance.data || [])];
sortByName(marketplace.data || []).forEach((app) => {
const appIndex = clonedData.findIndex(({ id }) => id === app.id);
const [installedApp] = appIndex > -1 ? clonedData.splice(appIndex, 1) : [];
const record = {
...app,
...(installedApp && {
private: installedApp.private,
clusterStatus: installedApp.clusterStatus,
installed: true,
status: installedApp.status,
version: installedApp.version,
licenseValidation: installedApp.licenseValidation,
migrated: installedApp.migrated,
installedAddon: installedApp.addon,
}),
bundledIn: app.bundledIn,
marketplaceVersion: app.version,
};
if (installedApp) {
if (installedApp.private) {
privateApps.push(record);
} else {
installedApps.push(record);
}
}
marketplaceApps.push(record);
});
sortByName(clonedData).forEach((app) => {
if (app.private) {
privateApps.push(app);
return;
}
installedApps.push(app);
});
return [marketplaceApps, installedApps, privateApps];
};
export const useApps = <
TData = {
installedApps: App[];
marketplaceApps: App[];
privateApps: App[];
},
>(
options?: Omit<
UseQueryOptions<
{
installedApps: App[];
marketplaceApps: App[];
privateApps: App[];
},
Error,
TData,
ReturnType<typeof marketplaceQueryKeys.appsStored>
>,
'queryKey' | 'queryFn' | 'enabled' | 'placeholderData'
>,
) => {
const orchestrator = useContext(AppsContext);
if (!orchestrator) throw new Error('Apps Orchestrator is not available');
const canManageApps = usePermission('manage-apps');
const queryClient = useQueryClient();
const { data: isEnterprise } = useIsEnterprise();
const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback();
const invalidateLicenseQuery = useInvalidateLicense();
const stream = useStream('apps');
const invalidate = useDebouncedCallback(
() => {
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.appsInstance() });
invalidateAppsCountQuery();
},
100,
[queryClient, invalidateAppsCountQuery],
);
useEffect(() => {
return stream('apps', ([key]) => {
if (['app/added', 'app/removed', 'app/updated', 'app/statusUpdate', 'app/settingUpdated'].includes(key)) {
invalidate();
}
if (['app/added', 'app/removed'].includes(key) && !isEnterprise) {
invalidateLicenseQuery();
}
});
}, [invalidate, invalidateLicenseQuery, isEnterprise, stream]);
const marketplace = useQuery({
queryKey: marketplaceQueryKeys.appsMarketplace(canManageApps),
queryFn: async () => {
const result = await orchestrator.getAppsFromMarketplace(canManageApps);
if (result.error && typeof result.error === 'string') {
throw new Error(result.error);
}
return result.apps;
},
staleTime: Infinity,
placeholderData: keepPreviousData,
});
const instance = useQuery({
queryKey: marketplaceQueryKeys.appsInstance(canManageApps),
queryFn: async () => {
const result = await orchestrator.getInstalledApps().then((result: App[]) =>
result.map((current: App) => ({
...current,
installed: true,
})),
);
return result;
},
staleTime: Infinity,
refetchOnMount: 'always',
});
return useQuery({
queryKey: marketplaceQueryKeys.appsStored(instance.data, marketplace.data),
queryFn: async () => {
if (marketplace.isError) {
throw marketplace.error;
}
const [marketplaceAppsData, installedAppsData, privateAppsData] = storeQueryFunction(marketplace, instance);
return {
installedApps: installedAppsData,
marketplaceApps: marketplaceAppsData,
privateApps: privateAppsData,
};
},
enabled: marketplace.isFetched && instance.isFetched,
placeholderData: keepPreviousData,
...options,
});
};

View File

@@ -0,0 +1,43 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested' | 'details';
export function isMarketplaceRouteContext(context: string): context is MarketplaceRouteContext {
return ['private', 'explore', 'installed', 'premium', 'requested'].includes(context);
}
export const useAppsCountQuery = (context: MarketplaceRouteContext) => {
const getAppsCount = useEndpoint('GET', '/apps/count');
return useQuery({
queryKey: ['apps/count', context],
queryFn: async () => {
const data = await getAppsCount();
const numberOfEnabledApps = context === 'private' ? data.totalPrivateEnabled : data.totalMarketplaceEnabled;
const enabledAppsLimit = context === 'private' ? data.maxPrivateApps : data.maxMarketplaceApps;
const hasUnlimitedApps = enabledAppsLimit === -1;
return {
hasUnlimitedApps,
enabled: numberOfEnabledApps,
limit: enabledAppsLimit,
// tooltip,
};
},
staleTime: 10_000,
});
};
export const useInvalidateAppsCountQueryCallback = () => {
const queryClient = useQueryClient();
return useCallback(() => {
queryClient.invalidateQueries({
queryKey: ['apps/count'],
});
}, [queryClient]);
};

View File

@@ -0,0 +1,5 @@
import { useContext } from 'react';
import { AppsContext } from '../../../contexts/AppsContext';
export const useAppsOrchestration = () => useContext(AppsContext);

View File

@@ -0,0 +1,11 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
export const useAppsReload = () => {
const queryClient = useQueryClient();
return useCallback(() => {
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.all });
}, [queryClient]);
};

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCategoryFlatList } from './useCategoryFlatList';
import { useCategoryToggle } from './useCategoryToggle';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
import type {
CategoryDropDownGroups,
CategoryDropdownItem,
CategoryDropDownListProps,
CategoryOnSelected,
selectedCategoriesList,
} from '../definitions/CategoryDropdownDefinitions';
import { handleAPIError } from '../helpers/handleAPIError';
export const useCategories = (): [CategoryDropDownGroups, selectedCategoriesList, selectedCategoriesList, CategoryOnSelected] => {
const { t } = useTranslation();
const [categories, setCategories] = useState<CategoryDropDownListProps['categories']>([]);
const fetchCategories = useCallback(async (): Promise<void> => {
try {
const fetchedCategories = await AppClientOrchestratorInstance.getCategories();
const mappedCategories = fetchedCategories
.filter((currentCategory) => !currentCategory.hidden)
.map((currentCategory) => ({
id: currentCategory.id,
label: currentCategory.title,
checked: false,
}));
setCategories([
{
items: [
{
id: 'all',
label: t('All_categories'),
},
],
},
{
label: t('Filter_by_category'),
items: mappedCategories,
},
]);
} catch (e: any) {
handleAPIError(e);
}
}, [t]);
useEffect(() => {
const fetchCategoriesWrapper = async (): Promise<void> => {
await fetchCategories();
};
fetchCategoriesWrapper();
}, [fetchCategories]);
const onSelected = useCategoryToggle(setCategories);
const flatCategories = useCategoryFlatList(categories);
const originalSize = useCategoryFlatList(categories).length;
const selectedCategories = useMemo(
() => flatCategories.filter((category) => Boolean(category.checked)),
[flatCategories],
) as (CategoryDropdownItem & { checked: true })[];
return [categories, selectedCategories, originalSize === selectedCategories.length ? [] : selectedCategories, onSelected];
};

View File

@@ -0,0 +1,6 @@
import { useMemo } from 'react';
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions';
export const useCategoryFlatList = (categories: CategoryDropDownListProps['categories']): CategoryDropdownItem[] =>
useMemo(() => categories.flatMap((group) => group.items).filter(({ id }) => id !== 'all'), [categories]);

View File

@@ -0,0 +1,39 @@
import type { Dispatch, SetStateAction } from 'react';
import { useCallback } from 'react';
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions';
export const useCategoryToggle = (
setData: Dispatch<SetStateAction<CategoryDropDownListProps['categories']>>,
): CategoryDropDownListProps['onSelected'] => {
const onSelected = useCallback(
(item: CategoryDropdownItem) =>
setData((prev) => {
const categories = prev.flatMap((group) => group.items);
const categoriesWithoutAll = categories.filter(({ id }) => id !== 'all');
const allCategoriesOption = categories.find(({ id }) => id === 'all');
const toggledCategory = categories.find(({ id }) => id === item.id);
const isAllCategoriesToggled = item.id === 'all';
if (isAllCategoriesToggled) {
categoriesWithoutAll.forEach((currentItem) => {
currentItem.checked = !item.checked;
});
}
if (toggledCategory) {
toggledCategory.checked = !toggledCategory.checked;
}
if (allCategoriesOption && categoriesWithoutAll.some((currentCategory) => currentCategory.checked === false)) {
allCategoriesOption.checked = false;
}
return [...prev];
}),
[setData],
);
return onSelected;
};

View File

@@ -0,0 +1,13 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
export const useFeaturedApps = (): UseQueryResult<OperationResult<'GET', '/apps/featured-apps'>> => {
const featuredApps = useEndpoint('GET', '/apps/featured-apps');
return useQuery({
queryKey: ['featured-apps'],
queryFn: () => featuredApps(),
});
};

View File

@@ -0,0 +1,130 @@
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { useApps } from './useApps';
import { filterAppsByCategories } from '../helpers/filterAppsByCategories';
import { filterAppsByDisabled } from '../helpers/filterAppsByDisabled';
import { filterAppsByEnabled } from '../helpers/filterAppsByEnabled';
import { filterAppsByEnterprise } from '../helpers/filterAppsByEnterprise';
import { filterAppsByFree } from '../helpers/filterAppsByFree';
import { filterAppsByPaid } from '../helpers/filterAppsByPaid';
import { filterAppsByText } from '../helpers/filterAppsByText';
import { sortAppsByAlphabeticalOrInverseOrder } from '../helpers/sortAppsByAlphabeticalOrInverseOrder';
import { sortAppsByClosestOrFarthestModificationDate } from '../helpers/sortAppsByClosestOrFarthestModificationDate';
import type { App } from '../types';
export const useFilteredApps = ({
text,
current,
itemsPerPage,
categories = [],
purchaseType,
sortingMethod,
status,
context,
}: {
text: string;
current: number;
itemsPerPage: number;
categories: string[];
purchaseType: string;
isEnterpriseOnly?: boolean;
sortingMethod: string;
status: string;
context?: string;
}) =>
useApps({
select: ({
marketplaceApps,
installedApps,
privateApps,
}): PaginatedResult<{
items: App[];
shouldShowSearchText: boolean;
allApps: App[];
totalAppsLength: number;
}> => {
const apps = (() => {
switch (context) {
case 'premium':
case 'explore':
case 'requested':
return marketplaceApps;
case 'private':
return privateApps;
default:
return installedApps;
}
})();
const fallback = (apps: App[]) => apps;
const sortingMethods: Record<string, (apps: App[]) => App[]> = {
urf: (apps: App[]) =>
apps.toSorted(
(firstApp, secondApp) => (secondApp?.appRequestStats?.totalUnseen || 0) - (firstApp?.appRequestStats?.totalUnseen || 0),
),
url: (apps: App[]) =>
apps.toSorted(
(firstApp, secondApp) => (firstApp?.appRequestStats?.totalUnseen || 0) - (secondApp?.appRequestStats?.totalUnseen || 0),
),
az: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(firstApp.name, secondApp.name)),
za: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(secondApp.name, firstApp.name)),
mru: (apps: App[]) =>
apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(firstApp.modifiedAt, secondApp.modifiedAt)),
lru: (apps: App[]) =>
apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(secondApp.modifiedAt, firstApp.modifiedAt)),
};
const filterByPurchaseType: Record<string, (apps: App[]) => App[]> = {
all: fallback,
paid: (apps: App[]) => apps.filter(filterAppsByPaid),
premium: (apps: App[]) => apps.filter(filterAppsByEnterprise),
free: (apps: App[]) => apps.filter(filterAppsByFree),
};
const filterByStatus: Record<string, (apps: App[]) => App[]> = {
all: fallback,
enabled: (apps: App[]) => apps.filter(filterAppsByEnabled),
disabled: (apps: App[]) => apps.filter(filterAppsByDisabled),
};
const filterByContext: Record<string, (apps: App[]) => App[]> = {
explore: fallback,
installed: fallback,
private: fallback,
premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Premium')),
requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed),
};
type appsFilterFunction = (apps: App[]) => App[];
const pipeAppsFilter =
(...functions: appsFilterFunction[]) =>
(initialValue: App[]) =>
functions.reduce((currentAppsList, currentFilterFunction) => currentFilterFunction(currentAppsList), initialValue);
const filtered = pipeAppsFilter(
context ? filterByContext[context] : fallback,
filterByPurchaseType[purchaseType],
filterByStatus[status],
categories.length ? (apps: App[]) => apps.filter((app) => filterAppsByCategories(app, categories)) : fallback,
text ? (apps: App[]) => apps.filter(({ name }) => filterAppsByText(name, text)) : fallback,
sortingMethods[sortingMethod],
)(apps);
const shouldShowSearchText = !!text;
const total = filtered.length;
const offset = current > total ? 0 : current;
const end = current + itemsPerPage;
const slice = filtered.slice(offset, end);
return {
items: slice,
offset,
total: filtered.length,
totalAppsLength: apps.length,
count: slice.length,
shouldShowSearchText,
allApps: filtered,
};
},
});

View File

@@ -0,0 +1,138 @@
import type { App, AppPermission } from '@rocket.chat/core-typings';
import { useRouter, useSetModal, useUpload } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { useAppsReload } from './useAppsReload';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import AppExemptModal from '../AppExemptModal';
import AppPermissionsReviewModal from '../AppPermissionsReviewModal';
import AppUpdateModal from '../AppUpdateModal';
import { useAppsCountQuery } from './useAppsCountQuery';
import { handleAPIError } from '../helpers/handleAPIError';
import { handleInstallError } from '../helpers/handleInstallError';
import { getManifestFromZippedApp } from '../lib/getManifestFromZippedApp';
export const useInstallApp = (file: File): { install: () => void; isInstalling: boolean } => {
const reloadAppsList = useAppsReload();
const setModal = useSetModal();
const router = useRouter();
const appCountQuery = useAppsCountQuery('private');
const uploadAppEndpoint = useUpload('/apps');
const uploadUpdateEndpoint = useUpload('/apps/update');
const { data } = useIsEnterprise();
const [isInstalling, setInstalling] = useState(false);
const { mutate: sendFile } = useMutation({
mutationKey: ['apps/installPrivateApp'],
mutationFn: ({ permissionsGranted, appFile, appId }: { permissionsGranted: AppPermission[]; appFile: File; appId?: string }) => {
const fileData = new FormData();
fileData.append('app', appFile, appFile.name);
fileData.append('permissions', JSON.stringify(permissionsGranted));
if (appId) {
return uploadUpdateEndpoint(fileData);
}
return uploadAppEndpoint(fileData) as any;
},
onSuccess: (data: { app: App }) => {
router.navigate({
name: 'marketplace',
params: {
context: 'private',
page: 'info',
id: data.app.id,
},
});
},
onError: (e) => {
handleAPIError(e);
},
onSettled: () => {
setInstalling(false);
setModal(null);
reloadAppsList();
},
});
const cancelAction = useCallback(() => {
setInstalling(false);
setModal(null);
}, [setInstalling, setModal]);
const isAppInstalled = async (appId: string) => {
try {
const app = await AppClientOrchestratorInstance.getApp(appId);
return !!app || false;
} catch (e) {
return false;
}
};
const handleAppPermissionsReview = async (permissions: AppPermission[], appFile: File, appId?: string) => {
setModal(
<AppPermissionsReviewModal
appPermissions={permissions}
onCancel={cancelAction}
onConfirm={(permissionsGranted) => sendFile({ permissionsGranted, appFile, appId })}
/>,
);
};
const uploadFile = async (appFile: File, { id, permissions }: { id: string; permissions: AppPermission[] }) => {
const isInstalled = await isAppInstalled(id);
const isExempt = !data?.isEnterprise && isInstalled;
if (isInstalled && isExempt) {
return setModal(<AppExemptModal appName={appFile.name} onCancel={cancelAction} />);
}
if (isInstalled) {
return setModal(<AppUpdateModal cancel={cancelAction} confirm={() => handleAppPermissionsReview(permissions, appFile, id)} />);
}
await handleAppPermissionsReview(permissions, appFile);
};
const extractManifestFromAppFile = async (appFile: File) => {
try {
return getManifestFromZippedApp(appFile);
} catch (error) {
handleInstallError(error as Error);
}
};
const install = async () => {
setInstalling(true);
if (!appCountQuery.data) {
return cancelAction();
}
const appFile = file;
if (!appFile) {
return cancelAction();
}
const manifest = await extractManifestFromAppFile(appFile);
if (!manifest) {
return cancelAction();
}
return uploadFile(appFile, manifest);
};
return { install, isInstalling };
};

View File

@@ -0,0 +1,46 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
export const useLogs = ({
appId,
current,
itemsPerPage,
logLevel,
method,
startDate,
endDate,
instanceId,
}: {
appId: string;
current: number;
itemsPerPage: number;
logLevel?: '0' | '1' | '2';
method?: string;
startDate?: string;
endDate?: string;
instanceId?: string;
}): UseQueryResult<OperationResult<'GET', '/apps/:id/logs'>> => {
const query = useMemo(
() => ({
count: itemsPerPage,
offset: current,
...(logLevel && { logLevel }),
...(method && { method }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(instanceId && { instanceId }),
}),
[itemsPerPage, current, logLevel, method, startDate, endDate, instanceId],
);
const logs = useEndpoint('GET', '/apps/:id/logs', { id: appId });
return useQuery({
queryKey: marketplaceQueryKeys.appLogs(appId, query),
queryFn: () => logs(query),
});
};

View File

@@ -0,0 +1,13 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
export const useLogsDistinctValues = (appId: string): UseQueryResult<OperationResult<'GET', '/apps/:id/logs/distinctValues'>> => {
const getValues = useEndpoint('GET', '/apps/:id/logs/distinctValues', { id: appId });
return useQuery({
queryKey: ['app-logs-filter-distinct-values', appId],
queryFn: async () => getValues(),
});
};

View File

@@ -0,0 +1,57 @@
import type { App, AppPermission } from '@rocket.chat/core-typings';
import { useMutation } from '@tanstack/react-query';
import { useAppsOrchestration } from './useAppsOrchestration';
import { handleAPIError } from '../helpers/handleAPIError';
import { warnAppInstall } from '../helpers/warnAppInstall';
import { warnStatusChange } from '../helpers/warnStatusChange';
type InstallAppParams = App & {
permissionsGranted?: AppPermission[];
};
type UpdateAppParams = App & {
permissionsGranted?: AppPermission[];
};
export const useMarketplaceActions = () => {
const appsOrchestrator = useAppsOrchestration();
if (!appsOrchestrator) {
throw new Error('Apps orchestrator is not available');
}
const installAppMutation = useMutation({
mutationFn: ({ id, marketplaceVersion, permissionsGranted }: InstallAppParams) =>
appsOrchestrator.installApp(id, marketplaceVersion, permissionsGranted),
onSuccess: ({ status }, { name }) => {
if (!status) return;
warnAppInstall(name, status);
},
onError: (error) => {
handleAPIError(error);
},
});
const updateAppMutation = useMutation({
mutationFn: ({ id, marketplaceVersion, permissionsGranted }: UpdateAppParams) =>
appsOrchestrator.updateApp(id, marketplaceVersion, permissionsGranted),
onSuccess: ({ status }, { name }) => {
if (!status) return;
warnStatusChange(name, status);
},
onError: (error) => {
handleAPIError(error);
},
});
return {
purchase: installAppMutation.mutateAsync,
install: installAppMutation.mutateAsync,
update: updateAppMutation.mutateAsync,
} as const;
};

View File

@@ -0,0 +1,28 @@
import type { App } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { AppPermissionsReviewModalProps } from '../AppPermissionsReviewModal';
import AppPermissionsReviewModal from '../AppPermissionsReviewModal';
export const useOpenAppPermissionsReviewModal: (params: {
app: App;
onCancel: AppPermissionsReviewModalProps['onCancel'];
onConfirm: AppPermissionsReviewModalProps['onConfirm'];
}) => () => void = ({ app, onCancel, onConfirm }) => {
const setModal = useSetModal();
return useCallback(() => {
const handleCancel = () => {
setModal(null);
onCancel();
};
const handleConfirm: typeof onConfirm = (appPermissions) => {
setModal(null);
onConfirm(appPermissions);
};
return setModal(<AppPermissionsReviewModal appPermissions={app.permissions} onCancel={handleCancel} onConfirm={handleConfirm} />);
}, [app.permissions, onCancel, onConfirm, setModal]);
};

View File

@@ -0,0 +1,39 @@
import type { App } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import IframeModal from '../IframeModal';
import { useAppsOrchestration } from './useAppsOrchestration';
import { handleAPIError } from '../helpers/handleAPIError';
export const useOpenIncompatibleModal = () => {
const setModal = useSetModal();
const appsOrchestrator = useAppsOrchestration();
if (!appsOrchestrator) {
throw new Error('Apps orchestrator is not available');
}
return useCallback(
async (app: App, actionName: string, cancelAction: () => void) => {
const handleCancel = () => {
setModal(null);
cancelAction();
};
const handleConfirm = () => {
setModal(null);
cancelAction();
};
try {
const incompatibleData = await appsOrchestrator.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, actionName);
setModal(<IframeModal url={incompatibleData.url} cancel={handleCancel} confirm={handleConfirm} />);
} catch (e) {
handleAPIError(e);
}
},
[appsOrchestrator, setModal],
);
};

View File

@@ -0,0 +1,3 @@
import { useLicense } from '@rocket.chat/ui-client';
export const usePrivateAppsEnabled = () => (useLicense({ loadValues: true }).data?.limits?.privateApps?.max ?? 0) !== 0;

View File

@@ -0,0 +1,18 @@
import { useCallback } from 'react';
import type { RadioDropDownItem, RadioDropDownOnSelected, RadioDropDownSetData } from '../definitions/RadioDropDownDefinitions';
export const useRadioToggle = (setData: RadioDropDownSetData): RadioDropDownOnSelected => {
const onSelected = useCallback(
(item: RadioDropDownItem) => {
setData((prevState) => {
prevState.items.forEach((currentItem) => {
currentItem.checked = currentItem === item;
});
return { ...prevState };
});
},
[setData],
);
return onSelected;
};