Skip to content

Commit 5702090

Browse files
authored
feat: redesign message annotations (#2956)
1 parent 44e2eaf commit 5702090

37 files changed

Lines changed: 362 additions & 96 deletions

src/components/Attachment/Attachment.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ export type AttachmentProps = {
5252
attachments: (StreamAttachment | SharedLocationResponse)[];
5353
/** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */
5454
actionHandler?: ActionHandlerReturnType;
55-
/** Which action should be focused on initial render, by attachment type (match by action.value) */
55+
/**
56+
* Which attachment action button receives focus on initial render, keyed by attachment type.
57+
* Values must match an action's `value` (e.g. `'send'`, `'cancel'`, `'shuffle'` for giphy attachment).
58+
* Default: `{ giphy: 'send' }`.
59+
* To disable auto-focus (e.g. when rendering the Giphy preview above the composer so focus
60+
* stays in the message input), pass an empty object: `attachmentActionsDefaultFocus={{}}`.
61+
*/
5662
attachmentActionsDefaultFocus?: AttachmentActionsDefaultFocusByType;
5763
/** Custom UI component for displaying attachment actions, defaults to and accepts same props as: [AttachmentActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/AttachmentActions.tsx) */
5864
AttachmentActions?: React.ComponentType<AttachmentActionsProps>;

src/components/Avatar/styling/Avatar.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
font-size: var(--typography-font-size-md);
101101
}
102102

103-
&.str-chat__avatar--size-md {
103+
&.str-chat__avatar--size-md {
104104
--avatar-size: 32px;
105105
--avatar-online-badge-size: 12px;
106106
--avatar-icon-size: var(--icon-size-md);

src/components/Dialog/styling/Alert.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
@mixin flex-column {
32
display: flex;
43
flex-direction: column;
@@ -45,4 +44,4 @@
4544
width: 100%;
4645
}
4746
}
48-
}
47+
}

src/components/Dialog/styling/Prompt.scss

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
@use '../../../styling/utils';
22

3-
// todo: once we have designs for dialogs + context menus create base class instead of a mixin
4-
@mixin dialog-base {
5-
6-
}
7-
83
.str-chat__dialog-overlay {
94
inset: 0;
105
position: absolute;

src/components/Icons/icons.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ export const IconArrowRight = createIcon(
3232
<path d='M8.90918 3.4095C9.14349 3.17519 9.5235 3.17519 9.75781 3.4095L13.9238 7.57552C14.0363 7.68804 14.0996 7.84119 14.0996 8.00032C14.0995 8.15933 14.0363 8.3117 13.9238 8.42415L9.75781 12.5911C9.52355 12.8254 9.14351 12.8253 8.90918 12.5911C8.67487 12.3568 8.67487 11.9768 8.90918 11.7425L12.0518 8.59993H2.5C2.16874 8.59993 1.90057 8.33154 1.90039 8.00032C1.90039 7.66895 2.16863 7.39973 2.5 7.39973H12.0518L8.90918 4.25716C8.67508 4.02288 8.67508 3.64377 8.90918 3.4095Z' />,
3333
);
3434

35+
export const IconArrowRightUp = createIcon(
36+
'IconArrowRightUp',
37+
<path
38+
d='M12.1667 10.1663V3.83301M12.1667 3.83301H5.83333M12.1667 3.83301L4 11.9997'
39+
fill='none'
40+
stroke='currentColor'
41+
strokeLinecap='round'
42+
strokeLinejoin='round'
43+
/>,
44+
);
45+
3546
export const IconArrowRotateClockwise = createIcon(
3647
'IconArrowRotateClockwise',
3748
<path d='M1.88638 8C1.88638 4.63106 4.61706 1.90039 7.98599 1.90039C9.79486 1.90039 11.0876 2.58323 12.2331 3.69824V2.66699C12.2331 2.33562 12.5023 2.06641 12.8336 2.06641C13.1649 2.06658 13.4333 2.33573 13.4333 2.66699V4.83301C13.4333 5.44042 12.941 5.93342 12.3336 5.93359H10.1667C9.83529 5.93359 9.56705 5.66438 9.56705 5.33301C9.56722 5.00179 9.8354 4.7334 10.1667 4.7334H11.571C10.5361 3.66485 9.48807 3.09961 7.98599 3.09961C5.2798 3.09961 3.0856 5.2938 3.0856 8C3.0856 10.7062 5.27981 12.9004 7.98599 12.9004C10.1184 12.9004 11.934 11.5375 12.6071 9.63379C12.7175 9.32146 13.0604 9.15735 13.3727 9.26758C13.6851 9.37799 13.8493 9.72081 13.7389 10.0332C12.9018 12.4016 10.6429 14.0996 7.98599 14.0996C4.61706 14.0996 1.88638 11.3689 1.88638 8Z' />,
@@ -73,7 +84,13 @@ export const IconAtSolid = createIcon(
7384

7485
export const IconBellNotification = createIcon(
7586
'IconBellNotification',
76-
<path d='M12.8926 10.7972L12.0674 9.19757C11.9412 8.95287 11.8684 8.68417 11.8545 8.40948L11.7314 5.97003C11.632 3.99113 9.99271 2.43292 8 2.43292C6.00726 2.43292 4.368 3.99015 4.26855 5.96906L4.14453 8.40948C4.13061 8.68431 4.0587 8.95302 3.93262 9.19757L3.10742 10.7972C3.1024 10.807 3.09961 10.8182 3.09961 10.8294C3.09962 10.8684 3.1319 10.8997 3.1709 10.8997H12.8291C12.8681 10.8997 12.9004 10.8684 12.9004 10.8294C12.9004 10.8183 12.8977 10.8071 12.8926 10.7972ZM6.02246 12.0999C6.2796 12.9486 7.06733 13.5667 8 13.5667C8.93265 13.5667 9.72039 12.9486 9.97754 12.0999H6.02246ZM14.0996 10.8294C14.0996 11.5311 13.5308 12.0999 12.8291 12.0999H11.2109C10.9292 13.6174 9.59912 14.7669 8 14.7669C6.40085 14.7669 5.07082 13.6174 4.78906 12.0999H3.1709C2.46916 12.0999 1.90041 11.5311 1.90039 10.8294C1.90039 10.627 1.94825 10.4274 2.04102 10.2474L2.86621 8.64777C2.91399 8.5551 2.94099 8.45314 2.94629 8.34894L3.06934 5.90948C3.20084 3.28834 5.37132 1.2337 8 1.2337C10.6286 1.2337 12.7992 3.28834 12.9307 5.90948L13.0537 8.34894C13.059 8.45327 13.0861 8.55526 13.1338 8.64777L13.959 10.2474C14.0517 10.4273 14.0996 10.6269 14.0996 10.8294Z' />,
87+
<path
88+
d='M10.6667 11.4997C10.6667 12.9724 9.47273 14.1663 8 14.1663C6.52724 14.1663 5.33333 12.9724 5.33333 11.4997M13.5 10.8291C13.5 11.1994 13.1997 11.4997 12.8294 11.4997H3.17062C2.80025 11.4997 2.5 11.1994 2.5 10.8291C2.5 10.7221 2.52557 10.6167 2.57457 10.5217L3.39922 8.92241C3.48623 8.75367 3.53621 8.56827 3.54578 8.37861L3.66901 5.93899C3.7844 3.63889 5.68924 1.83301 8 1.83301C10.3107 1.83301 12.2156 3.63889 12.331 5.93899L12.4542 8.37861C12.4638 8.56827 12.5137 8.75367 12.6008 8.92241L13.4254 10.5217C13.4744 10.6167 13.5 10.7221 13.5 10.8291Z'
89+
fill='none'
90+
stroke='currentColor'
91+
strokeLinecap='round'
92+
strokeLinejoin='round'
93+
/>,
7794
);
7895

7996
export const IconBellOff = createIcon(
@@ -90,7 +107,13 @@ export const IconBellOff = createIcon(
90107

91108
export const IconBookmark = createIcon(
92109
'IconBookmark',
93-
<path d='M6.92056 11.5033C7.57321 11.0641 8.42712 11.0641 9.07974 11.5033L12.1295 13.556C12.145 13.5664 12.1554 13.5685 12.1628 13.5687C12.1722 13.569 12.1849 13.5668 12.1979 13.5599C12.2109 13.553 12.2202 13.5436 12.2253 13.5355C12.2291 13.5293 12.233 13.5196 12.2331 13.5013V3.16638C12.2329 2.76152 11.9046 2.43298 11.4997 2.43298H4.49966C4.09491 2.43316 3.76644 2.76163 3.76627 3.16638V13.5013C3.76631 13.5198 3.77121 13.5293 3.77505 13.5355C3.7801 13.5436 3.7894 13.553 3.8024 13.5599C3.81508 13.5666 3.82721 13.569 3.83658 13.5687C3.84393 13.5685 3.85513 13.5666 3.87076 13.556L6.92056 11.5033ZM13.4333 13.5013C13.433 14.5152 12.3009 15.1181 11.4596 14.5521L8.40982 12.4994C8.19302 12.3535 7.91754 12.3351 7.68619 12.4447L7.59048 12.4994L4.54068 14.5521C3.69947 15.1182 2.56727 14.5154 2.56705 13.5013V3.16638C2.56722 2.09889 3.43217 1.23394 4.49966 1.23376H11.4997C12.5673 1.23376 13.4331 2.09879 13.4333 3.16638V13.5013Z' />,
110+
<path
111+
d='M12.8333 13.501V3.16666C12.8333 2.43028 12.2364 1.83333 11.5 1.83333H4.49999C3.76361 1.83333 3.16666 2.43028 3.16666 3.16666V13.501C3.16666 14.0348 3.76275 14.3521 4.20558 14.054L7.25546 12.0011C7.70559 11.6982 8.29439 11.6982 8.74452 12.0011L11.7944 14.054C12.2373 14.3521 12.8333 14.0348 12.8333 13.501Z'
112+
fill='none'
113+
stroke='black'
114+
strokeLinecap='round'
115+
strokeLinejoin='round'
116+
/>,
94117
);
95118

96119
export const IconBookmarkRemove = createIcon(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
3+
import { IconArrowRightUp } from '../Icons';
4+
import { useMessageContext, useTranslationContext } from '../../context';
5+
6+
/**
7+
* Indicator shown in thread message lists when the message was also sent to the main channel (show_in_channel === true).
8+
* Only visible inside Thread, not in the main channel list.
9+
*/
10+
export const MessageAlsoSentInChannelIndicator = () => {
11+
const { message, threadList } = useMessageContext('MessageAlsoSentInChannelIndicator');
12+
const { t } = useTranslationContext();
13+
14+
if (!threadList || !message?.show_in_channel) return null;
15+
16+
return (
17+
<div className='str-chat__message-also-sent-in-channel' role='status'>
18+
<IconArrowRightUp />
19+
<span>{t('Also sent in channel')}</span>
20+
</div>
21+
);
22+
};

src/components/Message/MessageSimple.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { MessageText } from './MessageText';
1212
import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp';
1313
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
1414
import { isDateSeparatorMessage } from '../MessageList';
15+
import { MessageAlsoSentInChannelIndicator as DefaultMessageAlsoSentInChannelIndicator } from './MessageAlsoSentInChannelIndicator';
1516
import { MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageIsThreadReplyInChannelButtonIndicator';
1617
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
1718
import { useMessageReminder } from './hooks';
@@ -38,7 +39,11 @@ import { useComponentContext } from '../../context/ComponentContext';
3839
import type { MessageContextValue } from '../../context/MessageContext';
3940
import { useMessageContext } from '../../context/MessageContext';
4041

41-
import { useChatContext, useTranslationContext } from '../../context';
42+
import {
43+
useChannelStateContext,
44+
useChatContext,
45+
useTranslationContext,
46+
} from '../../context';
4247
import { MessageEditedTimestamp } from './MessageEditedTimestamp';
4348

4449
import type { MessageUIComponentProps } from './types';
@@ -64,8 +69,10 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
6469
showAvatar = 'incoming',
6570
threadList,
6671
} = props;
67-
const { client } = useChatContext('MessageSimple');
68-
const { t } = useTranslationContext('MessageSimple');
72+
const { channel } = useChannelStateContext();
73+
const { client } = useChatContext();
74+
const { t } = useTranslationContext();
75+
const memberCount = Object.keys(channel?.state?.members ?? {}).length;
6976
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
7077
const [isEditedTimestampOpen, setEditedTimestampOpen] = useState(false);
7178
const reminder = useMessageReminder(message.id);
@@ -74,6 +81,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
7481
Attachment = DefaultAttachment,
7582
Avatar = DefaultAvatar,
7683
MessageActions = DefaultMessageActions,
84+
MessageAlsoSentInChannelIndicator = DefaultMessageAlsoSentInChannelIndicator,
7785
MessageBlocked = DefaultMessageBlocked,
7886
MessageBouncePrompt = DefaultMessageBouncePrompt,
7987
MessageDeleted = DefaultMessageDeleted,
@@ -183,6 +191,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
183191
{
184192
<div className={rootClassName} key={message.id}>
185193
{message.pinned && <PinIndicator message={message} />}
194+
{threadList && message.show_in_channel && <MessageAlsoSentInChannelIndicator />}
186195
{!!reminder && <ReminderNotification reminder={reminder} />}
187196
{message.user && (
188197
<Avatar
@@ -231,7 +240,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
231240
{showMetadata && (
232241
<div className='str-chat__message-metadata'>
233242
<MessageStatus />
234-
{!isMyMessage() && !!message.user && (
243+
{!isMyMessage() && !!message.user && memberCount > 2 && (
235244
<span className='str-chat__message-simple-name'>
236245
{message.user.name || message.user.id}
237246
</span>

src/components/Message/PinIndicator.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22

33
import { IconPin } from '../Icons';
4-
import { useTranslationContext } from '../../context';
4+
import { useChatContext, useTranslationContext } from '../../context';
55
import type { LocalMessage } from 'stream-chat';
66

77
export type PinIndicatorProps = {
@@ -14,12 +14,18 @@ export type PinIndicatorProps = {
1414
*/
1515
export const PinIndicator = ({ message }: PinIndicatorProps) => {
1616
const { t } = useTranslationContext();
17+
const { client } = useChatContext();
1718

1819
if (!message) return null;
1920

21+
const isOwnPin = !!message.pinned_by?.id && message.pinned_by.id === client.user?.id;
2022
const name = message.pinned_by?.name ?? message.pinned_by?.id ?? '';
2123

22-
const label = name ? t('Pinned by {{ name }}', { name }) : t('Message pinned');
24+
const label = isOwnPin
25+
? t('Pinned by You')
26+
: name
27+
? t('Pinned by {{ name }}', { name })
28+
: t('Message pinned');
2329

2430
return (
2531
<div className='str-chat__message-pin-indicator'>

src/components/Message/ReminderNotification.tsx

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { useTranslationContext } from '../../context';
33
import { useStateStore } from '../../store';
44
import type { Reminder, ReminderState } from 'stream-chat';
5+
import { IconBellNotification, IconBookmark } from '../Icons';
56

67
export type ReminderNotificationProps = {
78
reminder?: Reminder;
@@ -11,40 +12,92 @@ const reminderStateSelector = (state: ReminderState) => ({
1112
timeLeftMs: state.timeLeftMs,
1213
});
1314

14-
export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => {
15+
function SavedForLaterContent() {
16+
const { t } = useTranslationContext();
17+
return (
18+
<p className='str-chat__message-saved-for-later'>
19+
<IconBookmark />
20+
<span>{t('Saved for later')}</span>
21+
</p>
22+
);
23+
}
24+
25+
const THRESHOLD_RELATIVE_MINUTES = 59;
26+
27+
function RemindMeContent({ reminder }: { reminder: Reminder }) {
1528
const { t } = useTranslationContext();
1629
const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {};
1730

1831
const stopRefreshBoundaryMs = reminder?.timer.stopRefreshBoundaryMs;
1932
const stopRefreshTimeStamp =
2033
reminder?.remindAt && stopRefreshBoundaryMs
21-
? reminder?.remindAt.getTime() + stopRefreshBoundaryMs
34+
? reminder.remindAt.getTime() + stopRefreshBoundaryMs
2235
: undefined;
2336

2437
const isBehindRefreshBoundary =
2538
!!stopRefreshTimeStamp && new Date().getTime() > stopRefreshTimeStamp;
2639

40+
if (timeLeftMs === null || !reminder.remindAt) return null;
41+
42+
const nowMs = Date.now();
43+
const remindAtMs = reminder.remindAt.getTime();
44+
const diffMs = remindAtMs - nowMs;
45+
const diffMinutes = Math.abs(diffMs) / (60 * 1000);
46+
const useAbsoluteFormat = diffMinutes > THRESHOLD_RELATIVE_MINUTES;
47+
48+
const renderTime = () => {
49+
if (isBehindRefreshBoundary) {
50+
// Past: reminder time has passed
51+
if (useAbsoluteFormat) {
52+
// > 59 min ago: calendar + time (same as DateSeparator + HH:mm)
53+
// e.g. "Due since Today at 15:00", "Due since Yesterday at 09:30"
54+
return t('Due since {{ dueSince }}', {
55+
dueSince: t('timestamp/ReminderNotification', {
56+
timestamp: reminder.remindAt,
57+
}),
58+
});
59+
}
60+
// Within 59 min ago: relative
61+
// e.g. "Due since 5 minutes ago", "Due since a minute ago"
62+
return t('Due since {{ dueSince }}', {
63+
dueSince: t('duration/Message reminder', {
64+
milliseconds: diffMs,
65+
}),
66+
});
67+
}
68+
// Future: reminder not yet due
69+
if (useAbsoluteFormat) {
70+
// > 59 min from now: calendar + time (no "Due" prefix)
71+
// e.g. "Today at 15:00", "Tomorrow at 09:30"
72+
return t('timestamp/ReminderNotification', {
73+
timestamp: reminder.remindAt,
74+
});
75+
}
76+
// Within 59 min from now: relative
77+
// e.g. "Due in 30 minutes", "Due in a minute"
78+
return t('Due {{ timeLeft }}', {
79+
timeLeft: t('duration/Message reminder', {
80+
milliseconds: timeLeftMs,
81+
}),
82+
});
83+
};
84+
2785
return (
2886
<p className='str-chat__message-reminder'>
29-
<span>{t('Saved for later')}</span>
30-
{reminder?.remindAt && timeLeftMs !== null && (
31-
<>
32-
<span> | </span>
33-
<span>
34-
{isBehindRefreshBoundary
35-
? t('Due since {{ dueSince }}', {
36-
dueSince: t('timestamp/ReminderNotification', {
37-
timestamp: reminder.remindAt,
38-
}),
39-
})
40-
: t('Due {{ timeLeft }}', {
41-
timeLeft: t('duration/Message reminder', {
42-
milliseconds: timeLeftMs,
43-
}),
44-
})}
45-
</span>
46-
</>
47-
)}
87+
<IconBellNotification />
88+
<span>{t('Reminder set')}</span>
89+
<span> · </span>
90+
<span className='str-chat__message-reminder__time-left'>{renderTime()}</span>
4891
</p>
4992
);
93+
}
94+
95+
export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => {
96+
if (!reminder) return null;
97+
98+
if (!reminder.remindAt) {
99+
return <SavedForLaterContent />;
100+
}
101+
102+
return <RemindMeContent reminder={reminder} />;
50103
};

src/components/Message/__tests__/ReminderNotification.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ const renderComponent = async ({ reminder }) => {
2121
};
2222

2323
describe('ReminderNotification', () => {
24-
it('displays text for bookmark notifications', async () => {
24+
it('displays text for bookmark notifications (saved for later)', async () => {
2525
const reminder = new Reminder({ data: generateReminderResponse() });
2626
const { container } = await renderComponent({ reminder });
2727
expect(container).toMatchSnapshot();
2828
});
29-
it('displays text for time due in case of timed reminders', async () => {
29+
it('displays text for time due in case of timed reminders (remind me)', async () => {
3030
const reminder = new Reminder({
3131
data: generateReminderResponse({
3232
scheduleOffsetMs: 60 * 1000,

0 commit comments

Comments
 (0)