Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'ReactionSelector'
| 'ReactionsList'
| 'ReactionsListModal'
| 'ReminderNotification'
| 'SendButton'
| 'SendToChannelCheckbox'
| 'StartRecordingAudioButton'
Expand Down Expand Up @@ -1231,6 +1232,7 @@ const ChannelInner = (
ReactionSelector: props.ReactionSelector,
ReactionsList: props.ReactionsList,
ReactionsListModal: props.ReactionsListModal,
ReminderNotification: props.ReminderNotification,
SendButton: props.SendButton,
SendToChannelCheckbox: props.SendToChannelCheckbox,
StartRecordingAudioButton: props.StartRecordingAudioButton,
Expand Down Expand Up @@ -1296,6 +1298,7 @@ const ChannelInner = (
props.ReactionSelector,
props.ReactionsList,
props.ReactionsListModal,
props.ReminderNotification,
props.SendButton,
props.SendToChannelCheckbox,
props.StartRecordingAudioButton,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Channel/__tests__/Channel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1509,7 +1509,7 @@ describe('Channel', () => {
if (!hasSent) {
const m = generateMessage({
id: messageId,
status: 'sending', // FIXME: had to have been explicitly added
status: 'sending', // FIXME: had to be explicitly added
text: messageText,
});
sendMessage({ localMessage: { ...m, status: 'sending' }, message: m });
Expand Down
4 changes: 4 additions & 0 deletions src/components/Chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ export const useChat = ({

client.threads.registerSubscriptions();
client.polls.registerSubscriptions();
client.reminders.registerSubscriptions();
client.reminders.initTimers();

return () => {
client.threads.unregisterSubscriptions();
client.polls.unregisterSubscriptions();
client.reminders.unregisterSubscriptions();
client.reminders.clearTimers();
};
}, [client]);

Expand Down
137 changes: 137 additions & 0 deletions src/components/Dialog/ButtonWithSubmenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDialog, useDialogIsOpen } from './hooks';
import { useDialogAnchor } from './DialogAnchor';
import type { ComponentProps, ComponentType } from 'react';
import type { Placement } from '@popperjs/core';

type ButtonWithSubmenu = ComponentProps<'button'> & {
children: React.ReactNode;
placement: Placement;
Submenu: ComponentType;
submenuContainerProps?: ComponentProps<'div'>;
};
export const ButtonWithSubmenu = ({
children,
className,
placement,
Submenu,
submenuContainerProps,
...buttonProps
}: ButtonWithSubmenu) => {
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [dialogContainer, setDialogContainer] = useState<HTMLDivElement | null>(null);
const keepSubmenuOpen = useRef(false);
const dialogCloseTimeout = useRef<NodeJS.Timeout | null>(null);
const dialogId = useMemo(() => `submenu-${Math.random().toString(36).slice(2)}`, []);
const dialog = useDialog({ id: dialogId });
const dialogIsOpen = useDialogIsOpen(dialogId);
const { attributes, setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({

Check warning on line 29 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L22-L29

Added lines #L22 - L29 were not covered by tests
open: dialogIsOpen,
placement,
referenceElement: buttonRef.current,
});

const closeDialogLazily = useCallback(() => {

Check warning on line 35 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L35

Added line #L35 was not covered by tests
if (dialogCloseTimeout.current) clearTimeout(dialogCloseTimeout.current);
dialogCloseTimeout.current = setTimeout(() => {

Check warning on line 37 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L37

Added line #L37 was not covered by tests
if (keepSubmenuOpen.current) return;
dialog.close();

Check warning on line 39 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L39

Added line #L39 was not covered by tests
}, 100);
}, [dialog]);

const handleClose = useCallback(

Check warning on line 43 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L43

Added line #L43 was not covered by tests
(event: Event) => {
const parentButton = buttonRef.current;

Check warning on line 45 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L45

Added line #L45 was not covered by tests
if (!dialogIsOpen || !parentButton) return;
event.stopPropagation();
closeDialogLazily();
parentButton.focus();

Check warning on line 49 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L47-L49

Added lines #L47 - L49 were not covered by tests
},
[closeDialogLazily, dialogIsOpen, buttonRef],
);

const handleFocusParentButton = () => {

Check warning on line 54 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L54

Added line #L54 was not covered by tests
if (dialogIsOpen) return;
dialog.open();
keepSubmenuOpen.current = true;

Check warning on line 57 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L56-L57

Added lines #L56 - L57 were not covered by tests
};

useEffect(() => {
const parentButton = buttonRef.current;

Check warning on line 61 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L60-L61

Added lines #L60 - L61 were not covered by tests
if (!dialogIsOpen || !parentButton) return;
const hideOnEscape = (event: KeyboardEvent) => {

Check warning on line 63 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L63

Added line #L63 was not covered by tests
if (event.key !== 'Escape') return;
handleClose(event);
keepSubmenuOpen.current = false;

Check warning on line 66 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L65-L66

Added lines #L65 - L66 were not covered by tests
};

document.addEventListener('keyup', hideOnEscape, { capture: true });

Check warning on line 69 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L69

Added line #L69 was not covered by tests

return () => {
document.removeEventListener('keyup', hideOnEscape, { capture: true });

Check warning on line 72 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L71-L72

Added lines #L71 - L72 were not covered by tests
};
}, [dialogIsOpen, handleClose]);

return (

Check warning on line 76 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L76

Added line #L76 was not covered by tests
<>
<button
aria-selected='false'
className={clsx(className, 'str_chat__button-with-submenu', {
'str_chat__button-with-submenu--submenu-open': dialogIsOpen,
})}
onBlur={() => {
keepSubmenuOpen.current = false;
closeDialogLazily();

Check warning on line 85 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L84-L85

Added lines #L84 - L85 were not covered by tests
}}
onClick={(event) => {
event.stopPropagation();
dialog.toggle();

Check warning on line 89 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L88-L89

Added lines #L88 - L89 were not covered by tests
}}
onFocus={handleFocusParentButton}
onMouseEnter={handleFocusParentButton}
onMouseLeave={() => {
keepSubmenuOpen.current = false;
closeDialogLazily();

Check warning on line 95 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L94-L95

Added lines #L94 - L95 were not covered by tests
}}
ref={buttonRef}
role='option'
{...buttonProps}
>
{children}
</button>
{dialogIsOpen && (
<div
{...attributes.popper}
onBlur={(event) => {
const isBlurredDescendant =
event.relatedTarget instanceof Node &&
dialogContainer?.contains(event.relatedTarget);
if (isBlurredDescendant) return;
keepSubmenuOpen.current = false;
closeDialogLazily();

Check warning on line 112 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L111-L112

Added lines #L111 - L112 were not covered by tests
}}
onFocus={() => {
keepSubmenuOpen.current = true;

Check warning on line 115 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L115

Added line #L115 was not covered by tests
}}
onMouseEnter={() => {
keepSubmenuOpen.current = true;

Check warning on line 118 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L118

Added line #L118 was not covered by tests
}}
onMouseLeave={() => {
keepSubmenuOpen.current = false;
closeDialogLazily();

Check warning on line 122 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L121-L122

Added lines #L121 - L122 were not covered by tests
}}
ref={(element) => {
setPopperElement(element);
setDialogContainer(element);

Check warning on line 126 in src/components/Dialog/ButtonWithSubmenu.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/Dialog/ButtonWithSubmenu.tsx#L125-L126

Added lines #L125 - L126 were not covered by tests
}}
style={styles.popper}
tabIndex={-1}
{...submenuContainerProps}
>
<Submenu />
</div>
)}
</>
);
};
1 change: 1 addition & 0 deletions src/components/Dialog/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ButtonWithSubmenu';
export * from './DialogAnchor';
export * from './DialogManager';
export * from './DialogPortal';
Expand Down
32 changes: 19 additions & 13 deletions src/components/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ type MessagePropsToOmit =
type MessageContextPropsToPick =
| 'handleAction'
| 'handleDelete'
| 'handleFetchReactions'
| 'handleFlag'
| 'handleMarkUnread'
| 'handleMute'
| 'handleOpenThread'
| 'handlePin'
| 'handleReaction'
| 'handleFetchReactions'
| 'handleRetry'
| 'mutes'
| 'onMentionsClickMessage'
Expand Down Expand Up @@ -74,7 +74,7 @@ const MessageWithContext = (props: MessageWithContextProps) => {
} = props;

const { client, isMessageAIGenerated } = useChatContext('Message');
const { read } = useChannelStateContext('Message');
const { channelConfig, read } = useChannelStateContext('Message');
const { Message: contextMessage } = useComponentContext('Message');

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

const messageActionsHandler = useCallback(
() =>
getMessageActions(messageActions, {
canDelete,
canEdit,
canFlag,
canMarkUnread,
canMute,
canPin,
canQuote,
canReact,
canReply,
}),
getMessageActions(
messageActions,
{
canDelete,
canEdit,
canFlag,
canMarkUnread,
canMute,
canPin,
canQuote,
canReact,
canReply,
},
channelConfig,
),

[
messageActions,
canDelete,
Expand All @@ -137,6 +142,7 @@ const MessageWithContext = (props: MessageWithContextProps) => {
canQuote,
canReact,
canReply,
channelConfig,
],
);

Expand Down
5 changes: 5 additions & 0 deletions src/components/Message/MessageSimple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp'
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
import { isDateSeparatorMessage } from '../MessageList';
import { MessageThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageThreadReplyInChannelButtonIndicator';
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
import { useMessageReminder } from './hooks';
import {
areMessageUIPropsEqual,
isMessageBlocked,
Expand Down Expand Up @@ -63,6 +65,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
const { t } = useTranslationContext('MessageSimple');
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
const [isEditedTimestampOpen, setEditedTimestampOpen] = useState(false);
const reminder = useMessageReminder(message.id);

const {
Attachment = DefaultAttachment,
Expand All @@ -79,6 +82,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
ReactionsList = DefaultReactionList,
ReminderNotification = DefaultReminderNotification,
StreamedMessageText = DefaultStreamedMessageText,
PinIndicator,
} = useComponentContext('MessageSimple');
Expand Down Expand Up @@ -158,6 +162,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{
<div className={rootClassName} key={message.id}>
{PinIndicator && <PinIndicator />}
{!!reminder && <ReminderNotification reminder={reminder} />}
{message.user && (
<Avatar
image={message.user.image}
Expand Down
50 changes: 50 additions & 0 deletions src/components/Message/ReminderNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { useTranslationContext } from '../../context';
import { useStateStore } from '../../store';
import type { Reminder, ReminderState } from 'stream-chat';

export type ReminderNotificationProps = {
reminder?: Reminder;
};

const reminderStateSelector = (state: ReminderState) => ({
timeLeftMs: state.timeLeftMs,
});

export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => {
const { t } = useTranslationContext();
const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {};

const stopRefreshBoundaryMs = reminder?.timer.stopRefreshBoundaryMs;
const stopRefreshTimeStamp =
reminder?.remindAt && stopRefreshBoundaryMs
? reminder?.remindAt.getTime() + stopRefreshBoundaryMs
: undefined;

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

return (
<p className='str-chat__message-reminder'>
<span>{t<string>('Saved for later')}</span>
{reminder?.remindAt && timeLeftMs !== null && (
<>
<span> | </span>
<span>
{isBehindRefreshBoundary
? t<string>('Due since {{ dueSince }}', {
dueSince: t<string>(`timestamp/ReminderNotification`, {
timestamp: reminder.remindAt,
}),
})
: t<string>(`Due {{ timeLeft }}`, {
timeLeft: t<string>('duration/Message reminder', {
milliseconds: timeLeftMs,
}),
})}
</span>
</>
)}
</p>
);
};
23 changes: 23 additions & 0 deletions src/components/Message/__tests__/MessageSimple.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
useMockedApis,
} from '../../../mock-builders';
import { MessageBouncePrompt } from '../../MessageBounce';
import { generateReminderResponse } from '../../../mock-builders/generator/reminder';

expect.extend(toHaveNoViolations);

Expand Down Expand Up @@ -267,6 +268,28 @@ describe('<MessageSimple />', () => {
expect(results).toHaveNoViolations();
});

it('should render custom ReminderNotification component when one is given', async () => {
const message = generateAliceMessage({ reminder: generateReminderResponse() });
client.reminders.hydrateState([message]);
const testId = 'custom-reminder-notification';
const CustomReminderNotification = () => <div data-testid={testId} />;

const { container } = await renderMessageSimple({
channelConfigOverrides: {
user_message_reminder: true,
},
components: {
ReminderNotification: CustomReminderNotification,
},
message,
});

expect(await screen.findByTestId(testId)).toBeInTheDocument();

const results = await axe(container);
expect(results).toHaveNoViolations();
});

// FIXME: test relying on deprecated channel config parameter
it('should render reaction list even though sending reactions is disabled in channel config', async () => {
const reactions = [generateReaction({ user: bob })];
Expand Down
Loading