snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
8
apps/meteor/client/components/message/content/Action.tsx
Normal file
8
apps/meteor/client/components/message/content/Action.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type ActionProps = ComponentProps<typeof IconButton> & { icon: string };
|
||||
|
||||
const Action = (props: ActionProps) => <IconButton marginInline={2} mini {...props} />;
|
||||
|
||||
export default Action;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageAttachmentBase } from '@rocket.chat/core-typings';
|
||||
|
||||
import AttachmentsItem from './attachments/AttachmentsItem';
|
||||
import type { AudioAttachmentSource } from './attachments/file/AudioAttachment';
|
||||
|
||||
export type AttachmentsProps = {
|
||||
attachments: MessageAttachmentBase[];
|
||||
id?: string | undefined;
|
||||
source?: AudioAttachmentSource;
|
||||
/** Prefixes nested attachments' collapse-state keys so they don't collide with the top-level ones. */
|
||||
keyPrefix?: string;
|
||||
};
|
||||
|
||||
const Attachments = ({ attachments, id, source, keyPrefix }: AttachmentsProps) => {
|
||||
return (
|
||||
<>
|
||||
{attachments?.map((attachment, index) => {
|
||||
const path = keyPrefix ? `${keyPrefix}-${index}` : String(index);
|
||||
return <AttachmentsItem key={index} id={id} attachment={{ ...attachment }} source={source} path={path} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Attachments;
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageBlock, MessageMetrics, MessageMetricsReply } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChat } from '../../../views/room/contexts/ChatContext';
|
||||
|
||||
export type BroadcastMetricsProps = {
|
||||
username: string;
|
||||
message: IMessage;
|
||||
};
|
||||
|
||||
const BroadcastMetrics = ({ username, message }: BroadcastMetricsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chat = useChat();
|
||||
|
||||
const handleReplyButtonClick = () => {
|
||||
chat?.flows.replyBroadcast(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBlock>
|
||||
<MessageMetrics>
|
||||
<MessageMetricsReply data-username={username} data-mid={message._id} onClick={handleReplyButtonClick}>
|
||||
{t('Reply')}
|
||||
</MessageMetricsReply>
|
||||
</MessageMetrics>
|
||||
</MessageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default BroadcastMetrics;
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
MessageBlock,
|
||||
MessageMetrics,
|
||||
MessageMetricsItem,
|
||||
MessageMetricsItemIcon,
|
||||
MessageMetricsItemLabel,
|
||||
MessageMetricsReply,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTimeAgo } from '../../../hooks/useTimeAgo';
|
||||
import { useGoToRoom } from '../../../views/room/hooks/useGoToRoom';
|
||||
|
||||
export type DiscussionMetricsProps = {
|
||||
drid: string;
|
||||
rid: string;
|
||||
count: number;
|
||||
lm?: Date;
|
||||
};
|
||||
|
||||
const DiscussionMetrics = ({ lm, count, rid, drid }: DiscussionMetricsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const format = useTimeAgo();
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
|
||||
return (
|
||||
<MessageBlock>
|
||||
<MessageMetrics>
|
||||
<MessageMetricsReply data-rid={rid} data-drid={drid} onClick={() => goToRoom(drid)}>
|
||||
{count ? t('message_counter', { count }) : t('Reply')}
|
||||
</MessageMetricsReply>
|
||||
<MessageMetricsItem title={lm?.toLocaleString()}>
|
||||
<MessageMetricsItemIcon name='clock' />
|
||||
<MessageMetricsItemLabel>{lm ? format(lm) : t('No_messages_yet')}</MessageMetricsItemLabel>
|
||||
</MessageMetricsItem>
|
||||
</MessageMetrics>
|
||||
</MessageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscussionMetrics;
|
||||
19
apps/meteor/client/components/message/content/Location.tsx
Normal file
19
apps/meteor/client/components/message/content/Location.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import MapView from './location/MapView';
|
||||
|
||||
export type LocationProps = {
|
||||
location?: IMessage['location'];
|
||||
};
|
||||
|
||||
const Location = ({ location }: LocationProps) => {
|
||||
const [longitude, latitude] = location?.coordinates ?? [];
|
||||
|
||||
if (!latitude || !longitude) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MapView latitude={latitude} longitude={longitude} />;
|
||||
};
|
||||
|
||||
export default Location;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import MessageAction from './actions/MessageAction';
|
||||
import { actionLinks } from '../../../lib/actionLinks';
|
||||
|
||||
type MessageActionOptions = {
|
||||
icon: IconName;
|
||||
i18nLabel?: TranslationKey;
|
||||
label?: string;
|
||||
methodId: string;
|
||||
runAction?: (action: string) => () => void;
|
||||
actionLinksAlignment?: string;
|
||||
};
|
||||
|
||||
export type MessageActionsProps = {
|
||||
message: IMessage;
|
||||
actions: MessageActionOptions[];
|
||||
};
|
||||
|
||||
const MessageActions = ({ message, actions }: MessageActionsProps) => {
|
||||
const runAction = useStableCallback((action: string) => () => {
|
||||
actionLinks.run(action, message);
|
||||
});
|
||||
|
||||
const alignment = actions[0]?.actionLinksAlignment || 'center';
|
||||
|
||||
return (
|
||||
<Box display='flex' marginBlock={4} marginInline={-4} width='full' justifyContent={alignment}>
|
||||
<ButtonGroup align='center'>
|
||||
{actions.map((action, key) => (
|
||||
<MessageAction key={key} runAction={runAction} {...action} />
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageActions;
|
||||
46
apps/meteor/client/components/message/content/Reactions.tsx
Normal file
46
apps/meteor/client/components/message/content/Reactions.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useToolbar } from '@react-aria/toolbar';
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageReactions, MessageReactionAction } from '@rocket.chat/fuselage';
|
||||
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useContext, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MessageListContext, useOpenEmojiPicker, useUserHasReacted } from '../list/MessageListContext';
|
||||
import Reaction from './reactions/Reaction';
|
||||
import { useToggleReactionMutation } from './reactions/useToggleReactionMutation';
|
||||
|
||||
export type ReactionsProps = {
|
||||
message: IMessage;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const Reactions = ({ message, ...props }: ReactionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef(null);
|
||||
const hasReacted = useUserHasReacted(message);
|
||||
const openEmojiPicker = useOpenEmojiPicker(message);
|
||||
const { username } = useContext(MessageListContext);
|
||||
const toggleReactionMutation = useToggleReactionMutation();
|
||||
const { toolbarProps } = useToolbar(props, ref);
|
||||
const buttonProps = useButtonPattern(openEmojiPicker);
|
||||
|
||||
return (
|
||||
<MessageReactions ref={ref} {...toolbarProps} {...props}>
|
||||
{message.reactions &&
|
||||
Object.entries(message.reactions).map(([name, reactions]) => (
|
||||
<Reaction
|
||||
key={name}
|
||||
counter={reactions.usernames.length}
|
||||
hasReacted={hasReacted}
|
||||
name={name}
|
||||
names={reactions.usernames.filter((user) => user !== username).map((username) => `@${username}`)}
|
||||
messageId={message._id}
|
||||
onClick={() => toggleReactionMutation.mutate({ mid: message._id, reaction: name })}
|
||||
/>
|
||||
))}
|
||||
<MessageReactionAction title={t('Add_Reaction')} {...buttonProps} />
|
||||
</MessageReactions>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reactions;
|
||||
@@ -0,0 +1,312 @@
|
||||
import { mockAppRoot, MockedRouterContext } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import ThreadMetrics from './ThreadMetrics';
|
||||
import ThreadMetricsFollow from './ThreadMetricsFollow';
|
||||
import ThreadMetricsParticipants from './ThreadMetricsParticipants';
|
||||
|
||||
const toggleFollowMock =
|
||||
(done: jest.DoneCallback | (() => undefined)) =>
|
||||
({ mid }: { mid: string }) => {
|
||||
expect(mid).toBe('mid');
|
||||
done();
|
||||
return null;
|
||||
};
|
||||
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockRoot = () => {
|
||||
const AppRoot = mockAppRoot();
|
||||
const buildWithRouter = (navigate: (...args: any[]) => void) => {
|
||||
const Wrapper = AppRoot.build();
|
||||
return function Mock({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<MockedRouterContext router={{ navigate, getRouteName: () => 'thread' as any }}>{children}</MockedRouterContext>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
return Object.assign(AppRoot, { buildWithRouter });
|
||||
};
|
||||
|
||||
const mockedTranslations = [
|
||||
'en',
|
||||
'core',
|
||||
{
|
||||
Follower_one: 'follower',
|
||||
Follower_other: 'followers',
|
||||
__count__replies__date__: '{{count}} replies {{date}}',
|
||||
__count__replies: '{{count}} replies',
|
||||
},
|
||||
] as const;
|
||||
|
||||
let inlineSize = 400;
|
||||
jest.mock('@rocket.chat/fuselage-hooks', () => {
|
||||
const originalModule = jest.requireActual('@rocket.chat/fuselage-hooks');
|
||||
return {
|
||||
...originalModule,
|
||||
useResizeObserver: () => ({ ref: () => undefined, borderBoxSize: { inlineSize } }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Thread Metrics', () => {
|
||||
describe('Main component', () => {
|
||||
it('should render large followed with 3 participants and unread', async () => {
|
||||
const navigateSpy = jest.fn();
|
||||
const navigateCallback = (route: any) => {
|
||||
navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context);
|
||||
};
|
||||
|
||||
render(
|
||||
<ThreadMetrics
|
||||
lm={new Date(2024, 6, 1, 0, 0, 0)}
|
||||
counter={5}
|
||||
participants={['user1', 'user2', 'user3']}
|
||||
unread={true}
|
||||
mention={false}
|
||||
all={false}
|
||||
mid='mid'
|
||||
rid='rid'
|
||||
following={true}
|
||||
/>,
|
||||
{
|
||||
wrapper: mockRoot()
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.followMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.unfollowMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.withUserPreference('clockMode', 1)
|
||||
.withSetting('Message_TimeFormat', 'LT')
|
||||
.withTranslations(...mockedTranslations)
|
||||
.buildWithRouter(navigateCallback),
|
||||
},
|
||||
);
|
||||
|
||||
const followButton = screen.getByTitle('Following');
|
||||
expect(followButton).toBeVisible();
|
||||
|
||||
const badge = screen.getByTitle('Unread');
|
||||
expect(badge).toBeVisible();
|
||||
|
||||
expect(screen.getByTitle('followers')).toBeVisible();
|
||||
expect(screen.getByText('3')).toBeVisible();
|
||||
|
||||
const replyButton = screen.getByText('View_thread');
|
||||
expect(replyButton).toBeVisible();
|
||||
await userEvent.click(replyButton);
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid');
|
||||
|
||||
const threadCount = screen.getByTitle('Last_message__date__');
|
||||
expect(threadCount).toHaveTextContent('5 replies July 1st, 2024');
|
||||
});
|
||||
|
||||
it('should render small not followed with 3 participants and unread', async () => {
|
||||
const navigateSpy = jest.fn();
|
||||
const navigateCallback = (route: any) => {
|
||||
navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context);
|
||||
};
|
||||
inlineSize = 200;
|
||||
|
||||
render(
|
||||
<ThreadMetrics
|
||||
lm={new Date(2024, 6, 1, 0, 0, 0)}
|
||||
counter={5}
|
||||
participants={['user1', 'user2', 'user3']}
|
||||
unread={true}
|
||||
mention={false}
|
||||
all={false}
|
||||
mid='mid'
|
||||
rid='rid'
|
||||
following={false}
|
||||
/>,
|
||||
{
|
||||
wrapper: mockRoot()
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.followMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.unfollowMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.withUserPreference('clockMode', 1)
|
||||
.withSetting('Message_TimeFormat', 'LT')
|
||||
.withTranslations(...mockedTranslations)
|
||||
.buildWithRouter(navigateCallback),
|
||||
},
|
||||
);
|
||||
const followButton = screen.getByTitle('Not_following');
|
||||
expect(followButton).toBeVisible();
|
||||
|
||||
const badge = screen.getByTitle('Unread');
|
||||
expect(badge).toBeVisible();
|
||||
|
||||
expect(screen.getByTitle('followers')).toBeVisible();
|
||||
expect(screen.getByText('3')).toBeVisible();
|
||||
|
||||
const replyButton = screen.getByText('View_thread');
|
||||
expect(replyButton).toBeVisible();
|
||||
await userEvent.click(replyButton);
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid');
|
||||
|
||||
const threadCount = screen.getByTitle('Last_message__date__');
|
||||
expect(threadCount).toHaveTextContent('5 replies');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadMetricsFollow', () => {
|
||||
it('should render not followed', async () => {
|
||||
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={false} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.followMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.build(),
|
||||
});
|
||||
const followButton = screen.getByTitle('Not_following');
|
||||
expect(followButton).toBeVisible();
|
||||
await userEvent.click(followButton);
|
||||
});
|
||||
it('should render followed', async () => {
|
||||
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={true} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint(
|
||||
'POST',
|
||||
'/v1/chat.unfollowMessage',
|
||||
toggleFollowMock(() => undefined),
|
||||
)
|
||||
.build(),
|
||||
});
|
||||
const followButton = screen.getByTitle('Following');
|
||||
expect(followButton).toBeVisible();
|
||||
await userEvent.click(followButton);
|
||||
});
|
||||
it('should render unread badge', () => {
|
||||
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={false} />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
const badge = screen.getByTitle('Unread');
|
||||
expect(badge).toBeVisible();
|
||||
});
|
||||
it('should render mention-all badge', () => {
|
||||
render(<ThreadMetricsFollow unread={true} mention={false} all={true} mid='mid' rid='rid' following={false} />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
const badge = screen.getByTitle('mention-all');
|
||||
expect(badge).toBeVisible();
|
||||
});
|
||||
it('should render Mentions_you badge', () => {
|
||||
render(<ThreadMetricsFollow unread={true} mention={true} all={false} mid='mid' rid='rid' following={false} />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
const badge = screen.getByTitle('Mentions_you');
|
||||
expect(badge).toBeVisible();
|
||||
});
|
||||
});
|
||||
describe('ThreadMetricsParticipants', () => {
|
||||
it('should render 1 avatars', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', true)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
expect(screen.getByTitle('follower')).toBeVisible();
|
||||
const avatars = screen.getAllByRole('figure');
|
||||
expect(avatars.length).toBe(1);
|
||||
expect(avatars.pop()).toBeVisible();
|
||||
});
|
||||
it('should render 2 avatars', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1', 'user2']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', true)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
expect(screen.getByTitle('followers')).toBeVisible();
|
||||
const avatars = screen.getAllByRole('figure');
|
||||
expect(avatars.length).toBe(2);
|
||||
avatars.forEach((avatar) => expect(avatar).toBeVisible());
|
||||
});
|
||||
it('should render 2 avatars and "+1" text', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', true)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
expect(screen.getByTitle('followers')).toBeVisible();
|
||||
const avatars = screen.getAllByRole('figure');
|
||||
expect(avatars.length).toBe(2);
|
||||
avatars.forEach((avatar) => expect(avatar).toBeVisible());
|
||||
expect(screen.getByText('+1')).toBeVisible();
|
||||
});
|
||||
it('should render 2 avatars and "+5" text', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3', 'user4', 'user5', 'user6', 'user7']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', true)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
expect(screen.getByTitle('followers')).toBeVisible();
|
||||
|
||||
const avatars = screen.getAllByRole('figure');
|
||||
expect(avatars.length).toBe(2);
|
||||
avatars.forEach((avatar) => expect(avatar).toBeVisible());
|
||||
|
||||
expect(screen.getByText('+5')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render user icon and 1 follower', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', false)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
const follower = screen.getByTitle('follower');
|
||||
expect(follower).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('1')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render user icon and 5 followers', () => {
|
||||
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3', 'user4', 'user5']} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withUserPreference('displayAvatars', false)
|
||||
.withTranslations(...mockedTranslations)
|
||||
.build(),
|
||||
});
|
||||
const follower = screen.getByTitle('followers');
|
||||
expect(follower).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('5')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
MessageMetricsItem,
|
||||
MessageBlock,
|
||||
MessageMetrics,
|
||||
MessageMetricsReply,
|
||||
MessageMetricsItemIcon,
|
||||
MessageMetricsItemLabel,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ThreadMetricsFollow from './ThreadMetricsFollow';
|
||||
import ThreadMetricsParticipants from './ThreadMetricsParticipants';
|
||||
import { useTimeAgo } from '../../../hooks/useTimeAgo';
|
||||
import { useGoToThread } from '../../../views/room/hooks/useGoToThread';
|
||||
|
||||
export type ThreadMetricsProps = {
|
||||
unread: boolean;
|
||||
mention: boolean;
|
||||
all: boolean;
|
||||
lm: Date;
|
||||
mid: string;
|
||||
rid: string;
|
||||
counter: number;
|
||||
participants: string[];
|
||||
following: boolean;
|
||||
};
|
||||
|
||||
const ThreadMetrics = ({ unread, mention, all, rid, mid, counter, participants, following, lm }: ThreadMetricsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const format = useTimeAgo();
|
||||
|
||||
const goToThread = useGoToThread();
|
||||
|
||||
const { ref, borderBoxSize } = useResizeObserver<HTMLDivElement>();
|
||||
|
||||
const isSmall = (borderBoxSize.inlineSize || Infinity) < 320;
|
||||
|
||||
return (
|
||||
<MessageBlock ref={ref}>
|
||||
<MessageMetrics>
|
||||
<MessageMetricsReply
|
||||
data-rid={rid}
|
||||
data-mid={mid}
|
||||
onClick={() => goToThread({ rid, tmid: mid })}
|
||||
primary={!!unread}
|
||||
position='relative'
|
||||
overflow='visible'
|
||||
>
|
||||
{t('View_thread')}
|
||||
</MessageMetricsReply>
|
||||
<ThreadMetricsFollow unread={unread} mention={mention} all={all} mid={mid} rid={rid} following={following} />
|
||||
{participants?.length > 0 && <ThreadMetricsParticipants participants={participants} />}
|
||||
<MessageMetricsItem title={t('Last_message__date__', { date: format(lm) })}>
|
||||
<MessageMetricsItemIcon name='thread' />
|
||||
{isSmall ? (
|
||||
<MessageMetricsItemLabel>{t('__count__replies', { count: counter })}</MessageMetricsItemLabel>
|
||||
) : (
|
||||
<MessageMetricsItemLabel>{t('__count__replies__date__', { count: counter, date: format(lm) })}</MessageMetricsItemLabel>
|
||||
)}
|
||||
</MessageMetricsItem>
|
||||
</MessageMetrics>
|
||||
</MessageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadMetrics;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageMetricsItem, MessageMetricsFollowing } from '@rocket.chat/fuselage';
|
||||
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import ThreadMetricsBadge from './ThreadMetricsUnreadBadge';
|
||||
import { useToggleFollowingThreadMutation } from '../../../views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation';
|
||||
|
||||
export type ThreadMetricsFollowProps = {
|
||||
following: boolean;
|
||||
mid: IMessage['_id'];
|
||||
rid: IMessage['rid'];
|
||||
unread: boolean;
|
||||
mention: boolean;
|
||||
all: boolean;
|
||||
};
|
||||
|
||||
const ThreadMetricsFollow = ({ following, mid, rid, unread, mention, all }: ThreadMetricsFollowProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const toggleFollowingThreadMutation = useToggleFollowingThreadMutation({
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
});
|
||||
|
||||
const handleFollow = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFollowingThreadMutation.mutate({ rid, tmid: mid, follow: !following });
|
||||
},
|
||||
[following, rid, mid, toggleFollowingThreadMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageMetricsItem data-rid={rid}>
|
||||
<MessageMetricsFollowing
|
||||
title={t(following ? 'Following' : 'Not_following')}
|
||||
name={following ? 'bell' : 'bell-off'}
|
||||
onClick={handleFollow}
|
||||
badge={<ThreadMetricsBadge unread={unread} mention={mention} all={all} />}
|
||||
/>
|
||||
</MessageMetricsItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadMetricsFollow;
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
MessageMetricsItem,
|
||||
MessageMetricsItemLabel,
|
||||
MessageMetricsItemAvatarRow,
|
||||
MessageMetricsItemIcon,
|
||||
MessageMetricsItemAvatarRowContent,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useTranslation, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export type ThreadMetricsParticipantsProps = {
|
||||
participants: Array<string>;
|
||||
};
|
||||
|
||||
const ThreadMetricsParticipants = ({ participants }: ThreadMetricsParticipantsProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const hideAvatar = !useUserPreference('displayAvatars');
|
||||
|
||||
const participantsLengthExcludingVisibleAvatars = participants.length - 2;
|
||||
const participantsLabel = participantsLengthExcludingVisibleAvatars > 0 ? `+${participantsLengthExcludingVisibleAvatars}` : undefined;
|
||||
|
||||
return (
|
||||
<MessageMetricsItem title={t('Follower', { count: participants.length })}>
|
||||
{hideAvatar && (
|
||||
<>
|
||||
<MessageMetricsItemIcon name='user' />
|
||||
<MessageMetricsItemLabel>{participants.length}</MessageMetricsItemLabel>
|
||||
</>
|
||||
)}
|
||||
{!hideAvatar && (
|
||||
<>
|
||||
<MessageMetricsItemAvatarRow>
|
||||
{participants.slice(0, 2).map((uid) => (
|
||||
<MessageMetricsItemAvatarRowContent key={uid}>
|
||||
<UserAvatar size='x16' userId={uid} />
|
||||
</MessageMetricsItemAvatarRowContent>
|
||||
))}
|
||||
</MessageMetricsItemAvatarRow>
|
||||
{participantsLabel && <MessageMetricsItemLabel>{participantsLabel}</MessageMetricsItemLabel>}
|
||||
</>
|
||||
)}
|
||||
</MessageMetricsItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadMetricsParticipants;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Badge } from '@rocket.chat/fuselage';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const getBadgeVariantAndTitle = (
|
||||
unread: boolean,
|
||||
mention: boolean,
|
||||
all: boolean,
|
||||
): false | [ComponentProps<typeof Badge>['variant'], TranslationKey] => {
|
||||
if (!unread) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mention) {
|
||||
return ['danger', 'Mentions_you'];
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return ['warning', 'mention-all'];
|
||||
}
|
||||
|
||||
return ['primary', 'Unread'];
|
||||
};
|
||||
|
||||
export type ThreadMetricsUnreadBadgeProps = { unread: boolean; mention: boolean; all: boolean };
|
||||
|
||||
const ThreadMetricsUnreadBadge = ({ unread, mention, all }: ThreadMetricsUnreadBadgeProps) => {
|
||||
const t = useTranslation();
|
||||
const result = getBadgeVariantAndTitle(unread, mention, all);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const [variant, title] = result;
|
||||
|
||||
return <Badge small variant={variant} role='status' title={t(title)} />;
|
||||
};
|
||||
|
||||
export default ThreadMetricsUnreadBadge;
|
||||
133
apps/meteor/client/components/message/content/UrlPreviews.tsx
Normal file
133
apps/meteor/client/components/message/content/UrlPreviews.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageBlock } from '@rocket.chat/fuselage';
|
||||
|
||||
import { useOembedLayout } from '../hooks/useOembedLayout';
|
||||
import type { OEmbedPreviewMetadata } from './urlPreviews/OEmbedPreviewMetadata';
|
||||
import OEmbedResolver from './urlPreviews/OEmbedResolver';
|
||||
import UrlPreview from './urlPreviews/UrlPreview';
|
||||
import type { UrlPreviewMetadata } from './urlPreviews/UrlPreviewMetadata';
|
||||
import { buildImageURL } from './urlPreviews/buildImageURL';
|
||||
|
||||
type OembedUrlLegacy = Required<IMessage>['urls'][0];
|
||||
|
||||
type PreviewTypes = 'headers' | 'oembed';
|
||||
|
||||
type PreviewData = {
|
||||
type: PreviewTypes;
|
||||
data: OEmbedPreviewMetadata | UrlPreviewMetadata;
|
||||
};
|
||||
|
||||
const normalizeMeta = ({ url, meta }: { url: string; meta: Record<string, string> }): OEmbedPreviewMetadata => {
|
||||
const image = meta.ogImage || meta.twitterImage || meta.msapplicationTileImage || meta.oembedThumbnailUrl || meta.oembedThumbnailUrl;
|
||||
|
||||
const imageHeight = meta.ogImageHeight || meta.oembedHeight || meta.oembedThumbnailHeight;
|
||||
const imageWidth = meta.ogImageWidth || meta.oembedWidth || meta.oembedThumbnailWidth;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries({
|
||||
siteName: meta.ogSiteName || meta.oembedProviderName,
|
||||
siteUrl: meta.ogUrl || meta.oembedProviderUrl,
|
||||
title: meta.ogTitle || meta.twitterTitle || meta.title || meta.pageTitle || meta.oembedTitle,
|
||||
description: meta.ogDescription || meta.twitterDescription || meta.description,
|
||||
authorName: meta.oembedAuthorName,
|
||||
authorUrl: meta.oembedAuthorUrl,
|
||||
...(image && {
|
||||
image: {
|
||||
url: buildImageURL(url, image),
|
||||
dimensions: {
|
||||
...(imageHeight && { height: imageHeight }),
|
||||
...(imageWidth && { width: imageWidth }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
url: meta.oembedUrl || url,
|
||||
type: meta.ogType || meta.oembedType,
|
||||
...(meta.oembedHtml && { html: meta.oembedHtml }),
|
||||
}).filter(([, value]) => value),
|
||||
);
|
||||
};
|
||||
|
||||
const hasContentType = (headers: OembedUrlLegacy['headers']): headers is { contentType: string } =>
|
||||
headers ? 'contentType' in headers : false;
|
||||
|
||||
const getHeaderType = (headers: OembedUrlLegacy['headers']): UrlPreviewMetadata['type'] | undefined => {
|
||||
if (!hasContentType(headers)) {
|
||||
return;
|
||||
}
|
||||
if (headers.contentType.match(/image\/.*/)) {
|
||||
return 'image';
|
||||
}
|
||||
if (headers.contentType.match(/audio\/.*/)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (headers.contentType.match(/video\/.*/)) {
|
||||
return 'video';
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPreviewMeta = ({
|
||||
siteName,
|
||||
siteUrl,
|
||||
authorName,
|
||||
authorUrl,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
html,
|
||||
}: OEmbedPreviewMetadata): boolean =>
|
||||
!((!siteName || !siteUrl) && (!authorName || !authorUrl) && !title && !description && !image && !html);
|
||||
|
||||
const hasMeta = (url: OembedUrlLegacy): url is { url: string; meta: Record<string, string> } => !!url.meta && !!Object.values(url.meta);
|
||||
|
||||
const processMetaAndHeaders = (url: OembedUrlLegacy): PreviewData | false => {
|
||||
if (!url.headers && !url.meta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = hasMeta(url) ? normalizeMeta(url) : undefined;
|
||||
if (data && isValidPreviewMeta(data)) {
|
||||
return { type: 'oembed', data };
|
||||
}
|
||||
|
||||
const type = getHeaderType(url.headers);
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'headers',
|
||||
data: { url: url.url, type, originalType: hasContentType(url.headers) ? url.headers?.contentType : '' },
|
||||
};
|
||||
};
|
||||
|
||||
const isPreviewData = (data: PreviewData | false): data is PreviewData => !!data;
|
||||
|
||||
const isMetaPreview = (_data: PreviewData['data'], type: PreviewTypes): _data is OEmbedPreviewMetadata => type === 'oembed';
|
||||
|
||||
export type UrlPreviewsProps = { urls: OembedUrlLegacy[] };
|
||||
|
||||
const UrlPreviews = ({ urls }: UrlPreviewsProps) => {
|
||||
const { maxWidth: oembedMaxWidth } = useOembedLayout();
|
||||
const metaAndHeaders = urls.map(processMetaAndHeaders).filter(isPreviewData);
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaAndHeaders.map(({ type, data }, index) => {
|
||||
if (isMetaPreview(data, type)) {
|
||||
return (
|
||||
<MessageBlock width='100%' maxWidth={oembedMaxWidth} key={index}>
|
||||
<OEmbedResolver meta={data} />
|
||||
</MessageBlock>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageBlock width='100%' maxWidth={oembedMaxWidth} key={index}>
|
||||
<UrlPreview {...data} />
|
||||
</MessageBlock>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlPreviews;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import MessageAction from './MessageAction';
|
||||
|
||||
export default {
|
||||
component: MessageAction,
|
||||
args: {
|
||||
runAction: (_action: string) => action(_action),
|
||||
},
|
||||
} satisfies Meta<typeof MessageAction>;
|
||||
|
||||
export const Example: StoryObj<typeof MessageAction> = {
|
||||
args: {
|
||||
icon: 'quote' as const,
|
||||
i18nLabel: 'Quote' as const,
|
||||
methodId: 'method-id',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const resolveLegacyIcon = (legacyIcon: IconName | `icon-${IconName | 'videocam'}`): IconName => {
|
||||
if (legacyIcon === 'icon-videocam') {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return legacyIcon?.replace(/^icon-/, '') as IconName;
|
||||
};
|
||||
|
||||
export type MessageActionProps = {
|
||||
icon: IconName;
|
||||
i18nLabel?: TranslationKey;
|
||||
label?: string;
|
||||
methodId: string;
|
||||
runAction: (actionId: string) => () => void;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
const MessageAction = ({ icon, methodId, i18nLabel, label, runAction, danger }: MessageActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resolvedIcon = resolveLegacyIcon(icon);
|
||||
|
||||
return (
|
||||
<Button icon={resolvedIcon} data-method-id={methodId} onClick={runAction(methodId)} marginInline={4} small danger={danger}>
|
||||
{i18nLabel ? t(i18nLabel) : label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageAction;
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MessageAttachmentBase } from '@rocket.chat/core-typings';
|
||||
import { isFileAttachment, isQuoteAttachment } from '@rocket.chat/core-typings';
|
||||
import { memo } from 'react';
|
||||
|
||||
import DefaultAttachment from './DefaultAttachment';
|
||||
import FileAttachment from './FileAttachment';
|
||||
import { QuoteAttachment } from './QuoteAttachment';
|
||||
import type { AudioAttachmentSource } from './file/AudioAttachment';
|
||||
|
||||
export type AttachmentsItemProps = {
|
||||
attachment: MessageAttachmentBase;
|
||||
id: string | undefined;
|
||||
path: string;
|
||||
source?: AudioAttachmentSource;
|
||||
};
|
||||
|
||||
const AttachmentsItem = ({ attachment, id, path, source }: AttachmentsItemProps) => {
|
||||
if (isFileAttachment(attachment)) {
|
||||
return <FileAttachment id={id} source={source} {...attachment} />;
|
||||
}
|
||||
|
||||
if (isQuoteAttachment(attachment)) {
|
||||
return <QuoteAttachment attachment={attachment} source={source} path={path} />;
|
||||
}
|
||||
|
||||
return <DefaultAttachment {...attachment} collapseKey={source?.mid ? `${source.mid}-${path}` : undefined} />;
|
||||
};
|
||||
|
||||
export default memo(AttachmentsItem);
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { MarkdownFields, MessageAttachmentDefault } from '@rocket.chat/core-typings';
|
||||
import { isActionAttachment } from '@rocket.chat/core-typings';
|
||||
import type { ReactNode, ComponentProps } from 'react';
|
||||
|
||||
import { ActionAttachment } from './default/ActionAttachtment';
|
||||
import FieldsAttachment from './default/FieldsAttachment';
|
||||
import AttachmentAuthor from './structure/AttachmentAuthor';
|
||||
import AttachmentAuthorAvatar from './structure/AttachmentAuthorAvatar';
|
||||
import AttachmentAuthorName from './structure/AttachmentAuthorName';
|
||||
import AttachmentBlock from './structure/AttachmentBlock';
|
||||
import AttachmentContent from './structure/AttachmentContent';
|
||||
import AttachmentImage from './structure/AttachmentImage';
|
||||
import AttachmentRow from './structure/AttachmentRow';
|
||||
import AttachmentText from './structure/AttachmentText';
|
||||
import AttachmentThumb from './structure/AttachmentThumb';
|
||||
import AttachmentTitle from './structure/AttachmentTitle';
|
||||
import MarkdownText from '../../../MarkdownText';
|
||||
import { useCollapse } from '../../hooks/useCollapse';
|
||||
import CollapsibleContent from '../collapsible/CollapsibleContent';
|
||||
|
||||
const applyMarkdownIfRequires = (
|
||||
list: MessageAttachmentDefault['mrkdwn_in'] = ['text', 'pretext'],
|
||||
key: MarkdownFields,
|
||||
text: string,
|
||||
variant: ComponentProps<typeof MarkdownText>['variant'] = 'inline',
|
||||
): ReactNode => (list?.includes(key) ? <MarkdownText parseEmoji variant={variant} content={text} /> : text);
|
||||
|
||||
export type DefaultAttachmentProps = MessageAttachmentDefault & {
|
||||
collapseKey?: string;
|
||||
};
|
||||
|
||||
const DefaultAttachment = ({ collapseKey, ...attachment }: DefaultAttachmentProps) => {
|
||||
const [collapsed, toggleCollapse] = useCollapse(!!attachment.collapsed, collapseKey);
|
||||
|
||||
return (
|
||||
<AttachmentBlock
|
||||
color={attachment.color}
|
||||
pre={
|
||||
attachment.pretext && (
|
||||
<AttachmentText>{applyMarkdownIfRequires(attachment.mrkdwn_in, 'pretext', attachment.pretext)}</AttachmentText>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AttachmentContent>
|
||||
{attachment.author_name && (
|
||||
<AttachmentAuthor>
|
||||
{attachment.author_icon && <AttachmentAuthorAvatar url={attachment.author_icon} />}
|
||||
<AttachmentAuthorName
|
||||
{...(attachment.author_link && {
|
||||
is: 'a',
|
||||
href: attachment.author_link,
|
||||
target: '_blank',
|
||||
color: undefined,
|
||||
})}
|
||||
>
|
||||
{attachment.author_name}
|
||||
</AttachmentAuthorName>
|
||||
</AttachmentAuthor>
|
||||
)}
|
||||
{attachment.title && (
|
||||
<AttachmentRow>
|
||||
<AttachmentTitle
|
||||
{...(attachment.title_link && {
|
||||
is: 'a',
|
||||
href: attachment.title_link,
|
||||
target: '_blank',
|
||||
color: undefined,
|
||||
})}
|
||||
>
|
||||
{attachment.title}
|
||||
</AttachmentTitle>{' '}
|
||||
<CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
|
||||
</AttachmentRow>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
{attachment.text && (
|
||||
<AttachmentText>{applyMarkdownIfRequires(attachment.mrkdwn_in, 'text', attachment.text, 'document')}</AttachmentText>
|
||||
)}
|
||||
{/* {attachment.fields && <FieldsAttachment fields={attachment.mrkdwn_in?.includes('fields') ? attachment.fields.map(({ value, ...rest }) => ({ ...rest, value: <MarkdownText withRichContent={null} content={value} /> })) : attachment.fields} />} */}
|
||||
{attachment.fields && (
|
||||
<FieldsAttachment
|
||||
fields={attachment.fields.map((field) => {
|
||||
if (!field.value) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const { value, title, ...rest } = field;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
title: title ? (
|
||||
<MarkdownText variant='inline' parseEmoji content={title.replace(/(.*)/g, (line: string) => `${line} `)} />
|
||||
) : null,
|
||||
value: value ? (
|
||||
<MarkdownText variant='inline' parseEmoji content={String(value).replace(/(.*)/g, (line: string) => `${line} `)} />
|
||||
) : null,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{attachment.image_url && (
|
||||
<AttachmentImage {...(attachment.image_dimensions as any)} src={attachment.image_url} alt={attachment.image_alt || ''} />
|
||||
)}
|
||||
{/* DEPRECATED */}
|
||||
{isActionAttachment(attachment) && <ActionAttachment {...attachment} />}
|
||||
</>
|
||||
)}
|
||||
</AttachmentContent>
|
||||
{attachment.thumb_url && <AttachmentThumb url={attachment.thumb_url} />}
|
||||
</AttachmentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultAttachment;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type FileAttachmentProps, isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings';
|
||||
|
||||
import AudioAttachment from './file/AudioAttachment';
|
||||
import type { AudioAttachmentSource } from './file/AudioAttachment';
|
||||
import GenericFileAttachment from './file/GenericFileAttachment';
|
||||
import ImageAttachment from './file/ImageAttachment';
|
||||
import VideoAttachment from './file/VideoAttachment';
|
||||
|
||||
type FileAttachmentComponentProps = FileAttachmentProps & {
|
||||
source?: AudioAttachmentSource;
|
||||
};
|
||||
|
||||
const FileAttachment = ({ source, ...attachment }: FileAttachmentComponentProps) => {
|
||||
if (isFileImageAttachment(attachment)) {
|
||||
return <ImageAttachment {...attachment} />;
|
||||
}
|
||||
|
||||
if (isFileAudioAttachment(attachment)) {
|
||||
return <AudioAttachment {...attachment} source={source} />;
|
||||
}
|
||||
|
||||
if (isFileVideoAttachment(attachment)) {
|
||||
return <VideoAttachment {...attachment} />;
|
||||
}
|
||||
|
||||
return <GenericFileAttachment {...attachment} />;
|
||||
};
|
||||
|
||||
export default FileAttachment;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { MessageQuoteAttachment } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useTimeAgo } from '../../../../hooks/useTimeAgo';
|
||||
import MessageContentBody from '../../MessageContentBody';
|
||||
import Attachments from '../Attachments';
|
||||
import type { AudioAttachmentSource } from './file/AudioAttachment';
|
||||
import AttachmentAuthor from './structure/AttachmentAuthor';
|
||||
import AttachmentAuthorAvatar from './structure/AttachmentAuthorAvatar';
|
||||
import AttachmentAuthorName from './structure/AttachmentAuthorName';
|
||||
import AttachmentAuthorTimestamp from './structure/AttachmentAuthorTimestamp';
|
||||
import AttachmentContent from './structure/AttachmentContent';
|
||||
import AttachmentDetails from './structure/AttachmentDetails';
|
||||
import AttachmentInner from './structure/AttachmentInner';
|
||||
import AttachmentMessageLink from './structure/AttachmentMessageLink';
|
||||
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
|
||||
|
||||
// TODO: remove this team collaboration
|
||||
const quoteStyles = css`
|
||||
.rcx-attachment__details {
|
||||
.rcx-message-body {
|
||||
color: ${Palette.text['font-default']};
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:focus {
|
||||
.rcx-attachment__details {
|
||||
background: ${Palette.surface['surface-hover']};
|
||||
border-color: ${Palette.stroke['stroke-light']};
|
||||
border-inline-start-color: ${Palette.stroke['stroke-medium']};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export type QuoteAttachmentProps = {
|
||||
attachment: MessageQuoteAttachment;
|
||||
source?: AudioAttachmentSource;
|
||||
/** This quote's own path, used to prefix its nested attachments' collapse-state keys. */
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentProps) => {
|
||||
const formatTime = useTimeAgo();
|
||||
const displayAvatarPreference = useUserPreference<boolean>('displayAvatars');
|
||||
|
||||
return (
|
||||
<>
|
||||
<AttachmentContent className={quoteStyles} width='full'>
|
||||
<AttachmentDetails
|
||||
is='blockquote'
|
||||
borderRadius='x4'
|
||||
borderWidth='default'
|
||||
borderStyle='solid'
|
||||
borderColor='extra-light'
|
||||
borderInlineStartColor='light'
|
||||
>
|
||||
<AttachmentAuthor>
|
||||
{displayAvatarPreference && <AttachmentAuthorAvatar url={attachment.author_icon} />}
|
||||
<AttachmentAuthorName
|
||||
{...(attachment.author_link && { is: 'a', href: attachment.author_link, target: '_blank', color: 'default' })}
|
||||
>
|
||||
{attachment.author_name}
|
||||
</AttachmentAuthorName>
|
||||
{attachment.ts && (
|
||||
<AttachmentAuthorTimestamp href={attachment.message_link}>{formatTime(attachment.ts)}</AttachmentAuthorTimestamp>
|
||||
)}
|
||||
{attachment.message_link && <AttachmentMessageLink href={attachment.message_link} />}
|
||||
</AttachmentAuthor>
|
||||
{attachment.attachments && (
|
||||
<AttachmentInner>
|
||||
<Attachments
|
||||
attachments={attachment.attachments}
|
||||
id={attachment.attachments[0]?.title_link}
|
||||
source={source && { rid: source.rid, mid: source.mid, name: attachment.author_name }}
|
||||
keyPrefix={path}
|
||||
/>
|
||||
</AttachmentInner>
|
||||
)}
|
||||
<MessageContentBody md={attachment.md ?? toPlainTextRoot(attachment.text)} />
|
||||
</AttachmentDetails>
|
||||
</AttachmentContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings';
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { usePerformActionMutation } from './hooks/usePerformActionMutation';
|
||||
|
||||
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>;
|
||||
|
||||
export type ActionAttachmentButtonProps = {
|
||||
children: ReactNode;
|
||||
mid?: IMessage['_id'];
|
||||
msg?: string;
|
||||
processingType: ProcessingType;
|
||||
};
|
||||
|
||||
const ActionAttachmentButton = ({ children, processingType, msg, mid }: ActionAttachmentButtonProps) => {
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const performActionMutation = usePerformActionMutation({
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
small
|
||||
value={msg}
|
||||
id={mid}
|
||||
disabled={performActionMutation.isPending}
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
|
||||
performActionMutation.mutate({
|
||||
processingType,
|
||||
msg,
|
||||
mid,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionAttachmentButton;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MessageAttachmentAction } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
|
||||
import ActionAttachmentButton from './ActionAttachmentButton';
|
||||
import { useExternalLink } from '../../../../../hooks/useExternalLink';
|
||||
|
||||
export type ActionAttachmentProps = MessageAttachmentAction;
|
||||
|
||||
export const ActionAttachment = ({ actions }: ActionAttachmentProps) => {
|
||||
const handleLinkClick = useExternalLink();
|
||||
|
||||
return (
|
||||
<Box marginBlock={4}>
|
||||
<ButtonGroup small>
|
||||
{actions
|
||||
.filter(
|
||||
({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) =>
|
||||
type === 'button' && (image || text) && (url || msgInChatWindow),
|
||||
)
|
||||
.map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => {
|
||||
const content = image ? <Box is='img' src={image} maxHeight={200} /> : text;
|
||||
if (url) {
|
||||
return (
|
||||
<Button role='link' onClick={() => handleLinkClick(url)} key={index} small>
|
||||
{content}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ActionAttachmentButton key={index} processingType={processingType} msg={msg} mid={msgId}>
|
||||
{content}
|
||||
</ActionAttachmentButton>
|
||||
);
|
||||
})}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
|
||||
export type FieldProps = {
|
||||
short?: boolean;
|
||||
title: ReactNode;
|
||||
value: ReactNode;
|
||||
} & Omit<ComponentProps<typeof Box>, 'title' | 'value'>;
|
||||
|
||||
// TODO: description missing color token
|
||||
const Field = ({ title, value, ...props }: FieldProps) => (
|
||||
<Box marginBlock={4} paddingInline={4} width='full' flexBasis={100} flexShrink={0} color='default' {...props}>
|
||||
<Box fontScale='p2m'>{title}</Box>
|
||||
{value}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default Field;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import Field from './Field';
|
||||
import ShortField from './ShortField';
|
||||
|
||||
export type FieldsAttachmentProps = {
|
||||
fields: {
|
||||
short?: boolean;
|
||||
title: ReactNode;
|
||||
value: ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
const FieldsAttachment = ({ fields }: FieldsAttachmentProps) => (
|
||||
<Box flexWrap='wrap' display='flex' marginBlock={4} marginInline={-4}>
|
||||
{fields.map((field, index) => (field.short ? <ShortField key={index} {...field} /> : <Field key={index} {...field} />))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default FieldsAttachment;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import Field from './Field';
|
||||
|
||||
export type ShortFieldProps = ComponentPropsWithoutRef<typeof Field>;
|
||||
|
||||
const ShortField = (props: ShortFieldProps) => <Field {...props} flexGrow={1} width='50%' flexBasis={1} />;
|
||||
|
||||
export default ShortField;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useChat } from '../../../../../../views/room/contexts/ChatContext';
|
||||
|
||||
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>;
|
||||
|
||||
type UsePerfomActionMutationParams = {
|
||||
processingType: ProcessingType;
|
||||
msg?: string;
|
||||
mid?: IMessage['_id'];
|
||||
};
|
||||
|
||||
export const usePerformActionMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, UsePerfomActionMutationParams>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, UsePerfomActionMutationParams> => {
|
||||
const chat = useChat();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ processingType, msg, mid }) => {
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (processingType) {
|
||||
case 'sendMessage':
|
||||
if (!msg) return;
|
||||
await chat.flows.sendMessage({ text: msg });
|
||||
return;
|
||||
|
||||
case 'respondWithMessage':
|
||||
if (!msg) return;
|
||||
await chat.composer?.replyWith(msg);
|
||||
return;
|
||||
|
||||
case 'respondWithQuotedMessage':
|
||||
if (!mid) return;
|
||||
const message = await chat.data.getMessageByID(mid);
|
||||
await chat.composer?.quoteMessage(message);
|
||||
}
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { AudioAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { AudioPlayerControls, Box } from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useMediaPlayer } from '../../../../../providers/MediaPlayerProvider';
|
||||
import type { PersistentAudioTrack } from '../../../../../providers/MediaPlayerProvider';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
|
||||
/** Extra context about the message that owns this audio, used by the shared player. */
|
||||
export type AudioAttachmentSource = {
|
||||
rid?: string;
|
||||
mid?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type AudioAttachmentComponentProps = AudioAttachmentProps & {
|
||||
source?: AudioAttachmentSource;
|
||||
};
|
||||
|
||||
const AudioAttachment = ({
|
||||
title,
|
||||
audio_url: url,
|
||||
audio_type: type,
|
||||
audio_size: size,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
source,
|
||||
}: AudioAttachmentComponentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const src = useMemo(() => getURL(url), [getURL, url]);
|
||||
|
||||
const { play, toggle, seek, cyclePlaybackRate, isActive, playing, currentTime, duration, playbackRate } = useMediaPlayer();
|
||||
|
||||
const track = useMemo<PersistentAudioTrack>(
|
||||
() => ({
|
||||
id: `${source?.mid ?? ''}:${url}`,
|
||||
url: src,
|
||||
mediaType: type,
|
||||
title: title || url,
|
||||
size,
|
||||
rid: source?.rid,
|
||||
mid: source?.mid,
|
||||
username: source?.username,
|
||||
name: source?.name,
|
||||
}),
|
||||
[source?.mid, source?.rid, source?.username, source?.name, url, src, type, title, size],
|
||||
);
|
||||
|
||||
const active = isActive(track.id);
|
||||
const [previewDuration, setPreviewDuration] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<Box
|
||||
borderWidth='default'
|
||||
borderStyle='solid'
|
||||
borderColor='extra-light'
|
||||
backgroundColor='tint'
|
||||
paddingBlock={12}
|
||||
paddingInlineEnd={8}
|
||||
paddingInlineStart={16}
|
||||
borderRadius='x4'
|
||||
width='100%'
|
||||
maxWidth='x300'
|
||||
>
|
||||
<AudioPlayerControls
|
||||
isPlaying={active && playing}
|
||||
currentTime={active ? currentTime : 0}
|
||||
durationTime={active && duration ? duration : previewDuration}
|
||||
playbackSpeed={playbackRate}
|
||||
onTogglePlay={() => (active ? toggle() : play(track))}
|
||||
onSeek={(time) => (active ? seek(time) : play(track))}
|
||||
onChangePlaybackSpeed={cyclePlaybackRate}
|
||||
/>
|
||||
{/* Loads only metadata so the controls show the length before this track becomes the shared player's active one. */}
|
||||
<audio
|
||||
hidden
|
||||
preload='metadata'
|
||||
src={src}
|
||||
onLoadedMetadata={(e) => setPreviewDuration(Number.isFinite(e.currentTarget.duration) ? e.currentTarget.duration : 0)}
|
||||
>
|
||||
<track kind='captions' />
|
||||
</audio>
|
||||
</Box>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioAttachment;
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { MessageAttachmentBase } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
MessageGenericPreview,
|
||||
MessageGenericPreviewContent,
|
||||
MessageGenericPreviewIcon,
|
||||
MessageGenericPreviewTitle,
|
||||
MessageGenericPreviewDescription,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useId } from 'react';
|
||||
import type { UIEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOpenEncryptedPdf } from './hooks/useOpenEncryptedPdf';
|
||||
import { getFileExtension } from '../../../../../../lib/utils/getFileExtension';
|
||||
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
import AttachmentSize from '../structure/AttachmentSize';
|
||||
|
||||
const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer;
|
||||
|
||||
export type GenericFileAttachmentProps = MessageAttachmentBase;
|
||||
|
||||
const GenericFileAttachment = ({
|
||||
title,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
size,
|
||||
format,
|
||||
collapsed,
|
||||
}: GenericFileAttachmentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const uid = useId();
|
||||
const { t } = useTranslation();
|
||||
const openEncryptedPdf = useOpenEncryptedPdf();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const handleTitleClick = async (event: UIEvent): Promise<void> => {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEncrypted = link.includes('/file-decrypt/');
|
||||
|
||||
try {
|
||||
if (format === 'PDF' && openDocumentViewer) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isEncrypted) {
|
||||
await openEncryptedPdf(link, title, size, format, openDocumentViewer);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(getURL(link), window.location.origin);
|
||||
url.searchParams.set('contentDisposition', 'inline');
|
||||
openDocumentViewer(url.toString(), format, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
event.preventDefault();
|
||||
registerDownloadForUid(uid, t, title);
|
||||
forAttachmentDownload(uid, link);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: t('FileUpload_Error_Trying_To_Open_File') });
|
||||
}
|
||||
};
|
||||
|
||||
const getExternalUrl = () => {
|
||||
if (!hasDownload || !link) return undefined;
|
||||
|
||||
if (openDocumentViewer) {
|
||||
const url = new URL(getURL(link), window.location.origin);
|
||||
url.searchParams.set('download', '');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return getURL(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={link} isCollapsed={collapsed}>
|
||||
<MessageGenericPreview style={{ maxWidth: 368, width: '100%' }}>
|
||||
<MessageGenericPreviewContent
|
||||
thumb={<MessageGenericPreviewIcon name='attachment-file' type={format || getFileExtension(title)} />}
|
||||
>
|
||||
<MessageGenericPreviewTitle download={!!openDocumentViewer} externalUrl={getExternalUrl()} onClick={handleTitleClick}>
|
||||
{title}
|
||||
</MessageGenericPreviewTitle>
|
||||
{size && (
|
||||
<MessageGenericPreviewDescription>
|
||||
<AttachmentSize size={size} wrapper={false} />
|
||||
</MessageGenericPreviewDescription>
|
||||
)}
|
||||
</MessageGenericPreviewContent>
|
||||
</MessageGenericPreview>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenericFileAttachment;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ImageAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useLoadImage } from './hooks/useLoadImage';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
import AttachmentImage from '../structure/AttachmentImage';
|
||||
|
||||
const ImageAttachment = ({
|
||||
id,
|
||||
title,
|
||||
image_url: url,
|
||||
image_preview: imagePreview,
|
||||
image_size: size,
|
||||
image_dimensions: imageDimensions = {
|
||||
width: 368,
|
||||
height: 368,
|
||||
},
|
||||
image_alt: altText,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
}: ImageAttachmentProps) => {
|
||||
const [loadImage, setLoadImage] = useLoadImage();
|
||||
const getURL = useMediaUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<AttachmentImage
|
||||
{...imageDimensions}
|
||||
loadImage={loadImage}
|
||||
setLoadImage={setLoadImage}
|
||||
dataSrc={getURL(link || url)}
|
||||
src={getURL(url)}
|
||||
previewUrl={`data:image/png;base64,${imagePreview}`}
|
||||
id={id}
|
||||
alt={altText}
|
||||
/>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAttachment;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { VideoAttachmentProps } from '@rocket.chat/core-typings';
|
||||
import { Box, MessageGenericPreview } from '@rocket.chat/fuselage';
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useReloadOnError } from './hooks/useReloadOnError';
|
||||
import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback';
|
||||
import MarkdownText from '../../../../MarkdownText';
|
||||
import MessageCollapsible from '../../../MessageCollapsible';
|
||||
import MessageContentBody from '../../../MessageContentBody';
|
||||
|
||||
const VideoAttachment = ({
|
||||
title,
|
||||
video_url: url,
|
||||
video_type: type,
|
||||
video_size: size,
|
||||
description,
|
||||
descriptionMd,
|
||||
title_link: link,
|
||||
title_link_download: hasDownload,
|
||||
collapsed,
|
||||
}: VideoAttachmentProps) => {
|
||||
const getURL = useMediaUrl();
|
||||
const src = useMemo(() => getURL(url), [getURL, url]);
|
||||
const { mediaRef } = useReloadOnError(src, 'video');
|
||||
|
||||
return (
|
||||
<>
|
||||
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
|
||||
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
|
||||
<MessageGenericPreview style={{ maxWidth: 368, width: '100%' }}>
|
||||
<Box is='video' controls preload='metadata' ref={mediaRef} position='relative'>
|
||||
<source src={getURL(url)} type={userAgentMIMETypeFallback(type)} />
|
||||
</Box>
|
||||
</MessageGenericPreview>
|
||||
</MessageCollapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoAttachment;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useAttachmentAutoLoadEmbedMedia } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useLoadImage = (): [boolean, () => void] => {
|
||||
const [loadImage, setLoadImage] = useState(useAttachmentAutoLoadEmbedMedia());
|
||||
return [loadImage, useCallback(() => setLoadImage(true), [])];
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useOpenEncryptedPdf } from './useOpenEncryptedPdf';
|
||||
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker';
|
||||
|
||||
jest.mock('../../../../../../hooks/useDownloadFromServiceWorker', () => ({
|
||||
forAttachmentDownload: jest.fn(),
|
||||
registerDownloadForUid: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useMediaUrl: () => (url: string) => url,
|
||||
}));
|
||||
|
||||
const mockForAttachmentDownload = forAttachmentDownload as jest.MockedFunction<typeof forAttachmentDownload>;
|
||||
const mockRegisterDownloadForUid = registerDownloadForUid as jest.MockedFunction<typeof registerDownloadForUid>;
|
||||
|
||||
const mockAbort = jest.fn();
|
||||
const mockAbortController = jest.fn(() => {
|
||||
const signal = { aborted: false };
|
||||
return {
|
||||
abort: jest.fn(() => {
|
||||
mockAbort();
|
||||
signal.aborted = true;
|
||||
}),
|
||||
signal,
|
||||
};
|
||||
});
|
||||
|
||||
describe('useOpenEncryptedPdf', () => {
|
||||
const testBlob = new Blob(['content'], { type: 'application/pdf' });
|
||||
const title = 'My PDF';
|
||||
const link = '/file-decrypt/encrypted-pdf.pdf';
|
||||
const format = 'PDF';
|
||||
const allowedSize = 5 * 1024 * 1024;
|
||||
|
||||
let mockOpenDocumentViewer: jest.Mock;
|
||||
let mockFetch: jest.Mock;
|
||||
let mockRevokeObjectURL: jest.Mock;
|
||||
let mockCreateObjectURL: jest.Mock;
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalCreateObjectURL = global.URL.createObjectURL;
|
||||
const originalRevokeObjectURL = global.URL.revokeObjectURL;
|
||||
const originalAbortController = global.AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
window.RocketChatDesktop = {
|
||||
getE2ePdfPreviewSizeLimit: jest.fn(() => 15),
|
||||
} as any;
|
||||
|
||||
mockOpenDocumentViewer = jest.fn();
|
||||
|
||||
// Mock fetch
|
||||
mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock URL methods
|
||||
mockCreateObjectURL = jest.fn(() => `blob:mock-url-${Math.random()}`);
|
||||
mockRevokeObjectURL = jest.fn();
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
|
||||
// Mock AbortController
|
||||
global.AbortController = mockAbortController as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
global.URL.createObjectURL = originalCreateObjectURL;
|
||||
global.URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
global.AbortController = originalAbortController;
|
||||
delete (window as any).RocketChatDesktop;
|
||||
});
|
||||
|
||||
describe('file size is not within the limit', () => {
|
||||
it('should download file if it exceeds the preview size limit', async () => {
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const exceededSize = 20 * 1024 * 1024; // 20 MB (exceeds 15 MB limit)
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, exceededSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
expect(mockRegisterDownloadForUid).toHaveBeenCalled();
|
||||
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
|
||||
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download file if size is undefined', async () => {
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, undefined, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
expect(mockRegisterDownloadForUid).toHaveBeenCalled();
|
||||
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
|
||||
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file size is within the limit', () => {
|
||||
it('should fetch and open PDF', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
|
||||
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title);
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the PDF viewer if title is undefined, falling back to empty string', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, undefined, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
|
||||
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when RocketChatDesktop is undefined', () => {
|
||||
beforeEach(() => {
|
||||
delete (window as any).RocketChatDesktop;
|
||||
});
|
||||
|
||||
it('should fall back to 10MB limit and download if file exceeds it', async () => {
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
const exceededSize = 11 * 1024 * 1024; // 11 MB
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, exceededSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
expect(mockRegisterDownloadForUid).toHaveBeenCalled();
|
||||
expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link);
|
||||
expect(mockOpenDocumentViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to 10MB limit and open PDF if size is within limit', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) }));
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob);
|
||||
expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('blob URL management', () => {
|
||||
it('should revoke previous blob URL before creating a new one', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
// First call
|
||||
await act(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Second call
|
||||
await act(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalled();
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should revoke blob URL on component unmount', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: jest.fn().mockResolvedValueOnce(testBlob),
|
||||
});
|
||||
|
||||
const { result, unmount } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch failure handling', () => {
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw error if fetch response is not ok', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
}).rejects.toThrow('Failed to fetch encrypted PDF: 404');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error opening preview of encrypted PDF',
|
||||
expect.objectContaining({ message: expect.stringContaining('Failed to fetch encrypted PDF: 404') }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent requests', () => {
|
||||
it('should ignore blob from cancelled request', async () => {
|
||||
let resolveFirstFetch: any;
|
||||
|
||||
mockFetch
|
||||
.mockImplementationOnce(() => {
|
||||
return new Promise((resolve) => {
|
||||
resolveFirstFetch = resolve;
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
return Promise.resolve({ ok: true, blob: jest.fn().mockResolvedValueOnce(testBlob) });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOpenEncryptedPdf(), { wrapper: mockAppRoot().build() });
|
||||
|
||||
const promise1 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
const promise2 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstFetch({ ok: true, blob: jest.fn().mockResolvedValue(testBlob) });
|
||||
await Promise.all([promise1, promise2]);
|
||||
});
|
||||
|
||||
expect(mockOpenDocumentViewer).toHaveBeenCalledTimes(1);
|
||||
expect(mockAbort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMediaUrl } from '@rocket.chat/ui-contexts';
|
||||
import { useId, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker';
|
||||
|
||||
export const useOpenEncryptedPdf = () => {
|
||||
const getURL = useMediaUrl();
|
||||
const pdfPreviewSizeLimit = window.RocketChatDesktop?.getE2ePdfPreviewSizeLimit?.() ?? 10;
|
||||
const pdfPreviewSizeLimitInBytes = pdfPreviewSizeLimit * 1024 * 1024;
|
||||
const uid = useId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const blobUrlRef = useRef<string | undefined>(undefined);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openEncryptedPdf = async (
|
||||
link: string,
|
||||
title: string | undefined,
|
||||
size: number | undefined,
|
||||
format: string,
|
||||
openDocumentViewer: (url: string, format: string, options: any) => void,
|
||||
) => {
|
||||
if (size === undefined || size > pdfPreviewSizeLimitInBytes) {
|
||||
registerDownloadForUid(uid, t, title);
|
||||
forAttachmentDownload(uid, link);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = undefined;
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const response = await fetch(getURL(link), {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch encrypted PDF: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (abortController.signal.aborted || abortControllerRef.current !== abortController) {
|
||||
return;
|
||||
}
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = blobUrl;
|
||||
openDocumentViewer(blobUrl, format, title ?? '');
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error opening preview of encrypted PDF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return openEncryptedPdf;
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
import { useReloadOnError } from './useReloadOnError';
|
||||
import { FakeResponse } from '../../../../../../../tests/mocks/utils/FakeResponse';
|
||||
|
||||
interface ITestMediaElement extends HTMLAudioElement {
|
||||
_emit: (type: string) => void;
|
||||
}
|
||||
|
||||
function makeMediaEl(): ITestMediaElement {
|
||||
const el = document.createElement('audio') as ITestMediaElement;
|
||||
(el as any).play = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(el, 'paused', { value: false, configurable: true });
|
||||
el._emit = (type: string) => el.dispatchEvent(new Event(type));
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('useReloadOnError', () => {
|
||||
const OLD_FETCH = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(console, 'debug').mockImplementation(() => null);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => null);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => null);
|
||||
|
||||
// default mock: fresh redirect URL + ISO expiry 60s ahead
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/sampleurl?token=xyz',
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
global.fetch = OLD_FETCH as any;
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('refreshes media src on error and preserves playback position', async () => {
|
||||
const original = '/sampleurl?token=abc';
|
||||
const { result } = renderHook(() => useReloadOnError(original, 'audio'));
|
||||
|
||||
const media = makeMediaEl();
|
||||
media.currentTime = 12;
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('error');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(media.src).toContain('/sampleurl?token=xyz');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(12);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes media src on stalled and preserves playback position', async () => {
|
||||
const original = '/sampleurl?token=abc';
|
||||
const { result } = renderHook(() => useReloadOnError(original, 'audio'));
|
||||
|
||||
const media = makeMediaEl();
|
||||
media.currentTime = 12;
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(media.src).toContain('/sampleurl?token=xyz');
|
||||
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(12);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when URL is not expired (second event before expiry)', async () => {
|
||||
// Pin system time so Date.now() is deterministic under fake timers
|
||||
const fixed = new Date('2030-01-01T00:00:00.000Z');
|
||||
jest.setSystemTime(fixed);
|
||||
|
||||
// Backend replies with expiry 60s in the future (relative to pinned time)
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/new?x=1',
|
||||
expires: new Date(fixed.getTime() + 60_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
) as any;
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/sampleurl?token=abc', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
// First event → fetch + set expires
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second event before expiry → early return, no new fetch
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recovers on stalled after expiry and restores seek position', async () => {
|
||||
// Pin time
|
||||
const fixed = new Date('2030-01-01T00:00:00.000Z');
|
||||
jest.setSystemTime(fixed);
|
||||
|
||||
// 1st fetch (first recovery) -> expires in 5s
|
||||
const firstReply = new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/fresh?token=first',
|
||||
expires: new Date(fixed.getTime() + 5_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
// 2nd fetch (after expiry) -> new url, further expiry
|
||||
const secondReply = new FakeResponse(
|
||||
JSON.stringify({
|
||||
redirectUrl: '/fresh?token=second',
|
||||
expires: new Date(fixed.getTime() + 65_000).toISOString(),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
|
||||
// Mock fetch to return first, then second
|
||||
(global.fetch as unknown as jest.Mock) = jest.fn().mockResolvedValueOnce(firstReply).mockResolvedValueOnce(secondReply);
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/sampleurl?token=old', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
// Initial recovery to set expiresAt (simulate an error)
|
||||
await act(async () => {
|
||||
media._emit('error');
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(media.src).toContain('/fresh?token=first');
|
||||
|
||||
// Complete the ready cycle
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
// Fast-forward time beyond expiry
|
||||
jest.setSystemTime(new Date(fixed.getTime() + 6_000));
|
||||
|
||||
// User scrubs to a new position just before stall is detected
|
||||
media.currentTime = 42;
|
||||
|
||||
const loadSpy = jest.spyOn(media, 'load');
|
||||
|
||||
// Now we stall after expiry -> should trigger a new fetch
|
||||
await act(async () => {
|
||||
media._emit('stalled');
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(media.src).toContain('/fresh?token=second');
|
||||
|
||||
// Complete the ready cycle
|
||||
await act(async () => {
|
||||
media._emit('loadedmetadata');
|
||||
media._emit('canplay');
|
||||
});
|
||||
|
||||
// Ensure we reloaded and restored the seek position + playback
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(media.currentTime).toBe(42);
|
||||
expect((media as any).play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores initial play when expiry is unknown', async () => {
|
||||
// no fetch expected on first play because expiresAt is not known yet
|
||||
(global.fetch as unknown as jest.Mock) = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useReloadOnError('/foo', 'audio'));
|
||||
const media = makeMediaEl();
|
||||
|
||||
act(() => {
|
||||
result.current.mediaRef(media);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
media._emit('play');
|
||||
});
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useStableCallback, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const events = ['error', 'stalled', 'play'];
|
||||
|
||||
function toURL(urlString: string): URL {
|
||||
try {
|
||||
return new URL(urlString);
|
||||
} catch {
|
||||
return new URL(urlString, window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
const getRedirectURLInfo = async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => {
|
||||
const _url = toURL(url);
|
||||
_url.searchParams.set('replyWithRedirectUrl', 'true');
|
||||
const response = await fetch(_url, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
redirectUrl: data.redirectUrl,
|
||||
expires: data.expires ? new Date(data.expires).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
const renderBufferingUIFallback = (vidEl: HTMLVideoElement) => {
|
||||
const computed = getComputedStyle(vidEl);
|
||||
|
||||
const videoTempStyles = {
|
||||
width: vidEl.style.width,
|
||||
height: vidEl.style.height,
|
||||
};
|
||||
Object.assign(vidEl.style, {
|
||||
width: computed.width,
|
||||
height: computed.height,
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.assign(vidEl.style, videoTempStyles);
|
||||
};
|
||||
};
|
||||
|
||||
export const useReloadOnError = (url: string, type: 'video' | 'audio') => {
|
||||
const [expiresAt, setExpiresAt] = useState<number | null>(null);
|
||||
const isRecovering = useRef(false);
|
||||
const firstRecoveryAttempted = useRef(false);
|
||||
|
||||
const handleMediaURLRecovery = useStableCallback(async (event: Event) => {
|
||||
if (isRecovering.current) {
|
||||
console.debug(`Media URL recovery already in progress, skipping ${event.type} event`);
|
||||
return;
|
||||
}
|
||||
isRecovering.current = true;
|
||||
|
||||
const node = event.target as HTMLMediaElement | null;
|
||||
if (!node) {
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstRecoveryAttempted.current) {
|
||||
if (!expiresAt) {
|
||||
console.debug('No expiration time set, skipping recovery');
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
} else if (event.type === 'play') {
|
||||
// The user has initiated a playback for the first time, probably we should wait for the stalled or error event
|
||||
// the url may still be valid since we dont know the expiration time yet
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
firstRecoveryAttempted.current = true;
|
||||
|
||||
if (expiresAt && Date.now() < expiresAt) {
|
||||
console.debug('Media URL is still valid, skipping recovery');
|
||||
isRecovering.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Handling media URL recovery for event:', event.type);
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (type === 'video') {
|
||||
cleanup = renderBufferingUIFallback(node as HTMLVideoElement);
|
||||
}
|
||||
|
||||
const wasPlaying = !node.paused;
|
||||
const { currentTime } = node;
|
||||
|
||||
try {
|
||||
const { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url);
|
||||
setExpiresAt(newExpiresAt);
|
||||
node.src = newUrl || url;
|
||||
|
||||
const onCanPlay = async () => {
|
||||
node.removeEventListener('canplay', onCanPlay);
|
||||
|
||||
node.currentTime = currentTime;
|
||||
if (wasPlaying) {
|
||||
try {
|
||||
await node.play();
|
||||
} catch (playError) {
|
||||
console.warn('Failed to resume playback after URL recovery:', playError);
|
||||
} finally {
|
||||
isRecovering.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMetaDataLoaded = () => {
|
||||
node.removeEventListener('loadedmetadata', onMetaDataLoaded);
|
||||
isRecovering.current = false;
|
||||
cleanup?.();
|
||||
};
|
||||
|
||||
node.addEventListener('canplay', onCanPlay, { once: true });
|
||||
node.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true });
|
||||
node.load();
|
||||
} catch (err) {
|
||||
console.error('Error during URL recovery:', err);
|
||||
isRecovering.current = false;
|
||||
cleanup?.();
|
||||
}
|
||||
});
|
||||
|
||||
const mediaRefCallback = useSafeRefCallback(
|
||||
useCallback(
|
||||
(node: HTMLAudioElement) => {
|
||||
events.forEach((event) => {
|
||||
node.addEventListener(event, handleMediaURLRecovery);
|
||||
});
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
node.removeEventListener(event, handleMediaURLRecovery);
|
||||
});
|
||||
};
|
||||
},
|
||||
[handleMediaURLRecovery],
|
||||
),
|
||||
);
|
||||
|
||||
return { mediaRef: mediaRefCallback };
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useAttachmentDimensions } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
const className = css`
|
||||
white-space: normal;
|
||||
`;
|
||||
|
||||
export type AttachmentProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const Attachment = (props: AttachmentProps) => {
|
||||
const { width } = useAttachmentDimensions();
|
||||
return (
|
||||
<Box
|
||||
rcx-message-attachment
|
||||
marginBlock={4}
|
||||
maxWidth={width}
|
||||
width='full'
|
||||
display='flex'
|
||||
overflow='hidden'
|
||||
flexDirection='column'
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Attachment;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentAuthorProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentAuthor = (props: AttachmentAuthorProps) => (
|
||||
<Box display='flex' flexDirection='row' alignItems='center' marginBlockEnd={4} {...props} />
|
||||
);
|
||||
|
||||
export default AttachmentAuthor;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BaseAvatar } from '@rocket.chat/ui-avatar';
|
||||
|
||||
export type AttachmentAuthorAvatarProps = { url: string };
|
||||
|
||||
const AttachmentAuthorAvatar = ({ url }: AttachmentAuthorAvatarProps) => <BaseAvatar url={url} size='x24' />;
|
||||
|
||||
export default AttachmentAuthorAvatar;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentAuthorNameProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentAuthorName = (props: AttachmentAuthorNameProps) => <Box withTruncatedText fontScale='p2m' marginInline={8} {...props} />;
|
||||
|
||||
export default AttachmentAuthorName;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
type AttachmentAuthorTimestampProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentAuthorTimestamp = ({ href, ...props }: AttachmentAuthorTimestampProps) =>
|
||||
href ? <Box is='a' href={href} color='default' fontScale='c1' {...props} /> : <Box fontScale='c1' {...props} />;
|
||||
|
||||
export default AttachmentAuthorTimestamp;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import Attachment from './Attachment';
|
||||
|
||||
export type AttachmentBlockProps = { pre?: ReactNode; color?: string | undefined; children?: ReactNode };
|
||||
|
||||
const AttachmentBlock = ({ pre, color = 'annotation', children }: AttachmentBlockProps) => (
|
||||
<Attachment>
|
||||
{pre}
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='row'
|
||||
paddingInlineStart={16}
|
||||
borderRadius={2}
|
||||
borderInlineStartStyle='solid'
|
||||
borderInlineStartWidth='default'
|
||||
borderInlineStartColor={color}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Attachment>
|
||||
);
|
||||
|
||||
export default AttachmentBlock;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentContentProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentContent = (props: AttachmentContentProps) => <Box rcx-attachment__content width='full' marginBlock={4} {...props} />;
|
||||
|
||||
export default AttachmentContent;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentDetailsProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentDetails = (props: AttachmentDetailsProps) => (
|
||||
<Box rcx-attachment__details fontScale='p2' color='default' backgroundColor='surface-tint' padding={16} {...props} />
|
||||
);
|
||||
|
||||
export default AttachmentDetails;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import AttachmentDownloadBase from './AttachmentDownloadBase';
|
||||
import AttachmentEncryptedDownload from './AttachmentEncryptedDownload';
|
||||
import type Action from '../../Action';
|
||||
|
||||
export type AttachmentDownloadProps = Omit<ComponentPropsWithoutRef<typeof Action>, 'icon'> & { title?: string | undefined; href: string };
|
||||
|
||||
const AttachmentDownload = ({ title, href, ...props }: AttachmentDownloadProps) => {
|
||||
const isEncrypted = href.includes('/file-decrypt/');
|
||||
|
||||
if (isEncrypted) {
|
||||
return <AttachmentEncryptedDownload title={title} href={href} {...props} />;
|
||||
}
|
||||
|
||||
return <AttachmentDownloadBase title={title} href={href} {...props} />;
|
||||
};
|
||||
|
||||
export default AttachmentDownload;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Action from '../../Action';
|
||||
|
||||
export type AttachmentDownloadBaseProps = Omit<ComponentProps<typeof Action>, 'icon'> & { title?: string | undefined; href: string };
|
||||
|
||||
const AttachmentDownloadBase = ({ title, href, disabled, ...props }: AttachmentDownloadBaseProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Action
|
||||
icon='cloud-arrow-down'
|
||||
href={`${href}?download`}
|
||||
title={disabled ? t('Download_Disabled') : t('Download')}
|
||||
is='a'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
download={title}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentDownloadBase;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import AttachmentDownloadBase from './AttachmentDownloadBase';
|
||||
import { useDownloadFromServiceWorker } from '../../../../../hooks/useDownloadFromServiceWorker';
|
||||
|
||||
type AttachmentDownloadProps = ComponentProps<typeof AttachmentDownloadBase>;
|
||||
|
||||
const AttachmentEncryptedDownload = ({ title, href, ...props }: AttachmentDownloadProps) => {
|
||||
const encryptedAnchorProps = useDownloadFromServiceWorker(href, title);
|
||||
|
||||
return <AttachmentDownloadBase {...props} {...encryptedAnchorProps} title={title} href={href} />;
|
||||
};
|
||||
|
||||
export default AttachmentEncryptedDownload;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useAttachmentDimensions } from '@rocket.chat/ui-contexts';
|
||||
import { memo, useState, useMemo } from 'react';
|
||||
|
||||
import ImageBox from './image/ImageBox';
|
||||
import Load from './image/Load';
|
||||
import Retry from './image/Retry';
|
||||
|
||||
export type AttachmentImageProps = {
|
||||
previewUrl?: string;
|
||||
dataSrc?: string;
|
||||
src: string;
|
||||
loadImage?: boolean;
|
||||
setLoadImage: () => void;
|
||||
id: string | undefined;
|
||||
width: number;
|
||||
height: number;
|
||||
alt?: string;
|
||||
} & ({ loadImage: true } | { loadImage: false; setLoadImage: () => void });
|
||||
|
||||
const getDimensions = (
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
limits: { width: number; height: number },
|
||||
): { width: number; height: number; ratio: number } => {
|
||||
const widthRatio = originalWidth / limits.width;
|
||||
const heightRatio = originalHeight / limits.height;
|
||||
|
||||
if (widthRatio > heightRatio) {
|
||||
const width = Math.min(originalWidth, limits.width);
|
||||
const height = (width / originalWidth) * originalHeight;
|
||||
return { width, height, ratio: (height / width) * 100 };
|
||||
}
|
||||
|
||||
const height = Math.min(originalHeight, limits.height);
|
||||
const width = (height / originalHeight) * originalWidth;
|
||||
return { width, height, ratio: (height / width) * 100 };
|
||||
};
|
||||
|
||||
const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, alt = '', ...size }: AttachmentImageProps) => {
|
||||
const limits = useAttachmentDimensions();
|
||||
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const { width = limits.width, height = limits.height } = size;
|
||||
|
||||
const { setHasNoError } = useMemo(
|
||||
() => ({
|
||||
setHasNoError: (): void => setError(false),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const dimensions = getDimensions(width, height, limits);
|
||||
|
||||
const background = previewUrl && `url(${previewUrl}) center center / cover no-repeat fixed`;
|
||||
|
||||
if (!loadImage) {
|
||||
return <Load width={dimensions.width || limits.width} height={dimensions.height || limits.height} load={setLoadImage} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Retry retry={setHasNoError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={dimensions.width} maxWidth='full' position='relative'>
|
||||
<Box paddingBlockStart={`${dimensions.ratio}%`} position='relative'>
|
||||
<ImageBox
|
||||
is='picture'
|
||||
position='absolute'
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
...(previewUrl && { background, boxSizing: 'content-box' }),
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
data-id={id}
|
||||
className='gallery-item'
|
||||
data-src={dataSrc || src}
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
loading='lazy'
|
||||
/>
|
||||
</ImageBox>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(AttachmentImage);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentInnerProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentInner = (props: AttachmentInnerProps) => <Box {...props} />;
|
||||
|
||||
export default AttachmentInner;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type AttachmentAuthorMessageLinkProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentAuthorMessageLink = ({ href, ...props }: AttachmentAuthorMessageLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <IconButton marginInlineStart={4} mini is='a' href={href} icon='jump' title={t('Jump_to_message')} {...props} />;
|
||||
};
|
||||
|
||||
export default AttachmentAuthorMessageLink;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentRowProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentRow = (props: AttachmentRowProps) => (
|
||||
<Box marginInline={-2} marginBlockEnd={2} rcx-message-attachment display='flex' alignItems='center' {...props} />
|
||||
);
|
||||
|
||||
export default AttachmentRow;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import Title from './AttachmentTitle';
|
||||
import { useFormatMemorySize } from '../../../../../hooks/useFormatMemorySize';
|
||||
|
||||
export type AttachmentSizeProps = ComponentPropsWithoutRef<typeof Box> & { size: number; wrapper?: boolean };
|
||||
|
||||
const AttachmentSize = ({ size, wrapper = true, ...props }: AttachmentSizeProps) => {
|
||||
const format = useFormatMemorySize();
|
||||
|
||||
const formattedSize = wrapper ? `(${format(size)})` : format(size);
|
||||
|
||||
return (
|
||||
<Title flexShrink={0} {...props}>
|
||||
{formattedSize}
|
||||
</Title>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentSize;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type AttachmentTextProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentText = (props: AttachmentTextProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
role='document'
|
||||
aria-roledescription={t('message_attachment')}
|
||||
marginBlockEnd={4}
|
||||
marginInline={2}
|
||||
fontScale='p2'
|
||||
color='default'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentText;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Box, Avatar } from '@rocket.chat/fuselage';
|
||||
import { memo } from 'react';
|
||||
|
||||
export type AttachmentThumbProps = { url: string };
|
||||
|
||||
const AttachmentThumb = ({ url }: AttachmentThumbProps) => (
|
||||
<Box marginInlineStart={8}>
|
||||
<Avatar {...({ url, size: 'x48' } as any)} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default memo(AttachmentThumb);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type AttachmentTitleProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const AttachmentTitle = (props: AttachmentTitleProps) => <Box withTruncatedText marginInline={2} fontScale='c1' color='hint' {...props} />;
|
||||
|
||||
export default AttachmentTitle;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
export type ImageBoxProps = ComponentPropsWithoutRef<typeof Box>;
|
||||
|
||||
const ImageBox = (props: ImageBoxProps) => (
|
||||
<Box
|
||||
display='flex'
|
||||
maxWidth='full'
|
||||
flexDirection='column'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
alignContent='center'
|
||||
borderRadius={2}
|
||||
borderWidth='default'
|
||||
borderStyle='solid'
|
||||
borderColor='extra-light'
|
||||
position='relative'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ImageBox;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Icon, Palette } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ImageBox from './ImageBox';
|
||||
|
||||
export type LoadProps = ComponentPropsWithoutRef<typeof Box> & { load: () => void };
|
||||
|
||||
const Load = ({ load, ...props }: LoadProps) => {
|
||||
const { t } = useTranslation();
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
background: ${Palette.surface['surface-tint']};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${Palette.surface['surface-hover']};
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<ImageBox className={clickable} {...props} onClick={load}>
|
||||
<Icon name='image' color='hint' size='x64' />
|
||||
<Box fontScale='h2' color='default'>
|
||||
{t('Click_to_load')}
|
||||
</Box>
|
||||
</ImageBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Load;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Icon, Palette } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ImageBox from './ImageBox';
|
||||
|
||||
export type RetryProps = { retry: () => void };
|
||||
|
||||
const Retry = ({ retry }: RetryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
background: ${Palette.surface['surface-tint']};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${Palette.surface['surface-hover']};
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<ImageBox className={clickable} onClick={retry} size='x160'>
|
||||
<Icon name='refresh' color='hint' size='x64' />
|
||||
<Box fontScale='h3' color='default' textAlign='center'>
|
||||
{t('Retry')}
|
||||
</Box>
|
||||
</ImageBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Retry;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Action from '../Action';
|
||||
|
||||
export type CollapsibleContentProps = Omit<ComponentProps<typeof Action>, 'icon'> & { collapsed?: boolean };
|
||||
|
||||
const CollapsibleContent = ({ collapsed = false, ...props }: CollapsibleContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
return <Action title={collapsed ? t('Uncollapse') : t('Collapse')} icon={!collapsed ? 'chevron-down' : 'chevron-left'} {...props} />;
|
||||
};
|
||||
|
||||
export default CollapsibleContent;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
|
||||
import MapViewFallback from './MapViewFallback';
|
||||
import MapViewImage from './MapViewImage';
|
||||
import { useAsyncImage } from './hooks/useAsyncImage';
|
||||
|
||||
export type MapViewProps = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const MapView = ({ latitude, longitude }: MapViewProps) => {
|
||||
const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', '');
|
||||
|
||||
const { data: imageUrl } = useAsyncImage(
|
||||
googleMapsApiKey
|
||||
? `https://maps.googleapis.com/maps/api/staticmap?zoom=14&size=250x250&markers=color:gray%7Clabel:%7C${latitude},${longitude}&key=${googleMapsApiKey}`
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`;
|
||||
|
||||
if (!imageUrl) {
|
||||
return <MapViewFallback linkUrl={linkUrl} />;
|
||||
}
|
||||
|
||||
return <MapViewImage linkUrl={linkUrl} imageUrl={imageUrl} />;
|
||||
};
|
||||
|
||||
export default memo(MapView);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Box, Icon } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type MapViewFallbackProps = {
|
||||
linkUrl: string;
|
||||
};
|
||||
|
||||
const MapViewFallback = ({ linkUrl }: MapViewFallbackProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box is='span' fontScale='p2' display='inline-flex' alignItems='center' paddingBlock={4}>
|
||||
<Icon name='map-pin' size='x20' color='hint' />
|
||||
<ExternalLink to={linkUrl}>{t('Shared_Location')}</ExternalLink>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapViewFallback;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ExternalLink } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type MapViewImageProps = {
|
||||
linkUrl: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
const MapViewImage = ({ linkUrl, imageUrl }: MapViewImageProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ExternalLink to={linkUrl}>
|
||||
<img src={imageUrl} alt={t('Shared_Location')} />
|
||||
</ExternalLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapViewImage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useAsyncImage = (src: string | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ['async-image', src],
|
||||
queryFn: () =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
if (!src) {
|
||||
reject(new Error('No src provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
resolve(image.src);
|
||||
});
|
||||
image.addEventListener('error', () => {
|
||||
reject(new Error(`Failed to load image: ${src}`));
|
||||
});
|
||||
image.src = src;
|
||||
}),
|
||||
enabled: Boolean(src),
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MessageReaction as MessageReactionTemplate, MessageReactionEmoji, MessageReactionCounter } from '@rocket.chat/fuselage';
|
||||
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
|
||||
import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useRef, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ReactionTooltip from './ReactionTooltip';
|
||||
import { normalizeUsername } from '../../../../../lib/utils/normalizeUsername';
|
||||
import { getEmojiClassNameAndDataTitle } from '../../../../lib/utils/renderEmoji';
|
||||
import { MessageListContext } from '../../list/MessageListContext';
|
||||
|
||||
const normalizeUsernames = (names: string[]) => names.map<string>(normalizeUsername);
|
||||
|
||||
// TODO: replace it with proper usage of i18next plurals
|
||||
export type ReactionProps = {
|
||||
hasReacted: (name: string) => boolean;
|
||||
counter: number;
|
||||
name: string;
|
||||
names: string[];
|
||||
messageId: string;
|
||||
onClick: () => void;
|
||||
} & ComponentProps<typeof MessageReactionTemplate>;
|
||||
|
||||
const Reaction = ({ hasReacted, counter, name, names, messageId, onClick, ...props }: ReactionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const openTooltip = useTooltipOpen();
|
||||
const closeTooltip = useTooltipClose();
|
||||
const { showRealName, username } = useContext(MessageListContext);
|
||||
|
||||
const mine = hasReacted(name);
|
||||
|
||||
const emojiProps = getEmojiClassNameAndDataTitle(name);
|
||||
const buttonProps = useButtonPattern(onClick);
|
||||
|
||||
return (
|
||||
<MessageReactionTemplate
|
||||
ref={ref}
|
||||
key={name}
|
||||
mine={mine}
|
||||
aria-label={t('React_with__reaction__', { reaction: name })}
|
||||
// if data-tooltip is not set, the tooltip will close on first mouse enter
|
||||
data-tooltip=''
|
||||
onMouseEnter={async (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
ref.current &&
|
||||
openTooltip(
|
||||
<ReactionTooltip
|
||||
emojiName={name}
|
||||
usernames={normalizeUsernames(names)}
|
||||
mine={mine}
|
||||
messageId={messageId}
|
||||
showRealName={showRealName}
|
||||
username={username}
|
||||
/>,
|
||||
ref.current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
closeTooltip();
|
||||
}}
|
||||
{...buttonProps}
|
||||
{...props}
|
||||
>
|
||||
<MessageReactionEmoji {...emojiProps} />
|
||||
<MessageReactionCounter counter={counter} />
|
||||
</MessageReactionTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reaction;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useGetMessageByID } from '../../../../views/room/contextualBar/Threads/hooks/useGetMessageByID';
|
||||
import MarkdownText from '../../../MarkdownText';
|
||||
|
||||
export type ReactionTooltipProps = {
|
||||
emojiName: string;
|
||||
usernames: string[];
|
||||
username: string | undefined;
|
||||
mine: boolean;
|
||||
showRealName: boolean;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const getTranslationKey = (users: string[], mine: boolean): TranslationKey => {
|
||||
if (users.length === 0) {
|
||||
if (mine) {
|
||||
return 'You_reacted_with';
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length > 10) {
|
||||
if (mine) {
|
||||
return 'You_users_and_more_Reacted_with';
|
||||
}
|
||||
return 'Users_and_more_reacted_with';
|
||||
}
|
||||
|
||||
if (mine) {
|
||||
return 'You_and_users_Reacted_with';
|
||||
}
|
||||
return 'Users_reacted_with';
|
||||
};
|
||||
|
||||
const ReactionTooltip = ({ emojiName, usernames, mine, messageId, showRealName, username }: ReactionTooltipProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const key = getTranslationKey(usernames, mine);
|
||||
|
||||
const getMessage = useGetMessageByID();
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['chat.getMessage', 'reactions', messageId, usernames],
|
||||
|
||||
queryFn: async () => {
|
||||
// This happens if the only reaction is from the current user
|
||||
if (!usernames.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!showRealName) {
|
||||
return usernames;
|
||||
}
|
||||
|
||||
const data = await getMessage(messageId);
|
||||
|
||||
const { reactions } = data;
|
||||
|
||||
if (!reactions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const index = reactions[emojiName].usernames.indexOf(username);
|
||||
index >= 0 && reactions[emojiName].names?.splice(index, 1);
|
||||
return (reactions[emojiName].names || usernames).filter(Boolean);
|
||||
}
|
||||
|
||||
return reactions[emojiName].names || usernames;
|
||||
},
|
||||
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton width='x200' variant='text' backgroundColor='surface-light' />
|
||||
<Skeleton width='x200' variant='text' backgroundColor='surface-light' />
|
||||
{usernames.length > 5 && <Skeleton width='x200' variant='text' backgroundColor='surface-light' />}
|
||||
{usernames.length > 8 && <Skeleton width='x200' variant='text' backgroundColor='surface-light' />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkdownText
|
||||
content={t(key, {
|
||||
counter: usernames.length > 10 ? usernames.length - 10 : usernames.length,
|
||||
users: users?.slice(0, 10).join(', ') || '',
|
||||
emoji: emojiName,
|
||||
})}
|
||||
variant='inline'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionTooltip;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useToggleReactionMutation } from './useToggleReactionMutation';
|
||||
|
||||
it('should be call rest `POST /v1/chat.react` method', async () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useToggleReactionMutation(), {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).withJohnDoe().build(),
|
||||
});
|
||||
|
||||
result.current.mutate({ mid: 'MID', reaction: 'smile' });
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('success'));
|
||||
|
||||
expect(fn).toHaveBeenCalledWith({
|
||||
messageId: 'MID',
|
||||
reaction: 'smile',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not work for non-logged in users', async () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
const { result } = renderHook(() => useToggleReactionMutation(), {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/chat.react', fn).build(),
|
||||
});
|
||||
|
||||
result.current.mutate({ mid: 'MID', reaction: 'smile' });
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toEqual(new Error('Not logged in'));
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
type UseToggleReactionMutationVariables = {
|
||||
mid: IMessage['_id'];
|
||||
reaction: string;
|
||||
};
|
||||
|
||||
export const useToggleReactionMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, UseToggleReactionMutationVariables>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, UseToggleReactionMutationVariables> => {
|
||||
const uid = useUserId();
|
||||
const reactToMessage = useEndpoint('POST', '/v1/chat.react');
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ mid, reaction }) => {
|
||||
if (!uid) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
await reactToMessage({ messageId: mid, reaction });
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MessageGenericPreview } from '@rocket.chat/fuselage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import OEmbedPreviewContent from './OEmbedPreviewContent';
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
import MessageCollapsible from '../../MessageCollapsible';
|
||||
|
||||
export type OEmbedCollapsibleProps = { children?: ReactNode } & OEmbedPreviewMetadata;
|
||||
|
||||
const OEmbedCollapsible = ({ children, ...props }: OEmbedCollapsibleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MessageCollapsible title={t('Link_Preview')}>
|
||||
<MessageGenericPreview>
|
||||
{children}
|
||||
<OEmbedPreviewContent {...props} />
|
||||
</MessageGenericPreview>
|
||||
</MessageCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default OEmbedCollapsible;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import OEmbedCollapsible from './OEmbedCollapsible';
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
|
||||
const purifyOptions = {
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['frameborder', 'allow', 'allowfullscreen', 'scrolling', 'src', 'style', 'referrerpolicy'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: true,
|
||||
};
|
||||
|
||||
const OEmbedHtmlPreview = ({ html, ...props }: OEmbedPreviewMetadata) => {
|
||||
// Memoize the dangerouslySetInnerHTML object so its identity is stable across
|
||||
// re-renders. React 19 re-applies innerHTML when this object is a new reference
|
||||
// (even with an identical string), which reloads embedded iframes (e.g. YouTube)
|
||||
// on every re-render — visible as a flicker on new messages / reactions.
|
||||
const dangerous = useMemo(() => (html ? { __html: DOMPurify.sanitize(html, purifyOptions) } : undefined), [html]);
|
||||
|
||||
return <OEmbedCollapsible {...props}>{dangerous && <Box withRichContent dangerouslySetInnerHTML={dangerous} />}</OEmbedCollapsible>;
|
||||
};
|
||||
|
||||
export default OEmbedHtmlPreview;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MessageGenericPreviewCoverImage } from '@rocket.chat/fuselage';
|
||||
|
||||
import OEmbedCollapsible from './OEmbedCollapsible';
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
|
||||
const OEmbedImagePreview = ({ image, ...props }: OEmbedPreviewMetadata) => (
|
||||
<OEmbedCollapsible {...props}>
|
||||
{image?.url && <MessageGenericPreviewCoverImage height={192} width={368} url={image?.url} />}
|
||||
</OEmbedCollapsible>
|
||||
);
|
||||
|
||||
export default OEmbedImagePreview;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MessageGenericPreviewCoverImage } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink } from '@rocket.chat/ui-client';
|
||||
|
||||
import OEmbedCollapsible from './OEmbedCollapsible';
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
|
||||
const OEmbedLinkPreview = ({ image, url, ...props }: OEmbedPreviewMetadata) => (
|
||||
<OEmbedCollapsible url={url} {...props}>
|
||||
{image?.url && url && (
|
||||
<ExternalLink to={url}>
|
||||
<MessageGenericPreviewCoverImage height={192} width={368} url={image?.url} />
|
||||
</ExternalLink>
|
||||
)}
|
||||
</OEmbedCollapsible>
|
||||
);
|
||||
|
||||
export default OEmbedLinkPreview;
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
MessageGenericPreviewContent,
|
||||
MessageGenericPreviewTitle,
|
||||
MessageGenericPreviewDescription,
|
||||
MessageGenericPreviewFooter,
|
||||
Box,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
import MarkdownText from '../../../MarkdownText';
|
||||
|
||||
export type OEmbedPreviewContentProps = { thumb?: ReactElement<any>; children?: ReactNode } & OEmbedPreviewMetadata;
|
||||
|
||||
const OEmbedPreviewContent = ({ title, description, url, thumb, authorName, authorUrl, siteName, siteUrl }: OEmbedPreviewContentProps) => {
|
||||
const showSiteName = siteName && siteUrl;
|
||||
const showAuthorName = authorName && authorUrl;
|
||||
const showFooterSeparator = showSiteName && showAuthorName;
|
||||
|
||||
return (
|
||||
<MessageGenericPreviewContent thumb={thumb}>
|
||||
{title && (
|
||||
<MessageGenericPreviewTitle externalUrl={url} title={title}>
|
||||
{title}
|
||||
</MessageGenericPreviewTitle>
|
||||
)}
|
||||
{description && <MessageGenericPreviewDescription>{description}</MessageGenericPreviewDescription>}
|
||||
{(showSiteName || showAuthorName) && (
|
||||
<MessageGenericPreviewFooter>
|
||||
<Box display='flex' justifyContent='flex-start'>
|
||||
{showSiteName && <MarkdownText variant='inline' content={`[${siteName}](${siteUrl})`} />}
|
||||
{showFooterSeparator && <Box marginInline={4}>|</Box>}
|
||||
{showAuthorName && <MarkdownText variant='inline' content={`[${authorName}](${authorUrl})`} />}
|
||||
</Box>
|
||||
</MessageGenericPreviewFooter>
|
||||
)}
|
||||
</MessageGenericPreviewContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default OEmbedPreviewContent;
|
||||
@@ -0,0 +1,22 @@
|
||||
type PreviewType = 'photo' | 'video' | 'link' | 'rich';
|
||||
|
||||
export type OEmbedPreviewMetadata = Partial<{
|
||||
siteName: string; // nome
|
||||
siteUrl: string; // parsedUrl.host
|
||||
siteColor: string; // ogColor
|
||||
title: string; // ogTitle
|
||||
description: string; // ogDescription
|
||||
authorName: string;
|
||||
authorUrl?: string;
|
||||
image: {
|
||||
// preview?: string; // base64 low res preview
|
||||
url: string;
|
||||
dimensions: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
};
|
||||
url: string;
|
||||
type: PreviewType;
|
||||
html?: string; // for embedded OembedType
|
||||
}>;
|
||||
@@ -0,0 +1,24 @@
|
||||
import OEmbedHtmlPreview from './OEmbedHtmlPreview';
|
||||
import OEmbedImagePreview from './OEmbedImagePreview';
|
||||
import OEmbedLinkPreview from './OEmbedLinkPreview';
|
||||
import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata';
|
||||
|
||||
export type OEmbedResolverProps = {
|
||||
meta: OEmbedPreviewMetadata;
|
||||
};
|
||||
|
||||
const OEmbedResolver = ({ meta }: OEmbedResolverProps) => {
|
||||
switch (meta.type) {
|
||||
case 'rich':
|
||||
case 'video':
|
||||
return <OEmbedHtmlPreview {...meta} />;
|
||||
|
||||
case 'photo':
|
||||
return <OEmbedImagePreview {...meta} />;
|
||||
|
||||
default:
|
||||
return <OEmbedLinkPreview {...meta} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default OEmbedResolver;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AudioPlayer } from '@rocket.chat/fuselage';
|
||||
|
||||
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
|
||||
|
||||
export type UrlAudioPreviewProps = Pick<UrlPreviewMetadata, 'url'>;
|
||||
|
||||
const UrlAudioPreview = ({ url }: UrlAudioPreviewProps) => <AudioPlayer src={url} />;
|
||||
|
||||
export default UrlAudioPreview;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Box, MessageGenericPreviewImage } from '@rocket.chat/fuselage';
|
||||
|
||||
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
|
||||
import { useOembedLayout } from '../../hooks/useOembedLayout';
|
||||
|
||||
const UrlImagePreview = ({ url }: Pick<UrlPreviewMetadata, 'url'>) => {
|
||||
const { maxHeight: oembedMaxHeight } = useOembedLayout();
|
||||
|
||||
return (
|
||||
<Box maxHeight={oembedMaxHeight} maxWidth='100%'>
|
||||
<MessageGenericPreviewImage data-id={url} className='preview-image' url={url || ''} alt='' />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlImagePreview;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useAttachmentAutoLoadEmbedMedia } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
|
||||
import UrlPreviewResolver from './UrlPreviewResolver';
|
||||
import { useCollapse } from '../../hooks/useCollapse';
|
||||
import CollapsibleContent from '../collapsible/CollapsibleContent';
|
||||
|
||||
const UrlPreview = (props: UrlPreviewMetadata) => {
|
||||
const autoLoadMedia = useAttachmentAutoLoadEmbedMedia();
|
||||
const [collapsed, toggleCollapse] = useCollapse(!autoLoadMedia);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box display='flex' flexDirection='row' color='hint' fontScale='c1' alignItems='center'>
|
||||
{t('Link_Preview')} <CollapsibleContent key='collapsible-content-action' collapsed={collapsed} onClick={toggleCollapse} />
|
||||
</Box>
|
||||
{!collapsed && <UrlPreviewResolver {...props} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlPreview;
|
||||
@@ -0,0 +1,5 @@
|
||||
export type UrlPreviewMetadata = {
|
||||
type: 'image' | 'video' | 'audio';
|
||||
originalType: string;
|
||||
url: string;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import UrlAudioPreview from './UrlAudioPreview';
|
||||
import UrlImagePreview from './UrlImagePreview';
|
||||
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
|
||||
import UrlVideoPreview from './UrlVideoPreview';
|
||||
|
||||
const UrlPreviewResolver = ({ url, type, originalType }: UrlPreviewMetadata) => {
|
||||
switch (type) {
|
||||
case 'audio':
|
||||
return <UrlAudioPreview url={url} />;
|
||||
case 'video':
|
||||
return <UrlVideoPreview url={url} originalType={originalType} />;
|
||||
case 'image':
|
||||
return <UrlImagePreview url={url} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default UrlPreviewResolver;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UrlPreviewMetadata } from './UrlPreviewMetadata';
|
||||
|
||||
const style = { maxWidth: '100%' };
|
||||
const UrlVideoPreview = ({ url, originalType }: Omit<UrlPreviewMetadata, 'type'>) => (
|
||||
<video controls style={style}>
|
||||
<source src={url} type={originalType} />
|
||||
Your browser doesn't support the video element.
|
||||
<track kind='captions' />
|
||||
</video>
|
||||
);
|
||||
|
||||
export default UrlVideoPreview;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildImageURL } from './buildImageURL';
|
||||
|
||||
const testCases = [
|
||||
[
|
||||
'https://g1.globo.com/mundo/video/misseis-atingem-ponte-de-vidro-em-kiev-11012523.ghtml',
|
||||
'https://s2.glbimg.com/fXQKM_UZjF6I_3APIbPJzJTOUvw=/1200x/smart/filters:cover():strip_icc()/s04.video.glbimg.com/x720/11012523.jpg',
|
||||
'https://s2.glbimg.com/fXQKM_UZjF6I_3APIbPJzJTOUvw=/1200x/smart/filters:cover():strip_icc()/s04.video.glbimg.com/x720/11012523.jpg',
|
||||
],
|
||||
['https://open.rocket.chat/channel/general', 'assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'],
|
||||
['https://open.rocket.chat/channel/general', '/assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'],
|
||||
['https://open.rocket.chat/channel/general/', '/assets/favicon_512.png', 'https://open.rocket.chat/assets/favicon_512.png'],
|
||||
] as const;
|
||||
|
||||
testCases.forEach(([linkUrl, metaImgUrl, expectedResult]) => {
|
||||
it(`should return ${expectedResult} for ${metaImgUrl}`, () => {
|
||||
const result = buildImageURL(linkUrl, metaImgUrl);
|
||||
|
||||
expect(result).toBe(JSON.stringify(expectedResult));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { isValidLink } from '../../../../views/room/MessageList/lib/isValidLink';
|
||||
|
||||
export const buildImageURL = (url: string, imageUrl: string): string => {
|
||||
if (isValidLink(imageUrl)) {
|
||||
return JSON.stringify(imageUrl);
|
||||
}
|
||||
|
||||
const { origin } = new URL(url);
|
||||
const imgURL = `${origin}/${imageUrl}`;
|
||||
const normalizedUrl = imgURL.replace(/([^:]\/)\/+/gm, '$1');
|
||||
|
||||
return JSON.stringify(normalizedUrl);
|
||||
};
|
||||
Reference in New Issue
Block a user