Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"emoji-mart": "^5.4.0",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
"stream-chat": "^9.0.0"
"stream-chat": "^9.5.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -239,7 +239,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"semantic-release": "^24.2.3",
"stream-chat": "9.1.1",
"stream-chat": "^9.5.0",
"ts-jest": "^29.2.5",
"typescript": "^5.4.5",
"typescript-eslint": "^8.17.0"
Expand Down
3 changes: 3 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'ReactionSelector'
| 'ReactionsList'
| 'ReactionsListModal'
| 'ReminderNotification'
| 'SendButton'
| 'StartRecordingAudioButton'
| 'TextareaComposer'
Expand Down Expand Up @@ -1226,6 +1227,7 @@ const ChannelInner = (
ReactionSelector: props.ReactionSelector,
ReactionsList: props.ReactionsList,
ReactionsListModal: props.ReactionsListModal,
ReminderNotification: props.ReminderNotification,
SendButton: props.SendButton,
StartRecordingAudioButton: props.StartRecordingAudioButton,
StopAIGenerationButton: props.StopAIGenerationButton,
Expand Down Expand Up @@ -1289,6 +1291,7 @@ const ChannelInner = (
props.ReactionSelector,
props.ReactionsList,
props.ReactionsListModal,
props.ReminderNotification,
props.SendButton,
props.StartRecordingAudioButton,
props.StopAIGenerationButton,
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>({
open: dialogIsOpen,
placement,
referenceElement: buttonRef.current,
});

const closeDialogLazily = useCallback(() => {
if (dialogCloseTimeout.current) clearTimeout(dialogCloseTimeout.current);
dialogCloseTimeout.current = setTimeout(() => {
if (keepSubmenuOpen.current) return;
dialog.close();
}, 100);
}, [dialog]);

const handleClose = useCallback(
(event: Event) => {
const parentButton = buttonRef.current;
if (!dialogIsOpen || !parentButton) return;
event.stopPropagation();
closeDialogLazily();
parentButton.focus();
},
[closeDialogLazily, dialogIsOpen, buttonRef],
);

const handleFocusParentButton = () => {
if (dialogIsOpen) return;
dialog.open();
keepSubmenuOpen.current = true;
};

useEffect(() => {
const parentButton = buttonRef.current;
if (!dialogIsOpen || !parentButton) return;
const hideOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
handleClose(event);
keepSubmenuOpen.current = false;
};

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

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

return (
<>
<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();
}}
onClick={(event) => {
event.stopPropagation();
dialog.toggle();
}}
onFocus={handleFocusParentButton}
onMouseEnter={handleFocusParentButton}
onMouseLeave={() => {
keepSubmenuOpen.current = false;
closeDialogLazily();
}}
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();
}}
onFocus={() => {
keepSubmenuOpen.current = true;
}}
onMouseEnter={() => {
keepSubmenuOpen.current = true;
}}
onMouseLeave={() => {
keepSubmenuOpen.current = false;
closeDialogLazily();
}}
ref={(element) => {
setPopperElement(element);
setDialogContainer(element);
}}
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 @@ -37,6 +37,8 @@ import type { MessageUIComponentProps } from './types';

import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
import { isDateSeparatorMessage } from '../MessageList';
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
import { useMessageReminder } from './hooks';

type MessageSimpleWithContextProps = MessageContextValue;

Expand All @@ -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 @@ -78,6 +81,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
ReactionsList = DefaultReactionList,
ReminderNotification = DefaultReminderNotification,
StreamedMessageText = DefaultStreamedMessageText,
PinIndicator,
} = useComponentContext('MessageSimple');
Expand Down Expand Up @@ -155,6 +159,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
51 changes: 51 additions & 0 deletions src/components/Message/ReminderNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useMemo } from 'react';
import { useChatContext, 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 { client } = useChatContext();
const { t } = useTranslationContext();
const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {};

const isBehindRefreshBoundary = useMemo(() => {
const stopRefreshBoundaryMs = client.reminders.stopTimerRefreshBoundaryMs;
const stopRefreshTimeStamp =
reminder?.remindAt && stopRefreshBoundaryMs
? reminder?.remindAt.getTime() + stopRefreshBoundaryMs
: undefined;
return !!stopRefreshTimeStamp && new Date().getTime() > stopRefreshTimeStamp;
}, [client, reminder]);

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 {{ dueTimeElapsed }}`, {
dueTimeElapsed: t<string>('duration/Message reminder', {
Comment thread
MartinCupela marked this conversation as resolved.
Outdated
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 @@ -250,6 +251,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
Loading