diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 5499be401a..6d5266371b 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -141,6 +141,7 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'ReactionSelector' | 'ReactionsList' | 'ReactionsListModal' + | 'ReminderNotification' | 'SendButton' | 'SendToChannelCheckbox' | 'StartRecordingAudioButton' @@ -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, @@ -1296,6 +1298,7 @@ const ChannelInner = ( props.ReactionSelector, props.ReactionsList, props.ReactionsListModal, + props.ReminderNotification, props.SendButton, props.SendToChannelCheckbox, props.StartRecordingAudioButton, diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 341f51827c..74fbf5ed28 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -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 }); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 21523ecdfc..d94897f756 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -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]); diff --git a/src/components/Dialog/ButtonWithSubmenu.tsx b/src/components/Dialog/ButtonWithSubmenu.tsx new file mode 100644 index 0000000000..c8f2587a3d --- /dev/null +++ b/src/components/Dialog/ButtonWithSubmenu.tsx @@ -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(null); + const [dialogContainer, setDialogContainer] = useState(null); + const keepSubmenuOpen = useRef(false); + const dialogCloseTimeout = useRef(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({ + 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 ( + <> + + {dialogIsOpen && ( +
{ + 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} + > + +
+ )} + + ); +}; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts index a2462dbcdd..7e3fd2bf08 100644 --- a/src/components/Dialog/index.ts +++ b/src/components/Dialog/index.ts @@ -1,3 +1,4 @@ +export * from './ButtonWithSubmenu'; export * from './DialogAnchor'; export * from './DialogManager'; export * from './DialogPortal'; diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 26e6d9cd2a..7e1316f899 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -40,13 +40,13 @@ type MessagePropsToOmit = type MessageContextPropsToPick = | 'handleAction' | 'handleDelete' + | 'handleFetchReactions' | 'handleFlag' | 'handleMarkUnread' | 'handleMute' | 'handleOpenThread' | 'handlePin' | 'handleReaction' - | 'handleFetchReactions' | 'handleRetry' | 'mutes' | 'onMentionsClickMessage' @@ -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'; @@ -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, @@ -137,6 +142,7 @@ const MessageWithContext = (props: MessageWithContextProps) => { canQuote, canReact, canReply, + channelConfig, ], ); diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index c8c89c203d..142623273f 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -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, @@ -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, @@ -79,6 +82,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, ReactionsList = DefaultReactionList, + ReminderNotification = DefaultReminderNotification, StreamedMessageText = DefaultStreamedMessageText, PinIndicator, } = useComponentContext('MessageSimple'); @@ -158,6 +162,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { {
{PinIndicator && } + {!!reminder && } {message.user && ( ({ + 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 ( +

+ {t('Saved for later')} + {reminder?.remindAt && timeLeftMs !== null && ( + <> + | + + {isBehindRefreshBoundary + ? t('Due since {{ dueSince }}', { + dueSince: t(`timestamp/ReminderNotification`, { + timestamp: reminder.remindAt, + }), + }) + : t(`Due {{ timeLeft }}`, { + timeLeft: t('duration/Message reminder', { + milliseconds: timeLeftMs, + }), + })} + + + )} +

+ ); +}; diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index 02e12691c4..748b11e29c 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -36,6 +36,7 @@ import { useMockedApis, } from '../../../mock-builders'; import { MessageBouncePrompt } from '../../MessageBounce'; +import { generateReminderResponse } from '../../../mock-builders/generator/reminder'; expect.extend(toHaveNoViolations); @@ -267,6 +268,28 @@ describe('', () => { 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 = () =>
; + + 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 })]; diff --git a/src/components/Message/__tests__/ReminderNotification.test.js b/src/components/Message/__tests__/ReminderNotification.test.js new file mode 100644 index 0000000000..efc0037357 --- /dev/null +++ b/src/components/Message/__tests__/ReminderNotification.test.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { Reminder } from 'stream-chat'; +import { act, render } from '@testing-library/react'; +import { Chat } from '../../Chat'; +import { ReminderNotification } from '../ReminderNotification'; +import { generateUser, getTestClientWithUser } from '../../../mock-builders'; +import { generateReminderResponse } from '../../../mock-builders/generator/reminder'; + +const user = generateUser(); +const renderComponent = async ({ reminder }) => { + const client = await getTestClientWithUser(user); + let result; + await act(() => { + result = render( + + + , + ); + }); + return result; +}; + +describe('ReminderNotification', () => { + it('displays text for bookmark notifications', async () => { + const reminder = new Reminder({ data: generateReminderResponse() }); + const { container } = await renderComponent({ reminder }); + expect(container).toMatchSnapshot(); + }); + it('displays text for time due in case of timed reminders', async () => { + const reminder = new Reminder({ + data: generateReminderResponse({ + scheduleOffsetMs: 60 * 1000, + }), + }); + const { container } = await renderComponent({ reminder }); + expect(container).toMatchSnapshot(); + }); + it('displays text for reminder deadline if trespassed the refresh boundary', async () => { + const reminder = new Reminder({ + data: generateReminderResponse({ + data: { remind_at: new Date(0).toISOString() }, + }), + }); + const { container } = await renderComponent({ reminder }); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap b/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap new file mode 100644 index 0000000000..492cbc1c94 --- /dev/null +++ b/src/components/Message/__tests__/__snapshots__/ReminderNotification.test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReminderNotification displays text for bookmark notifications 1`] = ` +
+

+ + Saved for later + +

+
+`; + +exports[`ReminderNotification displays text for reminder deadline if trespassed the refresh boundary 1`] = ` +
+

+ + Saved for later + + + | + + + Due since 01/01/1970 + +

+
+`; + +exports[`ReminderNotification displays text for time due in case of timed reminders 1`] = ` +
+

+ + Saved for later + + + | + + + Due in a minute + +

+
+`; diff --git a/src/components/Message/__tests__/utils.test.js b/src/components/Message/__tests__/utils.test.js index cf6b7d9ee9..0def97d792 100644 --- a/src/components/Message/__tests__/utils.test.js +++ b/src/components/Message/__tests__/utils.test.js @@ -102,9 +102,18 @@ describe('Message utils', () => { }, ); - it('should return all message actions if actions are set to true', () => { + it('should return all message actions not depending on channel config if actions are set to true', () => { const result = getMessageActions(true, defaultCapabilities); - expect(result).toStrictEqual(actions); + expect(result).toStrictEqual( + actions.filter((a) => !['remindMe', 'saveForLater'].includes(a)), + ); + }); + + it('should include reminder actions if enabled in channel config', () => { + const result = getMessageActions(true, defaultCapabilities, { + user_message_reminders: true, + }); + expect(result).toEqual(actions); }); it.each([ diff --git a/src/components/Message/hooks/index.ts b/src/components/Message/hooks/index.ts index 14e403552a..801c9d374b 100644 --- a/src/components/Message/hooks/index.ts +++ b/src/components/Message/hooks/index.ts @@ -13,3 +13,4 @@ export * from './useUserHandler'; export * from './useUserRole'; export * from './useReactionsFetcher'; export * from './useMessageTextStreaming'; +export * from './useMessageReminder'; diff --git a/src/components/Message/hooks/useMessageReminder.ts b/src/components/Message/hooks/useMessageReminder.ts new file mode 100644 index 0000000000..677ab3a7d9 --- /dev/null +++ b/src/components/Message/hooks/useMessageReminder.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; +import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import type { ReminderManagerState } from 'stream-chat'; + +export const useMessageReminder = (messageId: string) => { + const { client } = useChatContext(); + const reminderSelector = useCallback( + (state: ReminderManagerState) => ({ + reminder: state.reminders.get(messageId), + }), + [messageId], + ); + const { reminder } = useStateStore(client.reminders.state, reminderSelector); + return reminder; +}; diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts index 222b4a8bcf..5cdcce78d8 100644 --- a/src/components/Message/index.ts +++ b/src/components/Message/index.ts @@ -10,6 +10,7 @@ export * from './MessageStatus'; export * from './MessageText'; export * from './MessageTimestamp'; export * from './QuotedMessage'; +export * from './ReminderNotification'; export * from './renderText'; export * from './types'; export * from './utils'; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index e8c2b64864..57464fbf2c 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -3,6 +3,7 @@ import emojiRegex from 'emoji-regex'; import type { TFunction } from 'i18next'; import type { + ChannelConfigWithInfo, LocalMessage, LocalMessageBase, MessageResponse, @@ -60,7 +61,9 @@ export const MESSAGE_ACTIONS = { pin: 'pin', quote: 'quote', react: 'react', + remindMe: 'remindMe', reply: 'reply', + saveForLater: 'saveForLater', }; export type MessageActionsArray = Array< @@ -151,6 +154,7 @@ export const getMessageActions = ( canReact, canReply, }: Capabilities, + channelConfig?: ChannelConfigWithInfo, ): MessageActionsArray => { const messageActionsAfterPermission: MessageActionsArray = []; let messageActions: MessageActionsArray = []; @@ -196,10 +200,24 @@ export const getMessageActions = ( messageActionsAfterPermission.push(MESSAGE_ACTIONS.react); } + if ( + channelConfig?.['user_message_reminders'] && + messageActions.indexOf(MESSAGE_ACTIONS.remindMe) + ) { + messageActionsAfterPermission.push(MESSAGE_ACTIONS.remindMe); + } + if (canReply && messageActions.indexOf(MESSAGE_ACTIONS.reply) > -1) { messageActionsAfterPermission.push(MESSAGE_ACTIONS.reply); } + if ( + channelConfig?.['user_message_reminders'] && + messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) + ) { + messageActionsAfterPermission.push(MESSAGE_ACTIONS.saveForLater); + } + return messageActionsAfterPermission; }; diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx index a1cca670a6..2e62c0a5c6 100644 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ b/src/components/MessageActions/MessageActionsBox.tsx @@ -1,18 +1,18 @@ import clsx from 'clsx'; import type { ComponentProps } from 'react'; import React from 'react'; - -import { MESSAGE_ACTIONS } from '../Message/utils'; - -import type { MessageContextValue } from '../../context'; +import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; +import { RemindMeActionButton } from './RemindMeSubmenu'; +import { useMessageReminder } from '../Message'; +import { useMessageComposer } from '../MessageInput'; import { + useChatContext, useComponentContext, useMessageContext, useTranslationContext, } from '../../context'; - -import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; -import { useMessageComposer } from '../MessageInput'; +import { MESSAGE_ACTIONS } from '../Message/utils'; +import type { MessageContextValue } from '../../context'; type PropsDrilledToMessageActionsBox = | 'getMessageActions' @@ -43,18 +43,19 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { handleMute, handlePin, isUserMuted, - // eslint-disable-next-line @typescript-eslint/no-unused-vars mine, open, ...restDivProps } = props; + const { client } = useChatContext(); const { CustomMessageActionsList = DefaultCustomMessageActionsList } = useComponentContext('MessageActionsBox'); const { customMessageActions, message, threadList } = useMessageContext('MessageActionsBox'); const { t } = useTranslationContext('MessageActionsBox'); const messageComposer = useMessageComposer(); + const reminder = useMessageReminder(message.id); const messageActions = getMessageActions(); @@ -161,6 +162,23 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { {t('Delete')} )} + {messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) > -1 && ( + + )}
); diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx new file mode 100644 index 0000000000..0f146b175e --- /dev/null +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; +import { ButtonWithSubmenu } from '../Dialog'; +import type { ComponentProps } from 'react'; + +export const RemindMeActionButton = ({ + className, + isMine, +}: { isMine: boolean } & ComponentProps<'button'>) => { + const { t } = useTranslationContext(); + + return ( + + {t('Remind Me')} + + ); +}; + +export const RemindMeSubmenu = () => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { message } = useMessageContext(); + return ( +
+ {client.reminders.scheduledOffsetsMs.map((offsetMs) => ( + + ))} + {/* todo: potential improvement to add a custom option that would trigger rendering modal with custom date picker - we need date picker */} +
+ ); +}; diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js index 1f25808fcc..14a62bad58 100644 --- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js +++ b/src/components/MessageActions/__tests__/MessageActionsBox.test.js @@ -89,7 +89,7 @@ describe('MessageActionsBox', () => { it('should not show any of the action buttons if no actions are returned by getMessageActions', async () => { const { result: { container, queryByText }, - } = await renderComponent({}); + } = await renderComponent({ message: generateMessage() }); expect(queryByText('Flag')).not.toBeInTheDocument(); expect(queryByText('Mute')).not.toBeInTheDocument(); expect(queryByText('Unmute')).not.toBeInTheDocument(); @@ -106,7 +106,7 @@ describe('MessageActionsBox', () => { const handleFlag = jest.fn(); const { result: { container, getByText }, - } = await renderComponent({ handleFlag }); + } = await renderComponent({ handleFlag, message: generateMessage() }); await act(async () => { await fireEvent.click(getByText('Flag')); }); @@ -123,6 +123,7 @@ describe('MessageActionsBox', () => { } = await renderComponent({ handleMute, isUserMuted: () => false, + message: generateMessage(), }); await act(async () => { await fireEvent.click(getByText('Mute')); @@ -140,6 +141,7 @@ describe('MessageActionsBox', () => { } = await renderComponent({ handleMute, isUserMuted: () => true, + message: generateMessage(), }); await act(async () => { await fireEvent.click(getByText('Unmute')); @@ -154,7 +156,7 @@ describe('MessageActionsBox', () => { const handleEdit = jest.fn(); const { result: { container, getByText }, - } = await renderComponent({ handleEdit }); + } = await renderComponent({ handleEdit, message: generateMessage() }); await act(async () => { await fireEvent.click(getByText('Edit Message')); }); @@ -168,7 +170,7 @@ describe('MessageActionsBox', () => { const handleDelete = jest.fn(); const { result: { container, getByText }, - } = await renderComponent({ handleDelete }); + } = await renderComponent({ handleDelete, message: generateMessage() }); await act(async () => { await fireEvent.click(getByText('Delete')); }); @@ -180,7 +182,7 @@ describe('MessageActionsBox', () => { it('should call the handlePin prop if the pin button is clicked', async () => { getMessageActionsMock.mockImplementationOnce(() => ['pin']); const handlePin = jest.fn(); - const message = generateMessage({ pinned: false }); + const message = generateMessage({ message: generateMessage(), pinned: false }); const { result: { container, getByText }, } = await renderComponent({ handlePin, message }); @@ -195,7 +197,7 @@ describe('MessageActionsBox', () => { it('should call the handlePin prop if the unpin button is clicked', async () => { getMessageActionsMock.mockImplementationOnce(() => ['pin']); const handlePin = jest.fn(); - const message = generateMessage({ pinned: true }); + const message = generateMessage({ message: generateMessage(), pinned: true }); const { result: { container, getByText }, } = await renderComponent({ handlePin, message }); diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c3e516a7f0..176f017508 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -37,6 +37,7 @@ import type { ReactionsListModalProps, ReactionsListProps, RecordingPermissionDeniedNotificationProps, + ReminderNotificationProps, SendButtonProps, StartRecordingAudioButtonProps, StreamedMessageTextProps, @@ -174,6 +175,7 @@ export type ComponentContextValue = { /** Custom UI component to display the reactions modal, defaults to and accepts same props as: [ReactionsListModal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsListModal.tsx) */ ReactionsListModal?: React.ComponentType; RecordingPermissionDeniedNotification?: React.ComponentType; + ReminderNotification?: React.ComponentType; /** Custom component to display the search UI, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/Search.tsx) */ Search?: React.ComponentType; /** Custom component to display the UI where the searched string is entered, defaults to and accepts same props as: [SearchBar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/SearchBar/SearchBar.tsx) */ diff --git a/src/experimental/MessageActions/defaults.tsx b/src/experimental/MessageActions/defaults.tsx index cfbe8519e7..bd6652a5fa 100644 --- a/src/experimental/MessageActions/defaults.tsx +++ b/src/experimental/MessageActions/defaults.tsx @@ -1,23 +1,24 @@ /* eslint-disable sort-keys */ +import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; -import { isUserMuted } from '../../components'; +import { isUserMuted, useMessageComposer, useMessageReminder } from '../../components'; import { ReactionIcon as DefaultReactionIcon, ThreadIcon, } from '../../components/Message/icons'; import { ReactionSelectorWithButton } from '../../components/Reactions/ReactionSelectorWithButton'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { useMessageComposer } from '../../components'; - -import type { ComponentPropsWithoutRef } from 'react'; - +import { RemindMeActionButton } from '../../components/MessageActions/RemindMeSubmenu'; import type { MessageActionSetItem } from './MessageActions'; +const msgActionsBoxButtonClassName = + 'str-chat__message-actions-list-item-button' as const; + export const DefaultDropdownActionButton = ({ 'aria-selected': ariaSelected = 'false', children, - className = 'str-chat__message-actions-list-item-button', + className = msgActionsBoxButtonClassName, role = 'option', ...rest }: ComponentPropsWithoutRef<'button'>) => ( @@ -113,6 +114,33 @@ const DefaultMessageActionComponents = { ); }, + RemindMe() { + const { isMyMessage } = useMessageContext(); + return ( + + ); + }, + SaveForLater() { + const { client } = useChatContext(); + const { message } = useMessageContext(); + const { t } = useTranslationContext(); + const reminder = useMessageReminder(message.id); + + return ( + + reminder + ? client.reminders.deleteReminder(reminder.id) + : client.reminders.createReminder({ messageId: message.id }) + } + > + {reminder ? t('Remove reminder') : t('Save for later')} + + ); + }, }, quick: { React() { @@ -183,4 +211,14 @@ export const defaultMessageActionSet: MessageActionSetItem[] = [ placement: 'dropdown', type: 'markUnread', }, + { + Component: DefaultMessageActionComponents.dropdown.RemindMe, + placement: 'dropdown', + type: 'remindMe', + }, + { + Component: DefaultMessageActionComponents.dropdown.SaveForLater, + placement: 'dropdown', + type: 'saveForLater', + }, ] as const; diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 0a4f53bb2b..3b596d163b 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -5,6 +5,7 @@ import updateLocale from 'dayjs/plugin/updateLocale'; import LocalizedFormat from 'dayjs/plugin/localizedFormat'; import localeData from 'dayjs/plugin/localeData'; import relativeTime from 'dayjs/plugin/relativeTime'; +import duration from 'dayjs/plugin/duration'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import { defaultTranslatorFunction, predefinedFormatters } from './utils'; @@ -526,6 +527,7 @@ export class Streami18n { this.DateTimeParser.extend(calendar); this.DateTimeParser.extend(localeData); this.DateTimeParser.extend(relativeTime); + this.DateTimeParser.extend(duration); } } catch (error) { throw Error( diff --git a/src/i18n/de.json b/src/i18n/de.json index 968d17aab1..06c3d89057 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Anhang {{ name }} herunterladen", "Drag your files here": "Ziehen Sie Ihre Dateien hierher", "Drag your files here to add to your post": "Ziehen Sie Ihre Dateien hierher, um sie Ihrem Beitrag hinzuzufügen", + "Due since {{ dueSince }}": "Fällig seit {{ dueSince }}", + "Due {{ timeLeft }}": "Fällig {{ timeLeft }}", "Edit Message": "Nachricht bearbeiten", "Edit message request failed": "Anfrage zum Bearbeiten der Nachricht fehlgeschlagen", "Edited": "Bearbeitet", @@ -95,8 +97,12 @@ "Question": "Frage", "Quote": "Zitieren", "Recording format is not supported and cannot be reproduced": "Aufnahmeformat wird nicht unterstützt und kann nicht wiedergegeben werden", + "Remind Me": "Erinnern", + "Remove reminder": "Erinnerung entfernen", "Reply": "Antworten", "Reply to Message": "Auf Nachricht antworten", + "Save for later": "Für später speichern", + "Saved for later": "Für später gespeichert", "Search": "Suche", "Searching...": "Suchen...", "See all options ({{count}})_one": "Alle Optionen anzeigen ({{count}})", @@ -162,6 +168,7 @@ "aria/Open Reaction Selector": "Reaktionsauswahl öffnen", "aria/Open Thread": "Thread öffnen", "aria/Reaction list": "Reaktionsliste", + "aria/Remind Me Options": "Erinnerungsoptionen", "aria/Remove attachment": "Anhang entfernen", "aria/Retry upload": "Upload erneut versuchen", "aria/Search results": "Suchergebnisse", @@ -170,6 +177,7 @@ "aria/Stop AI Generation": "KI-Generierung stoppen", "ban-command-args": "[@Benutzername] [Text]", "ban-command-description": "Einen Benutzer verbannen", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", "live": "live", @@ -187,6 +195,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@Benutzername]", "unban-command-description": "Einen Benutzer entbannen", diff --git a/src/i18n/en.json b/src/i18n/en.json index e7b90ba402..83f339f8d4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Download attachment {{ name }}", "Drag your files here": "Drag your files here", "Drag your files here to add to your post": "Drag your files here to add to your post", + "Due since {{ dueSince }}": "Due since {{ dueSince }}", + "Due {{ timeLeft }}": "Due {{ timeLeft }}", "Edit Message": "Edit Message", "Edit message request failed": "Edit message request failed", "Edited": "Edited", @@ -95,8 +97,12 @@ "Question": "Question", "Quote": "Quote", "Recording format is not supported and cannot be reproduced": "Recording format is not supported and cannot be reproduced", + "Remind Me": "Remind Me", + "Remove reminder": "Remove reminder", "Reply": "Reply", "Reply to Message": "Reply to Message", + "Save for later": "Save for later", + "Saved for later": "Saved for later", "Search": "Search", "Searching...": "Searching...", "See all options ({{count}})_one": "See all options ({{count}})", @@ -162,12 +168,15 @@ "aria/Open Reaction Selector": "Open Reaction Selector", "aria/Open Thread": "Open Thread", "aria/Reaction list": "Reaction list", + "aria/Remind Me Options": "aria/Remind Me Options", "aria/Remove attachment": "Remove attachment", "aria/Retry upload": "Retry upload", "aria/Search results": "Search results", "aria/Search results header filter button": "Search results header filter button", "aria/Send": "Send", "aria/Stop AI Generation": "Stop AI Generation", + "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "live": "live", "replyCount_one": "1 reply", "replyCount_other": "{{ count }} replies", @@ -181,6 +190,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unreadMessagesSeparatorText_one": "1 unread message", "unreadMessagesSeparatorText_other": "{{count}} unread messages", diff --git a/src/i18n/es.json b/src/i18n/es.json index 31cf8a800d..49a480830d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Descargar adjunto {{ name }}", "Drag your files here": "Arrastra tus archivos aquí", "Drag your files here to add to your post": "Arrastra tus archivos aquí para agregarlos a tu publicación", + "Due since {{ dueSince }}": "Vencido desde {{ dueSince }}", + "Due {{ timeLeft }}": "Vence en {{ timeLeft }}", "Edit Message": "Editar mensaje", "Edit message request failed": "Error al editar la solicitud de mensaje", "Edited": "Editado", @@ -95,8 +97,12 @@ "Question": "Pregunta", "Quote": "Citar", "Recording format is not supported and cannot be reproduced": "El formato de grabación no es compatible y no se puede reproducir", + "Remind Me": "Recordarme", + "Remove reminder": "Eliminar recordatorio", "Reply": "Responder", "Reply to Message": "Responder al mensaje", + "Save for later": "Guardar para más tarde", + "Saved for later": "Guardado para más tarde", "Search": "Buscar", "Searching...": "Buscando...", "See all options ({{count}})_many": "Ver todas las opciones ({{count}})", @@ -165,6 +171,7 @@ "aria/Open Reaction Selector": "Abrir selector de reacciones", "aria/Open Thread": "Abrir hilo", "aria/Reaction list": "Lista de reacciones", + "aria/Remind Me Options": "Opciones de recordatorio", "aria/Remove attachment": "Eliminar adjunto", "aria/Retry upload": "Reintentar carga", "aria/Search results": "Resultados de búsqueda", @@ -173,6 +180,7 @@ "aria/Stop AI Generation": "Detener generación de IA", "ban-command-args": "[@usuario] [texto]", "ban-command-description": "Prohibir a un usuario", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", "live": "En vivo", @@ -192,6 +200,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@usuario]", "unban-command-description": "Quitar la prohibición a un usuario", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 732c6e3c2e..ddf1aeb74c 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Télécharger la pièce jointe {{ name }}", "Drag your files here": "Glissez vos fichiers ici", "Drag your files here to add to your post": "Glissez vos fichiers ici pour les ajouter à votre publication", + "Due since {{ dueSince }}": "Échéance depuis {{ dueSince }}", + "Due {{ timeLeft }}": "Échéance dans {{ timeLeft }}", "Edit Message": "Éditer un message", "Edit message request failed": "Échec de la demande de modification du message", "Edited": "Modifié", @@ -95,8 +97,12 @@ "Question": "Question", "Quote": "Citer", "Recording format is not supported and cannot be reproduced": "Le format d'enregistrement n'est pas pris en charge et ne peut pas être reproduit", + "Remind Me": "Me rappeler", + "Remove reminder": "Supprimer le rappel", "Reply": "Répondre", "Reply to Message": "Répondre au message", + "Save for later": "Enregistrer pour plus tard", + "Saved for later": "Enregistré pour plus tard", "Search": "Rechercher", "Searching...": "Recherche en cours...", "See all options ({{count}})_many": "Voir toutes les options ({{count}})", @@ -165,6 +171,7 @@ "aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions", "aria/Open Thread": "Ouvrir le fil", "aria/Reaction list": "Liste des réactions", + "aria/Remind Me Options": "Options de rappel", "aria/Remove attachment": "Supprimer la pièce jointe", "aria/Retry upload": "Réessayer le téléchargement", "aria/Search results": "Résultats de recherche", @@ -173,6 +180,7 @@ "aria/Stop AI Generation": "Arrêter la génération d'IA", "ban-command-args": "[@nomdutilisateur] [texte]", "ban-command-description": "Bannir un utilisateur", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", "live": "en direct", @@ -192,6 +200,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@nomdutilisateur]", "unban-command-description": "Débannir un utilisateur", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 949d1ec15d..420bdef856 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "अनुलग्नक {{ name }} डाउनलोड करें", "Drag your files here": "अपनी फ़ाइलें यहाँ खींचें", "Drag your files here to add to your post": "अपनी फ़ाइलें यहाँ खींचें और अपने पोस्ट में जोड़ने के लिए", + "Due since {{ dueSince }}": "{{ dueSince }} से देय", + "Due {{ timeLeft }}": "{{ timeLeft }} में देय", "Edit Message": "मैसेज में बदलाव करे", "Edit message request failed": "संदेश संपादित करने का अनुरोध विफल रहा", "Edited": "संपादित", @@ -96,8 +98,12 @@ "Question": "प्रश्न", "Quote": "उद्धरण", "Recording format is not supported and cannot be reproduced": "रेकॉर्डिंग फ़ॉर्मेट समर्थित नहीं है और पुनः उत्पन्न नहीं किया जा सकता", + "Remind Me": "मुझे याद दिलाएं", + "Remove reminder": "रिमाइंडर हटाएं", "Reply": "जवाब दे दो", "Reply to Message": "संदेश का जवाब दें", + "Save for later": "बाद के लिए सहेजें", + "Saved for later": "बाद के लिए सहेजा गया", "Search": "खोज", "Searching...": "खोज कर...", "See all options ({{count}})_one": "सभी विकल्प देखें ({{count}})", @@ -163,6 +169,7 @@ "aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें", "aria/Open Thread": "थ्रेड खोलें", "aria/Reaction list": "प्रतिक्रिया सूची", + "aria/Remind Me Options": "रिमाइंडर विकल्प", "aria/Remove attachment": "संलग्नक हटाएं", "aria/Retry upload": "अपलोड पुनः प्रयास करें", "aria/Search results": "खोज परिणाम", @@ -171,6 +178,7 @@ "aria/Stop AI Generation": "एआई जनरेशन रोकें", "ban-command-args": "[@उपयोगकर्तनाम] [पाठ]", "ban-command-description": "एक उपयोगकर्ता को प्रतिषेधित करें", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", "live": "लाइव", @@ -188,6 +196,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@उपयोगकर्तनाम]", "unban-command-description": "एक उपयोगकर्ता को प्रतिषेध से मुक्त करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 6a2440506d..ce71869716 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Scarica l'allegato {{ name }}", "Drag your files here": "Trascina i tuoi file qui", "Drag your files here to add to your post": "Trascina i tuoi file qui per aggiungerli al tuo post", + "Due since {{ dueSince }}": "Scaduto dal {{ dueSince }}", + "Due {{ timeLeft }}": "Scadenza tra {{ timeLeft }}", "Edit Message": "Modifica messaggio", "Edit message request failed": "Richiesta di modifica del messaggio non riuscita", "Edited": "Modificato", @@ -95,8 +97,12 @@ "Question": "Domanda", "Quote": "Citazione", "Recording format is not supported and cannot be reproduced": "Il formato di registrazione non è supportato e non può essere riprodotto", + "Remind Me": "Ricordami", + "Remove reminder": "Rimuovi promemoria", "Reply": "Rispondi", "Reply to Message": "Rispondi al messaggio", + "Save for later": "Salva per dopo", + "Saved for later": "Salvato per dopo", "Search": "Cerca", "Searching...": "Ricerca in corso...", "See all options ({{count}})_many": "Vedi tutte le opzioni ({{count}})", @@ -165,6 +171,7 @@ "aria/Open Reaction Selector": "Apri il selettore di reazione", "aria/Open Thread": "Apri discussione", "aria/Reaction list": "Elenco delle reazioni", + "aria/Remind Me Options": "Opzioni promemoria", "aria/Remove attachment": "Rimuovi allegato", "aria/Retry upload": "Riprova caricamento", "aria/Search results": "Risultati della ricerca", @@ -173,6 +180,7 @@ "aria/Stop AI Generation": "Interrompi generazione IA", "ban-command-args": "[@nomeutente] [testo]", "ban-command-description": "Vietare un utente", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", "live": "live", @@ -192,6 +200,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@nomeutente]", "unban-command-description": "Togliere il divieto a un utente", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 9e9a1d4639..255622efdc 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "添付ファイル {{ name }} をダウンロード", "Drag your files here": "ここにファイルをドラッグ", "Drag your files here to add to your post": "投稿に追加するためにここにファイルをドラッグ", + "Due since {{ dueSince }}": "{{ dueSince }}から期限切れ", + "Due {{ timeLeft }}": "{{ timeLeft }}に期限切れ", "Edit Message": "メッセージを編集", "Edit message request failed": "メッセージの編集要求が失敗しました", "Edited": "編集済み", @@ -95,8 +97,12 @@ "Question": "質問", "Quote": "引用", "Recording format is not supported and cannot be reproduced": "録音形式はサポートされておらず、再生できません", + "Remind Me": "リマインダー", + "Remove reminder": "リマインダーを削除", "Reply": "返事", "Reply to Message": "メッセージに返信", + "Save for later": "後で保存", + "Saved for later": "後で保存済み", "Search": "探す", "Searching...": "検索中...", "See all options ({{count}})_other": "すべてのオプションを見る ({{count}})", @@ -159,6 +165,7 @@ "aria/Open Reaction Selector": "リアクションセレクターを開く", "aria/Open Thread": "スレッドを開く", "aria/Reaction list": "リアクション一覧", + "aria/Remind Me Options": "リマインダーオプション", "aria/Remove attachment": "添付ファイルを削除", "aria/Retry upload": "アップロードを再試行", "aria/Search results": "検索結果", @@ -167,6 +174,7 @@ "aria/Stop AI Generation": "AI生成を停止", "ban-command-args": "[@ユーザ名] [テキスト]", "ban-command-description": "ユーザーを禁止する", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", "live": "ライブ", @@ -184,6 +192,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@ユーザ名]", "unban-command-description": "ユーザーの禁止を解除する", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 7e37d39374..19220cd94d 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "첨부 파일 {{ name }} 다운로드", "Drag your files here": "여기로 파일을 끌어다 놓으세요", "Drag your files here to add to your post": "게시물에 추가하려면 파일을 여기로 끌어다 놓으세요", + "Due since {{ dueSince }}": "{{ dueSince }}부터 기한", + "Due {{ timeLeft }}": "{{ timeLeft }}에 기한", "Edit Message": "메시지 수정", "Edit message request failed": "메시지 수정 요청 실패", "Edited": "편집됨", @@ -95,8 +97,12 @@ "Question": "질문", "Quote": "인용", "Recording format is not supported and cannot be reproduced": "녹음 형식이 지원되지 않으므로 재생할 수 없습니다", + "Remind Me": "알림 설정", + "Remove reminder": "알림 제거", "Reply": "답장", "Reply to Message": "메시지에 답장", + "Save for later": "나중에 저장", + "Saved for later": "나중에 저장됨", "Search": "찾다", "Searching...": "수색...", "See all options ({{count}})_other": "모든 옵션 보기 ({{count}})", @@ -159,6 +165,7 @@ "aria/Open Reaction Selector": "반응 선택기 열기", "aria/Open Thread": "스레드 열기", "aria/Reaction list": "반응 목록", + "aria/Remind Me Options": "알림 옵션", "aria/Remove attachment": "첨부 파일 제거", "aria/Retry upload": "업로드 다시 시도", "aria/Search results": "검색 결과", @@ -167,6 +174,7 @@ "aria/Stop AI Generation": "AI 생성 중지", "ban-command-args": "[@사용자이름] [텍스트]", "ban-command-description": "사용자를 차단", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", "live": "라이브", @@ -184,6 +192,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@사용자이름]", "unban-command-description": "사용자 차단 해제", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 2f24f9e440..4e274bd21b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Bijlage {{ name }} downloaden", "Drag your files here": "Sleep je bestanden hier naartoe", "Drag your files here to add to your post": "Sleep je bestanden hier naartoe om aan je bericht toe te voegen", + "Due since {{ dueSince }}": "Vervallen sinds {{ dueSince }}", + "Due {{ timeLeft }}": "Vervallen in {{ timeLeft }}", "Edit Message": "Bericht bewerken", "Edit message request failed": "Verzoek om bericht bewerken mislukt", "Edited": "Bewerkt", @@ -95,8 +97,12 @@ "Question": "Vraag", "Quote": "Citeer", "Recording format is not supported and cannot be reproduced": "Opnameformaat wordt niet ondersteund en kan niet worden gereproduceerd", + "Remind Me": "Herinner mij", + "Remove reminder": "Herinnering verwijderen", "Reply": "Antwoord", "Reply to Message": "Antwoord op bericht", + "Save for later": "Bewaren voor later", + "Saved for later": "Bewaard voor later", "Search": "Zoeken", "Searching...": "Zoeken...", "See all options ({{count}})_one": "Bekijk alle opties ({{count}})", @@ -162,6 +168,7 @@ "aria/Open Reaction Selector": "Reactiekiezer openen", "aria/Open Thread": "Draad openen", "aria/Reaction list": "Reactielijst", + "aria/Remind Me Options": "Herinneringsopties", "aria/Remove attachment": "Bijlage verwijderen", "aria/Retry upload": "Upload opnieuw proberen", "aria/Search results": "Zoekresultaten", @@ -170,6 +177,7 @@ "aria/Stop AI Generation": "AI-generatie stoppen", "ban-command-args": "[@gebruikersnaam] [tekst]", "ban-command-description": "Een gebruiker verbannen", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", "live": "live", @@ -187,6 +195,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@gebruikersnaam]", "unban-command-description": "Een gebruiker debannen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 971e7b9e22..62cae8d450 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Baixar anexo {{ name }}", "Drag your files here": "Arraste seus arquivos aqui", "Drag your files here to add to your post": "Arraste seus arquivos aqui para adicionar ao seu post", + "Due since {{ dueSince }}": "Vencido desde {{ dueSince }}", + "Due {{ timeLeft }}": "Vence em {{ timeLeft }}", "Edit Message": "Editar Mensagem", "Edit message request failed": "O pedido de edição da mensagem falhou", "Edited": "Editada", @@ -95,8 +97,12 @@ "Question": "Pergunta", "Quote": "Citar", "Recording format is not supported and cannot be reproduced": "Formato de gravação não é suportado e não pode ser reproduzido", + "Remind Me": "Lembrar-me", + "Remove reminder": "Remover lembrete", "Reply": "Responder", "Reply to Message": "Responder à mensagem", + "Save for later": "Salvar para depois", + "Saved for later": "Salvo para depois", "Search": "Buscar", "Searching...": "Buscando...", "See all options ({{count}})_many": "Ver todas as opções ({{count}})", @@ -165,6 +171,7 @@ "aria/Open Reaction Selector": "Abrir seletor de reações", "aria/Open Thread": "Abrir tópico", "aria/Reaction list": "Lista de reações", + "aria/Remind Me Options": "Opções de lembrete", "aria/Remove attachment": "Remover anexo", "aria/Retry upload": "Tentar upload novamente", "aria/Search results": "Resultados da pesquisa", @@ -173,6 +180,7 @@ "aria/Stop AI Generation": "Parar geração de IA", "ban-command-args": "[@nomedeusuário] [texto]", "ban-command-description": "Banir um usuário", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", "live": "ao vivo", @@ -192,6 +200,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@nomedeusuário]", "unban-command-description": "Desbanir um usuário", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index d092adc9f1..fb3ee4b7c9 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Скачать вложение {{ name }}", "Drag your files here": "Перетащите ваши файлы сюда", "Drag your files here to add to your post": "Перетащите ваши файлы сюда, чтобы добавить их в ваш пост", + "Due since {{ dueSince }}": "Просрочено с {{ dueSince }}", + "Due {{ timeLeft }}": "Просрочено в {{ timeLeft }}", "Edit Message": "Редактировать сообщение", "Edit message request failed": "Не удалось изменить запрос сообщения", "Edited": "Отредактировано", @@ -95,8 +97,12 @@ "Question": "Вопрос", "Quote": "Цитировать", "Recording format is not supported and cannot be reproduced": "Формат записи не поддерживается и не может быть воспроизведен", + "Remind Me": "Напомнить мне", + "Remove reminder": "Удалить напоминание", "Reply": "Ответить", "Reply to Message": "Ответить на сообщение", + "Save for later": "Сохранить на потом", + "Saved for later": "Сохранено на потом", "Search": "Поиск", "Searching...": "Ищем...", "See all options ({{count}})_few": "Посмотреть все варианты ({{count}})", @@ -168,6 +174,7 @@ "aria/Open Reaction Selector": "Открыть селектор реакций", "aria/Open Thread": "Открыть тему", "aria/Reaction list": "Список реакций", + "aria/Remind Me Options": "Параметры напоминания", "aria/Remove attachment": "Удалить вложение", "aria/Retry upload": "Повторить загрузку", "aria/Search results": "Результаты поиска", @@ -176,6 +183,7 @@ "aria/Stop AI Generation": "Остановить генерацию ИИ", "ban-command-args": "[@имяпользователя] [текст]", "ban-command-description": "Заблокировать пользователя", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", "live": "В прямом эфире", @@ -197,6 +205,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@имяпользователя]", "unban-command-description": "Разблокировать пользователя", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index d50ffb7fd9..80b71562be 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -29,6 +29,8 @@ "Download attachment {{ name }}": "Ek {{ name }}'i indir", "Drag your files here": "Dosyalarınızı buraya sürükleyin", "Drag your files here to add to your post": "Gönderinize eklemek için dosyalarınızı buraya sürükleyin", + "Due since {{ dueSince }}": "{{ dueSince }}'den beri süresi dolmuş", + "Due {{ timeLeft }}": "{{ timeLeft }} içinde süresi dolacak", "Edit Message": "Mesajı Düzenle", "Edit message request failed": "Mesaj düzenleme isteği başarısız oldu", "Edited": "Düzenlendi", @@ -95,8 +97,12 @@ "Question": "Soru", "Quote": "Alıntı", "Recording format is not supported and cannot be reproduced": "Kayıt formatı desteklenmiyor ve çoğaltılamıyor", + "Remind Me": "Hatırlat", + "Remove reminder": "Hatırlatıcıyı kaldır", "Reply": "Cevapla", "Reply to Message": "Mesaja Cevapla", + "Save for later": "Daha sonra kaydet", + "Saved for later": "Daha sonra kaydedildi", "Search": "Arama", "Searching...": "Aranıyor...", "See all options ({{count}})_one": "Tüm seçenekleri göster ({{count}})", @@ -162,6 +168,7 @@ "aria/Open Reaction Selector": "Tepki Seçiciyi Aç", "aria/Open Thread": "Konuyu Aç", "aria/Reaction list": "Tepki listesi", + "aria/Remind Me Options": "Hatırlatma seçenekleri", "aria/Remove attachment": "Eki kaldır", "aria/Retry upload": "Yüklemeyi Tekrar Dene", "aria/Search results": "Arama sonuçları", @@ -170,6 +177,7 @@ "aria/Stop AI Generation": "Yapay Zeka Üretimini Durdur", "ban-command-args": "[@kullanıcıadı] [metin]", "ban-command-description": "Bir kullanıcıyı yasakla", + "duration/Remind Me": "duration/Remind Me", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", "live": "canlı", @@ -187,6 +195,7 @@ "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "unban-command-args": "[@kullanıcıadı]", "unban-command-description": "Bir kullanıcının yasağını kaldır", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a923a92e8a..f4c214647c 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -17,6 +17,60 @@ export type TimestampFormatterOptions = { format?: string; }; +/** + * import dayjs from 'dayjs'; + * import duration from 'dayjs/plugin/duration'; + * + * dayjs.extend(duration); + * + * // Basic formatting + * dayjs.duration(1000).format('HH:mm:ss'); // "00:00:01" + * dayjs.duration(3661000).format('HH:mm:ss'); // "01:01:01" + * + * // Different format tokens + * dayjs.duration(3661000).format('D[d] H[h] m[m] s[s]'); // "0d 1h 1m 1s" + * dayjs.duration(3661000).format('D [days] H [hours] m [minutes] s [seconds]'); // "0 days 1 hours 1 minutes 1 seconds" + * + * // Zero padding + * dayjs.duration(1000).format('HH:mm:ss'); // "00:00:01" + * dayjs.duration(1000).format('H:m:s'); // "0:0:1" + * + * // Different units + * dayjs.duration(3661000).format('D'); // "0" + * dayjs.duration(3661000).format('H'); // "1" + * dayjs.duration(3661000).format('m'); // "1" + * dayjs.duration(3661000).format('s'); // "1" + * + * // Complex examples + * dayjs.duration(3661000).format('DD:HH:mm:ss'); // "00:01:01:01" + * dayjs.duration(3661000).format('D [days] HH:mm:ss'); // "0 days 01:01:01" + * dayjs.duration(3661000).format('H[h] m[m] s[s]'); // "1h 1m 1s" + * + * // Negative durations + * dayjs.duration(-3661000).format('HH:mm:ss'); // "-01:01:01" + * + * // Long durations + * dayjs.duration(86400000).format('D [days]'); // "1 days" + * dayjs.duration(2592000000).format('M [months]'); // "30 months" + * + * + * Format tokens: + * D - days + * H - hours + * m - minutes + * s - seconds + * S - milliseconds + * M - months + * Y - years + * You can also use: + * HH, mm, ss for zero-padded numbers + * [text] for literal text + */ +export type DurationFormatterOptions = { + format?: string; + withSuffix?: boolean; +}; + export type TDateTimeParserInput = string | number | Date; export type TDateTimeParserOutput = string | number | Date | Dayjs.Dayjs | Moment; export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; @@ -49,5 +103,6 @@ export type DateFormatterOptions = TimestampFormatterOptions & { export type CustomFormatters = Record>; export type PredefinedFormatters = { + durationFormatter: FormatterFactory; timestampFormatter: FormatterFactory; }; diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 431ccc17ed..b23279b557 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -1,9 +1,11 @@ -import Dayjs from 'dayjs'; +import Dayjs, { isDayjs } from 'dayjs'; +import type { Duration as DayjsDuration } from 'dayjs/plugin/duration'; import type { TFunction } from 'i18next'; import type { Moment } from 'moment-timezone'; import type { DateFormatterOptions, + DurationFormatterOptions, PredefinedFormatters, SupportedTranslations, TDateTimeParserInput, @@ -95,6 +97,16 @@ export function getDateString({ } export const predefinedFormatters: PredefinedFormatters = { + durationFormatter: + (streamI18n) => + (value, _, { format, withSuffix }: DurationFormatterOptions) => { + if (format && isDayjs(streamI18n.DateTimeParser)) { + return (streamI18n.DateTimeParser.duration(value) as DayjsDuration).format( + format, + ); + } + return streamI18n.DateTimeParser.duration(value).humanize(!!withSuffix); + }, timestampFormatter: (streamI18n) => ( diff --git a/src/mock-builders/generator/message.js b/src/mock-builders/generator/message.js index bc13785ec8..985003f0ad 100644 --- a/src/mock-builders/generator/message.js +++ b/src/mock-builders/generator/message.js @@ -1,17 +1,23 @@ import { nanoid } from 'nanoid'; -export const generateMessage = (options) => ({ - __html: '

regular

', - attachments: [], - created_at: new Date(), - html: '

regular

', - id: nanoid(), - mentioned_users: [], - pinned_at: null, - status: 'received', - text: nanoid(), - type: 'regular', - updated_at: new Date(), - user: null, - ...options, -}); +export const generateMessage = (options) => { + const data = { + __html: '

regular

', + attachments: [], + created_at: new Date(), + html: '

regular

', + id: nanoid(), + mentioned_users: [], + pinned_at: null, + status: 'received', + text: nanoid(), + type: 'regular', + updated_at: new Date(), + user: null, + ...options, + }; + if (data.reminder) { + data.reminder.message_id = data.id; + } + return data; +}; diff --git a/src/mock-builders/generator/reminder.ts b/src/mock-builders/generator/reminder.ts new file mode 100644 index 0000000000..6ab877a1d2 --- /dev/null +++ b/src/mock-builders/generator/reminder.ts @@ -0,0 +1,33 @@ +import type { ReminderResponse } from 'stream-chat'; + +const baseData = { + channel_cid: 'channel_cid', + message_id: 'message_id', + user_id: 'user_id', +} as const; + +export const generateReminderResponse = ({ + data, + scheduleOffsetMs, +}: { + data?: Partial; + scheduleOffsetMs?: number; +} = {}): ReminderResponse => { + const created_at = new Date().toISOString(); + const basePayload: ReminderResponse = { + ...baseData, + created_at, + message: { id: baseData.message_id, type: 'regular' }, + updated_at: created_at, + user: { id: baseData.user_id }, + }; + if (typeof scheduleOffsetMs === 'number') { + basePayload.remind_at = new Date( + new Date(created_at).getTime() + scheduleOffsetMs, + ).toISOString(); + } + return { + ...basePayload, + ...data, + }; +};