diff --git a/.changeset/orange-paws-poke.md b/.changeset/orange-paws-poke.md new file mode 100644 index 0000000000000..2b49cea8f1442 --- /dev/null +++ b/.changeset/orange-paws-poke.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Deprecates `Anonymous write`. Feature will be removed in version 9.0.0. diff --git a/.changeset/tame-tables-complain.md b/.changeset/tame-tables-complain.md new file mode 100644 index 0000000000000..2d8c05a3f1432 --- /dev/null +++ b/.changeset/tame-tables-complain.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes "Join" button on Outlook Calendar bubbling click event, also opening the calendar event details. diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index a2fa2b675f07f..149a8a20a79e0 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -1,8 +1,9 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { CustomSounds } from '@rocket.chat/models'; -import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { isCustomSoundsGetOneProps, + isCustomSoundsListProps, ajv, validateBadRequestErrorResponse, validateNotFoundErrorResponse, @@ -15,38 +16,6 @@ import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -type CustomSoundsList = PaginatedRequest<{ name?: string }>; - -const CustomSoundsListSchema = { - type: 'object', - properties: { - count: { - type: 'number', - nullable: true, - }, - offset: { - type: 'number', - nullable: true, - }, - sort: { - type: 'string', - nullable: true, - }, - name: { - type: 'string', - nullable: true, - }, - query: { - type: 'string', - nullable: true, - }, - }, - required: [], - additionalProperties: false, -}; - -export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); - const customSoundsEndpoints = API.v1 .get( 'custom-sounds.list', diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 600bf15793cc9..c54f84c03fac2 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -19,16 +19,16 @@ export const permissions = [ { _id: 'ban-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'bulk-register-user', roles: ['admin'] }, { _id: 'change-livechat-room-visitor', roles: ['admin', 'livechat-manager', 'livechat-agent'] }, - { _id: 'create-c', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-d', roles: ['admin', 'user', 'bot', 'app'] }, - { _id: 'create-p', roles: ['admin', 'user', 'bot', 'app'] }, + { _id: 'create-c', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-d', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, + { _id: 'create-p', roles: ['admin', 'user', 'federated-external', 'bot', 'app'] }, { _id: 'create-personal-access-tokens', roles: ['admin', 'user'] }, { _id: 'create-user', roles: ['admin'] }, { _id: 'clean-channel-history', roles: ['admin'] }, { _id: 'delete-c', roles: ['admin', 'owner'] }, { _id: 'delete-d', roles: ['admin'] }, { _id: 'delete-message', roles: ['admin', 'owner', 'moderator'] }, - { _id: 'delete-own-message', roles: ['admin', 'user'] }, + { _id: 'delete-own-message', roles: ['admin', 'user', 'federated-external'] }, { _id: 'delete-p', roles: ['admin', 'owner'] }, { _id: 'delete-user', roles: ['admin'] }, { _id: 'edit-message', roles: ['admin', 'owner', 'moderator'] }, @@ -44,8 +44,8 @@ export const permissions = [ { _id: 'edit-room-retention-policy', roles: ['admin'] }, { _id: 'force-delete-message', roles: ['admin', 'owner'] }, { _id: 'join-without-join-code', roles: ['admin', 'bot', 'app'] }, - { _id: 'leave-c', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, - { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-c', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, + { _id: 'leave-p', roles: ['admin', 'user', 'federated-external', 'bot', 'anonymous', 'app'] }, { _id: 'logout-other-user', roles: ['admin'] }, { _id: 'manage-assets', roles: ['admin'] }, { _id: 'manage-email-inbox', roles: ['admin'] }, @@ -57,8 +57,8 @@ export const permissions = [ { _id: 'manage-own-incoming-integrations', roles: ['admin'] }, { _id: 'manage-oauth-apps', roles: ['admin'] }, { _id: 'manage-selected-settings', roles: ['admin'] }, - { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user'] }, - { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'mention-all', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, + { _id: 'mention-here', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, { _id: 'mute-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'remove-user', roles: ['admin', 'owner', 'moderator'] }, { _id: 'run-import', roles: ['admin'] }, @@ -67,12 +67,12 @@ export const permissions = [ { _id: 'set-owner', roles: ['admin', 'owner'] }, { _id: 'send-many-messages', roles: ['admin', 'bot', 'app'] }, { _id: 'set-leader', roles: ['admin', 'owner'] }, - { _id: 'start-discussion', roles: ['admin', 'user', 'guest', 'app'] }, - { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'owner', 'app'] }, + { _id: 'start-discussion', roles: ['admin', 'user', 'federated-external', 'guest', 'app'] }, + { _id: 'start-discussion-other-user', roles: ['admin', 'user', 'federated-external', 'owner', 'app'] }, { _id: 'unarchive-room', roles: ['admin'] }, - { _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'app', 'anonymous'] }, + { _id: 'view-c-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'anonymous'] }, { _id: 'user-generate-access-token', roles: ['admin'] }, - { _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app', 'guest'] }, + { _id: 'view-d-room', roles: ['admin', 'user', 'federated-external', 'bot', 'app', 'guest'] }, { _id: 'view-device-management', roles: ['admin'] }, { _id: 'view-engagement-dashboard', roles: ['admin'] }, { _id: 'view-full-other-user-info', roles: ['admin'] }, @@ -80,13 +80,13 @@ export const permissions = [ { _id: 'view-join-code', roles: ['admin'] }, { _id: 'view-logs', roles: ['admin'] }, { _id: 'view-other-user-channels', roles: ['admin'] }, - { _id: 'view-p-room', roles: ['admin', 'user', 'anonymous', 'guest'] }, + { _id: 'view-p-room', roles: ['admin', 'user', 'federated-external', 'anonymous', 'guest'] }, { _id: 'view-privileged-setting', roles: ['admin'] }, { _id: 'view-room-administration', roles: ['admin'] }, { _id: 'view-statistics', roles: ['admin'] }, { _id: 'view-user-administration', roles: ['admin'] }, - { _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }, - { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user'] }, + { _id: 'preview-c-room', roles: ['admin', 'user', 'federated-external', 'anonymous'] }, + { _id: 'view-outside-room', roles: ['admin', 'owner', 'moderator', 'user', 'federated-external'] }, { _id: 'view-broadcast-member-list', roles: ['admin', 'owner', 'moderator'] }, { _id: 'call-management', roles: ['admin', 'owner', 'moderator', 'user'] }, { _id: 'create-invite-links', roles: ['admin', 'owner', 'moderator'] }, @@ -220,10 +220,10 @@ export const permissions = [ { _id: 'manage-sounds', roles: ['admin'] }, { _id: 'access-mailer', roles: ['admin'] }, { _id: 'pin-message', roles: ['owner', 'moderator', 'admin'] }, - { _id: 'mobile-upload-file', roles: ['user', 'admin'] }, + { _id: 'mobile-upload-file', roles: ['user', 'federated-external', 'admin'] }, { _id: 'send-mail', roles: ['admin'] }, { _id: 'view-federation-data', roles: ['admin'] }, - { _id: 'access-federation', roles: ['admin', 'user'] }, + { _id: 'access-federation', roles: ['admin', 'user', 'federated-external'] }, { _id: 'add-all-to-room', roles: ['admin'] }, { _id: 'get-server-info', roles: ['admin'] }, { _id: 'register-on-cloud', roles: ['admin'] }, diff --git a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts index 747899aa7fb99..21dac7808f00d 100644 --- a/apps/meteor/app/authorization/server/functions/upsertPermissions.ts +++ b/apps/meteor/app/authorization/server/functions/upsertPermissions.ts @@ -18,6 +18,7 @@ export const upsertPermissions = async (): Promise => { { name: 'leader', scope: 'Subscriptions', description: 'Leader' }, { name: 'owner', scope: 'Subscriptions', description: 'Owner' }, { name: 'user', scope: 'Users', description: '' }, + { name: 'federated-external', scope: 'Users', description: 'External Federated User' }, { name: 'bot', scope: 'Users', description: '' }, { name: 'app', scope: 'Users', description: '' }, { name: 'guest', scope: 'Users', description: '' }, diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx index 528d261f4fab0..0380f63742d74 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx @@ -186,10 +186,9 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh dispatchToastMessage({ type: 'success', message: t('Room_has_been_created') }); reload?.(); + onClose(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - } finally { - onClose(); } }; diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx index 87feeea766266..e375ed28f6f54 100644 --- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx +++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx @@ -146,10 +146,9 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const { team } = await createTeamAction(params); dispatchToastMessage({ type: 'success', message: t('Team_has_been_created') }); goToRoomById(team.roomId); + onClose(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - } finally { - onClose(); } }; diff --git a/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventItem.tsx b/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventItem.tsx index ec8692cfdecba..d7c0362dbb986 100644 --- a/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventItem.tsx +++ b/apps/meteor/client/views/outlookCalendar/OutlookEventsList/OutlookEventItem.tsx @@ -5,6 +5,7 @@ import { useSetModal } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; +import { usePreventPropagation } from '../../../hooks/usePreventPropagation'; import OutlookCalendarEventModal from '../OutlookCalendarEventModal'; import { useOutlookOpenCall } from '../hooks/useOutlookOpenCall'; @@ -15,7 +16,7 @@ const OutlookEventItem = ({ subject, description, startTime, meetingUrl }: Outlo const setModal = useSetModal(); const formatDateAndTime = useFormatDateAndTime(); const openCall = useOutlookOpenCall(meetingUrl); - + const handleMeetingClick = usePreventPropagation(openCall); const hovered = css` &:hover { cursor: pointer; @@ -57,7 +58,7 @@ const OutlookEventItem = ({ subject, description, startTime, meetingUrl }: Outlo {meetingUrl && ( - )} diff --git a/apps/meteor/public/fonts/InterVariable.woff2 b/apps/meteor/public/fonts/InterVariable.woff2 new file mode 120000 index 0000000000000..3efff30c43186 --- /dev/null +++ b/apps/meteor/public/fonts/InterVariable.woff2 @@ -0,0 +1 @@ +../../node_modules/@rocket.chat/fuselage/dist/fonts/InterVariable.woff2 \ No newline at end of file diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index 9860fb3b26455..3a279f51b316f 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -181,6 +181,7 @@ export const createAccountSettings = () => await this.add('Accounts_AllowAnonymousWrite', false, { type: 'boolean', public: true, + alert: 'Accounts_AllowAnonymousWrite_Deprecation_Alert', enableQuery: { _id: 'Accounts_AllowAnonymousRead', value: true, diff --git a/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.spec.ts b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.spec.ts new file mode 100644 index 0000000000000..6decf26c6d918 --- /dev/null +++ b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.spec.ts @@ -0,0 +1,116 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser'; + +jest.mock('@rocket.chat/models', () => ({ + Users: { + findOneAndUpdate: jest.fn(), + }, +})); + +const mockFindOneAndUpdate = Users.findOneAndUpdate as jest.MockedFunction; + +describe('createOrUpdateFederatedUser', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should assign the "federated" role to the new user', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(1); + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.roles).toEqual(['federated-external']); + }); + + it('should not assign the "user" role to federated users', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.roles).not.toContain('user'); + }); + + it('should set federated=true on the created/updated user', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.federated).toBe(true); + }); + + it('should use the provided name when supplied', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', name: 'Alice', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.name).toBe('Alice'); + }); + + it('should default name to username when name is not provided', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.name).toBe('@alice:example.com'); + }); + + it('should set initial status to OFFLINE', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.status).toBe(UserStatus.OFFLINE); + }); + + it('should store the origin server in the federation object', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0]; + expect((updateDoc as any).$set.federation.origin).toBe('example.com'); + }); + + it('should use upsert so the user is created if not found', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + const [, , options] = mockFindOneAndUpdate.mock.calls[0]; + expect((options as any).upsert).toBe(true); + }); + + it('should throw when findOneAndUpdate returns null', async () => { + mockFindOneAndUpdate.mockResolvedValueOnce(null as any); + + await expect(createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' })).rejects.toThrow( + 'Failed to create or update federated user: @alice:example.com', + ); + }); + + it('should return the user returned by findOneAndUpdate', async () => { + const fakeUser = { _id: 'user123', username: '@alice:example.com' }; + mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any); + + const result = await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' }); + + expect(result).toBe(fakeUser); + }); +}); diff --git a/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts index 27a7d42c0019d..2ed4f22c6c59d 100644 --- a/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts +++ b/ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts @@ -11,8 +11,6 @@ import { Users } from '@rocket.chat/models'; export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise { const { username, name = username, origin } = options; - console.log('createOrUpdateFederatedUser ->', options); - // TODO: Have a specific method to handle this upsert const user = await Users.findOneAndUpdate( { @@ -25,7 +23,7 @@ export async function createOrUpdateFederatedUser(options: { username: string; n type: 'user' as const, status: UserStatus.OFFLINE, active: true, - roles: ['user'], + roles: ['federated-external'], requirePasswordChange: false, federated: true, federation: { diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts new file mode 100644 index 0000000000000..65fd47040f926 --- /dev/null +++ b/ee/packages/media-calls/src/constants.ts @@ -0,0 +1,4 @@ +import type { CallFeature } from '@rocket.chat/media-signaling'; + +export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio']; +export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold']; diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index cb0af26822ffa..b9683a6d6a3fc 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -15,7 +15,7 @@ export type InternalCallParams = { requestedService?: CallService; parentCallId?: string; requestedBy?: MediaCallSignedContact; - features?: CallFeature[]; + features: CallFeature[]; }; export type MediaCallHeader = AtLeast; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 19a0e9f619330..35d9b5546198e 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -10,6 +10,7 @@ import type { } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; +import { DEFAULT_CALL_FEATURES } from '../constants'; import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { mediaCallDirector } from '../server/CallDirector'; @@ -137,9 +138,8 @@ export class GlobalSignalProcessor { // If this user's side of the call has already been signed if (actor.contractId) { - // If it's signed to the same session that is now registering - // Or it was signed by a session that the current session is replacing (as in a browser refresh) - if (actor.contractId === signal.contractId || actor.contractId === signal.oldContractId) { + // If it was signed by a session that the current session is replacing (as in a browser refresh) + if (actor.contractId === signal.oldContractId) { logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id }); await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); return; @@ -181,7 +181,7 @@ export class GlobalSignalProcessor { const services = signal.supportedServices ?? []; const requestedService = services.includes('webrtc') ? 'webrtc' : services[0]; - const features = signal.supportedFeatures ?? ['audio']; + const features = signal.supportedFeatures ?? DEFAULT_CALL_FEATURES; const params: InternalCallParams = { caller: { diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index a7592891d256c..ae8b1f29ab319 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -20,6 +20,7 @@ import type { } from '@rocket.chat/media-signaling'; import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; +import { DEFAULT_CALL_FEATURES } from '../../constants'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -137,7 +138,7 @@ export class UserActorSignalProcessor { case 'ack': return this.clientIsReachable(); case 'accept': - return this.clientHasAccepted(signal.supportedFeatures || ['audio']); + return this.clientHasAccepted(signal.supportedFeatures || DEFAULT_CALL_FEATURES); case 'unavailable': return this.clientIsUnavailable(); case 'reject': diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 112e5fa3e5c61..4a8623ac65776 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -151,6 +151,7 @@ export class UserActorAgent extends BaseMediaCallAgent { requestedService: call.service, requestedBy: call.transferredBy, parentCallId: call._id, + features: call.features as CallFeature[], }); } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index e59a6dca3412e..46120ce23e51b 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -11,6 +11,7 @@ import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector, getMediaCallServer } from './injection'; +import { DEFAULT_CALL_FEATURES } from '../constants'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; import type { InternalCallParams, MediaCallHeader } from '../definition/common'; @@ -79,7 +80,7 @@ class MediaCallDirector { this.scheduleExpirationCheckByCallId(call._id); const updatedCall = await MediaCalls.findOneById(call._id, { projection: { features: 1 } }); - const features = (updatedCall?.features || ['audio']) as CallFeature[]; + const features = (updatedCall?.features || DEFAULT_CALL_FEATURES) as CallFeature[]; await calleeAgent.onCallAccepted(call._id, { signedContractId: data.calleeContractId, features }); await calleeAgent.oppositeAgent?.onCallAccepted(call._id, { signedContractId: call.caller.contractId, features }); @@ -177,17 +178,7 @@ class MediaCallDirector { } public async createCall(params: CreateCallParams): Promise { - const { - caller, - callee, - requestedCallId, - requestedService, - callerAgent, - calleeAgent, - parentCallId, - requestedBy, - features = ['audio'], - } = params; + const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, parentCallId, requestedBy, features } = params; // The caller must always have a contract to create the call if (!caller.contractId) { diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index c340d2acb9602..33670bbce9ff4 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -11,6 +11,7 @@ import type { SipMessage, SrfRequest, SrfResponse } from 'drachtio-srf'; import type Srf from 'drachtio-srf'; import { BaseSipCall } from './BaseSipCall'; +import { SIP_CALL_FEATURES } from '../../constants'; import { logger } from '../../logger'; import { BroadcastActorAgent } from '../../server/BroadcastAgent'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -103,6 +104,7 @@ export class IncomingSipCall extends BaseSipCall { callee, callerAgent, calleeAgent, + features: SIP_CALL_FEATURES, }); const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer); diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index fca9efd3ca010..850e684c468ea 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -5,6 +5,7 @@ import type Srf from 'drachtio-srf'; import type { SrfRequest, SrfResponse } from 'drachtio-srf'; import { BaseSipCall } from './BaseSipCall'; +import { SIP_CALL_FEATURES } from '../../constants'; import type { InternalCallParams } from '../../definition/common'; import { logger } from '../../logger'; import { BroadcastActorAgent } from '../../server/BroadcastAgent'; @@ -72,7 +73,7 @@ export class OutgoingSipCall extends BaseSipCall { callee: signedCallee, calleeAgent, callerAgent, - features: ['audio'], + features: SIP_CALL_FEATURES, }); const channel = await calleeAgent.getOrCreateChannel(call, session.sessionId); @@ -267,7 +268,7 @@ export class OutgoingSipCall extends BaseSipCall { await mediaCallDirector.acceptCall(call, this.agent, { calleeContractId: this.session.sessionId, webrtcAnswer: { type: 'answer', sdp: this.sipDialog.remote.sdp }, - supportedFeatures: ['audio'], + supportedFeatures: SIP_CALL_FEATURES, }); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c465511523212..82967fddf1acb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -172,6 +172,7 @@ "Accounts_Admin_Email_Approval_Needed_With_Reason_Default": "

The user [name] ([email]) has been registered.

Reason: [reason]

Please check \"Administration -> Users\" to activate or delete it.

", "Accounts_AllowAnonymousRead": "Allow Anonymous Read", "Accounts_AllowAnonymousWrite": "Allow Anonymous Write", + "Accounts_AllowAnonymousWrite_Deprecation_Alert": "This feature is deprecated and will be removed in version 9.0.0.", "Accounts_AllowDeleteOwnAccount": "Allow Users to Delete Own Account", "Accounts_AllowEmailChange": "Allow Email Change", "Accounts_AllowEmailNotifications": "Allow Email Notifications", @@ -989,6 +990,7 @@ "Call_ended_bold": "*Voice call ended*", "Call_not_answered_bold": "*Voice call not answered*", "Call_failed_bold": "*Voice call failed*", + "Call_feature_unsupported": "Other party doesn't support this", "Call_transferred_bold": "*Voice call transferred*", "Call_history": "Call history", "Call_history_provides_a_record_of_when_calls_took_place_and_who_joined": "Call history provides a record of when calls took place and who joined.", diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index ca18c3668484a..23559f9043d09 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -19,7 +19,7 @@ export type CallRole = 'caller' | 'callee'; export type CallService = 'webrtc'; -export const callFeatureList = ['audio'] as const; +export const callFeatureList = ['audio', 'transfer', 'hold'] as const; export type CallFeature = (typeof callFeatureList)[number]; @@ -80,6 +80,7 @@ export interface IClientMediaCall { role: CallRole; service: CallService | null; flags: readonly CallFlag[]; + features: readonly CallFeature[]; state: CallState; ignored: boolean; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index f98c076afdd82..024109a55c61b 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -205,6 +205,10 @@ export class ClientMediaCall implements IClientMediaCall { return this._flags; } + public get features(): CallFeature[] { + return [...(this.enabledFeatures || [])]; + } + constructor( private readonly config: IClientMediaCallConfig, callId: string, diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index e55e92a8eae5f..4158f07c960b6 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -23,6 +23,7 @@ export type MediaSignalingEvents = { export type MediaSignalingSessionConfig = { userId: string; + mobileDeviceId?: string; oldSessionId?: string; logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; @@ -74,7 +75,7 @@ export class MediaSignalingSession extends Emitter { constructor(private config: MediaSignalingSessionConfig) { super(); this._userId = config.userId; - this._sessionId = config.randomStringFactory(); + this._sessionId = config.mobileDeviceId || config.randomStringFactory(); this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index ba87e72d0b215..759d995b6620f 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -1,6 +1,7 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; +import { type PaginatedRequest } from '../helpers/PaginatedRequest'; type CustomSoundsGetOne = { _id: ICustomSound['_id'] }; @@ -17,3 +18,35 @@ const CustomSoundsGetOneSchema = { }; export const isCustomSoundsGetOneProps = ajv.compile(CustomSoundsGetOneSchema); + +type CustomSoundsList = PaginatedRequest<{ name?: string }>; + +const CustomSoundsListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + +export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); diff --git a/packages/ui-voip/src/components/ActionButton.tsx b/packages/ui-voip/src/components/ActionButton.tsx index 499b34f93d36d..cba521566b5b5 100644 --- a/packages/ui-voip/src/components/ActionButton.tsx +++ b/packages/ui-voip/src/components/ActionButton.tsx @@ -8,10 +8,10 @@ type ActionButtonProps = { icon: Keys; disabled?: boolean; onClick?: () => void; -} & Omit, 'icon' | 'title' | 'aria-label' | 'disabled' | 'onClick'>; +} & Omit, 'icon' | 'aria-label' | 'disabled' | 'onClick'>; const ActionButton = forwardRef(function ActionButton( - { disabled, label, icon, onClick, secondary = true, ...props }, + { disabled, label, icon, onClick, title, secondary = true, ...props }, ref, ) { return ( @@ -20,7 +20,7 @@ const ActionButton = forwardRef(function A medium secondary={secondary} icon={} - title={label} + title={title || label} aria-label={label} disabled={disabled} onClick={onClick} diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index 1b4d4dfeb9779..0433095608cb4 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -28,6 +28,7 @@ const defaultSessionState: SessionState = { remoteMuted: false, remoteHeld: false, callId: undefined, + supportedFeatures: ['audio', 'transfer', 'hold'], }; export const defaultMediaCallContextValue: MediaCallViewContextValue = { diff --git a/packages/ui-voip/src/context/definitions.d.ts b/packages/ui-voip/src/context/definitions.d.ts index 45bcccb1b7086..7add44b2d7426 100644 --- a/packages/ui-voip/src/context/definitions.d.ts +++ b/packages/ui-voip/src/context/definitions.d.ts @@ -1,4 +1,5 @@ import type { UserStatus } from '@rocket.chat/core-typings'; +import type { CallFeature } from '@rocket.chat/media-signaling'; export type InternalPeerInfo = { displayName: string; @@ -30,6 +31,7 @@ interface IBaseSession { remoteHeld: boolean; startedAt?: Date | null; // todo not sure if I need this hidden: boolean; + supportedFeatures: readonly CallFeature[]; } interface IEmptySession extends IBaseSession { diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index d222bab37321c..50ee8374fa9ff 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -139,6 +139,7 @@ const MockedMediaCallProvider = ({ remoteMuted, remoteHeld, callId: undefined, + supportedFeatures: ['audio', 'transfer', 'hold'], } as SessionState; const contextValue = { diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 5a938a8b7bdde..a7c8f7551702e 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -19,6 +19,7 @@ const defaultSessionInfo: SessionState = { remoteHeld: false, startedAt: new Date(), hidden: false, + supportedFeatures: ['audio', 'transfer', 'hold'], }; export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { @@ -135,6 +136,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS remoteHeld, remoteMute, callId, + features: supportedFeatures, } = mainCall; const state = deriveWidgetStateFromCallState(callState, role); @@ -161,6 +163,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS remoteHeld, remoteMuted: remoteMute, callId, + supportedFeatures, }, }); return; @@ -182,7 +185,19 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS dispatch({ type: 'instance_updated', - payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute, callId }, + payload: { + state, + peerInfo, + transferredBy, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted: remoteMute, + callId, + supportedFeatures, + }, }); }; diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index e5436321c64a5..34bf1d23752d7 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -93,7 +93,7 @@ class MediaSessionStore extends Emitter<{ change: void }> { randomStringFactory, oldSessionId: this.getOldSessionId(userId), logger: new MediaCallLogger(), - features: ['audio'], + features: ['audio', 'transfer', 'hold'], }); if (window.sessionStorage) { diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index 1697be4fabdc8..929f91d820a9e 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -22,7 +22,7 @@ const OngoingCall = () => { const { t } = useTranslation(); const { sessionState, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState } = sessionState; + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures } = sessionState; const { element: keypad, buttonProps: keypadButtonProps } = useKeypad(onTone); @@ -32,6 +32,9 @@ const OngoingCall = () => { const connecting = connectionState === 'CONNECTING'; const reconnecting = connectionState === 'RECONNECTING'; + const transferDisabled = !supportedFeatures.includes('transfer'); + const holdDisabled = !supportedFeatures.includes('hold'); + // TODO: Figure out how to ensure this always exist before rendering the component if (!peerInfo) { throw new Error('Peer info is required'); @@ -58,11 +61,18 @@ const OngoingCall = () => { + -