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,13 @@
import { Skeleton } from '@rocket.chat/fuselage';
const SKELETON_ITEMS = 3;
const AccordionLoading = () => (
<>
{Array.from({ length: SKELETON_ITEMS }, (_v, k) => (
<Skeleton key={k} variant='rect' height='80px' margin='2px' />
))}
</>
);
export default AccordionLoading;

View File

@@ -0,0 +1,20 @@
import type { Meta } from '@storybook/react';
import AppInstallModal from './AppInstallModal';
export default {
component: AppInstallModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof AppInstallModal>;
export const Default = {
name: 'AppInstallModal',
args: {
enabled: 1,
limit: 3,
appName: 'Example-app-name',
},
};

View File

@@ -0,0 +1,93 @@
import {
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../../../components/MarkdownText';
type AppsInstallationModalProps = {
enabled: number;
limit: number;
appName: string;
handleClose: () => void;
handleConfirm: () => void;
handleEnableUnlimitedApps: () => void;
};
const AppInstallationModal = ({
enabled,
limit,
appName,
handleClose,
handleConfirm,
handleEnableUnlimitedApps,
}: AppsInstallationModalProps) => {
const { t } = useTranslation();
const getTitle = () => {
if (enabled === limit) {
return t('App_limit_reached');
}
if (enabled > limit) {
return t('App_limit_exceeded');
}
return t('Apps_Currently_Enabled', { context: '', enabled, limit });
};
const getContent = () => {
if (enabled === limit) {
return t('Enable_of_limit_apps_currently_enabled', { context: '', enabled, limit, appName });
}
if (enabled > limit) {
return t('Enable_of_limit_apps_currently_enabled_exceeded', {
enabled,
limit,
exceed: enabled - limit + 1,
appName,
});
}
return t('Workspaces_on_Community_edition_install_app', { context: '', enabled, limit });
};
const confirmButtonOverLimitLabel = t('Install_anyway');
return (
<>
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{getTitle()}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={handleClose} />
</ModalHeader>
<ModalContent>
<MarkdownText content={getContent()} />
</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button onClick={handleEnableUnlimitedApps}>{t('Enable_unlimited_apps')}</Button>
<Button {...(enabled < limit && { primary: true })} onClick={handleConfirm}>
{enabled < limit ? t('Next') : confirmButtonOverLimitLabel}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
</>
);
};
export default AppInstallationModal;

View File

@@ -0,0 +1,71 @@
import type { AppPermission } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
const defaultPermissions = [
'user.read',
'user.write',
'upload.read',
'upload.write',
'ui.interact',
'server-setting.read',
'server-setting.write',
'room.read',
'room.write',
'message.read',
'message.write',
'livechat-department.read',
'livechat-department.write',
'livechat-room.read',
'livechat-room.write',
'livechat-message.read',
'livechat-message.write',
'livechat-visitor.read',
'livechat-visitor.write',
'livechat-status.read',
'livechat-custom-fields.write',
'scheduler',
'networking',
'persistence',
'env.read',
'slashcommand',
'api',
];
export type AppPermissionsListProps = { appPermissions: AppPermission[] | undefined };
const AppPermissionsList = ({ appPermissions }: AppPermissionsListProps) => {
const { t } = useTranslation();
const handleAppPermissions = (permission: string): string => t(`Apps_Permissions_${permission.replace('.', '_')}` as TranslationKey);
if (appPermissions?.length) {
return (
<>
{appPermissions.map((permission) => (
<Fragment key={permission.name}>
<li>{handleAppPermissions(permission.name)}</li>
{permission.required && (
<Box is='span' color='status-font-on-danger'>
({t('required')})
</Box>
)}
</Fragment>
))}
</>
);
}
return (
<>
{defaultPermissions.map((permission) => (
<Fragment key={permission}>
<li>{handleAppPermissions(permission)}</li>
</Fragment>
))}
</>
);
};
export default AppPermissionsList;

View File

@@ -0,0 +1,40 @@
import { Banner, Icon } from '@rocket.chat/fuselage';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { usePermission, useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
const BannerEnterpriseTrialEnded = () => {
const { t } = useTranslation();
const isAdmin = usePermission('manage-apps');
const bannerLink = {
link: '/links/manage-subscription',
linkText: t('Upgrade_tab_upgrade_your_plan'),
linkTarget: '_blank',
};
const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial', false);
const [showTrialBanner, setShowTrialBanner] = useLocalStorage('showAppsTrialEndBanner', cloudWorkspaceHadTrial);
return (
<>
{showTrialBanner && (
<Banner
closeable
icon={<Icon name='store' size='x24' />}
variant='warning'
title={t('Apps_disabled_when_Premium_trial_ended')}
onClose={() => setShowTrialBanner(false)}
{...(isAdmin && bannerLink)}
>
{isAdmin
? t('Apps_disabled_when_Premium_trial_ended_description_admin')
: t('Apps_disabled_when_Premium_trial_ended_description')}
</Banner>
)}
</>
);
};
export default BannerEnterpriseTrialEnded;

View File

@@ -0,0 +1,83 @@
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
import type { StoryFn } from '@storybook/react';
import { useState } from 'react';
import CategoryDropDown from './CategoryDropDown';
import CategoryDropDownAnchor from './CategoryDropDownAnchor';
import CategoryDropDownList from './CategoryDropDownList';
import TagList from './TagList';
import type { CategoryDropDownListProps } from '../../definitions/CategoryDropdownDefinitions';
import { useCategories } from '../../hooks/useCategories';
import { useCategoryToggle } from '../../hooks/useCategoryToggle';
export default {
component: CategoryDropDownAnchor,
};
const testCategories: CategoryDropDownListProps['categories'] = [
{
items: [
{
id: 'all',
label: 'All categories',
},
],
},
{
label: 'Filter by Category',
items: [
{ id: '0', label: 'Analytics', checked: false },
{ id: '1', label: 'Bots', checked: false },
{ id: '2', label: 'Communication', checked: false },
{ id: '3', label: 'Content Management', checked: false },
{ id: '4', label: 'Customer Support', checked: false },
{ id: '5', label: 'Design', checked: false },
{ id: '6', label: 'Developer Tools', checked: false },
{ id: '7', label: 'File Management', checked: false },
{ id: '8', label: 'Finance', checked: false },
{ id: '9', label: 'Health & Wellness', checked: false },
{ id: '10', label: 'Human Resources', checked: false },
{ id: '11', label: 'Marketing', checked: false },
{ id: '12', label: 'Media & News', checked: false },
{ id: '13', label: 'Office Management', checked: false },
{ id: '14', label: 'Omnichannel', checked: false },
{ id: '15', label: 'Other', checked: false },
{ id: '16', label: 'Productivity', checked: false },
{ id: '17', label: 'Project Management', checked: false },
{ id: '18', label: 'Sales', checked: false },
{ id: '19', label: 'Security & Compliance', checked: false },
{ id: '20', label: 'Social & Fun', checked: false },
{ id: '21', label: 'Team Culture', checked: false },
{ id: '22', label: 'Travel', checked: false },
{ id: '23', label: 'Voice & Video', checked: false },
],
},
];
export const Anchor: StoryFn = () => (
<CategoryDropDownAnchor selectedCategoriesCount={testCategories.flatMap((group) => group.items).length} />
);
export const List: StoryFn = () => {
const [data, setData] = useState(() => testCategories);
const onSelected = useCategoryToggle(setData);
return <CategoryDropDownList categories={data} onSelected={onSelected} />;
};
export const Default: StoryFn = () => {
const [, selectedCategories, categoryTagList] = useCategories();
const [data, setData] = useState(testCategories);
const onSelected = useCategoryToggle(setData);
return (
<Box width='x144'>
<ButtonGroup>
<CategoryDropDown categories={data} selectedCategories={selectedCategories} onSelected={onSelected} />
</ButtonGroup>
<TagList categories={categoryTagList} onClick={onSelected} />
</Box>
);
};

View File

@@ -0,0 +1,52 @@
import type { Button } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { useRef, useCallback } from 'react';
import CategoryDropDownAnchor from './CategoryDropDownAnchor';
import CategoryDropDownList from './CategoryDropDownList';
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../../definitions/CategoryDropdownDefinitions';
import { isValidReference } from '../../helpers/isValidReference';
import { onMouseEventPreventSideEffects } from '../../helpers/onMouseEventPreventSideEffects';
import DropDownListWrapper from '../DropDownListWrapper';
export type CategoryDropDownProps = {
categories: CategoryDropDownListProps['categories'];
onSelected: CategoryDropDownListProps['onSelected'];
selectedCategories: (CategoryDropdownItem & { checked: true })[];
} & ComponentProps<typeof Button>;
const CategoryDropDown = ({ categories, onSelected, selectedCategories, ...props }: CategoryDropDownProps) => {
const reference = useRef<HTMLInputElement>(null);
const [collapsed, toggleCollapsed] = useToggle(false);
const onClose = useCallback(
(e: MouseEvent) => {
if (isValidReference(reference, e as { target: Node | null })) {
toggleCollapsed(false);
return;
}
onMouseEventPreventSideEffects(e);
},
[toggleCollapsed],
);
return (
<>
<CategoryDropDownAnchor
ref={reference}
onClick={() => toggleCollapsed()}
selectedCategoriesCount={selectedCategories.length}
{...props}
/>
{collapsed && (
<DropDownListWrapper ref={reference} onClose={onClose}>
<CategoryDropDownList categories={categories} onSelected={onSelected} />
</DropDownListWrapper>
)}
</>
);
};
export default CategoryDropDown;

View File

@@ -0,0 +1,70 @@
import type { Button } from '@rocket.chat/fuselage';
import { Box, Icon } from '@rocket.chat/fuselage';
import colorTokens from '@rocket.chat/fuselage-tokens/dist/colors.json';
import type { ComponentProps, MouseEventHandler } from 'react';
import { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
export type CategoryDropDownAnchorProps = { selectedCategoriesCount: number; onClick?: MouseEventHandler<HTMLElement> } & ComponentProps<
typeof Button
>;
const CategoryDropDownAnchor = forwardRef<HTMLElement, CategoryDropDownAnchorProps>(function CategoryDropDownAnchor(
{ onClick, selectedCategoriesCount, ...props },
ref,
) {
const { t } = useTranslation();
return (
<Box
is='button'
ref={ref}
onClick={onClick}
alignItems='center'
backgroundColor={selectedCategoriesCount ? colorTokens.b500 : 'light'}
borderColor={selectedCategoriesCount ? 'none' : 'light'}
borderRadius='x4'
borderWidth={selectedCategoriesCount ? 'none' : 'x1'}
display='flex'
flexGrow={1}
flexShrink={1}
justifyContent='space-between'
minWidth='x144'
height='x40'
paddingInlineEnd={7}
paddingInlineStart={14}
lineHeight='unset'
rcx-input-box
{...props}
>
{selectedCategoriesCount > 0 && (
<Box
is='span'
alignItems='center'
backgroundColor='light'
borderRadius='x32'
color='info'
display='flex'
fontSize='micro'
fontWeight={700}
height='fit-content'
justifyContent='center'
marginInlineEnd={6}
minWidth={22}
paddingBlock={4}
paddingInline={0}
>
{selectedCategoriesCount}
</Box>
)}
<Box is='span' display='flex' flexGrow={1} fontScale='p2' color={selectedCategoriesCount ? 'white' : 'hint'}>
{selectedCategoriesCount > 0 ? t('Categories') : t('All_categories')}
</Box>
<Box marginInline={4} display='flex' alignItems='center' justifyContent='center'>
<Icon name='chevron-down' size='x20' color={selectedCategoriesCount ? 'white' : 'hint'} />
</Box>
</Box>
);
});
export default CategoryDropDownAnchor;

View File

@@ -0,0 +1,27 @@
import { Box, CheckBox, Option, Tile } from '@rocket.chat/fuselage';
import { Fragment } from 'react';
import type { CategoryDropDownListProps } from '../../definitions/CategoryDropdownDefinitions';
const CategoryDropDownList = ({ categories, onSelected }: CategoryDropDownListProps) => {
return (
<Tile overflow='auto' paddingBlock={12} paddingInline={0} elevation='2' width='full' backgroundColor='light' borderRadius={2}>
{categories.map((category, index) => (
<Fragment key={index}>
{category.label && (
<Box paddingInline={16} paddingBlockStart={8} paddingBlockEnd={4} fontScale='micro' textTransform='uppercase' color='default'>
{category.label}
</Box>
)}
{category.items.map((item) => (
<Option key={item.id} {...({ label: item.label } as any)} onClick={(): void => onSelected(item)}>
<CheckBox checked={item.checked} onChange={(): void => onSelected(item)} />
</Option>
))}
</Fragment>
))}
</Tile>
);
};
export default CategoryDropDownList;

View File

@@ -0,0 +1,26 @@
import { Chip, ButtonGroup } from '@rocket.chat/fuselage';
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../../definitions/CategoryDropdownDefinitions';
export type TagListProps = {
categories: (CategoryDropdownItem & { checked: true })[];
onClick: CategoryDropDownListProps['onSelected'];
};
const TagList = ({ categories, onClick }: TagListProps) => {
if (!categories.length) {
return null;
}
return (
<ButtonGroup wrap small>
{categories.map((category) => (
<Chip key={category.id} onClick={() => onClick(category)}>
{category.label}
</Chip>
))}
</ButtonGroup>
);
};
export default TagList;

View File

@@ -0,0 +1,30 @@
import { Box } from '@rocket.chat/fuselage';
import { usePosition, useOutsideClick } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { forwardRef, useRef } from 'react';
const options = {
margin: 8,
placement: 'bottom-end',
} as const;
const hidden = {
visibility: 'hidden',
opacity: 0,
position: 'fixed',
} as const;
const DropDownListWrapper = forwardRef<Element, ComponentProps<typeof Box> & { onClose: (e: MouseEvent) => void }>(
function CategoryDropDownListWrapper({ children, onClose }, ref) {
const target = useRef<HTMLElement>(null);
useOutsideClick([target], onClose);
const { style = hidden } = usePosition(ref as Parameters<typeof usePosition>[0], target, options);
return (
<Box ref={target} style={style} minWidth={224} zIndex='99999'>
{children}
</Box>
);
},
);
export default DropDownListWrapper;

View File

@@ -0,0 +1,58 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import EnabledAppsCount from './EnabledAppsCount';
describe('in private context', () => {
const context = 'private';
it('should work under the limit', async () => {
render(<EnabledAppsCount enabled={1} limit={2} context={context} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Private_Apps_Count_Enabled_one: '{{count}} private app enabled',
Private_Apps_Count_Enabled_other: '{{count}} private apps enabled',
})
.build(),
});
expect(screen.getByText('1 private app enabled')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100');
});
it('should work with private apps disabled', async () => {
render(<EnabledAppsCount enabled={0} limit={0} context={context} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Private_Apps_Count_Enabled_one: '{{count}} private app enabled',
Private_Apps_Count_Enabled_other: '{{count}} private apps enabled',
})
.build(),
});
expect(screen.getByText('0 private apps enabled')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100');
});
});
describe.each(['explore', 'installed', 'premium', 'requested'] as const)('in %s context', (context) => {
it('should work', async () => {
render(<EnabledAppsCount enabled={1} limit={2} context={context} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Apps_Count_Enabled_one: '{{count}} app enabled',
Apps_Count_Enabled_other: '{{count}} apps enabled',
})
.build(),
});
expect(screen.getByText('1 app enabled')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100');
});
});

View File

@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericResourceUsage } from '../../../components/GenericResourceUsage';
const EnabledAppsCount = ({
limit,
enabled,
context,
tooltip,
}: {
limit: number;
enabled: number;
context: 'private' | 'explore' | 'installed' | 'premium' | 'requested';
tooltip?: string;
}) => {
const { t } = useTranslation();
const variant = useMemo(() => {
if (enabled + 1 === limit) {
return 'warning';
}
if (limit === 0 || enabled >= limit) {
return 'danger';
}
return 'success';
}, [enabled, limit]);
const percentage = limit === 0 ? 100 : Math.round((enabled * 100) / limit);
return (
<GenericResourceUsage
title={context === 'private' ? t('Private_Apps_Count_Enabled', { count: enabled }) : t('Apps_Count_Enabled', { count: enabled })}
value={enabled}
max={limit}
percentage={percentage}
threshold={80}
variant={variant}
tooltip={tooltip}
/>
);
};
export default EnabledAppsCount;

View File

@@ -0,0 +1,82 @@
import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage';
import { PageHeader } from '@rocket.chat/ui-client';
import { usePermission, useRoute, useRouteParameter, useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { GenericResourceUsageSkeleton } from '../../../components/GenericResourceUsage';
import UpgradeButton from '../../admin/subscription/components/UpgradeButton';
import UnlimitedAppsUpsellModal from '../UnlimitedAppsUpsellModal';
import EnabledAppsCount from './EnabledAppsCount';
import { useAppsCountQuery } from '../hooks/useAppsCountQuery';
import { usePrivateAppsEnabled } from '../hooks/usePrivateAppsEnabled';
import PrivateAppInstallModal from './PrivateAppInstallModal/PrivateAppInstallModal';
import UpdateRocketChatButton from './UpdateRocketChatButton';
export type MarketplaceHeaderProps = { title: string; unsupportedVersion: boolean };
const MarketplaceHeader = ({ title, unsupportedVersion }: MarketplaceHeaderProps) => {
const { t } = useTranslation();
const isAdmin = usePermission('manage-apps');
const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested';
const route = useRoute('marketplace');
const setModal = useSetModal();
const result = useAppsCountQuery(context);
const privateAppsEnabled = usePrivateAppsEnabled();
const handleProceed = (): void => {
setModal(null);
route.push({ context, page: 'install' });
};
const handleClickPrivate = () => {
if (!privateAppsEnabled) {
setModal(<PrivateAppInstallModal onClose={() => setModal(null)} onProceed={handleProceed} />);
return;
}
route.push({ context, page: 'install' });
};
if (result.isError) {
return null;
}
return (
<PageHeader title={title}>
{result.isLoading && <GenericResourceUsageSkeleton marginInline={16} />}
{!unsupportedVersion && result.isSuccess && !result.data.hasUnlimitedApps && (
<Margins inline={16}>
<EnabledAppsCount
{...result.data}
tooltip={context === 'private' && !privateAppsEnabled ? t('Private_apps_premium_message') : undefined}
context={context}
/>
</Margins>
)}
<ButtonGroup wrap align='end'>
{!unsupportedVersion && isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && context !== 'private' && (
<Button
onClick={() => {
setModal(<UnlimitedAppsUpsellModal onClose={() => setModal(null)} />);
}}
>
{t('Enable_unlimited_apps')}
</Button>
)}
{isAdmin && context === 'private' && <Button onClick={handleClickPrivate}>{t('Upload_private_app')}</Button>}
{isAdmin && result.isSuccess && !privateAppsEnabled && context === 'private' && (
<UpgradeButton primary target='private-apps-header' action='upgrade'>
{t('Upgrade')}
</UpgradeButton>
)}
{unsupportedVersion && isAdmin && context !== 'private' && <UpdateRocketChatButton />}
</ButtonGroup>
</PageHeader>
);
};
export default MarketplaceHeader;

View File

@@ -0,0 +1,20 @@
import { Badge, Skeleton } from '@rocket.chat/fuselage';
import { useAppRequestStats } from '../hooks/useAppRequestStats';
const MarketplaceRequestBadge = () => {
const requestStatsResult = useAppRequestStats();
if (requestStatsResult.isPending)
return requestStatsResult.fetchStatus !== 'idle' ? <Skeleton variant='circle' height='x16' width='x16' /> : null;
if (requestStatsResult.isError) return null;
if (!requestStatsResult.data.totalUnseen) {
return null;
}
return <Badge variant='primary'>{requestStatsResult.data.totalUnseen}</Badge>;
};
export default MarketplaceRequestBadge;

View File

@@ -0,0 +1,67 @@
import {
Box,
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../../../admin/subscription/hooks/useCheckoutUrl';
import { PRICING_LINK } from '../../../admin/subscription/utils/links';
export type PrivateAppInstallModalProps = {
onClose: () => void;
onProceed: () => void;
};
const PrivateAppInstallModal = ({ onClose, onProceed }: PrivateAppInstallModalProps) => {
const { t } = useTranslation();
const openExternalLink = useExternalLink();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'private-apps-page', action: 'upgrade' });
const goToManageSubscriptionPage = (): void => {
openExternalLink(manageSubscriptionUrl);
onClose();
};
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('Private_app_install_modal_title')}</ModalTitle>
</ModalHeaderText>
<ModalClose aria-label={t('Close')} onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box marginBlockEnd={28}>{t('Private_app_install_modal_content')}</Box>
{t('Upgrade_subscription_to_enable_private_apps')}
</ModalContent>
<ModalFooter justifyContent='space-between'>
<ModalFooterAnnotation>
<a target='_blank' rel='noopener noreferrer' href={PRICING_LINK}>
{t('Compare_plans')}
</a>
</ModalFooterAnnotation>
<ModalFooterControllers>
<Button onClick={onProceed}>{t('Upload_anyway')}</Button>
<Button onClick={goToManageSubscriptionPage} primary>
{t('Upgrade')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default PrivateAppInstallModal;

View File

@@ -0,0 +1,20 @@
import { Box, Option, RadioButton, Tile } from '@rocket.chat/fuselage';
import type { RadioDropDownProps } from '../definitions/RadioDropDownDefinitions';
const RadioButtonList = ({ group, onSelected }: RadioDropDownProps) => (
<Tile overflow='auto' paddingBlock={12} paddingInline={0} elevation='2' width='full' backgroundColor='light' borderRadius='x2'>
{group.label && (
<Box paddingInline={16} paddingBlockStart={8} paddingBlockEnd={4} fontScale='micro' textTransform='uppercase' color='default'>
{group.label}
</Box>
)}
{group.items.map((item) => (
<Option key={item.id} label={item.label} onClick={(): void => onSelected(item)}>
<RadioButton checked={item.checked} onChange={(): void => onSelected(item)} />
</Option>
))}
</Tile>
);
export default RadioButtonList;

View File

@@ -0,0 +1,45 @@
import type { Button } from '@rocket.chat/fuselage';
import { Box, Icon } from '@rocket.chat/fuselage';
import type { ComponentProps, MouseEvent } from 'react';
import { forwardRef } from 'react';
import type { RadioDropDownGroup } from '../../definitions/RadioDropDownDefinitions';
type RadioDropdownAnchorProps = {
onClick: (event: MouseEvent<HTMLElement>) => void;
group: RadioDropDownGroup;
} & Omit<ComponentProps<typeof Button>, 'onClick'>;
const RadioDownAnchor = forwardRef<HTMLElement, RadioDropdownAnchorProps>(function SortDropDownAnchor({ onClick, group, ...props }, ref) {
const selected = group?.items.find((item) => item.checked)?.label;
return (
<Box
is='button'
ref={ref}
onClick={onClick}
alignItems='center'
backgroundColor='light'
borderColor='light'
borderRadius='x4'
borderWidth='x1'
color='secondary-info'
display='flex'
flexGrow={1}
flexShrink={1}
fontScale='p2'
height='x40'
justifyContent='space-between'
minWidth='x144'
paddingInlineEnd={10}
paddingInlineStart={14}
rcx-input-box
{...props}
>
{selected}
<Icon name='chevron-down' size='x20' />
</Box>
);
});
export default RadioDownAnchor;

View File

@@ -0,0 +1,40 @@
import type { StoryFn } from '@storybook/react';
import { useState } from 'react';
import { useRadioToggle } from '../../hooks/useRadioToggle';
import RadioButtonList from '../RadioButtonList';
import RadioDropDown from './RadioDropDown';
export default {
component: RadioDropDown,
};
const testGroup = {
label: 'Sort by',
items: [
{ id: 'az', label: 'A-Z', checked: true },
{ id: 'za', label: 'Z-A', checked: false },
{ id: 'MRU', label: 'Most recent updated', checked: false },
{ id: 'LRU', label: 'Least recent updated', checked: false },
],
};
export const List: StoryFn = () => {
const [data, setData] = useState(() => testGroup);
const onSelected = useRadioToggle(setData);
return <RadioButtonList group={data} onSelected={onSelected} />;
};
export const Default: StoryFn = () => {
const [data, setData] = useState(() => testGroup);
const onSelected = useRadioToggle(setData);
return (
<>
<RadioDropDown group={data} onSelected={onSelected} />
</>
);
};

View File

@@ -0,0 +1,43 @@
import type { Button } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { useCallback, useRef } from 'react';
import RadioDropDownAnchor from './RadioDownAnchor';
import type { RadioDropDownProps } from '../../definitions/RadioDropDownDefinitions';
import { isValidReference } from '../../helpers/isValidReference';
import { onMouseEventPreventSideEffects } from '../../helpers/onMouseEventPreventSideEffects';
import DropDownListWrapper from '../DropDownListWrapper';
import RadioButtonList from '../RadioButtonList';
const RadioDropDown = ({ group, onSelected, ...props }: RadioDropDownProps & ComponentProps<typeof Button>) => {
const reference = useRef<HTMLElement>(null);
const [collapsed, toggleCollapsed] = useToggle(false);
const onClose = useCallback(
(e: MouseEvent) => {
if (isValidReference(reference, e as { target: Node | null })) {
toggleCollapsed(false);
return;
}
onMouseEventPreventSideEffects(e);
return false;
},
[toggleCollapsed],
);
return (
<>
<RadioDropDownAnchor ref={reference} group={group} onClick={() => toggleCollapsed()} {...props} />
{collapsed && (
<DropDownListWrapper ref={reference} onClose={onClose}>
<RadioButtonList group={group} onSelected={onSelected} />
</DropDownListWrapper>
)}
</>
);
};
export default RadioDropDown;

View File

@@ -0,0 +1,97 @@
import type { AppScreenshot } from '@rocket.chat/core-typings';
import { Box, IconButton } from '@rocket.chat/fuselage';
export type ScreenshotCarouselProps = {
AppScreenshots: Array<AppScreenshot>;
setViewCarousel: (state: boolean) => void;
handleNextSlide: () => void;
handlePrevSlide: () => void;
isFirstSlide: boolean;
isLastSlide: boolean;
currentSlideIndex: number;
};
const ScreenshotCarousel = ({
AppScreenshots,
setViewCarousel,
handleNextSlide,
handlePrevSlide,
isFirstSlide,
isLastSlide,
currentSlideIndex,
}: ScreenshotCarouselProps) => {
const handleScreenshotRender = () =>
AppScreenshots.map((currentScreenshot, index) => {
const isCurrentImageOnScreen = index === currentSlideIndex;
const screenshotWrapperStyle = isCurrentImageOnScreen
? {
opacity: '1',
transitionDuration: '1s',
transform: 'scale(1.08)',
}
: {
opacity: '0',
transitionDuration: '1s ease',
};
return (
<Box style={screenshotWrapperStyle} key={currentScreenshot.id}>
{isCurrentImageOnScreen && (
<Box
is='img'
src={currentScreenshot.accessUrl}
alt='Carousel image'
maxWidth='x1200'
maxHeight='x600'
width='100%'
height='100%'
/>
)}
</Box>
);
});
return (
<>
<Box position='fixed' width='100%' height='100vh' backgroundColor='font-pure-black' opacity='0.7' marginBlock='-0.75px' zIndex='2' />
{!isFirstSlide && (
<IconButton
secondary
icon='chevron-right'
onClick={handlePrevSlide}
style={{ top: '50%', left: '10px', cursor: 'pointer', transform: 'translateY(-50%)' }}
position='absolute'
zIndex={3}
/>
)}
{!isLastSlide && (
<IconButton
secondary
icon='chevron-left'
onClick={handleNextSlide}
style={{ top: '50%', right: '10px', cursor: 'pointer', transform: 'translateY(-50%)' }}
position='absolute'
zIndex={3}
/>
)}
<Box
onClick={(): void => setViewCarousel(false)}
position='fixed'
width='100%'
height='100vh'
display='flex'
justifyContent='center'
alignItems='center'
zIndex='2'
marginInline={38}
>
{handleScreenshotRender()}
</Box>
</>
);
};
export default ScreenshotCarousel;

View File

@@ -0,0 +1,119 @@
import type { AppScreenshot } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, Icon } from '@rocket.chat/fuselage';
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import ScreenshotCarousel from './ScreenshotCarousel';
export type ScreenshotCarouselAnchorProps = {
screenshots: AppScreenshot[];
};
type voidFunction = () => void;
const ScreenshotCarouselAnchor = ({ screenshots }: ScreenshotCarouselAnchorProps) => {
const [viewCarousel, setViewCarousel] = useState(false);
const [currentSlideIndex, setCurrentSlideIndex] = useState(0);
const [currentPreviewIndex, setCurrentPreviewIndex] = useState(0);
const { length } = screenshots;
const isFirstSlide = currentSlideIndex === 0;
const isLastSlide = currentSlideIndex === length - 1;
const isCarouselVisible = viewCarousel && screenshots?.length;
const handleNextSlide = (): void => {
setCurrentSlideIndex(currentSlideIndex + 1);
};
const handlePrevSlide = (): void => {
setCurrentSlideIndex(currentSlideIndex - 1);
};
const handleKeyboardKey = useCallback(
(onKeyDownEvent: KeyboardEvent): void => {
const keysObject: Record<string, voidFunction> = {
ArrowLeft: () => setCurrentSlideIndex((prevSlideIndex) => (prevSlideIndex !== 0 ? prevSlideIndex - 1 : 0)),
ArrowRight: () => setCurrentSlideIndex((prevSlideIndex) => (prevSlideIndex !== length - 1 ? prevSlideIndex + 1 : length - 1)),
Escape: () => setViewCarousel(false),
};
keysObject[onKeyDownEvent.key]();
},
[length],
);
useEffect(() => {
const intervalId = setInterval(() => {
setCurrentPreviewIndex((prevIndex) => {
if (prevIndex === length - 1) return 0;
return prevIndex + 1;
});
}, 5000);
document.addEventListener('keydown', handleKeyboardKey);
return (): void => {
clearInterval(intervalId);
document.removeEventListener('keydown', handleKeyboardKey);
};
}, [handleKeyboardKey, length]);
const carouselPortal = createPortal(
<ScreenshotCarousel
AppScreenshots={screenshots}
setViewCarousel={setViewCarousel}
handleNextSlide={handleNextSlide}
handlePrevSlide={handlePrevSlide}
isFirstSlide={isFirstSlide}
isLastSlide={isLastSlide}
currentSlideIndex={currentSlideIndex}
/>,
document.body,
);
return (
<>
<Box
onClick={(): void => setViewCarousel(true)}
display='flex'
flexDirection='column'
maxWidth='x640'
width='100%'
style={{
cursor: 'pointer',
}}
tabIndex={0}
>
<Box
is='img'
src={screenshots[currentPreviewIndex]?.accessUrl}
alt='App preview image'
className={[
css`
transition: filter 0.2s ease;
&:hover {
filter: brightness(90%);
}
`,
]}
/>
<Box display='flex' flexDirection='row' backgroundColor='tint' paddingInline={16} paddingBlock={10} alignItems='center'>
<Icon name='image' size='x24' marginInlineEnd={8} />{' '}
<Box is='span' fontScale='p2m' color='default'>
{currentPreviewIndex + 1} of {screenshots.length}
</Box>
</Box>
</Box>
{isCarouselVisible && carouselPortal}
</>
);
};
export default ScreenshotCarouselAnchor;

View File

@@ -0,0 +1,18 @@
import type { Meta } from '@storybook/react';
import UninstallGrandfatheredAppModal from './UninstallGrandfatheredAppModal';
export default {
component: UninstallGrandfatheredAppModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof UninstallGrandfatheredAppModal>;
export const Default = {
name: 'UninstallGrandfatheredAppModal',
args: {
appName: 'Example-App-Name',
},
};

View File

@@ -0,0 +1,65 @@
import {
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterAnnotation,
ModalFooterControllers,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../../../components/MarkdownText';
import { links } from '../../../../lib/links';
import type { MarketplaceRouteContext } from '../../hooks/useAppsCountQuery';
import { usePrivateAppsEnabled } from '../../hooks/usePrivateAppsEnabled';
export type UninstallGrandfatheredAppModalProps = {
context: MarketplaceRouteContext;
limit: number;
appName: string;
handleUninstall: () => void;
handleClose: () => void;
};
const UninstallGrandfatheredAppModal = ({ context, limit, appName, handleUninstall, handleClose }: UninstallGrandfatheredAppModalProps) => {
const { t } = useTranslation();
const privateAppsEnabled = usePrivateAppsEnabled();
const modalContent =
context === 'private' && !privateAppsEnabled
? t('App_will_lose_grandfathered_status_private')
: t('App_will_lose_grandfathered_status', { limit });
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('Uninstall_grandfathered_app', { appName })}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={handleClose} />
</ModalHeader>
<ModalContent>
<MarkdownText content={modalContent} />
</ModalContent>
<ModalFooter justifyContent='space-between'>
<ModalFooterAnnotation>
<a target='_blank' rel='noopener noreferrer' href={links.go.appsDocumentation}>
{t('Learn_more')}
</a>
</ModalFooterAnnotation>
<ModalFooterControllers>
<Button onClick={handleClose}>{t('Cancel')}</Button>
<Button danger onClick={handleUninstall}>
{t('Uninstall')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default UninstallGrandfatheredAppModal;

View File

@@ -0,0 +1,16 @@
import { Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { links } from '../../../lib/links';
const UpdateRocketChatButton = () => {
const { t } = useTranslation();
return (
<Button icon='new-window' primary is='a' href={links.updatingRocketChat} external>
{t('Update')}
</Button>
);
};
export default UpdateRocketChatButton;