Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/funny-wings-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/ui-client': minor
'@rocket.chat/models': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Replaces the "Drafts in sidebar" feature preview with an always-on draft indicator. Thread-composer drafts are also persisted per thread and indicated in the thread list
22 changes: 22 additions & 0 deletions apps/meteor/client/lib/utils/getSubscriptionDraft.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getSubscriptionDraft } from './getSubscriptionDraft';

it('should return undefined when there is no draft', () => {
expect(getSubscriptionDraft({})).toBeUndefined();
expect(getSubscriptionDraft({ threadDrafts: {} })).toBeUndefined();
});

it('should return the main composer draft when present', () => {
expect(getSubscriptionDraft({ draft: 'main draft' })).toBe('main draft');
});

it('should prefer the main composer draft over thread drafts', () => {
expect(getSubscriptionDraft({ draft: 'main draft', threadDrafts: { t1: 'thread draft' } })).toBe('main draft');
});

it('should fall back to a thread draft when there is no main draft', () => {
expect(getSubscriptionDraft({ threadDrafts: { t1: 'thread draft' } })).toBe('thread draft');
});

it('should return the first non-empty thread draft', () => {
expect(getSubscriptionDraft({ threadDrafts: { t1: '', t2: 'second' } })).toBe('second');
});
9 changes: 9 additions & 0 deletions apps/meteor/client/lib/utils/getSubscriptionDraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ISubscription } from '@rocket.chat/core-typings';

export const getSubscriptionDraft = (subscription: Pick<ISubscription, 'draft' | 'threadDrafts'>): string | undefined => {
if (subscription.draft) {
return subscription.draft;
}

return subscription.threadDrafts && Object.values(subscription.threadDrafts).find(Boolean);
Comment thread
KevLehman marked this conversation as resolved.
Resolved
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,46 @@ describe('getMessagePreview', () => {
mockNormalizeMessagePreview.mockReturnValue('normalized message');
});

describe('when room has a draft', () => {
it('should return "Draft: content" taking precedence over the last message', () => {
const room = createFakeSubscription({ draft: 'unfinished text' });
const message = createFakeMessage();

const result = getMessagePreview(room, message, mockT);

expect(result).toBe('Draft: unfinished text');
expect(mockT).toHaveBeenCalledWith('Draft');
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
});

it('should escape HTML in the draft content', () => {
const room = createFakeSubscription({ draft: '<img src=x onerror=alert(1)>' });

const result = getMessagePreview(room, undefined, mockT);

expect(result).toBe('Draft: &lt;img src=x onerror=alert(1)&gt;');
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
});

it('should fall back to a thread-composer draft when there is no main draft', () => {
const room = createFakeSubscription({ draft: undefined, threadDrafts: { 'thread-id': 'unfinished thread reply' } });
const message = createFakeMessage();

const result = getMessagePreview(room, message, mockT);

expect(result).toBe('Draft: unfinished thread reply');
expect(mockNormalizeMessagePreview).not.toHaveBeenCalled();
});

it('should prefer the main draft over thread-composer drafts', () => {
const room = createFakeSubscription({ draft: 'main draft', threadDrafts: { 'thread-id': 'thread draft' } });

const result = getMessagePreview(room, undefined, mockT);

expect(result).toBe('Draft: main draft');
});
});

describe('when lastMessage is undefined', () => {
it('should return translation for No_messages_yet', () => {
const room = createFakeSubscription();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import type { TFunction } from 'i18next';

import { normalizeMessagePreview } from './normalizeMessagePreview';
import { getSubscriptionDraft } from '../getSubscriptionDraft';

export const getMessagePreview = (room: SubscriptionWithRoom, lastMessage: IMessage | undefined, t: TFunction): string | undefined => {
const draft = getSubscriptionDraft(room);
if (draft) {
return `${t('Draft')}: ${escapeHTML(draft)}`;
}

if (!lastMessage) {
return t('No_messages_yet');
}
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/sidebar/Item/Condensed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ export type CondensedProps = {
clickable?: boolean;
} & Omit<HTMLAttributes<HTMLAnchorElement>, 'is'>;

const Condensed = ({ icon, title, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => {
const Condensed = ({ icon, title, titleIcon, avatar, actions, unread, menu, badges, ...props }: CondensedProps) => {
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item {...props} onFocus={mountNow} onPointerEnter={requestMount}>
{avatar && <SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>}
{icon}
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
{titleIcon}
{badges}
{actions}
{menu && (
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/sidebar/Item/Extended.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const Extended = ({
menu,
menuOptions: _menuOptions,
subtitle = '',
titleIcon: _titleIcon,
titleIcon,
badges,
threadUnread: _threadUnread,
unread,
Expand All @@ -63,6 +63,7 @@ const Extended = ({
</SidebarV2ItemRow>
<SidebarV2ItemRow>
<SidebarV2ItemContent unread={unread}>{subtitle}</SidebarV2ItemContent>
{titleIcon}
{badges}
{actions}
{menu && (
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/sidebar/Item/Medium.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ export type MediumProps = {
menuOptions?: any;
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;

const Medium = ({ icon, title, avatar, actions, badges, unread, menu, ...props }: MediumProps) => {
const Medium = ({ icon, title, titleIcon, avatar, actions, badges, unread, menu, ...props }: MediumProps) => {
const { mounted: menuVisibility, requestMount, mountNow } = useDeferredMenuMount();

return (
<SidebarV2Item {...props} onFocus={mountNow} onPointerEnter={requestMount}>
<SidebarV2ItemAvatarWrapper>{avatar}</SidebarV2ItemAvatarWrapper>
{icon}
<SidebarV2ItemTitle unread={unread}>{title}</SidebarV2ItemTitle>
{titleIcon}
{badges}
{actions}
{menu && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage';
import { Icon, SidebarV2Action, SidebarV2Actions, SidebarV2ItemIcon } from '@rocket.chat/fuselage';
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useLayout } from '@rocket.chat/ui-contexts';
import type { TFunction } from 'i18next';
Expand All @@ -9,6 +9,7 @@ import { memo, useMemo } from 'react';
import { RoomIcon } from '../../components/RoomIcon';
import { useUserStatusTooltip } from '../../hooks/useUserStatusTooltip';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { getSubscriptionDraft } from '../../lib/utils/getSubscriptionDraft';
import { getUidDirectMessage } from '../../lib/utils/getUidDirectMessage';
import { isIOsDevice } from '../../lib/utils/isIOsDevice';
import { getMessagePreview } from '../../lib/utils/normalizeMessagePreview/getMessagePreview';
Expand Down Expand Up @@ -91,6 +92,10 @@ const SidebarItemTemplateWithData = ({
/>
);

const titleIcon = getSubscriptionDraft(room) ? (
Comment thread
dougfabris marked this conversation as resolved.
Resolved
<Icon name='pencil' size='x12' title={room.draft ? t('Unfinished_message') : t('Unfinished_thread_message')} />
) : undefined;

const actions = useMemo(
() =>
videoConfActions && (
Expand Down Expand Up @@ -125,6 +130,7 @@ const SidebarItemTemplateWithData = ({
time={lastMessage?.ts}
subtitle={subtitle}
icon={icon}
titleIcon={titleIcon}
style={style}
badges={<SidebarItemBadges room={room} roomTitle={title} />}
avatar={AvatarTemplate && <AvatarTemplate {...room} />}
Expand Down Expand Up @@ -194,6 +200,12 @@ export default memo(SidebarItemTemplateWithData, (prevProps, nextProps) => {
if (prevProps.room.alert !== nextProps.room.alert) {
return false;
}
if (prevProps.room.draft !== nextProps.room.draft) {
return false;
}
if (prevProps.room.threadDrafts !== nextProps.room.threadDrafts) {
return false;
}
if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) {
return false;
}
Expand Down
11 changes: 0 additions & 11 deletions apps/meteor/client/sidebar/hooks/useRoomList.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts';
import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf';
Expand All @@ -20,7 +19,6 @@ const order = [
'Open_Livechats',
'On_Hold_Chats',
'Unread',
'Drafts',
'Favorites',
'Teams',
'Discussions',
Expand All @@ -42,7 +40,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
const showOmnichannel = useOmnichannelEnabled();
const sidebarGroupByType = useUserPreference('sidebarGroupByType');
const favoritesEnabled = useUserPreference('sidebarShowFavorites');
const sidebarDrafts = useFeaturePreview('sidebarDrafts');
const sidebarOrder = useUserPreference<typeof order>('sidebarSectionsOrder') ?? order;
const isDiscussionEnabled = useSetting('Discussion_enabled');
const sidebarShowUnread = useUserPreference('sidebarShowUnread');
Expand All @@ -61,7 +58,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
useMemo(() => {
const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle);

const drafts = new Set();
const incomingCall = new Set();
const favorite = new Set();
const team = new Set();
Expand All @@ -86,10 +82,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
return unread.add(room);
}

if (sidebarDrafts && room.draft) {
return drafts.add(room);
}

if (favoritesEnabled && room.f) {
return favorite.add(room);
}
Expand Down Expand Up @@ -130,8 +122,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })

sidebarShowUnread && unread.size && groups.set('Unread', unread);

sidebarDrafts && drafts.size && groups.set('Drafts', drafts);

favoritesEnabled && favorite.size && groups.set('Favorites', favorite);

sidebarGroupByType && team.size && groups.set('Teams', team);
Expand Down Expand Up @@ -203,7 +193,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
rooms,
showOmnichannel,
inquiries.enabled,
sidebarDrafts,
queue,
sidebarShowUnread,
favoritesEnabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage';
import { Icon, SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useUserId, type SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
Expand All @@ -11,6 +11,7 @@ import SidePanelParent from './SidePanelParent';
import SidePanelItem from './SidepanelItem';
import { RoomIcon } from '../../../../components/RoomIcon';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { getSubscriptionDraft } from '../../../../lib/utils/getSubscriptionDraft';
import { isIOsDevice } from '../../../../lib/utils/isIOsDevice';
import { getMessagePreview } from '../../../../lib/utils/normalizeMessagePreview/getMessagePreview';
import { useOmnichannelPriorities } from '../../../omnichannel/hooks/useOmnichannelPriorities';
Expand Down Expand Up @@ -39,6 +40,10 @@ const RoomSidePanelItem = ({ room, openedRoom, isRoomFilter, ...props }: RoomSid

const parentRoomId = Boolean(room.prid || (room.teamId && !room.teamMain));

const titleIcon = getSubscriptionDraft(room) ? (
<Icon name='pencil' size='x12' title={room.draft ? t('Unfinished_message') : t('Unfinished_thread_message')} />
) : undefined;

const menu =
!isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) ? (
<RoomMenu
Expand All @@ -60,6 +65,7 @@ const RoomSidePanelItem = ({ room, openedRoom, isRoomFilter, ...props }: RoomSid
href={href}
selected={rid === openedRoom}
title={title}
titleIcon={titleIcon}
avatar={<RoomAvatar size='x20' room={{ ...room, _id: room.rid || room._id, type: room.t }} />}
icon={<SidebarItemIcon highlighted={highlighted} icon={<RoomIcon room={room} placement='sidebar' size='x20' />} />}
unread={highlighted}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type SidePanelItemProps = {
href: string;
selected: boolean;
title: string;
titleIcon?: ReactNode;
avatar: ReactNode;
icon: ReactNode;
unread: boolean;
Expand All @@ -33,6 +34,7 @@ const SidePanelItem = ({
href,
selected,
title,
titleIcon,
avatar,
icon,
unread,
Expand Down Expand Up @@ -72,6 +74,7 @@ const SidePanelItem = ({
<SidebarV2ItemRow>
<SidebarV2ItemContent unread={unread}>{subtitle}</SidebarV2ItemContent>
{parentRoom}
{titleIcon}
{badges}
{menu && (
<SidebarV2ItemMenu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import MessageBox from './messageBox/MessageBox';

export type ComposerMessageProps = {
tmid?: IMessage['_id'];
threadExists?: boolean;
children?: ReactNode;
subscription?: ISubscription;
tshow?: boolean;
Expand Down Expand Up @@ -87,7 +88,7 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps) => {
return <ComposerSkeleton />;
}

return <MessageBox key={room._id} tmid={tmid} {...composerProps} showFormattingTips={true} {...props} />;
return <MessageBox key={tmid ? `${room._id}-${tmid}` : room._id} tmid={tmid} {...composerProps} showFormattingTips={true} {...props} />;
};

export default memo(ComposerMessage);
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export type MessageBoxProps = {
subscription?: ISubscription;
showFormattingTips: boolean;
isEmbedded?: boolean;
threadExists?: boolean;
};

const MessageBox = ({
Expand All @@ -107,6 +108,7 @@ const MessageBox = ({
onTyping,
tshow,
previewUrls,
threadExists,
}: MessageBoxProps) => {
const chat = useChat();
const room = useRoom();
Expand All @@ -130,7 +132,12 @@ const MessageBox = ({
const messageComposerRef = useRef<HTMLElement>(null);

const subscription = useRoomSubscription();
const { initialValue, persistLocal, flushDraft } = useDraft(room._id, tmid ? undefined : subscription?.draft, tmid);
const { initialValue, persistLocal, flushDraft } = useDraft(
room._id,
tmid ? subscription?.threadDrafts?.[tmid] : subscription?.draft,
tmid,
threadExists,
);

const callbackRef = useCallback(
(node: HTMLTextAreaElement) => {
Expand Down
Loading
Loading