diff --git a/.changeset/cyan-ladybugs-thank.md b/.changeset/cyan-ladybugs-thank.md new file mode 100644 index 000000000000..377a014fcb72 --- /dev/null +++ b/.changeset/cyan-ladybugs-thank.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed error during sendmessage client stub diff --git a/.changeset/five-coats-rhyme.md b/.changeset/five-coats-rhyme.md new file mode 100644 index 000000000000..c5359e3c978a --- /dev/null +++ b/.changeset/five-coats-rhyme.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/fuselage-ui-kit': patch +--- + +Fixed an error that incorrectly showed conference calls as not answered after they ended diff --git a/.changeset/kind-llamas-grin.md b/.changeset/kind-llamas-grin.md new file mode 100644 index 000000000000..fd349e82d7f9 --- /dev/null +++ b/.changeset/kind-llamas-grin.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Changed the contextualbar behavior based on chat size instead the viewport diff --git a/.changeset/late-planes-sniff.md b/.changeset/late-planes-sniff.md new file mode 100644 index 000000000000..d702a938da78 --- /dev/null +++ b/.changeset/late-planes-sniff.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": patch +"@rocket.chat/i18n": patch +--- + +Added a new setting to enable mentions in end to end encrypted channels diff --git a/.changeset/sweet-nails-grin.md b/.changeset/sweet-nails-grin.md new file mode 100644 index 000000000000..de240bfc0e3f --- /dev/null +++ b/.changeset/sweet-nails-grin.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed inconsistency between the markdown parser from the composer and the rest of the application when using bold and italics in a text. diff --git a/.changeset/witty-lemons-type.md b/.changeset/witty-lemons-type.md new file mode 100644 index 000000000000..a007cbe6260e --- /dev/null +++ b/.changeset/witty-lemons-type.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/meteor': minor +--- + +Implemented new feature preview for Sidepanel diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 000000000000..4a1e67fca33a --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,35 @@ +name: Release candidate cut +on: + schedule: + - cron: '28 0 20 * *' # run at minute 28 to avoid the chance of delay due to high load on GH +jobs: + new-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: 14.21.3 + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - uses: rharkor/caching-for-turbo@v1.5 + + - name: Build packages + run: yarn build + + - name: 'Start release candidate' + uses: ./packages/release-action + with: + action: next + base-ref: ${{ github.ref_name }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.CI_PAT }} diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 3ccc9caeafa0..d04d1a2418b5 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -376,7 +376,7 @@ API.v1.addRoute( throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); } - await executeSetReaction(this.userId, emoji, msg._id, this.bodyParams.shouldReact); + await executeSetReaction(this.userId, emoji, msg, this.bodyParams.shouldReact); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 5da59d977fb1..3dc62e462ddf 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -420,7 +420,7 @@ API.v1.addRoute( const discussionParent = room.prid && (await Rooms.findOneById>(room.prid, { - projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1, sidepanel: 1 }, })); const { team, parentRoom } = await Team.getRoomInfo(room); const parent = discussionParent || parentRoom; diff --git a/apps/meteor/app/lib/client/methods/sendMessage.ts b/apps/meteor/app/lib/client/methods/sendMessage.ts index bdaca587493a..19220f901458 100644 --- a/apps/meteor/app/lib/client/methods/sendMessage.ts +++ b/apps/meteor/app/lib/client/methods/sendMessage.ts @@ -43,7 +43,7 @@ Meteor.methods({ await onClientMessageReceived(message as IMessage).then((message) => { ChatMessage.insert(message); - return callbacks.run('afterSaveMessage', message, room); + return callbacks.run('afterSaveMessage', message, { room }); }); }, }); diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index 8cebe563cd23..c03208a438e9 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -10,7 +10,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { settings } from '../../../settings/server'; import { updateMessage } from '../functions/updateMessage'; -const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content']; +const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions']; export async function executeUpdateMessage( uid: IUser['_id'], diff --git a/apps/meteor/app/mentions/server/Mentions.ts b/apps/meteor/app/mentions/server/Mentions.ts index 9eda56fea21c..779af2087932 100644 --- a/apps/meteor/app/mentions/server/Mentions.ts +++ b/apps/meteor/app/mentions/server/Mentions.ts @@ -2,7 +2,7 @@ * Mentions is a named function that will process Mentions * @param {Object} message - The message object */ -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import { isE2EEMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; import { type MentionsParserArgs, MentionsParser } from '../lib/MentionsParser'; @@ -43,8 +43,13 @@ export class MentionsServer extends MentionsParser { }); } - async getUsersByMentions({ msg, rid, u: sender }: Pick): Promise { - const mentions = this.getUserMentions(msg); + async getUsersByMentions(message: IMessage): Promise { + const { msg, rid, u: sender, e2eMentions }: Pick = message; + + const mentions = + isE2EEMessage(message) && e2eMentions?.e2eUserMentions && e2eMentions?.e2eUserMentions.length > 0 + ? e2eMentions?.e2eUserMentions + : this.getUserMentions(msg); const mentionsAll: { _id: string; username: string }[] = []; const userMentions = []; @@ -67,8 +72,13 @@ export class MentionsServer extends MentionsParser { return [...mentionsAll, ...(userMentions.length ? await this.getUsers(userMentions) : [])]; } - async getChannelbyMentions({ msg }: Pick) { - const channels = this.getChannelMentions(msg); + async getChannelbyMentions(message: IMessage) { + const { msg, e2eMentions }: Pick = message; + + const channels = + isE2EEMessage(message) && e2eMentions?.e2eChannelMentions && e2eMentions?.e2eChannelMentions.length > 0 + ? e2eMentions?.e2eChannelMentions + : this.getChannelMentions(msg); return this.getChannels(channels.map((c) => c.trim().substr(1))); } diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index 8f9c24633407..be6e5aed4a54 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -4,7 +4,6 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; @@ -12,26 +11,39 @@ import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; -import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; +import { notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; -const removeUserReaction = (message: IMessage, reaction: string, username: string) => { +export const removeUserReaction = (message: IMessage, reaction: string, username: string) => { if (!message.reactions) { return message; } - message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(username), 1); - if (message.reactions[reaction].usernames.length === 0) { + const idx = message.reactions[reaction].usernames.indexOf(username); + + // user not found in reaction array + if (idx === -1) { + return message; + } + + message.reactions[reaction].usernames.splice(idx, 1); + if (!message.reactions[reaction].usernames.length) { delete message.reactions[reaction]; } return message; }; -async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, shouldReact?: boolean) { - reaction = `:${reaction.replace(/:/g, '')}:`; +export async function setReaction( + room: Pick, + user: IUser, + message: IMessage, + reaction: string, + userAlreadyReacted?: boolean, +) { + await Message.beforeReacted(message, room); - if (!emoji.list[reaction] && (await EmojiCustom.findByNameOrAlias(reaction, {}).count()) === 0) { - throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { - method: 'setReaction', + if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) { + throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { + rid: room._id, }); } @@ -42,50 +54,23 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction } } - if (Array.isArray(room.muted) && room.muted.indexOf(user.username as string) !== -1) { - throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { - rid: room._id, - }); - } - - // if (!('reactions' in message)) { - // return; - // } - - await Message.beforeReacted(message, room); - - const userAlreadyReacted = - message.reactions && - Boolean(message.reactions[reaction]) && - message.reactions[reaction].usernames.indexOf(user.username as string) !== -1; - // When shouldReact was not informed, toggle the reaction. - if (shouldReact === undefined) { - shouldReact = !userAlreadyReacted; - } - - if (userAlreadyReacted === shouldReact) { - return; - } - let isReacted; - if (userAlreadyReacted) { const oldMessage = JSON.parse(JSON.stringify(message)); removeUserReaction(message, reaction, user.username as string); - if (_.isEmpty(message.reactions)) { + if (Object.keys(message.reactions || {}).length === 0) { delete message.reactions; + await Messages.unsetReactions(message._id); if (isTheLastMessage(room, message)) { await Rooms.unsetReactionsInLastMessage(room._id); - void notifyOnRoomChangedById(room._id); } - await Messages.unsetReactions(message._id); } else { await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); } } - await callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact, oldMessage }); + void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage }); isReacted = false; } else { @@ -101,33 +86,61 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); - void notifyOnRoomChangedById(room._id); } - await callbacks.run('afterSetReaction', message, { user, reaction, shouldReact }); + + void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true }); isReacted = true; } - await Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); void notifyOnMessageChange({ id: message._id, }); } -export async function executeSetReaction(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean) { - const user = await Users.findOneById(userId); +export async function executeSetReaction( + userId: string, + reaction: string, + messageParam: IMessage['_id'] | IMessage, + shouldReact?: boolean, +) { + // Check if the emoji is valid before proceeding + const reactionWithoutColons = reaction.replace(/:/g, ''); + reaction = `:${reactionWithoutColons}:`; + + if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) { + throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { + method: 'setReaction', + }); + } + const user = await Users.findOneById(userId); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); } - const message = await Messages.findOneById(messageId); + const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam; if (!message) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const room = await Rooms.findOneById(message.rid); + const userAlreadyReacted = + message.reactions && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.includes(user.username as string); + + // When shouldReact was not informed, toggle the reaction. + if (shouldReact === undefined) { + shouldReact = !userAlreadyReacted; + } + + if (userAlreadyReacted === shouldReact) { + return; + } + + const room = await Rooms.findOneById< + Pick + >(message.rid, { projection: { _id: 1, ro: 1, muted: 1, reactWhenReadOnly: 1, lastMessage: 1, t: 1, prid: 1, federated: 1 } }); if (!room) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } @@ -136,7 +149,7 @@ export async function executeSetReaction(userId: string, reaction: string, messa throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' }); } - return setReaction(room, user, message, reaction, shouldReact); + return setReaction(room, user, message, reaction, userAlreadyReacted); } declare module '@rocket.chat/ddp-client' { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx index 23def16a94a1..4e4640270087 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx @@ -18,7 +18,7 @@ type ContextualbarDialogProps = AriaDialogProps & ComponentProps { const ref = useRef(null); const { dialogProps } = useDialog({ 'aria-labelledby': 'contextualbarTitle', ...props }, ref); - const sizes = useLayoutSizes(); + const { contextualBar } = useLayoutSizes(); const position = useLayoutContextualBarPosition(); const { closeTab } = useRoomToolbox(); @@ -42,12 +42,12 @@ const ContextualbarDialog = (props: ContextualbarDialogProps) => { - + - + diff --git a/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx b/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx new file mode 100644 index 000000000000..f5d658ccb2f2 --- /dev/null +++ b/apps/meteor/client/components/FeaturePreviewSidePanelNavigation.tsx @@ -0,0 +1,10 @@ +import { FeaturePreview } from '@rocket.chat/ui-client'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useSidePanelNavigationScreenSize } from '../hooks/useSidePanelNavigation'; + +export const FeaturePreviewSidePanelNavigation = ({ children }: { children: ReactElement[] }) => { + const disabled = !useSidePanelNavigationScreenSize(); + return ; +}; diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx new file mode 100644 index 000000000000..86ebadad8463 --- /dev/null +++ b/apps/meteor/client/components/MarkdownText.spec.tsx @@ -0,0 +1,92 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render } from '@testing-library/react'; +import React from 'react'; + +import MarkdownText from './MarkdownText'; + +import '@testing-library/jest-dom'; + +const normalizeHtml = (html: any) => { + return html.replace(/\s+/g, ' ').trim(); +}; + +const markdownText = ` + # Heading 1 + **Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit. + ## Heading 2 + _Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + ### Heading 3 + Lists, Links and elements + **Unordered List** + - List Item 1 + - List Item 2 + - List Item 3 + - List Item 4 + **Ordered List** + 1. List Item 1 + 2. List Item 2 + 3. List Item 3 + 4. List Item 4 + **Links:** + [Rocket.Chat](rocket.chat) + gabriel.engel@rocket.chat + +55991999999 + \`Inline code\` + \`\`\`typescript + const test = 'this is code' + \`\`\` +`; + +it('should render html elements as expected using default parser', async () => { + const { container } = render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + const normalizedHtml = normalizeHtml(container.innerHTML); + + expect(normalizedHtml).toContain('

Heading 1

'); + expect(normalizedHtml).toContain( + 'Paragraph text: Bold with one asterisk Bold with two asterisks Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('

Heading 2

'); + expect(normalizedHtml).toContain( + 'Italic Text: Italic with one underscore Italic with two underscores Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('

Heading 3

'); + expect(normalizedHtml).toContain('
  • List Item 1
  • List Item 2
  • List Item 3
  • List Item 4'); + expect(normalizedHtml).toContain('
    1. List Item 1
    2. List Item 2
    3. List Item 3
    4. List Item 4'); + expect(normalizedHtml).toContain('Rocket.Chat'); + expect(normalizedHtml).toContain('gabriel.engel@rocket.chat'); + expect(normalizedHtml).toContain('+55991999999'); + expect(normalizedHtml).toContain('Inline code'); + expect(normalizedHtml).toContain('
      const test = \'this is code\' 
      '); +}); + +it('should render html elements as expected using inline parser', async () => { + const { container } = render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + const normalizedHtml = normalizeHtml(container.innerHTML); + + expect(normalizedHtml).toContain('# Heading 1'); + expect(normalizedHtml).toContain( + 'Bold with one asterisk Bold with two asterisks Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ); + expect(normalizedHtml).toContain('## Heading 2'); + expect(normalizedHtml).toContain( + 'Italic Text: Italic with one underscore Italic with two underscores Lorem ipsum dolor sit amet', + ); + expect(normalizedHtml).toContain('### Heading 3'); + expect(normalizedHtml).toContain('Unordered List - List Item 1 - List Item 2 - List Item 3 - List Item 4'); + expect(normalizedHtml).toContain('Ordered List 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4'); + expect(normalizedHtml).toContain(`Rocket.Chat`); + expect(normalizedHtml).toContain( + `gabriel.engel@rocket.chat`, + ); + expect(normalizedHtml).toContain('+55991999999'); + expect(normalizedHtml).toContain('Inline code'); + expect(normalizedHtml).toContain(`typescript const test = 'this is code'`); +}); diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx index 6426b24810ee..0b7d2efa780e 100644 --- a/apps/meteor/client/components/MarkdownText.tsx +++ b/apps/meteor/client/components/MarkdownText.tsx @@ -20,12 +20,18 @@ const documentRenderer = new marked.Renderer(); const inlineRenderer = new marked.Renderer(); const inlineWithoutBreaks = new marked.Renderer(); -marked.Lexer.rules.gfm = { - ...marked.Lexer.rules.gfm, - strong: /^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/, - em: /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/, +const walkTokens = (token: marked.Token) => { + const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/; + const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/; + if (boldPattern.test(token.raw)) { + token.type = 'strong'; + } else if (italicPattern.test(token.raw)) { + token.type = 'em'; + } }; +marked.use({ walkTokens }); + const linkMarked = (href: string | null, _title: string | null, text: string): string => `${text} `; const paragraphMarked = (text: string): string => text; diff --git a/apps/meteor/client/hooks/useRoomInfoEndpoint.ts b/apps/meteor/client/hooks/useRoomInfoEndpoint.ts index 47ea84af20b6..0bac1d7eb413 100644 --- a/apps/meteor/client/hooks/useRoomInfoEndpoint.ts +++ b/apps/meteor/client/hooks/useRoomInfoEndpoint.ts @@ -1,6 +1,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import { minutesToMilliseconds } from 'date-fns'; @@ -8,6 +8,7 @@ import type { Meteor } from 'meteor/meteor'; export const useRoomInfoEndpoint = (rid: IRoom['_id']): UseQueryResult> => { const getRoomInfo = useEndpoint('GET', '/v1/rooms.info'); + const uid = useUserId(); return useQuery(['/v1/rooms.info', rid], () => getRoomInfo({ roomId: rid }), { cacheTime: minutesToMilliseconds(15), staleTime: minutesToMilliseconds(5), @@ -17,5 +18,6 @@ export const useRoomInfoEndpoint = (rid: IRoom['_id']): UseQueryResult { + const isSidepanelFeatureEnabled = useFeaturePreview('sidepanelNavigation'); + // ["xs", "sm", "md", "lg", "xl", xxl"] + return useSidePanelNavigationScreenSize() && isSidepanelFeatureEnabled; +}; + +export const useSidePanelNavigationScreenSize = () => { + const breakpoints = useBreakpoints(); + // ["xs", "sm", "md", "lg", "xl", xxl"] + return breakpoints.includes('lg'); +}; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index 34f64e4f4c78..840493aae406 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -55,6 +55,8 @@ export const RoomManager = new (class RoomManager extends Emitter<{ private rooms: Map = new Map(); + private parentRid?: IRoom['_id'] | undefined; + constructor() { super(); debugRoomManager && @@ -78,6 +80,13 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } get opened(): IRoom['_id'] | undefined { + return this.parentRid ?? this.rid; + } + + get openedSecondLevel(): IRoom['_id'] | undefined { + if (!this.parentRid) { + return undefined; + } return this.rid; } @@ -106,20 +115,28 @@ export const RoomManager = new (class RoomManager extends Emitter<{ this.emit('changed', this.rid); } - open(rid: IRoom['_id']): void { + private _open(rid: IRoom['_id'], parent?: IRoom['_id']): void { if (rid === this.rid) { return; } - this.back(rid); if (!this.rooms.has(rid)) { this.rooms.set(rid, new RoomStore(rid)); } this.rid = rid; + this.parentRid = parent; this.emit('opened', this.rid); this.emit('changed', this.rid); } + open(rid: IRoom['_id']): void { + this._open(rid); + } + + openSecondLevel(parentId: IRoom['_id'], rid: IRoom['_id']): void { + this._open(rid, parentId); + } + getStore(rid: IRoom['_id']): RoomStore | undefined { return this.rooms.get(rid); } @@ -130,4 +147,11 @@ const subscribeOpenedRoom = [ (): IRoom['_id'] | undefined => RoomManager.opened, ] as const; +const subscribeOpenedSecondLevelRoom = [ + (callback: () => void): (() => void) => RoomManager.on('changed', callback), + (): IRoom['_id'] | undefined => RoomManager.openedSecondLevel, +] as const; + export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom); + +export const useSecondLevelOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedSecondLevelRoom); diff --git a/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx index a7e7b506de0f..9de721d8bbcd 100644 --- a/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx @@ -1,3 +1,4 @@ +import type { SidepanelItem } from '@rocket.chat/core-typings'; import { Box, Button, @@ -16,6 +17,7 @@ import { AccordionItem, } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useEndpoint, usePermission, @@ -40,6 +42,8 @@ type CreateTeamModalInputs = { encrypted: boolean; broadcast: boolean; members?: string[]; + showDiscussions?: boolean; + showChannels?: boolean; }; type CreateTeamModalProps = { onClose: () => void }; @@ -50,6 +54,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); const namesValidation = useSetting('UTF8_Channel_Names_Validation'); const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + const dispatchToastMessage = useToastMessageDispatch(); const canCreateTeam = usePermission('create-team'); const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); @@ -94,6 +99,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, broadcast: false, members: [], + showChannels: true, + showDiscussions: true, }, }); @@ -123,7 +130,10 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { topic, broadcast, encrypted, + showChannels, + showDiscussions, }: CreateTeamModalInputs): Promise => { + const sidepanelItem = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [SidepanelItem, SidepanelItem?]; const params = { name, members, @@ -136,6 +146,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { encrypted, }, }, + ...((showChannels || showDiscussions) && { sidepanel: { items: sidepanelItem } }), }; try { @@ -157,6 +168,8 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { const encryptedId = useUniqueId(); const broadcastId = useUniqueId(); const addMembersId = useUniqueId(); + const showChannelsId = useUniqueId(); + const showDiscussionsId = useUniqueId(); return ( { + + {null} + + + + {t('Navigation')} + + + + {t('Channels')} + ( + + )} + /> + + {t('Show_channels_description')} + + + + + {t('Discussions')} + ( + + )} + /> + + {t('Show_discussions_description')} + + + + {t('Security_and_permissions')} diff --git a/apps/meteor/client/sidebarv2/header/SearchSection.tsx b/apps/meteor/client/sidebarv2/header/SearchSection.tsx index 660b8ee19cd5..71b26b2e4053 100644 --- a/apps/meteor/client/sidebarv2/header/SearchSection.tsx +++ b/apps/meteor/client/sidebarv2/header/SearchSection.tsx @@ -22,6 +22,32 @@ const wrapperStyle = css` background-color: ${Palette.surface['surface-sidebar']}; `; +const mobileCheck = function () { + let check = false; + (function (a: string) { + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + a, + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + a.substr(0, 4), + ) + ) + check = true; + })(navigator.userAgent || navigator.vendor || window.opera || ''); + return check; +}; + +const shortcut = ((): string => { + if (navigator.userAgentData?.mobile || mobileCheck()) { + return ''; + } + if (window.navigator.platform.toLowerCase().includes('mac')) { + return '(\u2318+K)'; + } + return '(Ctrl+K)'; +})(); + const SearchSection = () => { const t = useTranslation(); const user = useUser(); @@ -68,11 +94,13 @@ const SearchSection = () => { }; }, [handleEscSearch, setFocus]); + const placeholder = [t('Search'), shortcut].filter(Boolean).join(' '); + return ( { return message; } + const mentionsEnabled = settings.get('E2E_Enabled_Mentions'); + + if (mentionsEnabled) { + const me = Meteor.user()?.username || ''; + const pattern = settings.get('UTF8_User_Names_Validation'); + const useRealName = settings.get('UI_Use_Real_Name'); + + const mentions = new MentionsParser({ + pattern: () => pattern, + useRealName: () => useRealName, + me: () => me, + }); + + const e2eMentions: IMessage['e2eMentions'] = { + e2eUserMentions: mentions.getUserMentions(message.msg), + e2eChannelMentions: mentions.getChannelMentions(message.msg), + }; + + message.e2eMentions = e2eMentions; + } + // Should encrypt this message. return e2eRoom.encryptMessage(message); }); diff --git a/apps/meteor/client/views/room/RoomOpener.tsx b/apps/meteor/client/views/room/RoomOpener.tsx index c30acf6f0e83..f87339a67a68 100644 --- a/apps/meteor/client/views/room/RoomOpener.tsx +++ b/apps/meteor/client/views/room/RoomOpener.tsx @@ -1,15 +1,18 @@ import type { RoomType } from '@rocket.chat/core-typings'; -import { States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import { FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React, { lazy, Suspense } from 'react'; import { useTranslation } from 'react-i18next'; +import { FeaturePreviewSidePanelNavigation } from '../../components/FeaturePreviewSidePanelNavigation'; import { Header } from '../../components/Header'; import { getErrorMessage } from '../../lib/errorHandling'; import { NotAuthorizedError } from '../../lib/errors/NotAuthorizedError'; import { OldUrlRoomError } from '../../lib/errors/OldUrlRoomError'; import { RoomNotFoundError } from '../../lib/errors/RoomNotFoundError'; import RoomSkeleton from './RoomSkeleton'; +import RoomSidepanel from './Sidepanel/RoomSidepanel'; import { useOpenRoom } from './hooks/useOpenRoom'; const RoomProvider = lazy(() => import('./providers/RoomProvider')); @@ -23,46 +26,59 @@ type RoomOpenerProps = { reference: string; }; +const isDirectOrOmnichannelRoom = (type: RoomType) => type === 'd' || type === 'l'; + const RoomOpener = ({ type, reference }: RoomOpenerProps): ReactElement => { const { data, error, isSuccess, isError, isLoading } = useOpenRoom({ type, reference }); const { t } = useTranslation(); return ( - }> - {isLoading && } - {isSuccess && ( - - - + + {!isDirectOrOmnichannelRoom(type) && ( + + {null} + + + + )} - {isError && - (() => { - if (error instanceof OldUrlRoomError) { - return ; - } - if (error instanceof RoomNotFoundError) { - return ; - } + }> + {isLoading && } + {isSuccess && ( + + + + )} + {isError && + (() => { + if (error instanceof OldUrlRoomError) { + return ; + } + + if (error instanceof RoomNotFoundError) { + return ; + } - if (error instanceof NotAuthorizedError) { - return ; - } + if (error instanceof NotAuthorizedError) { + return ; + } - return ( - } - body={ - - - {t('core.Error')} - {getErrorMessage(error)} - - } - /> - ); - })()} - + return ( + } + body={ + + + {t('core.Error')} + {getErrorMessage(error)} + + } + /> + ); + })()} + + ); }; diff --git a/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx b/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx new file mode 100644 index 000000000000..27c45e2774e8 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx @@ -0,0 +1,66 @@ +/* eslint-disable react/no-multi-comp */ +import { Box, Sidepanel, SidepanelListItem } from '@rocket.chat/fuselage'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +import { VirtuosoScrollbars } from '../../../components/CustomScrollbars'; +import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint'; +import { useOpenedRoom, useSecondLevelOpenedRoom } from '../../../lib/RoomManager'; +import RoomSidepanelListWrapper from './RoomSidepanelListWrapper'; +import RoomSidepanelLoading from './RoomSidepanelLoading'; +import RoomSidepanelItem from './SidepanelItem'; +import { useTeamsListChildrenUpdate } from './hooks/useTeamslistChildren'; + +const RoomSidepanel = () => { + const parentRid = useOpenedRoom(); + const secondLevelOpenedRoom = useSecondLevelOpenedRoom() ?? parentRid; + + if (!parentRid || !secondLevelOpenedRoom) { + return null; + } + + return ; +}; + +const RoomSidepanelWithData = ({ parentRid, openedRoom }: { parentRid: string; openedRoom: string }) => { + const sidebarViewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode'); + + const roomInfo = useRoomInfoEndpoint(parentRid); + const sidepanelItems = roomInfo.data?.room?.sidepanel?.items || roomInfo.data?.parent?.sidepanel?.items; + + const result = useTeamsListChildrenUpdate( + parentRid, + !roomInfo.data ? null : roomInfo.data.room?.teamId, + // eslint-disable-next-line no-nested-ternary + !sidepanelItems ? null : sidepanelItems?.length === 1 ? sidepanelItems[0] : undefined, + ); + if (roomInfo.isSuccess && !roomInfo.data.room?.sidepanel && !roomInfo.data.parent?.sidepanel) { + return null; + } + + if (roomInfo.isLoading || (roomInfo.isSuccess && result.isLoading)) { + return ; + } + + if (!result.isSuccess || !roomInfo.isSuccess) { + return null; + } + + return ( + + + ( + + )} + /> + + + ); +}; + +export default memo(RoomSidepanel); diff --git a/apps/meteor/client/views/room/Sidepanel/RoomSidepanelListWrapper.tsx b/apps/meteor/client/views/room/Sidepanel/RoomSidepanelListWrapper.tsx new file mode 100644 index 000000000000..dd9e6e6ec221 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/RoomSidepanelListWrapper.tsx @@ -0,0 +1,19 @@ +import { SidepanelList } from '@rocket.chat/fuselage'; +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ForwardedRef, HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; + +import { useSidebarListNavigation } from '../../../sidebar/RoomList/useSidebarListNavigation'; + +type RoomListWrapperProps = HTMLAttributes; + +const RoomSidepanelListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef) { + const t = useTranslation(); + const { sidebarListRef } = useSidebarListNavigation(); + const mergedRefs = useMergedRefs(ref, sidebarListRef); + + return ; +}); + +export default RoomSidepanelListWrapper; diff --git a/apps/meteor/client/views/room/Sidepanel/RoomSidepanelLoading.tsx b/apps/meteor/client/views/room/Sidepanel/RoomSidepanelLoading.tsx new file mode 100644 index 000000000000..00609ae6c496 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/RoomSidepanelLoading.tsx @@ -0,0 +1,20 @@ +import { SidebarV2Item as SidebarItem, Sidepanel, SidepanelList, Skeleton } from '@rocket.chat/fuselage'; +import React from 'react'; + +const RoomSidepanelLoading = () => ( + + + + + + + + + + + + + +); + +export default RoomSidepanelLoading; diff --git a/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx new file mode 100644 index 000000000000..dceb69e1aba3 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx @@ -0,0 +1,29 @@ +import type { IRoom, ISubscription, Serialized } from '@rocket.chat/core-typings'; +import { useUserSubscription } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; + +import { goToRoomById } from '../../../../lib/utils/goToRoomById'; +import { useTemplateByViewMode } from '../../../../sidebarv2/hooks/useTemplateByViewMode'; +import { useItemData } from '../hooks/useItemData'; + +export type RoomSidepanelItemProps = { + openedRoom?: string; + room: Serialized; + parentRid: string; + viewMode?: 'extended' | 'medium' | 'condensed'; +}; + +const RoomSidepanelItem = ({ room, openedRoom, viewMode }: RoomSidepanelItemProps) => { + const SidepanelItem = useTemplateByViewMode(); + const subscription = useUserSubscription(room._id); + + const itemData = useItemData({ ...room, ...subscription } as ISubscription & IRoom, { viewMode, openedRoom }); // as any because of divergent and overlaping timestamp types in subs and room (type Date vs type string) + + if (!subscription) { + return ; + } + + return ; +}; + +export default memo(RoomSidepanelItem); diff --git a/apps/meteor/client/views/room/Sidepanel/SidepanelItem/index.ts b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/index.ts new file mode 100644 index 000000000000..5cfc0da3055b --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/index.ts @@ -0,0 +1 @@ +export { default } from './RoomSidepanelItem'; diff --git a/apps/meteor/client/views/room/Sidepanel/hooks/useItemData.tsx b/apps/meteor/client/views/room/Sidepanel/hooks/useItemData.tsx new file mode 100644 index 000000000000..a6de01e22084 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/hooks/useItemData.tsx @@ -0,0 +1,68 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { SidebarV2ItemBadge as SidebarItemBadge, SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import { RoomIcon } from '../../../../components/RoomIcon'; +import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; +import { getBadgeTitle, getMessage } from '../../../../sidebarv2/RoomList/SidebarItemTemplateWithData'; +import { useAvatarTemplate } from '../../../../sidebarv2/hooks/useAvatarTemplate'; + +export const useItemData = ( + room: ISubscription & IRoom, + { openedRoom, viewMode }: { openedRoom: string | undefined; viewMode?: 'extended' | 'medium' | 'condensed' }, +) => { + const t = useTranslation(); + const AvatarTemplate = useAvatarTemplate(); + + const highlighted = Boolean(!room.hideUnreadStatus && (room.alert || room.unread)); + + const icon = useMemo( + () => } />, + [highlighted, room], + ); + const time = 'lastMessage' in room ? room.lastMessage?.ts : undefined; + const message = viewMode === 'extended' && getMessage(room, room.lastMessage, t); + + const threadUnread = Number(room.tunread?.length) > 0; + const isUnread = room.unread > 0 || threadUnread; + const showBadge = + !room.hideUnreadStatus || (!room.hideMentionStatus && (Boolean(room.userMentions) || Number(room.tunreadUser?.length) > 0)); + const badgeTitle = getBadgeTitle(room.userMentions, Number(room.tunread?.length), room.groupMentions, room.unread, t); + const variant = + ((room.userMentions || room.tunreadUser?.length) && 'danger') || + (threadUnread && 'primary') || + (room.groupMentions && 'warning') || + 'secondary'; + + const badges = useMemo( + () => ( + <> + {showBadge && isUnread && ( + + {room.unread + (room.tunread?.length || 0)} + + )} + + ), + [badgeTitle, isUnread, room.tunread?.length, room.unread, showBadge, variant], + ); + + const itemData = useMemo( + () => ({ + unread: highlighted, + selected: room.rid === openedRoom, + t, + href: roomCoordinator.getRouteLink(room.t, room) || '', + title: roomCoordinator.getRoomName(room.t, room) || '', + icon, + time, + badges, + avatar: AvatarTemplate && , + subtitle: message, + }), + [AvatarTemplate, badges, highlighted, icon, message, openedRoom, room, t, time], + ); + + return itemData; +}; diff --git a/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts b/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts new file mode 100644 index 000000000000..5791a6e5d547 --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts @@ -0,0 +1,106 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +import { ChatRoom } from '../../../../../app/models/client'; + +const sortRoomByLastMessage = (a: IRoom, b: IRoom) => { + if (!a.lm) { + return 1; + } + if (!b.lm) { + return -1; + } + return new Date(b.lm).toUTCString().localeCompare(new Date(a.lm).toUTCString()); +}; + +export const useTeamsListChildrenUpdate = ( + parentRid: string, + teamId?: string | null, + sidepanelItems?: 'channels' | 'discussions' | null, +) => { + const queryClient = useQueryClient(); + + const query = useMemo(() => { + const query: Parameters[0] = { + $or: [ + { + _id: parentRid, + }, + { + prid: parentRid, + }, + ], + }; + + if (teamId && query.$or) { + query.$or.push({ + teamId, + }); + } + return query; + }, [parentRid, teamId]); + + const teamList = useEndpoint('GET', '/v1/teams.listChildren'); + + const listRoomsAndDiscussions = useEndpoint('GET', '/v1/teams.listChildren'); + const result = useQuery({ + queryKey: ['sidepanel', 'list', parentRid, sidepanelItems], + queryFn: () => + listRoomsAndDiscussions({ + roomId: parentRid, + sort: JSON.stringify({ lm: -1 }), + type: sidepanelItems || undefined, + }), + enabled: sidepanelItems !== null && teamId !== null, + refetchInterval: 5 * 60 * 1000, + keepPreviousData: true, + }); + + const { mutate: update } = useMutation({ + mutationFn: async (params?: { action: 'add' | 'remove' | 'update'; data: IRoom }) => { + queryClient.setQueryData(['sidepanel', 'list', parentRid, sidepanelItems], (data: Awaited> | void) => { + if (!data) { + return; + } + + if (params?.action === 'add') { + data.data = [JSON.parse(JSON.stringify(params.data)), ...data.data].sort(sortRoomByLastMessage); + } + + if (params?.action === 'remove') { + data.data = data.data.filter((item) => item._id !== params.data?._id); + } + + if (params?.action === 'update') { + data.data = data.data + .map((item) => (item._id === params.data?._id ? JSON.parse(JSON.stringify(params.data)) : item)) + .sort(sortRoomByLastMessage); + } + + return { ...data }; + }); + }, + }); + + useEffect(() => { + const liveQueryHandle = ChatRoom.find(query).observe({ + added: (item) => { + queueMicrotask(() => update({ action: 'add', data: item })); + }, + changed: (item) => { + queueMicrotask(() => update({ action: 'update', data: item })); + }, + removed: (item) => { + queueMicrotask(() => update({ action: 'remove', data: item })); + }, + }); + + return () => { + liveQueryHandle.stop(); + }; + }, [update, query]); + + return result; +}; diff --git a/apps/meteor/client/views/room/Sidepanel/index.ts b/apps/meteor/client/views/room/Sidepanel/index.ts new file mode 100644 index 000000000000..c236142b8b0f --- /dev/null +++ b/apps/meteor/client/views/room/Sidepanel/index.ts @@ -0,0 +1 @@ +export { default } from './RoomSidepanel'; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index b2aac49927a1..1233768a671c 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -1,5 +1,5 @@ /* eslint-disable complexity */ -import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import type { IRoomWithRetentionPolicy, SidepanelItem } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; import { @@ -21,10 +21,13 @@ import { Box, TextAreaInput, AccordionItem, + Divider, } from '@rocket.chat/fuselage'; import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetting, useTranslation, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; import type { ChangeEvent } from 'react'; import React, { useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; @@ -72,11 +75,12 @@ const getRetentionSetting = (roomType: IRoomWithRetentionPolicy['t']): string => }; const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => { + const query = useQueryClient(); const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const isFederated = useMemo(() => isRoomFederated(room), [room]); // eslint-disable-next-line no-nested-ternary - const roomType = 'prid' in room ? 'discussion' : room.teamId ? 'team' : 'channel'; + const roomType = 'prid' in room ? 'discussion' : room.teamMain ? 'team' : 'channel'; const retentionPolicy = useRetentionPolicy(room); const retentionMaxAgeDefault = msToTimeUnit(TIMEUNIT.days, Number(useSetting(getRetentionSetting(room.t)))) ?? 30; @@ -118,6 +122,8 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => retentionOverrideGlobal, roomType: roomTypeP, reactWhenReadOnly, + showChannels, + showDiscussions, } = watch(); const { @@ -158,13 +164,23 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => retentionIgnoreThreads, ...formData }) => { - const data = getDirtyFields(formData, dirtyFields); + const data = getDirtyFields>(formData, dirtyFields); delete data.archived; + delete data.showChannels; + delete data.showDiscussions; + + const sidepanelItems = [showChannels && 'channels', showDiscussions && 'discussions'].filter(Boolean) as [ + SidepanelItem, + SidepanelItem?, + ]; + + const sidepanel = sidepanelItems.length > 0 ? { items: sidepanelItems } : null; try { await saveAction({ rid: room._id, ...data, + ...(roomType === 'team' ? { sidepanel } : null), ...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }), ...((data.systemMessages || !hideSysMes) && { systemMessages: hideSysMes && data.systemMessages, @@ -180,6 +196,7 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => }), }); + await query.invalidateQueries(['/v1/rooms.info', room._id]); dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') }); onClickClose(); } catch (error) { @@ -224,6 +241,8 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => const retentionExcludePinnedField = useUniqueId(); const retentionFilesOnlyField = useUniqueId(); const retentionIgnoreThreads = useUniqueId(); + const showDiscussionsField = useUniqueId(); + const showChannelsField = useUniqueId(); const showAdvancedSettings = canViewEncrypted || canViewReadOnly || readOnly || canViewArchived || canViewJoinCode || canViewHideSysMes; const showRetentionPolicy = canEditRoomRetentionPolicy && retentionPolicy?.enabled; @@ -355,6 +374,49 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => {showAdvancedSettings && ( + {roomType === 'team' && ( + + {null} + + + + {t('Navigation')} + + + + {t('Channels')} + ( + + )} + /> + + + {t('Show_channels_description')} + + + + + {t('Discussions')} + ( + + )} + /> + + + {t('Show_discussions_description')} + + + + + + + )} {t('Security_and_permissions')} diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts index 1a002727358f..b71f8e1b5bc5 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts @@ -10,7 +10,20 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => { const retentionPolicy = useRetentionPolicy(room); const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id); - const { t, ro, archived, topic, description, announcement, joinCodeRequired, sysMes, encrypted, retention, reactWhenReadOnly } = room; + const { + t, + ro, + archived, + topic, + description, + announcement, + joinCodeRequired, + sysMes, + encrypted, + retention, + reactWhenReadOnly, + sidepanel, + } = room; return useMemo( () => ({ @@ -37,6 +50,8 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => { retentionFilesOnly: retention?.filesOnly ?? retentionPolicy.filesOnly, retentionIgnoreThreads: retention?.ignoreThreads ?? retentionPolicy.ignoreThreads, }), + showDiscussions: sidepanel?.items.includes('discussions'), + showChannels: sidepanel?.items.includes('channels'), }), [ announcement, @@ -53,6 +68,7 @@ export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => { encrypted, reactWhenReadOnly, canEditRoomRetentionPolicy, + sidepanel, ], ); }; diff --git a/apps/meteor/client/views/room/layout/RoomLayout.tsx b/apps/meteor/client/views/room/layout/RoomLayout.tsx index fd080b916b70..3c0c4c313c99 100644 --- a/apps/meteor/client/views/room/layout/RoomLayout.tsx +++ b/apps/meteor/client/views/room/layout/RoomLayout.tsx @@ -1,7 +1,11 @@ +/* eslint-disable no-nested-ternary */ import { Box } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import breakpointsDefinitions from '@rocket.chat/fuselage-tokens/breakpoints.json'; import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import { LayoutContext, useLayout } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement, ReactNode } from 'react'; -import React, { Suspense } from 'react'; +import React, { Suspense, useMemo } from 'react'; import { ContextualbarDialog } from '../../../components/Contextualbar'; import HeaderSkeleton from '../Header/HeaderSkeleton'; @@ -14,36 +18,78 @@ type RoomLayoutProps = { aside?: ReactNode; } & ComponentProps; -const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps): ReactElement => ( - - - - - - - - - - } +const useBreakpointsElement = () => { + const { ref, borderBoxSize } = useResizeObserver({ + debounceDelay: 30, + }); + + const breakpoints = useMemo( + () => + breakpointsDefinitions + .filter(({ minViewportWidth }) => minViewportWidth && borderBoxSize.inlineSize && borderBoxSize.inlineSize >= minViewportWidth) + .map(({ name }) => name), + [borderBoxSize], + ); + + return { + ref, + breakpoints, + }; +}; + +const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps): ReactElement => { + const { ref, breakpoints } = useBreakpointsElement(); + + const contextualbarPosition = breakpoints.includes('md') ? 'relative' : 'absolute'; + const contextualbarSize = breakpoints.includes('sm') ? (breakpoints.includes('xl') ? '38%' : '380px') : '100%'; + + const layout = useLayout(); + + return ( + ({ + ...layout, + contextualBarPosition: contextualbarPosition, + size: { + ...layout.size, + contextualBar: contextualbarSize, + }, + }), + [layout, contextualbarPosition, contextualbarSize], + )} > - {header} - - - - - {body} + + + + + + + + + + } + > + {header} + + + + + {body} + + {footer && {footer}} + + {aside && ( + + {aside} + + )} - {footer && {footer}} - {aside && ( - - {aside} - - )} - - -); + + ); +}; export default RoomLayout; diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index 65c83100b1c0..d67c8566e3c6 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -8,12 +8,15 @@ import { RoomHistoryManager } from '../../../../app/ui-utils/client'; import { UserAction } from '../../../../app/ui/client/lib/UserAction'; import { useReactiveQuery } from '../../../hooks/useReactiveQuery'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; +import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint'; +import { useSidePanelNavigation } from '../../../hooks/useSidePanelNavigation'; import { RoomManager } from '../../../lib/RoomManager'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; import ImageGalleryProvider from '../../../providers/ImageGalleryProvider'; import RoomNotFound from '../RoomNotFound'; import RoomSkeleton from '../RoomSkeleton'; import { useRoomRolesManagement } from '../body/hooks/useRoomRolesManagement'; +import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext'; import { RoomContext } from '../contexts/RoomContext'; import ComposerPopupProvider from './ComposerPopupProvider'; import RoomToolboxProvider from './RoomToolboxProvider'; @@ -30,15 +33,17 @@ type RoomProviderProps = { const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { useRoomRolesManagement(rid); - const { data: room, isSuccess } = useRoomQuery(rid); + const resultFromServer = useRoomInfoEndpoint(rid); + + const resultFromLocal = useRoomQuery(rid); // TODO: the following effect is a workaround while we don't have a general and definitive solution for it const router = useRouter(); useEffect(() => { - if (isSuccess && !room) { + if (resultFromLocal.isSuccess && !resultFromLocal.data) { router.navigate('/home'); } - }, [isSuccess, room, router]); + }, [resultFromLocal.data, resultFromLocal.isSuccess, resultFromServer, router]); const subscriptionQuery = useReactiveQuery(['subscriptions', { rid }], () => ChatSubscription.findOne({ rid }) ?? null); @@ -46,7 +51,8 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { useUsersNameChanged(); - const pseudoRoom = useMemo(() => { + const pseudoRoom: IRoomWithFederationOriginalName | null = useMemo(() => { + const room = resultFromLocal.data; if (!room) { return null; } @@ -57,7 +63,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { name: roomCoordinator.getRoomName(room.t, room), federationOriginalName: room.name, }; - }, [room, subscriptionQuery.data]); + }, [resultFromLocal.data, subscriptionQuery.data]); const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useReactiveValue( useCallback(() => { @@ -86,12 +92,69 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { }; }, [hasMoreNextMessages, hasMorePreviousMessages, isLoadingMoreMessages, pseudoRoom, rid, subscriptionQuery.data]); + const isSidepanelFeatureEnabled = useSidePanelNavigation(); + useEffect(() => { + if (isSidepanelFeatureEnabled) { + if (resultFromServer.isSuccess) { + if (resultFromServer.data.room?.teamMain) { + if ( + resultFromServer.data.room.sidepanel?.items.includes('channels') || + resultFromServer.data.room?.sidepanel?.items.includes('discussions') + ) { + RoomManager.openSecondLevel(rid, rid); + } else { + RoomManager.open(rid); + } + return (): void => { + RoomManager.back(rid); + }; + } + + switch (true) { + case resultFromServer.data.room?.prid && + resultFromServer.data.parent && + resultFromServer.data.parent.sidepanel?.items.includes('discussions'): + RoomManager.openSecondLevel(resultFromServer.data.parent._id, rid); + break; + case resultFromServer.data.team?.roomId && + !resultFromServer.data.room?.teamMain && + resultFromServer.data.parent?.sidepanel?.items.includes('channels'): + RoomManager.openSecondLevel(resultFromServer.data.team.roomId, rid); + break; + + default: + if ( + resultFromServer.data.parent?.sidepanel?.items.includes('channels') || + resultFromServer.data.parent?.sidepanel?.items.includes('discussions') + ) { + RoomManager.openSecondLevel(rid, rid); + } else { + RoomManager.open(rid); + } + break; + } + } + return (): void => { + RoomManager.back(rid); + }; + } + RoomManager.open(rid); return (): void => { RoomManager.back(rid); }; - }, [rid]); + }, [ + isSidepanelFeatureEnabled, + rid, + resultFromServer.data?.room?.prid, + resultFromServer.data?.room?.teamId, + resultFromServer.data?.room?.teamMain, + resultFromServer.isSuccess, + resultFromServer.data?.parent, + resultFromServer.data?.team?.roomId, + resultFromServer.data, + ]); const subscribed = !!subscriptionQuery.data; @@ -104,7 +167,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { }, [rid, subscribed]); if (!pseudoRoom) { - return isSuccess && !room ? : ; + return resultFromLocal.isSuccess && !resultFromLocal.data ? : ; } return ( diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index c1483ea86cd1..48d0f9c87d5c 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -74,6 +74,7 @@ export const roomFields = { avatarETag: 1, usersCount: 1, msgs: 1, + sidepanel: 1, // @TODO create an API to register this fields based on room type tags: 1, diff --git a/apps/meteor/server/models/raw/EmojiCustom.ts b/apps/meteor/server/models/raw/EmojiCustom.ts index 300721f60d7b..ec3c6390f64d 100644 --- a/apps/meteor/server/models/raw/EmojiCustom.ts +++ b/apps/meteor/server/models/raw/EmojiCustom.ts @@ -72,4 +72,12 @@ export class EmojiCustomRaw extends BaseRaw implements IEmojiCusto create(data: InsertionModel): Promise>> { return this.insertOne(data); } + + countByNameOrAlias(name: string): Promise { + const query = { + $or: [{ name }, { aliases: name }], + }; + + return this.countDocuments(query); + } } diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index f5218c88402a..4be96bff9866 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -1055,8 +1055,10 @@ export class TeamService extends ServiceClassInternal implements ITeamService { return rooms; } - private getParentRoom(team: AtLeast): Promise | null> { - return Rooms.findOneById>(team.roomId, { projection: { name: 1, fname: 1, t: 1 } }); + private getParentRoom(team: AtLeast): Promise | null> { + return Rooms.findOneById>(team.roomId, { + projection: { name: 1, fname: 1, t: 1, sidepanel: 1 }, + }); } async getRoomInfo( diff --git a/apps/meteor/server/settings/e2e.ts b/apps/meteor/server/settings/e2e.ts index 6f22784f1709..c8a69757128b 100644 --- a/apps/meteor/server/settings/e2e.ts +++ b/apps/meteor/server/settings/e2e.ts @@ -35,4 +35,10 @@ export const createE2ESettings = () => public: true, enableQuery: { _id: 'E2E_Enable', value: true }, }); + + await this.add('E2E_Enabled_Mentions', false, { + type: 'boolean', + public: true, + enableQuery: { _id: 'E2E_Enable', value: true }, + }); }); diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 8c6297e9975d..ad98df1aaa53 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -133,11 +133,13 @@ test.describe.serial('e2e-encryption', () => { test.beforeAll(async ({ api }) => { expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true })).status()).toBe(200); + expect((await api.post('/settings/E2E_Enabled_Mentions', { value: true })).status()).toBe(200); }); test.afterAll(async ({ api }) => { expect((await api.post('/settings/E2E_Enable', { value: false })).status()).toBe(200); expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200); + expect((await api.post('/settings/E2E_Enabled_Mentions', { value: false })).status()).toBe(200); }); test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { @@ -265,6 +267,75 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); + test('expect create a encrypted private channel and mention user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello @user1'); + + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + }); + + test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + + const channelMention = await page.getByRole('button', { + name: 'general', + }); + + await expect(channelMention).toBeVisible(); + + await channelMention.click(); + + await expect(page).toHaveURL(`/channel/general`); + }); + + test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); + + const channelMention = await page.getByRole('button', { + name: 'general', + }); + + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + await expect(channelMention).toBeVisible(); + }); + test('should encrypted field be available on edit room', async ({ page }) => { const channelName = faker.string.uuid(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 9d5e2081ca93..519d9a4102aa 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -85,7 +85,7 @@ export class HomeContent { await this.joinRoomIfNeeded(); await this.page.waitForSelector('[name="msg"]:not([disabled])'); await this.page.locator('[name="msg"]').fill(text); - await this.page.keyboard.press('Enter'); + await this.page.getByLabel('Send').click(); } async dispatchSlashCommand(text: string): Promise { diff --git a/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts new file mode 100644 index 000000000000..e267825a6a18 --- /dev/null +++ b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts @@ -0,0 +1,428 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const meteorMethodsMock = sinon.stub(); +const emojiList: Record = {}; +const modelsMock = { + EmojiCustom: { + countByNameOrAlias: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Messages: { + findOneById: sinon.stub(), + setReactions: sinon.stub(), + unsetReactions: sinon.stub(), + }, + Rooms: { + findOneById: sinon.stub(), + unsetReactionsInLastMessage: sinon.stub(), + setReactionsInLastMessage: sinon.stub(), + }, +}; +const canAccessRoomAsyncMock = sinon.stub(); +const isTheLastMessageMock = sinon.stub(); +const notifyOnMessageChangeMock = sinon.stub(); +const hasPermissionAsyncMock = sinon.stub(); +const i18nMock = { t: sinon.stub() }; +const callbacksRunMock = sinon.stub(); +const meteorErrorMock = class extends Error { + constructor(message: string) { + super(message); + } +}; + +const { removeUserReaction, executeSetReaction, setReaction } = p.noCallThru().load('../../../../../app/reactions/server/setReaction.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { Message: { beforeReacted: sinon.stub() } }, + 'meteor/meteor': { Meteor: { methods: meteorMethodsMock, Error: meteorErrorMock } }, + '../../../lib/callbacks': { callbacks: { run: callbacksRunMock } }, + '../../../server/lib/i18n': { i18n: i18nMock }, + '../../authorization/server': { canAccessRoomAsync: canAccessRoomAsyncMock }, + '../../authorization/server/functions/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock }, + '../../emoji/server': { emoji: { list: emojiList } }, + '../../lib/server/functions/isTheLastMessage': { isTheLastMessage: isTheLastMessageMock }, + '../../lib/server/lib/notifyListener': { + notifyOnMessageChange: notifyOnMessageChangeMock, + }, +}); + +describe('Reactions', () => { + describe('removeUserReaction', () => { + it('should return the message unmodified when no reactions exist', () => { + const message = {}; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result).to.equal(message); + }); + it('should remove the reaction from a message', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + it('should remove the reaction from a message when the user is the last one on the array', () => { + const message = { + reactions: { + test: { + usernames: ['test'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test).to.be.undefined; + }); + it('should remove username only from the reaction thats passed in', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + other: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + expect(result.reactions.other.usernames).to.include('test'); + expect(result.reactions.other.usernames).to.include('test2'); + }); + it('should do nothing if username is not in the reaction', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test3'); + expect(result.reactions.test.usernames).to.not.include('test3'); + expect(result.reactions.test.usernames).to.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + }); + describe('executeSetReaction', () => { + beforeEach(() => { + modelsMock.EmojiCustom.countByNameOrAlias.reset(); + }); + it('should throw an error if reaction is not on emojione list', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(0); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-invalid-user'); + }); + it('should fail if message does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should return nothing if user already reacted and its trying to react again', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + expect(await executeSetReaction('test', 'test', 'test', true)).to.be.undefined; + }); + it('should return nothing if user hasnt reacted and its trying to unreact', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['testxxxx'] } } }); + expect(await executeSetReaction('test', 'test', 'test', false)).to.be.undefined; + }); + it('should fail if room does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves(undefined); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user doesnt have acccess to the room', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'd' }); + canAccessRoomAsyncMock.resolves(false); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('not-authorized'); + }); + it('should call setReaction with correct params', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + const res = await executeSetReaction('test', 'test', 'test'); + expect(res).to.be.undefined; + }); + it('should use the message from param when the type is not an string', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + await executeSetReaction('test', 'test', { reactions: { ':test:': { usernames: ['test'] } } }); + expect(modelsMock.Messages.findOneById.calledOnce).to.be.false; + }); + }); + describe('setReaction', () => { + beforeEach(() => { + canAccessRoomAsyncMock.reset(); + hasPermissionAsyncMock.reset(); + isTheLastMessageMock.reset(); + modelsMock.Messages.setReactions.reset(); + modelsMock.Rooms.setReactionsInLastMessage.reset(); + modelsMock.Rooms.unsetReactionsInLastMessage.reset(); + modelsMock.Messages.unsetReactions.reset(); + callbacksRunMock.reset(); + }); + it('should throw an error if user is muted from the room', async () => { + const room = { + muted: ['test'], + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith('error-not-allowed'); + }); + it('should throw an error if room is readonly and cannot be reacted when readonly and user trying doesnt have permissions and user is not unmuted from room', async () => { + const room = { + ro: true, + reactWhenReadOnly: false, + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + canAccessRoomAsyncMock.resolves(false); + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith("You can't send messages because the room is readonly."); + }); + it('should remove the user reaction if userAlreadyReacted is true and call unsetReaction if reaction is the last one on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + }); + it('should call Rooms.unsetReactionsInLastMessage when userAlreadyReacted is true and reaction is the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + expect(modelsMock.Rooms.unsetReactionsInLastMessage.calledWith(room._id)).to.be.true; + }); + it('should update the reactions object when userAlreadyReacted is true and there is more reactions on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + }); + it('should call Rooms.setReactionsInLastMessage when userAlreadyReacted is true and reaction is not the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterUnsetReaction callback when userAlreadyReacted is true', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect( + callbacksRunMock.calledWith( + 'afterUnsetReaction', + sinon.match({ _id: 'test' }), + sinon.match({ user, reaction, shouldReact: false, oldMessage: message }), + ), + ).to.be.true; + }); + it('should set reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + }); + it('should properly add username to the list of reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test2', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test', 'test2'] } }))).to.be + .true; + }); + it('should call Rooms.setReactionInLastMessage when userAlreadyReacted is false', async () => { + const room = { + _id: 'x5', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterSetReaction callback when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect( + callbacksRunMock.calledWith('afterSetReaction', sinon.match({ _id: 'test' }), sinon.match({ user, reaction, shouldReact: true })), + ).to.be.true; + }); + it('should return undefined on a successful reaction', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + expect(await setReaction(room, user, message, reaction, false)).to.be.undefined; + }); + }); +}); diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 791526c46f9d..4cca93dc00e9 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -15,6 +15,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", + "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/fuselage": "^0.59.0", "@rocket.chat/fuselage-hooks": "^0.33.1", diff --git a/apps/uikit-playground/vite.config.ts b/apps/uikit-playground/vite.config.ts index 61a5ab30e647..4d382d652859 100644 --- a/apps/uikit-playground/vite.config.ts +++ b/apps/uikit-playground/vite.config.ts @@ -7,11 +7,11 @@ export default defineConfig(() => ({ esbuild: {}, plugins: [react()], optimizeDeps: { - include: ['@rocket.chat/ui-contexts', '@rocket.chat/message-parser'], + include: ['@rocket.chat/ui-contexts', '@rocket.chat/message-parser', '@rocket.chat/core-typings'], }, build: { commonjsOptions: { - include: [/ui-contexts/, /message-parser/, /node_modules/], + include: [/ui-contexts/, /core-typings/, /message-parser/, /node_modules/], }, }, })); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 205cbaccd466..6c5511966ac8 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -170,6 +170,7 @@ export interface IMessage extends IRocketChatRecord { tcount?: number; t?: MessageTypesValues; e2e?: 'pending' | 'done'; + e2eMentions?: { e2eUserMentions?: string[]; e2eChannelMentions?: string[] }; otrAck?: string; urls?: MessageUrl[]; diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 0ce1b1041de6..95f2836da1e7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -99,6 +99,9 @@ export const isSidepanelItem = (item: any): item is SidepanelItem => { }; export const isValidSidepanel = (sidepanel: IRoom['sidepanel']) => { + if (sidepanel === null) { + return true; + } if (!sidepanel?.items) { return false; } diff --git a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx index 969ad0af1d7c..7125dbbf1bc4 100644 --- a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx +++ b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx @@ -1,3 +1,4 @@ +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { useGoToRoom, useTranslation, @@ -133,9 +134,14 @@ const VideoConferenceBlock = ({ {isUserCaller ? t('Call_again') : t('Call_back')} - - {t('Call_was_not_answered')} - + {[ + VideoConferenceStatus.EXPIRED, + VideoConferenceStatus.DECLINED, + ].includes(data.status) && ( + + {t('Call_was_not_answered')} + + )} )} {data.type !== 'direct' && @@ -151,16 +157,21 @@ const VideoConferenceBlock = ({ ) : ( - - {t('Call_was_not_answered')} - + [ + VideoConferenceStatus.EXPIRED, + VideoConferenceStatus.DECLINED, + ].includes(data.status) && ( + + {t('Call_was_not_answered')} + + ) ))} ); } - if (data.type === 'direct' && data.status === 0) { + if (data.type === 'direct' && data.status === VideoConferenceStatus.CALLING) { return ( diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 796e9d0519ff..9f37642263da 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1788,8 +1788,8 @@ "Duplicated_Email_address_will_be_ignored": "Duplicated email address will be ignored.", "Markdown_Marked_Tables": "Enable Marked Tables", "duplicated-account": "Duplicated account", - "E2E_Allow_Unencrypted_Messages": "Unencrypted messages in encrypted rooms", - "E2E_Allow_Unencrypted_Messages_Description": "Allow plain text messages to be sent in encrypted rooms. These messages will not be encrypted.", + "E2E_Allow_Unencrypted_Messages": "Access unencrypted content in encrypted rooms", + "E2E_Allow_Unencrypted_Messages_Description": "Allow access to encrypted rooms to people without room encryption keys. They'll be able to see and send unencrypted messages.", "E2E Encryption": "E2E Encryption", "E2E_Encryption_enabled_for_room": "End-to-end encryption enabled for #{{roomName}}", "E2E_Encryption_disabled_for_room": "End-to-end encryption disabled for #{{roomName}}", @@ -1805,6 +1805,8 @@ "E2E_Enabled": "E2E Enabled", "E2E_Enabled_Default_DirectRooms": "Enable encryption for Direct Rooms by default", "E2E_Enabled_Default_PrivateRooms": "Enable encryption for Private Rooms by default", + "E2E_Enabled_Mentions": "Mentions", + "E2E_Enabled_Mentions_Description": "Notify people, and highlight user, channel, and team mentions in encrypted content.", "E2E_Enable_Encrypt_Files": "Encrypt files", "E2E_Enable_Encrypt_Files_Description": "Encrypt files sent inside encrypted rooms. Check for possible conflicts in [file upload settings.](admin/settings/FileUpload)", "E2E_Encryption_Password_Change": "Change Encryption Password", @@ -6541,8 +6543,8 @@ "Incoming_Calls": "Incoming calls", "Advanced_settings": "Advanced settings", "Security_and_permissions": "Security and permissions", - "Sidepanel_navigation": "Sidepanel navigation for teams", - "Sidepanel_navigation_description": "Option to open a sidepanel with channels and/or discussions associated with the team. This allows team owners to customize communication methods to best meet their team’s needs. This feature is only available when Enhanced navigation experience is enabled.", + "Sidepanel_navigation": "Secondary navigation for teams", + "Sidepanel_navigation_description": "Display channels and/or discussions associated with teams by default. This allows team owners to customize communication methods to best meet their team’s needs. This is currently in feature preview and will be a premium capability once fully released.", "Show_channels_description": "Show team channels in second sidebar", "Show_discussions_description": "Show team discussions in second sidebar" } diff --git a/packages/model-typings/src/models/IEmojiCustomModel.ts b/packages/model-typings/src/models/IEmojiCustomModel.ts index fba5f1c3ea10..30f0323c1ec7 100644 --- a/packages/model-typings/src/models/IEmojiCustomModel.ts +++ b/packages/model-typings/src/models/IEmojiCustomModel.ts @@ -10,4 +10,5 @@ export interface IEmojiCustomModel extends IBaseModel { setAliases(_id: string, aliases: string[]): Promise; setExtension(_id: string, extension: string): Promise; create(data: InsertionModel): Promise>>; + countByNameOrAlias(name: string): Promise; } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index c837ba7186bd..16debe87e44c 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -626,7 +626,7 @@ export type RoomsEndpoints = { '/v1/rooms.info': { GET: (params: RoomsInfoProps) => { room: IRoom | undefined; - parent?: Pick; + parent?: Pick; team?: Pick; }; }; diff --git a/packages/ui-client/src/components/FeaturePreview/FeaturePreview.tsx b/packages/ui-client/src/components/FeaturePreview/FeaturePreview.tsx index 09ec0700cb79..c297cc839abc 100644 --- a/packages/ui-client/src/components/FeaturePreview/FeaturePreview.tsx +++ b/packages/ui-client/src/components/FeaturePreview/FeaturePreview.tsx @@ -5,8 +5,16 @@ import { Children, Suspense, cloneElement } from 'react'; import { useFeaturePreview } from '../../hooks/useFeaturePreview'; import { FeaturesAvailable } from '../../hooks/useFeaturePreviewList'; -export const FeaturePreview = ({ feature, children }: { feature: FeaturesAvailable; children: ReactElement[] }) => { - const featureToggleEnabled = useFeaturePreview(feature); +export const FeaturePreview = ({ + feature, + disabled = false, + children, +}: { + disabled?: boolean; + feature: FeaturesAvailable; + children: ReactElement[]; +}) => { + const featureToggleEnabled = useFeaturePreview(feature) && !disabled; const toggledChildren = Children.map(children, (child) => cloneElement(child, { diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index 172045197f8c..ff103a8d84ef 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -72,7 +72,7 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ description: 'Sidepanel_navigation_description', group: 'Navigation', value: false, - enabled: false, + enabled: true, enableQuery: { name: 'newNavigation', value: true,