Skip to content

Commit fae82a2

Browse files
committed
feat: add message reminders
1 parent 4c1cff7 commit fae82a2

28 files changed

Lines changed: 511 additions & 29 deletions

src/components/Channel/__tests__/Channel.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1509,7 +1509,7 @@ describe('Channel', () => {
15091509
if (!hasSent) {
15101510
const m = generateMessage({
15111511
id: messageId,
1512-
status: 'sending', // FIXME: had to have been explicitly added
1512+
status: 'sending', // FIXME: had to be explicitly added
15131513
text: messageText,
15141514
});
15151515
sendMessage({ localMessage: { ...m, status: 'sending' }, message: m });

src/components/Chat/hooks/useChat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,14 @@ export const useChat = ({
6666

6767
client.threads.registerSubscriptions();
6868
client.polls.registerSubscriptions();
69+
client.reminders.registerSubscriptions();
70+
client.reminders.initTimers();
6971

7072
return () => {
7173
client.threads.unregisterSubscriptions();
7274
client.polls.unregisterSubscriptions();
75+
client.reminders.unregisterSubscriptions();
76+
client.reminders.clearTimers();
7377
};
7478
}, [client]);
7579

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import clsx from 'clsx';
2+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
import { useDialog, useDialogIsOpen } from './hooks';
4+
import { useDialogAnchor } from './DialogAnchor';
5+
import type { ComponentProps, ComponentType } from 'react';
6+
import type { Placement } from '@popperjs/core';
7+
8+
type ButtonWithSubmenu = ComponentProps<'button'> & {
9+
children: React.ReactNode;
10+
placement: Placement;
11+
Submenu: ComponentType;
12+
submenuContainerProps?: ComponentProps<'div'>;
13+
};
14+
export const ButtonWithSubmenu = ({
15+
children,
16+
className,
17+
placement,
18+
Submenu,
19+
submenuContainerProps,
20+
...buttonProps
21+
}: ButtonWithSubmenu) => {
22+
const buttonRef = useRef<HTMLButtonElement | null>(null);
23+
const [dialogContainer, setDialogContainer] = useState<HTMLDivElement | null>(null);
24+
const keepSubmenuOpen = useRef(false);
25+
const dialogCloseTimeout = useRef<NodeJS.Timeout | null>(null);
26+
const dialogId = useMemo(() => `submenu-${Math.random().toString(36).slice(2)}`, []);
27+
const dialog = useDialog({ id: dialogId });
28+
const dialogIsOpen = useDialogIsOpen(dialogId);
29+
const { attributes, setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({
30+
open: dialogIsOpen,
31+
placement,
32+
referenceElement: buttonRef.current,
33+
});
34+
35+
const closeDialogLazily = useCallback(() => {
36+
if (dialogCloseTimeout.current) clearTimeout(dialogCloseTimeout.current);
37+
dialogCloseTimeout.current = setTimeout(() => {
38+
if (keepSubmenuOpen.current) return;
39+
dialog.close();
40+
}, 100);
41+
}, [dialog]);
42+
43+
const handleClose = useCallback(
44+
(event: Event) => {
45+
const parentButton = buttonRef.current;
46+
if (!dialogIsOpen || !parentButton) return;
47+
event.stopPropagation();
48+
closeDialogLazily();
49+
parentButton.focus();
50+
},
51+
[closeDialogLazily, dialogIsOpen, buttonRef],
52+
);
53+
54+
const handleFocusParentButton = () => {
55+
if (dialogIsOpen) return;
56+
dialog.open();
57+
keepSubmenuOpen.current = true;
58+
};
59+
60+
useEffect(() => {
61+
const parentButton = buttonRef.current;
62+
if (!dialogIsOpen || !parentButton) return;
63+
const hideOnEscape = (event: KeyboardEvent) => {
64+
if (event.key !== 'Escape') return;
65+
handleClose(event);
66+
keepSubmenuOpen.current = false;
67+
};
68+
69+
document.addEventListener('keyup', hideOnEscape, { capture: true });
70+
71+
return () => {
72+
document.removeEventListener('keyup', hideOnEscape, { capture: true });
73+
};
74+
}, [dialogIsOpen, handleClose]);
75+
76+
return (
77+
<>
78+
<button
79+
aria-selected='false'
80+
className={clsx(className, 'str_chat__button-with-submenu', {
81+
'str_chat__button-with-submenu--submenu-open': dialogIsOpen,
82+
})}
83+
onBlur={() => {
84+
keepSubmenuOpen.current = false;
85+
closeDialogLazily();
86+
}}
87+
onClick={(event) => {
88+
event.stopPropagation();
89+
dialog.toggle();
90+
}}
91+
onFocus={handleFocusParentButton}
92+
onMouseEnter={handleFocusParentButton}
93+
onMouseLeave={() => {
94+
keepSubmenuOpen.current = false;
95+
closeDialogLazily();
96+
}}
97+
ref={buttonRef}
98+
role='option'
99+
{...buttonProps}
100+
>
101+
{children}
102+
</button>
103+
{dialogIsOpen && (
104+
<div
105+
{...attributes.popper}
106+
onBlur={(event) => {
107+
const isBlurredDescendant =
108+
event.relatedTarget instanceof Node &&
109+
dialogContainer?.contains(event.relatedTarget);
110+
if (isBlurredDescendant) return;
111+
keepSubmenuOpen.current = false;
112+
closeDialogLazily();
113+
}}
114+
onFocus={() => {
115+
keepSubmenuOpen.current = true;
116+
}}
117+
onMouseEnter={() => {
118+
keepSubmenuOpen.current = true;
119+
}}
120+
onMouseLeave={() => {
121+
keepSubmenuOpen.current = false;
122+
closeDialogLazily();
123+
}}
124+
ref={(element) => {
125+
setPopperElement(element);
126+
setDialogContainer(element);
127+
}}
128+
style={styles.popper}
129+
tabIndex={-1}
130+
{...submenuContainerProps}
131+
>
132+
<Submenu />
133+
</div>
134+
)}
135+
</>
136+
);
137+
};

src/components/Dialog/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './ButtonWithSubmenu';
12
export * from './DialogAnchor';
23
export * from './DialogManager';
34
export * from './DialogPortal';

src/components/Message/Message.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ type MessagePropsToOmit =
4040
type MessageContextPropsToPick =
4141
| 'handleAction'
4242
| 'handleDelete'
43+
| 'handleFetchReactions'
4344
| 'handleFlag'
4445
| 'handleMarkUnread'
4546
| 'handleMute'
4647
| 'handleOpenThread'
4748
| 'handlePin'
4849
| 'handleReaction'
49-
| 'handleFetchReactions'
5050
| 'handleRetry'
5151
| 'mutes'
5252
| 'onMentionsClickMessage'
@@ -74,7 +74,7 @@ const MessageWithContext = (props: MessageWithContextProps) => {
7474
} = props;
7575

7676
const { client, isMessageAIGenerated } = useChatContext('Message');
77-
const { read } = useChannelStateContext('Message');
77+
const { channelConfig, read } = useChannelStateContext('Message');
7878
const { Message: contextMessage } = useComponentContext('Message');
7979

8080
const actionsEnabled = message.type === 'regular' && message.status === 'received';
@@ -115,17 +115,22 @@ const MessageWithContext = (props: MessageWithContextProps) => {
115115

116116
const messageActionsHandler = useCallback(
117117
() =>
118-
getMessageActions(messageActions, {
119-
canDelete,
120-
canEdit,
121-
canFlag,
122-
canMarkUnread,
123-
canMute,
124-
canPin,
125-
canQuote,
126-
canReact,
127-
canReply,
128-
}),
118+
getMessageActions(
119+
messageActions,
120+
{
121+
canDelete,
122+
canEdit,
123+
canFlag,
124+
canMarkUnread,
125+
canMute,
126+
canPin,
127+
canQuote,
128+
canReact,
129+
canReply,
130+
},
131+
channelConfig,
132+
),
133+
129134
[
130135
messageActions,
131136
canDelete,
@@ -137,6 +142,7 @@ const MessageWithContext = (props: MessageWithContextProps) => {
137142
canQuote,
138143
canReact,
139144
canReply,
145+
channelConfig,
140146
],
141147
);
142148

src/components/Message/MessageSimple.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import type { MessageUIComponentProps } from './types';
3737

3838
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
3939
import { isDateSeparatorMessage } from '../MessageList';
40+
import { ReminderNotification } from './ReminderNotification';
41+
import { useMessageReminder } from './hooks';
4042

4143
type MessageSimpleWithContextProps = MessageContextValue;
4244

@@ -63,6 +65,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
6365
const { t } = useTranslationContext('MessageSimple');
6466
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
6567
const [isEditedTimestampOpen, setEditedTimestampOpen] = useState(false);
68+
const reminder = useMessageReminder(message.id);
6669

6770
const {
6871
Attachment = DefaultAttachment,
@@ -155,6 +158,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
155158
{
156159
<div className={rootClassName} key={message.id}>
157160
{PinIndicator && <PinIndicator />}
161+
{!!reminder && <ReminderNotification reminder={reminder} />}
158162
{message.user && (
159163
<Avatar
160164
image={message.user.image}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from 'react';
2+
import { useTranslationContext } from '../../context';
3+
import { useStateStore } from '../../store';
4+
import type { Reminder, ReminderState } from 'stream-chat';
5+
6+
export type ReminderNotificationProps = {
7+
reminder?: Reminder;
8+
};
9+
10+
const reminderStateSelector = (state: ReminderState) => ({
11+
timeLeftMs: state.timeLeftMs,
12+
});
13+
export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => {
14+
const { t } = useTranslationContext();
15+
const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {};
16+
17+
return (
18+
<p className='str-chat__message-reminder'>
19+
<span>{t<string>('Saved for later')}</span>
20+
{timeLeftMs !== null && (
21+
<>
22+
<span> | </span>
23+
<span>
24+
{t<string>(`Due {{ dueTimeElapsed }}`, {
25+
dueTimeElapsed: t<string>('duration/Message reminder', {
26+
milliseconds: timeLeftMs,
27+
}),
28+
})}
29+
</span>
30+
</>
31+
)}
32+
</p>
33+
);
34+
};

src/components/Message/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './useUserHandler';
1313
export * from './useUserRole';
1414
export * from './useReactionsFetcher';
1515
export * from './useMessageTextStreaming';
16+
export * from './useMessageReminder';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useCallback } from 'react';
2+
import { useChatContext } from '../../../context';
3+
import { useStateStore } from '../../../store';
4+
import type { ReminderManagerState } from 'stream-chat';
5+
6+
export const useMessageReminder = (messageId: string) => {
7+
const { client } = useChatContext();
8+
const reminderSelector = useCallback(
9+
(state: ReminderManagerState) => ({
10+
reminder: state.reminders.get(messageId),
11+
}),
12+
[messageId],
13+
);
14+
const { reminder } = useStateStore(client.reminders.state, reminderSelector);
15+
return reminder;
16+
};

src/components/Message/utils.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import emojiRegex from 'emoji-regex';
33

44
import type { TFunction } from 'i18next';
55
import type {
6+
ChannelConfigWithInfo,
67
LocalMessage,
78
LocalMessageBase,
89
MessageResponse,
@@ -60,7 +61,9 @@ export const MESSAGE_ACTIONS = {
6061
pin: 'pin',
6162
quote: 'quote',
6263
react: 'react',
64+
remindMe: 'remindMe',
6365
reply: 'reply',
66+
saveForLater: 'saveForLater',
6467
};
6568

6669
export type MessageActionsArray<T extends string = string> = Array<
@@ -151,6 +154,7 @@ export const getMessageActions = (
151154
canReact,
152155
canReply,
153156
}: Capabilities,
157+
channelConfig?: ChannelConfigWithInfo,
154158
): MessageActionsArray => {
155159
const messageActionsAfterPermission: MessageActionsArray = [];
156160
let messageActions: MessageActionsArray = [];
@@ -164,6 +168,20 @@ export const getMessageActions = (
164168
return [];
165169
}
166170

171+
if (
172+
channelConfig?.['user_message_reminders'] &&
173+
messageActions.indexOf(MESSAGE_ACTIONS.remindMe)
174+
) {
175+
messageActionsAfterPermission.push(MESSAGE_ACTIONS.remindMe);
176+
}
177+
178+
if (
179+
channelConfig?.['user_message_reminders'] &&
180+
messageActions.indexOf(MESSAGE_ACTIONS.saveForLater)
181+
) {
182+
messageActionsAfterPermission.push(MESSAGE_ACTIONS.saveForLater);
183+
}
184+
167185
if (canDelete && messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1) {
168186
messageActionsAfterPermission.push(MESSAGE_ACTIONS.delete);
169187
}

0 commit comments

Comments
 (0)