Skip to content

Commit

Permalink
[NEW] Add federated users on channel creation (#25986)
Browse files Browse the repository at this point in the history
* convert useHasLicense to TS

* Make federation setting public

* Allow creation of federated channels

* Create new autocomplete

* Fix state

* Fix broken types

* Icons

* Change Federated hint

* fix: fix errors due to the models migration

* fix: fix lint

Co-authored-by: Marcos Defendi <marcos.defendi@rocket.chat>
  • Loading branch information
gabriellsh and MarcosSpessatto committed Jun 24, 2022
1 parent d7d3ca1 commit 2299887
Show file tree
Hide file tree
Showing 23 changed files with 311 additions and 132 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export class RocketChatSettingsAdapter {
i18nLabel: 'Federation_Matrix_enabled',
i18nDescription: 'Federation_Matrix_enabled_desc',
alert: 'Federation_Matrix_Enabled_Alert',
public: true,
});

const uniqueId = settings.get('uniqueID') || uuidv4().slice(0, 15).replace(new RegExp('-', 'g'), '_');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const executeSlashCommand = async (
return;
}

const [command, ...params] = stringParams.split(' ');
const [command, ...params] = stringParams.trim().split(' ');
const [rawUserId] = params;
const currentUserId = Meteor.userId();
if (!currentUserId || !commands[command]) {
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/lib/server/functions/createDirectRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { AppsEngineException } from '@rocket.chat/apps-engine/definition/excepti
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import type { IUser } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Rooms } from '@rocket.chat/models';
import { Subscriptions } from '@rocket.chat/models';

import { Users, Rooms } from '../../../models/server';
import { Apps } from '../../../apps/server';
import { callbacks } from '../../../../lib/callbacks';
import { settings } from '../../../settings/server';
Expand Down
36 changes: 11 additions & 25 deletions apps/meteor/client/components/RoomIcon/RoomIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { IRoom, isDirectMessageRoom, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { IRoom, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';
import React, { ComponentProps, ReactElement, isValidElement } from 'react';

import { ReactiveUserStatus } from '../UserStatus';
import { useRoomIcon } from '../../hooks/useRoomIcon';
import { OmnichannelRoomIcon } from './OmnichannelRoomIcon';

export const RoomIcon = ({
Expand All @@ -14,33 +14,19 @@ export const RoomIcon = ({
size?: ComponentProps<typeof Icon>['size'];
placement: 'sidebar' | 'default';
}): ReactElement | null => {
if (room.prid) {
return <Icon name='baloons' size={size} />;
}

if (room.teamMain) {
return <Icon name={room.t === 'p' ? 'team-lock' : 'team'} size={size} />;
}
const iconPropsOrReactNode = useRoomIcon(room);

if (isOmnichannelRoom(room)) {
return <OmnichannelRoomIcon placement={placement} room={room} size={size} />;
}
if (isDirectMessageRoom(room)) {
if (room.uids && room.uids.length > 2) {
return <Icon name='balloon' size={size} />;
}
if (room.uids && room.uids.length > 0) {
return <ReactiveUserStatus uid={room.uids.filter((uid) => uid !== room.u._id)[0] || room.u._id} />;
}
return <Icon name='at' size={size} />;

if (isValidElement(iconPropsOrReactNode)) {
return iconPropsOrReactNode;
}

switch (room.t) {
case 'p':
return <Icon name='hashtag-lock' size={size} />;
case 'c':
return <Icon name='hash' size={size} />;
default:
return null;
if (!iconPropsOrReactNode) {
return null;
}

return <Icon {...iconPropsOrReactNode} size={size} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { MultiSelectFiltered, Icon, Box, Chip } from '@rocket.chat/fuselage';
import type { Options } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import React, { memo, ReactElement, useState, ComponentProps } from 'react';
import { useQuery } from 'react-query';

import UserAvatar from '../avatar/UserAvatar';
import renderOptions from './UserAutoCompleteMultipleOptions';

type UserAutoCompleteMultipleFederatedProps = {
onChange: (value: Array<string>) => void;
value: Array<string>;
placeholder?: string;
};

export type UserAutoCompleteOptionType = {
name: string;
username: string;
_federated?: boolean;
};

type UserAutoCompleteOptions = {
[k: string]: UserAutoCompleteOptionType;
};

const matrixRegex = new RegExp('(.*:.*)', 'gi');

const UserAutoCompleteMultipleFederated = ({
onChange,
value,
placeholder,
...props
}: UserAutoCompleteMultipleFederatedProps): ReactElement => {
const [filter, setFilter] = useState('');
const [selectedCache, setSelectedCache] = useState<UserAutoCompleteOptions>({});

const debouncedFilter = useDebouncedValue(filter, 1000);
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');

const { data } = useQuery(['users.autocomplete', debouncedFilter], async () => {
const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) });
const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]);

// Add extra option if filter text matches `username:server`
// Used to add federated users that do not exist yet
if (matrixRegex.test(debouncedFilter)) {
options.unshift([debouncedFilter, { name: debouncedFilter, username: debouncedFilter, _federated: true }]);
}

return options;
});

const options = data || [];

const onAddSelected: ComponentProps<typeof Options>['onSelect'] = ([value]) => {
const cachedOption = options.find(([curVal]) => curVal === value)?.[1];
if (!cachedOption) {
throw new Error('UserAutoCompleteMultiple - onAddSelected - failed to cache option');
}
setSelectedCache({ ...selectedCache, [value]: cachedOption });
};

return (
<MultiSelectFiltered
placeholder={placeholder}
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
renderSelected={({ value, onMouseDown }: { value: string; onMouseDown: () => void }): ReactElement => {
const currentCachedOption = selectedCache[value];

return (
<Chip key={value} {...props} height='x20' onMouseDown={onMouseDown} mie='x4' mb='x2'>
{currentCachedOption._federated ? <Icon size='x20' name='globe' /> : <UserAvatar size='x20' username={value} />}
<Box is='span' margin='none' mis='x4'>
{currentCachedOption.name || currentCachedOption.username}
</Box>
</Chip>
);
}}
renderOptions={renderOptions(options, onAddSelected)}
options={options.concat(Object.entries(selectedCache)).map(([, item]) => [item.username, item.name || item.username])}
/>
);
};

export default memo(UserAutoCompleteMultipleFederated);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { IUser } from '@rocket.chat/core-typings';
import { Option, OptionDescription } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';

import UserAvatar from '../avatar/UserAvatar';

type UserAutoCompleteMultipleOptionProps = {
label: {
_federated?: boolean;
} & Pick<IUser, 'username' | 'name'>;
};

const UserAutoCompleteMultipleOption = ({ label, ...props }: UserAutoCompleteMultipleOptionProps): ReactElement => {
const { name, username, _federated } = label;

return (
<Option
{...props}
avatar={_federated ? undefined : <UserAvatar username={username || ''} size='x20' />}
icon={_federated ? 'globe' : undefined}
key={username}
label={
<>
{name || username} {!_federated && <OptionDescription>({username})</OptionDescription>}
</>
}
/>
);
};

export default UserAutoCompleteMultipleOption;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Options } from '@rocket.chat/fuselage';
import React, { forwardRef, ComponentProps, RefAttributes, ReactElement, Ref } from 'react';

import { UserAutoCompleteOptionType } from './UserAutoCompleteMultipleFederated';
import UserAutoCompleteMultipleOption from './UserAutoCompleteMultipleOption';

type Options = Array<[UserAutoCompleteOptionType['username'], UserAutoCompleteOptionType]>;

const renderOptions = (
options: Options,
onSelect: ComponentProps<typeof Options>['onSelect'],
): ((props: ComponentProps<typeof Options> & RefAttributes<HTMLElement>) => ReactElement | null) =>
forwardRef(function UserAutoCompleteMultipleOptions(
{ onSelect: _onSelect, ...props }: ComponentProps<typeof Options>,
ref: Ref<HTMLElement>,
): ReactElement {
return (
<Options
{...props}
options={options}
onSelect={(val): void => {
onSelect(val);
_onSelect(val);
}}
ref={ref}
renderItem={UserAutoCompleteMultipleOption}
/>
);
});

export default renderOptions;
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { MouseEvent } from 'react';

export const usePreventProgation = (fn: (e: MouseEvent) => void): ((e: MouseEvent) => void) => {
export const usePreventPropagation = (fn?: (e: MouseEvent) => void): ((e: MouseEvent) => void) => {
const preventClickPropagation = useMutableCallback((e): void => {
e.stopPropagation();
fn?.(e);
Expand Down
7 changes: 6 additions & 1 deletion apps/meteor/client/hooks/useRoomIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isIRoomFederated, isDirectMessageRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';

Expand All @@ -14,6 +15,10 @@ export const colors = {
export const useRoomIcon = (
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u'>,
): ReactElement | ComponentProps<typeof Icon> | null => {
if (isIRoomFederated(room)) {
return { name: 'globe' };
}

if (room.prid) {
return { name: 'baloons' };
}
Expand All @@ -22,7 +27,7 @@ export const useRoomIcon = (
return { name: room.t === 'p' ? 'team-lock' : 'team' };
}

if (room.t === 'd') {
if (isDirectMessageRoom(room)) {
if (room.uids && room.uids.length > 2) {
return { name: 'balloon' };
}
Expand Down
44 changes: 36 additions & 8 deletions apps/meteor/client/sidebar/header/CreateChannel.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,57 @@
import { Box, Modal, ButtonGroup, Button, TextInput, Icon, Field, ToggleSwitch, FieldGroup } from '@rocket.chat/fuselage';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useSetting, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import { useSetting, useMethod, useTranslation, TranslationKey } from '@rocket.chat/ui-contexts';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';

import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple';
import { useHasLicense } from '../../../ee/client/hooks/useHasLicense';
import UserAutoCompleteMultipleFederated from '../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated';

export type CreateChannelProps = {
values: {
name: string;
type: boolean;
federated?: boolean;
readOnly?: boolean;
encrypted?: boolean;
broadcast?: boolean;
users?: string[];
users: string[];
description?: string;
};
handlers: {
handleName?: () => void;
handleDescription?: () => void;
handleEncrypted?: () => void;
handleReadOnly?: () => void;
handleUsers: (users: Array<string>) => void;
};
hasUnsavedChanges: boolean;
onChangeUsers: (value: string, action: 'remove' | undefined) => void;
onChangeType: React.FormEventHandler<HTMLElement>;
onChangeBroadcast: React.FormEventHandler<HTMLElement>;
onChangeFederated: React.FormEventHandler<HTMLElement>;
canOnlyCreateOneType?: false | 'p' | 'c';
e2eEnabledForPrivateByDefault?: boolean;
onCreate: () => void;
onClose: () => void;
};

const getFederationHintKey = (licenseModule: ReturnType<typeof useHasLicense>, featureToggle: boolean): TranslationKey => {
if (licenseModule === 'loading' || !licenseModule) {
return 'error-this-is-an-ee-feature';
}
if (!featureToggle) {
return 'Federation_Matrix_Federated_Description_disabled';
}
return 'Federation_Matrix_Federated_Description';
};

const CreateChannel = ({
values,
handlers,
hasUnsavedChanges,
onChangeUsers,
onChangeType,
onChangeBroadcast,
canOnlyCreateOneType,
onChangeFederated,
e2eEnabledForPrivateByDefault,
onCreate,
onClose,
Expand All @@ -46,12 +60,17 @@ const CreateChannel = ({
const e2eEnabled = useSetting('E2E_Enable');
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const federationEnabled = useSetting('Federation_Matrix_enabled');
const channelNameExists = useMethod('roomNameExists');

const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]);

const [nameError, setNameError] = useState<string>();

const federatedModule = useHasLicense('federation');

const canUseFederation = federatedModule !== 'loading' && federatedModule && federationEnabled;

const checkName = useDebouncedCallback(
async (name: string) => {
setNameError(undefined);
Expand Down Expand Up @@ -126,6 +145,15 @@ const CreateChannel = ({
<ToggleSwitch checked={!!values.type} disabled={!!canOnlyCreateOneType} onChange={onChangeType} />
</Box>
</Field>
<Field>
<Box display='flex' justifyContent='space-between' alignItems='start'>
<Box display='flex' flexDirection='column' width='full'>
<Field.Label>{t('Federation_Matrix_Federated')}</Field.Label>
<Field.Description>{t(getFederationHintKey(federatedModule, Boolean(federationEnabled)))}</Field.Description>
</Box>
<ToggleSwitch checked={values.federated} onChange={onChangeFederated} disabled={!canUseFederation} />
</Box>
</Field>
<Field>
<Box display='flex' justifyContent='space-between' alignItems='start'>
<Box display='flex' flexDirection='column' width='full'>
Expand All @@ -145,7 +173,7 @@ const CreateChannel = ({
<Field.Label>{t('Encrypted')}</Field.Label>
<Field.Description>{values.type ? t('Encrypted_channel_Description') : t('Encrypted_not_available')}</Field.Description>
</Box>
<ToggleSwitch checked={values.encrypted} disabled={e2edisabled} onChange={handlers.handleEncrypted} />
<ToggleSwitch checked={values.encrypted} disabled={e2edisabled || values.federated} onChange={handlers.handleEncrypted} />
</Box>
</Field>
<Field>
Expand All @@ -154,12 +182,12 @@ const CreateChannel = ({
<Field.Label>{t('Broadcast')}</Field.Label>
<Field.Description>{t('Broadcast_channel_Description')}</Field.Description>
</Box>
<ToggleSwitch checked={values.broadcast} onChange={onChangeBroadcast} />
<ToggleSwitch checked={values.broadcast} onChange={onChangeBroadcast} disabled={!!values.federated} />
</Box>
</Field>
<Field>
<Field.Label>{`${t('Add_members')} (${t('optional')})`}</Field.Label>
<UserAutoCompleteMultiple value={values.users} onChange={onChangeUsers} />
<UserAutoCompleteMultipleFederated value={values.users} onChange={handlers.handleUsers} />
</Field>
</FieldGroup>
</Modal.Content>
Expand Down
Loading

0 comments on commit 2299887

Please sign in to comment.