diff --git a/src/components/Dialog/FormDialog.tsx b/src/components/Dialog/FormDialog.tsx deleted file mode 100644 index 4cc1525d6b..0000000000 --- a/src/components/Dialog/FormDialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import type { ChangeEvent, ChangeEventHandler, ComponentProps } from 'react'; -import React, { useCallback, useState } from 'react'; -import clsx from 'clsx'; -import { FieldError } from '../Form/FieldError'; -import { useTranslationContext } from '../../context'; - -type FormElements = 'input' | 'textarea'; -type FieldId = string; -type Validator = ( - value: string | readonly string[] | number | boolean | undefined, -) => Error | undefined; - -export type FieldConfig = { - element: FormElements; - props: ComponentProps; - label?: React.ReactNode; - validator?: Validator; -}; - -type TextInputFormProps>> = { - close: () => void; - fields: Record; - onSubmit: (formValue: F) => Promise; - className?: string; - shouldDisableSubmitButton?: (formValue: F) => boolean; - title?: string; -}; - -type FormValue> = { - [K in keyof F]: F[K]['props']['value']; -}; - -export const FormDialog = < - F extends FormValue> = FormValue< - Record - >, ->({ - className, - close, - fields, - onSubmit, - shouldDisableSubmitButton, - title, -}: TextInputFormProps) => { - const { t } = useTranslationContext(); - const [fieldErrors, setFieldErrors] = useState>({}); - const [value, setValue] = useState(() => { - let acc: Partial = {}; - for (const [id, config] of Object.entries(fields)) { - acc = { ...acc, [id]: config.props.value }; - } - return acc as F; - }); - - const handleChange = useCallback< - ChangeEventHandler - >( - (event) => { - const fieldId = event.target.id; - const fieldConfig = fields[fieldId]; - if (!fieldConfig) return; - - const error = fieldConfig.validator?.(event.target.value); - if (error) { - setFieldErrors((prev) => ({ [fieldId]: error, ...prev })); - } else { - setFieldErrors((prev) => { - delete prev[fieldId]; - return prev; - }); - } - setValue((prev) => ({ ...prev, [fieldId]: event.target.value })); - - if (!fieldConfig.props.onChange) return; - - if (fieldConfig.element === 'input') { - (fieldConfig.props.onChange as ChangeEventHandler)( - event as ChangeEvent, - ); - } else if (fieldConfig.element === 'textarea') { - (fieldConfig.props.onChange as ChangeEventHandler)( - event as ChangeEvent, - ); - } - }, - [fields], - ); - - const handleSubmit = async () => { - if (!Object.keys(value).length) return; - const errors: Record = {}; - for (const [id, fieldValue] of Object.entries(value)) { - const thisFieldError = fields[id].validator?.(fieldValue); - if (thisFieldError) { - errors[id] = thisFieldError; - } - } - if (Object.keys(errors).length) { - setFieldErrors(errors); - return; - } - await onSubmit(value); - close(); - }; - - return ( -
-
- {title &&
{title}
} -
{ - e.preventDefault(); - handleSubmit(); - }} - > - {Object.entries(fields).map(([id, fieldConfig]) => ( -
- {fieldConfig.label && ( - - )} - {React.createElement(fieldConfig.element, { - id, - ...fieldConfig.props, - onChange: handleChange, - value: value[id], - })} - -
- ))} -
- - -
-
-
-
- ); -}; diff --git a/src/components/Dialog/PromptDialog.tsx b/src/components/Dialog/PromptDialog.tsx deleted file mode 100644 index f777ff168b..0000000000 --- a/src/components/Dialog/PromptDialog.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ComponentProps } from 'react'; -import React from 'react'; -import clsx from 'clsx'; - -export type ConfirmationDialogProps = { - actions: ComponentProps<'button'>[]; - prompt: string; - className?: string; - title?: string; -}; - -export const PromptDialog = ({ - actions, - className, - prompt, - title, -}: ConfirmationDialogProps) => ( -
-
- {title &&
{title}
} -
{prompt}
-
-
- {actions.map(({ className, ...props }, i) => ( -
-
-); diff --git a/src/components/Dialog/base/ContextMenuButton.tsx b/src/components/Dialog/base/ContextMenuButton.tsx deleted file mode 100644 index 5aebdbca58..0000000000 --- a/src/components/Dialog/base/ContextMenuButton.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import clsx from 'clsx'; -import type { ComponentProps, ComponentType, ReactNode } from 'react'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PopperLikePlacement } from '../hooks'; -import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; -import { useDialogAnchor } from '../service'; -import { IconChevronRight } from '../../Icons'; -import { Avatar, type AvatarProps } from '../../Avatar'; - -export type BaseContextMenuButtonProps = { - details?: ReactNode; - hasSubMenu?: boolean; - label?: ReactNode; - Icon?: ComponentType>; - SubmenuIcon?: ComponentType>; -} & ComponentProps<'button'>; - -export const BaseContextMenuButton = ({ - children, - className, - details, - hasSubMenu, - Icon, - label, - SubmenuIcon = IconChevronRight, - ...props -}: BaseContextMenuButtonProps) => ( - -); - -export type UserContextMenuButtonProps = Pick & - ComponentProps<'button'>; - -export const UserContextMenuButton = ({ - children, - className, - imageUrl, - userName, - ...props -}: UserContextMenuButtonProps) => ( - -); - -export type EmojiContextMenuButtonProps = { emoji: string } & Pick< - BaseContextMenuButtonProps, - 'label' -> & - ComponentProps<'button'>; - -export const EmojiContextMenuButton = ({ - children, - className, - emoji, - label, - ...props -}: EmojiContextMenuButtonProps) => ( - -); - -type ButtonWithSubmenuProps = { - Submenu: ComponentType; - submenuContainerProps?: ComponentProps<'div'>; - submenuPlacement?: PopperLikePlacement; -}; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ContextMenuButtonWithSubmenu = ({ - children, - className, - Submenu, - submenuContainerProps, - submenuPlacement = 'right-start', - ...buttonProps -}: BaseContextMenuButtonProps & ButtonWithSubmenuProps) => { - 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, dialogManager } = useDialogOnNearestManager({ id: dialogId }); - const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); - const { setPopperElement, styles } = useDialogAnchor({ - open: dialogIsOpen, - placement: submenuPlacement, - 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 ( - <> - { - keepSubmenuOpen.current = false; - closeDialogLazily(); - }} - onClick={(event) => { - event.stopPropagation(); - dialog.toggle(); - }} - onFocus={handleFocusParentButton} - onMouseEnter={handleFocusParentButton} - onMouseLeave={() => { - keepSubmenuOpen.current = false; - closeDialogLazily(); - }} - role='option' - {...buttonProps} - ref={buttonRef} - > - {children} - - {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} - tabIndex={-1} - {...submenuContainerProps} - > - -
- )} - - ); -}; - -type ContextMenuButtonProps = BaseContextMenuButtonProps; - -export const ContextMenuButton = ({ - onBlur, - onFocus, - ...props -}: ContextMenuButtonProps) => { - const [isFocused, setIsFocused] = useState(false); - return ( - { - setIsFocused(false); - onBlur?.(e); - }} - onFocus={(e) => { - setIsFocused(true); - onFocus?.(e); - }} - /> - ); -}; diff --git a/src/components/Dialog/base/Prompt.tsx b/src/components/Dialog/components/Alert.tsx similarity index 55% rename from src/components/Dialog/base/Prompt.tsx rename to src/components/Dialog/components/Alert.tsx index 3d9d5dbf97..13627c58d8 100644 --- a/src/components/Dialog/base/Prompt.tsx +++ b/src/components/Dialog/components/Alert.tsx @@ -1,36 +1,36 @@ import { type ComponentProps, type ComponentType, forwardRef } from 'react'; import clsx from 'clsx'; -export const Root = forwardRef>(function PromptRoot( +export const Root = forwardRef>(function AlertRoot( { children, className, ...props }: ComponentProps<'div'>, ref, ) { return ( -
+
{children}
); }); -export type PromptHeaderProps = ComponentProps<'div'> & { +export type AlertHeaderProps = ComponentProps<'div'> & { title?: string; description?: string; Icon?: ComponentType; }; -export const Header = forwardRef(function PromptRoot( +export const Header = forwardRef(function AlertHeader( { children, className, description, Icon, title, ...props }, ref, ) { return ( -
+
{title ? ( <> {Icon && } -
-
{title}
+
+
{title}
{description && ( -
{description}
+
{description}
)}
@@ -41,18 +41,18 @@ export const Header = forwardRef(function Pro ); }); -const Actions = forwardRef>(function PromptRoot( +const Actions = forwardRef>(function AlertActions( { children, className, ...props }, ref, ) { return ( -
+
{children}
); }); -export const Prompt = { +export const Alert = { Actions, Header, Root, diff --git a/src/components/Dialog/base/Callout.tsx b/src/components/Dialog/components/Callout.tsx similarity index 100% rename from src/components/Dialog/base/Callout.tsx rename to src/components/Dialog/components/Callout.tsx diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/components/ContextMenu.tsx similarity index 50% rename from src/components/Dialog/base/ContextMenu.tsx rename to src/components/Dialog/components/ContextMenu.tsx index b7e551a404..6512e2738b 100644 --- a/src/components/Dialog/base/ContextMenu.tsx +++ b/src/components/Dialog/components/ContextMenu.tsx @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import clsx from 'clsx'; import React, { type ComponentProps, type ComponentType, @@ -7,13 +8,263 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; -import clsx from 'clsx'; -import { IconChevronLeft } from '../../Icons'; -import { useDialogIsOpen } from '../hooks'; +import { Avatar, type AvatarProps } from '../../Avatar'; +import { IconChevronLeft, IconChevronRight } from '../../Icons'; +import type { PopperLikePlacement } from '../hooks'; +import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; import type { DialogAnchorProps } from '../service/DialogAnchor'; -import { DialogAnchor } from '../service/DialogAnchor'; +import { DialogAnchor, useDialogAnchor } from '../service/DialogAnchor'; + +export type BaseContextMenuButtonProps = { + details?: ReactNode; + hasSubMenu?: boolean; + label?: ReactNode; + Icon?: ComponentType>; + SubmenuIcon?: ComponentType>; +} & ComponentProps<'button'>; + +export const BaseContextMenuButton = ({ + children, + className, + details, + hasSubMenu, + Icon, + label, + SubmenuIcon = IconChevronRight, + ...props +}: BaseContextMenuButtonProps) => ( + +); + +export type UserContextMenuButtonProps = Pick & + ComponentProps<'button'>; + +export const UserContextMenuButton = ({ + children, + className, + imageUrl, + userName, + ...props +}: UserContextMenuButtonProps) => ( + +); + +export type EmojiContextMenuButtonProps = { emoji: string } & Pick< + BaseContextMenuButtonProps, + 'label' +> & + ComponentProps<'button'>; + +export const EmojiContextMenuButton = ({ + children, + className, + emoji, + label, + ...props +}: EmojiContextMenuButtonProps) => ( + +); + +type ButtonWithSubmenuProps = { + Submenu: ComponentType; + submenuContainerProps?: ComponentProps<'div'>; + submenuPlacement?: PopperLikePlacement; +}; + +const ContextMenuButtonWithSubmenu = ({ + children, + className, + Submenu, + submenuContainerProps, + submenuPlacement = 'right-start', + ...buttonProps +}: BaseContextMenuButtonProps & ButtonWithSubmenuProps) => { + 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, dialogManager } = useDialogOnNearestManager({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); + const { setPopperElement, styles } = useDialogAnchor({ + open: dialogIsOpen, + placement: submenuPlacement, + 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 ( + <> + { + keepSubmenuOpen.current = false; + closeDialogLazily(); + }} + onClick={(event) => { + event.stopPropagation(); + dialog.toggle(); + }} + onFocus={handleFocusParentButton} + onMouseEnter={handleFocusParentButton} + onMouseLeave={() => { + keepSubmenuOpen.current = false; + closeDialogLazily(); + }} + role='option' + {...buttonProps} + ref={buttonRef} + > + {children} + + {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} + tabIndex={-1} + {...submenuContainerProps} + > + +
+ )} + + ); +}; + +type ContextMenuButtonProps = BaseContextMenuButtonProps; + +export const ContextMenuButton = ({ + onBlur, + onFocus, + ...props +}: ContextMenuButtonProps) => { + const [isFocused, setIsFocused] = useState(false); + return ( + { + setIsFocused(false); + onBlur?.(e); + }} + onFocus={(e) => { + setIsFocused(true); + onFocus?.(e); + }} + /> + ); +}; export const ContextMenuBackButton = ({ children, diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx new file mode 100644 index 0000000000..42ff6870fc --- /dev/null +++ b/src/components/Dialog/components/Prompt.tsx @@ -0,0 +1,123 @@ +import React, { type ComponentProps, type PropsWithChildren } from 'react'; +import clsx from 'clsx'; +import { Button, type ButtonProps } from '../../Button'; +import { IconArrowLeft, IconCrossMedium } from '../../Icons'; + +const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ( +
+ {children} +
+); + +export type PromptHeaderProps = { + title: string; + description?: string; + className?: string; + close?: () => void; + goBack?: () => void; +}; + +const PromptHeader = ({ + className, + close, + description, + goBack, + title, +}: PromptHeaderProps) => ( +
+ {goBack && ( + + )} +
+
{title}
+ {description != null && description !== '' && ( +
{description}
+ )} +
+ {close && ( + + )} +
+); + +export type PromptBodyProps = PropsWithChildren<{ + className?: string; +}>; + +const PromptBody = ({ children, className }: PromptBodyProps) => ( +
{children}
+); + +export type PromptFooterProps = PropsWithChildren<{ + className?: string; +}>; + +const PromptFooter = ({ children, className }: PromptFooterProps) => ( +
{children}
+); + +type PromptFooterControlsProps = PropsWithChildren<{ + className?: string; +}>; + +const PromptFooterControls = ({ children, className }: PromptFooterControlsProps) => ( +
{children}
+); + +const PromptFooterControlsButtonSecondary = ({ className, ...props }: ButtonProps) => ( + - - -
-
+ close(); + }} + type='submit' + > + {t('Share')} + + + + ); }; diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts index 6adcac545b..b8ad751115 100644 --- a/src/components/Message/hooks/useDeleteHandler.ts +++ b/src/components/Message/hooks/useDeleteHandler.ts @@ -5,7 +5,7 @@ import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; import type { DeleteMessageOptions, LocalMessage } from 'stream-chat'; -import type { ReactEventHandler } from '../types'; +import type { MessageContextValue } from '../../../context'; export type DeleteMessageNotifications = { getErrorNotification?: (message: LocalMessage) => string; @@ -15,15 +15,14 @@ export type DeleteMessageNotifications = { export const useDeleteHandler = ( message?: LocalMessage, notifications: DeleteMessageNotifications = {}, -): ReactEventHandler => { +): MessageContextValue['handleDelete'] => { const { getErrorNotification, notify } = notifications; const { deleteMessage, updateMessage } = useChannelActionContext('useDeleteHandler'); const { client } = useChatContext('useDeleteHandler'); const { t } = useTranslationContext('useDeleteHandler'); - return async (event, options?: DeleteMessageOptions) => { - event.preventDefault(); + return async (options?: DeleteMessageOptions) => { if (!message?.id || !client || !updateMessage) { return; } diff --git a/src/components/MessageActions/DeleteMessageAlert.tsx b/src/components/MessageActions/DeleteMessageAlert.tsx new file mode 100644 index 0000000000..254a72ef8d --- /dev/null +++ b/src/components/MessageActions/DeleteMessageAlert.tsx @@ -0,0 +1,51 @@ +import { Alert } from '../Dialog'; +import { Button } from '../Button'; +import clsx from 'clsx'; +import React from 'react'; +import { useTranslationContext } from '../../context'; +import type { ModalProps } from '../Modal'; + +export type DeleteMessageAlertProps = Pick & { + onDelete: () => void; +}; + +export const DeleteMessageAlert = ({ onClose, onDelete }: DeleteMessageAlertProps) => { + const { t } = useTranslationContext(); + return ( + + + + + + + + ); +}; diff --git a/src/components/MessageActions/defaults.tsx b/src/components/MessageActions/defaults.tsx index d5d169ae4b..a9db58cd6f 100644 --- a/src/components/MessageActions/defaults.tsx +++ b/src/components/MessageActions/defaults.tsx @@ -1,7 +1,8 @@ /* eslint-disable sort-keys */ -import React from 'react'; +import React, { useState } from 'react'; import { + GlobalModal, IconArrowRotateClockwise, IconBellNotification, IconBellOff, @@ -32,6 +33,7 @@ import { ReactionSelectorWithButton } from '../../components/Reactions/ReactionS import { useChannelActionContext, useChatContext, + useComponentContext, useMessageContext, useTranslationContext, } from '../../context'; @@ -44,6 +46,7 @@ import { ContextMenuButton } from '../../components/Dialog'; import type { MessageActionSetItem } from './MessageActions'; import { QuickMessageActionsButton } from './QuickMessageActionButton'; import clsx from 'clsx'; +import { DeleteMessageAlert } from './DeleteMessageAlert'; const msgActionsBoxButtonClassName = 'str-chat__message-actions-list-item-button' as const; @@ -290,26 +293,41 @@ const DefaultMessageActionComponents = { ); }, Delete({ closeMenu }: ContextMenuItemProps) { + const { Modal = GlobalModal } = useComponentContext(); const { removeMessage } = useChannelActionContext(); const { handleDelete, message } = useMessageContext(); const { t } = useTranslationContext(); + const [openModal, setOpenModal] = useState(false); return ( - { - if (message.type === 'error') removeMessage(message); - else handleDelete(event); - closeMenu(); - }} - > - {t('Delete')} - + <> + { + setOpenModal(true); + }} + > + {t('Delete')} + + + { + setOpenModal(false); + }} + onDelete={() => { + if (message.type === 'error') removeMessage(message); + else handleDelete(); + setOpenModal(false); + closeMenu(); + }} + /> + + ); }, BlockUser({ closeMenu }: ContextMenuItemProps) { diff --git a/src/components/MessageBounce/MessageBounceModal.tsx b/src/components/MessageBounce/MessageBounceModal.tsx index ff6c435fcd..dd8f00108b 100644 --- a/src/components/MessageBounce/MessageBounceModal.tsx +++ b/src/components/MessageBounce/MessageBounceModal.tsx @@ -1,7 +1,6 @@ import type { ComponentType, PropsWithChildren } from 'react'; import React from 'react'; -import type { ModalProps } from '../Modal'; -import { Modal as DefaultModal } from '../Modal'; +import { GlobalModal, type ModalProps } from '../Modal'; import { MessageBounceProvider, useComponentContext } from '../../context'; import type { MessageBouncePromptProps } from './MessageBouncePrompt'; @@ -15,7 +14,7 @@ export function MessageBounceModal({ MessageBouncePrompt, ...modalProps }: MessageBounceModalProps) { - const { Modal = DefaultModal } = useComponentContext(); + const { Modal = GlobalModal } = useComponentContext(); return ( diff --git a/src/components/MessageBounce/MessageBouncePrompt.tsx b/src/components/MessageBounce/MessageBouncePrompt.tsx index 2e3bc1c094..f49d3b993a 100644 --- a/src/components/MessageBounce/MessageBouncePrompt.tsx +++ b/src/components/MessageBounce/MessageBouncePrompt.tsx @@ -7,14 +7,14 @@ import type { ModalProps } from '../Modal'; import { Button } from '../Button'; import clsx from 'clsx'; import { IconExclamationCircle } from '../Icons'; -import { Prompt } from '../Dialog/base/Prompt'; +import { Alert } from '../Dialog'; export type MessageBouncePromptProps = PropsWithChildren>; +// todo: shall we rename this to MessageBounceAlert? export function MessageBouncePrompt({ children, onClose }: MessageBouncePromptProps) { - const { handleDelete, handleEdit, handleRetry } = - useMessageBounceContext('MessageBouncePrompt'); - const { t } = useTranslationContext('MessageBouncePrompt'); + const { handleDelete, handleEdit, handleRetry } = useMessageBounceContext(); + const { t } = useTranslationContext(); function createHandler( handle: MouseEventHandler, @@ -26,20 +26,20 @@ export function MessageBouncePrompt({ children, onClose }: MessageBouncePromptPr } return ( - - {children} - - + + @@ -77,7 +76,7 @@ export function MessageBouncePrompt({ children, onClose }: MessageBouncePromptPr > {t('Send Anyway')} - - + + ); } diff --git a/src/components/MessageBounce/styling/MessageBouncePrompt.scss b/src/components/MessageBounce/styling/MessageBouncePrompt.scss index d9ab9a7433..66228d201b 100644 --- a/src/components/MessageBounce/styling/MessageBouncePrompt.scss +++ b/src/components/MessageBounce/styling/MessageBouncePrompt.scss @@ -1,9 +1,9 @@ @use '../../../styling/utils'; -.str-chat__message-bounce-prompt { +.str-chat__message-bounce-alert { max-width: 300px; - .str-chat__prompt-header svg.str-chat__icon--exclamation-circle { + .str-chat__alert-header svg.str-chat__icon--exclamation-circle { color: var(--text-tertiary); } } diff --git a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index 223acc11fc..f0f71b2180 100644 --- a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -20,7 +20,7 @@ import { useDialogIsOpen, useDialogOnNearestManager, } from '../../Dialog'; -import { Modal as DefaultModal } from '../../Modal'; +import { GlobalModal } from '../../Modal'; import { ShareLocationDialog as DefaultLocationDialog } from '../../Location'; import { PollCreationDialog as DefaultPollCreationDialog } from '../../Poll'; import { Portal } from '../../Portal/Portal'; @@ -302,7 +302,7 @@ export const AttachmentSelector = ({ getModalPortalDestination, }: AttachmentSelectorProps) => { const { t } = useTranslationContext(); - const { Modal = DefaultModal } = useComponentContext(); + const { Modal = GlobalModal } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const messageComposer = useMessageComposer(); const isCooldownActive = useIsCooldownActive(); diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index e2e7caf963..148f26fab4 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -1,5 +1,4 @@ @use '../../../styling/utils'; -@use '../../Dialog/styling/Dialog'; .str-chat { /* diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx index c626d2dfdf..9cd294324e 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -24,6 +24,7 @@ export const GlobalModal = ({ const isOpen = useModalDialogIsOpen(); const innerRef = useRef(null); const closeButtonRef = useRef(null); + const closingRef = useRef(false); const maybeClose = useCallback( (source: ModalCloseSource, event: ModalCloseEvent) => { @@ -31,12 +32,17 @@ export const GlobalModal = ({ if (allow !== false) { onClose?.(event); dialog.close(); + closingRef.current = true; } }, [dialog, onClose, onCloseAttempt], ); const handleClick = (event: React.MouseEvent) => { + // Prevent DialogPortalDestination overlay from handling any click (closeAll). + // Ensures overlay/button close is fully controlled by onCloseAttempt/onClose. + event.stopPropagation(); + const target = event.target as HTMLButtonElement | HTMLDivElement; if (innerRef.current?.contains(target)) return; @@ -58,8 +64,13 @@ export const GlobalModal = ({ return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, maybeClose, open]); + // Sync open prop → dialog open. Don't close here (dialog ref changes after close → effect loop). + // closingRef blocks re-open when we just closed and parent hasn't set open=false yet. useEffect(() => { - if (open && !isOpen) { + if (!open) { + closingRef.current = false; + } + if (open && !isOpen && !closingRef.current) { dialog.open(); } }, [dialog, isOpen, open]); diff --git a/src/components/Modal/ModalHeader.tsx b/src/components/Modal/ModalHeader.tsx deleted file mode 100644 index b0a0948567..0000000000 --- a/src/components/Modal/ModalHeader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; -import { Button } from '../Button'; -import { IconCrossMedium } from '../Icons'; - -export type ModalHeaderProps = { - title: string; - className?: string; - close?: () => void; - goBack?: () => void; -}; - -export const ModalHeader = ({ className, close, goBack, title }: ModalHeaderProps) => ( -
- {goBack && ( - - )} -
-); diff --git a/src/components/Modal/styling/Modal.scss b/src/components/Modal/styling/Modal.scss index f6a1ca679f..dfbff1dc16 100644 --- a/src/components/Modal/styling/Modal.scss +++ b/src/components/Modal/styling/Modal.scss @@ -49,35 +49,10 @@ @include utils.component-layer-overrides('modal'); @include utils.flex-col-center; position: relative; - padding: var(--str-chat__spacing-8) var(--str-chat__spacing-4); max-height: 80%; min-width: 0; min-height: 0; - } - - .str-chat__modal-header { - display: flex; - align-items: center; - gap: var(--spacing-md); - width: 100%; - padding: var(--spacing-xl); - - .str-chat__modal-header__title { - flex: 1; - font: var(--str-chat__heading-sm-text); - color: var(--text-primary); - } - - button.str-chat__modal-header__go-back-button { - padding: 1rem; - background-size: 0.875rem; - background-repeat: no-repeat; - background-position: center; - } - - button.str-chat__modal-header__go-back-button { - background-image: var(--str-chat__arrow-left-icon); - } + overflow: hidden; } .str-chat__modal__overlay__close-button { diff --git a/src/components/Poll/PollActions/AddCommentForm.tsx b/src/components/Poll/PollActions/AddCommentForm.tsx index 2f6a0afc2f..a58d658181 100644 --- a/src/components/Poll/PollActions/AddCommentForm.tsx +++ b/src/components/Poll/PollActions/AddCommentForm.tsx @@ -1,8 +1,10 @@ -import React from 'react'; -import { FormDialog } from '../../Dialog/FormDialog'; +import React, { useCallback, useMemo } from 'react'; import { useStateStore } from '../../../store'; import { usePollContext, useTranslationContext } from '../../../context'; import type { PollAnswer, PollState } from 'stream-chat'; +import { Prompt } from '../../Dialog'; +import { TextInput } from '../../Form'; +import { useFormState } from '../../Form/hooks'; type PollStateSelectorReturnValue = { ownAnswer: PollAnswer | undefined }; const pollStateSelector = (nextValue: PollState): PollStateSelectorReturnValue => ({ @@ -16,41 +18,78 @@ export type AddCommentFormProps = { export const AddCommentForm = ({ close, messageId }: AddCommentFormProps) => { const { t } = useTranslationContext('AddCommentForm'); - const { poll } = usePollContext(); const { ownAnswer } = useStateStore(poll.state, pollStateSelector); + const initialComment = ownAnswer?.answer_text ?? ''; + const initialValue = useMemo(() => ({ comment: initialComment }), [initialComment]); + const validators = useMemo( + () => ({ + comment: (v: string) => { + const trimmed = typeof v === 'string' ? v.trim() : ''; + if (!trimmed) { + return new Error(t('This field cannot be empty or contain only spaces')); + } + return undefined; + }, + }), + [t], + ); + const onSubmit = useCallback( + async (formValue: { comment: string }) => { + await poll.addAnswer(formValue.comment, messageId); + close(); + }, + [poll, messageId, close], + ); + const { fieldErrors, handleSubmit, setFieldValue, value } = useFormState<{ + comment: string; + }>({ + initialValue, + onSubmit, + validators, + }); + + const title = ownAnswer ? t('Update your comment') : t('Add a comment'); + const submitDisabled = + !value.comment?.trim() || value.comment === ownAnswer?.answer_text; + return ( - - className='str-chat__prompt-dialog str-chat__modal__poll-add-comment' - close={close} - fields={{ - comment: { - element: 'input', - props: { - id: 'comment', - name: 'comment', - required: true, - type: 'text', - value: ownAnswer?.answer_text ?? '', - }, - validator: (value) => { - const valueString = typeof value !== 'undefined' ? value.toString() : value; - const trimmedValue = valueString?.trim(); - if (!trimmedValue) { - return new Error(t('This field cannot be empty or contain only spaces')); - } - return; - }, - }, - }} - onSubmit={async (value) => { - await poll.addAnswer(value.comment, messageId); - }} - shouldDisableSubmitButton={(value) => - !value.comment || value.comment === ownAnswer?.answer_text - } - title={ownAnswer ? t('Update your comment') : t('Add a comment')} - /> + + {title && } + +
+ setFieldValue('comment', e.target.value)} + required + title={title} + type='text' + value={value.comment} + /> + +
+ + + + {t('Cancel')} + + 0 || submitDisabled} + type='submit' + > + {t('Send')} + + + +
); }; diff --git a/src/components/Poll/PollActions/EndPollDialog.tsx b/src/components/Poll/PollActions/EndPollDialog.tsx index e3bbf18641..ccac9ac50d 100644 --- a/src/components/Poll/PollActions/EndPollDialog.tsx +++ b/src/components/Poll/PollActions/EndPollDialog.tsx @@ -1,5 +1,5 @@ -import { PromptDialog } from '../../Dialog/PromptDialog'; import React from 'react'; +import { Prompt } from '../../Dialog'; import { usePollContext, useTranslationContext } from '../../../context'; export type EndPollDialogProps = { @@ -11,23 +11,32 @@ export const EndPollDialog = ({ close }: EndPollDialogProps) => { const { poll } = usePollContext(); return ( - + + + +
+ {t('Nobody will be able to vote in this poll anymore.')} +
+
+ + + + {t('Cancel')} + + { + poll.close(); + close(); + }} + > + {t('End')} + + + +
); }; diff --git a/src/components/Poll/PollActions/PollAnswerList.tsx b/src/components/Poll/PollActions/PollAnswerList.tsx index 3eb4076223..e773828c0b 100644 --- a/src/components/Poll/PollActions/PollAnswerList.tsx +++ b/src/components/Poll/PollActions/PollAnswerList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ModalHeader } from '../../Modal/ModalHeader'; +import { Prompt } from '../../Dialog'; import { PollVote } from '../PollVote'; import { usePollAnswerPagination } from '../hooks'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; @@ -34,9 +34,9 @@ export const PollAnswerList = ({ const { answers, error, hasNextPage, loading, loadMore } = usePollAnswerPagination(); return ( -
- -
+ + +
{answers.map((answer) => ( @@ -55,12 +55,19 @@ export const PollAnswerList = ({ )} {error?.message &&
{error?.message}
} -
- {answers.length > 0 && !is_closed && ( - - )} -
+ + + {answers.length > 0 && !is_closed && ( + + + {ownAnswer ? t('Update your comment') : t('Add a comment')} + + + )} + + ); }; diff --git a/src/components/Poll/PollActions/PollOptionsFullList.tsx b/src/components/Poll/PollActions/PollOptionsFullList.tsx index 560bae0786..10bd00bba1 100644 --- a/src/components/Poll/PollActions/PollOptionsFullList.tsx +++ b/src/components/Poll/PollActions/PollOptionsFullList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ModalHeader } from '../../Modal/ModalHeader'; +import { Prompt } from '../../Dialog'; import { PollOptionList } from '../PollOptionList'; import { useStateStore } from '../../../store'; import { usePollContext, useTranslationContext } from '../../../context'; @@ -21,12 +21,12 @@ export const PollOptionsFullList = ({ close }: FullPollOptionsListingProps) => { const { name } = useStateStore(poll.state, pollStateSelector); return ( -
- -
+ + +
{name}
-
-
+ + ); }; diff --git a/src/components/Poll/PollActions/PollResults/PollResults.tsx b/src/components/Poll/PollActions/PollResults/PollResults.tsx index aa079bf69b..ab875cf0b5 100644 --- a/src/components/Poll/PollActions/PollResults/PollResults.tsx +++ b/src/components/Poll/PollActions/PollResults/PollResults.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { useCallback, useState } from 'react'; import { PollOptionVotesList } from './PollOptionVotesList'; import { PollOptionWithLatestVotes } from './PollOptionWithLatestVotes'; -import { ModalHeader } from '../../../Modal/ModalHeader'; +import { Prompt } from '../../../Dialog'; import { useStateStore } from '../../../../store'; import { usePollContext, useTranslationContext } from '../../../../context'; import type { PollOption, PollState } from 'stream-chat'; @@ -29,25 +29,25 @@ export const PollResults = ({ close }: PollResultsProps) => { const goBack = useCallback(() => setOptionToView(undefined), []); return ( -
{optionToView ? ( <> - -
+ + -
+ ) : ( <> - -
+ +
{name}
{options @@ -65,9 +65,9 @@ export const PollResults = ({ close }: PollResultsProps) => { /> ))}
-
+ )} -
+ ); }; diff --git a/src/components/Poll/PollActions/SuggestPollOptionForm.tsx b/src/components/Poll/PollActions/SuggestPollOptionForm.tsx index ac50228a2d..2cfd9364a0 100644 --- a/src/components/Poll/PollActions/SuggestPollOptionForm.tsx +++ b/src/components/Poll/PollActions/SuggestPollOptionForm.tsx @@ -1,8 +1,10 @@ -import React from 'react'; -import { FormDialog } from '../../Dialog/FormDialog'; +import React, { useCallback, useMemo } from 'react'; import { useChatContext, usePollContext, useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; import type { PollOption, PollState } from 'stream-chat'; +import { Prompt } from '../../Dialog'; +import { TextInput } from '../../Form'; +import { useFormState } from '../../Form/hooks'; type PollStateSelectorReturnValue = { options: PollOption[] }; const pollStateSelector = (nextValue: PollState): PollStateSelectorReturnValue => ({ @@ -23,42 +25,80 @@ export const SuggestPollOptionForm = ({ const { poll } = usePollContext(); const { options } = useStateStore(poll.state, pollStateSelector); + const initialValue = useMemo(() => ({ optionText: '' }), []); + const validators = useMemo( + () => ({ + optionText: (v: string) => { + const trimmed = typeof v === 'string' ? v.trim() : ''; + if (!trimmed) { + return new Error(t('This field cannot be empty or contain only spaces')); + } + const existingOption = options.find((option) => option.text === trimmed); + if (existingOption) { + return new Error(t('Option already exists')); + } + return undefined; + }, + }), + [t, options], + ); + + const onSubmit = useCallback( + async (formValue: { optionText: string }) => { + const { poll_option } = await client.createPollOption(poll.id, { + text: formValue.optionText, + }); + poll.castVote(poll_option.id, messageId); + close(); + }, + [client, poll, messageId, close], + ); + + const { fieldErrors, handleSubmit, setFieldValue, value } = useFormState<{ + optionText: string; + }>({ + initialValue, + onSubmit, + validators, + }); + + const submitDisabled = !value.optionText?.trim(); + return ( - - className='str-chat__prompt-dialog str-chat__modal__suggest-poll-option' - close={close} - fields={{ - optionText: { - element: 'input', - props: { - id: 'optionText', - name: 'optionText', - required: true, - type: 'text', - value: '', - }, - validator: (value) => { - const valueString = typeof value !== 'undefined' ? value.toString() : value; - const trimmedValue = valueString?.trim(); - if (!trimmedValue) { - return new Error(t('This field cannot be empty or contain only spaces')); - } - const existingOption = options.find((option) => option.text === trimmedValue); - if (existingOption) { - return new Error(t('Option already exists')); - } - return; - }, - }, - }} - onSubmit={async (value) => { - const { poll_option } = await client.createPollOption(poll.id, { - text: value.optionText, - }); - poll.castVote(poll_option.id, messageId); - }} - shouldDisableSubmitButton={(value) => !value.optionText} - title={t('Suggest an option')} - /> + + + +
+ setFieldValue('optionText', e.target.value)} + required + title={t('Suggest an option')} + type='text' + value={value.optionText} + /> + +
+ + + + {t('Cancel')} + + 0 || submitDisabled} + type='submit' + > + {t('Send')} + + + +
); }; diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx index a0e0d40170..d5636eb98b 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx @@ -5,7 +5,7 @@ import { MultipleAnswersField } from './MultipleAnswersField'; import { NameField } from './NameField'; import { OptionFieldSet } from './OptionFieldSet'; import { PollCreationDialogControls } from './PollCreationDialogControls'; -import { ModalHeader } from '../../Modal/ModalHeader'; +import { Prompt } from '../../Dialog'; import { SwitchField } from '../../Form/SwitchField'; import { useMessageComposer } from '../../MessageInput'; import { useTranslationContext } from '../../../context'; @@ -33,12 +33,12 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { }, [pollComposer, close]); return ( -
- -
+ +
@@ -79,8 +79,8 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { />
-
+ -
+ ); }; diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index f4b67a6285..1daa055d68 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { useCanCreatePoll, useMessageComposer } from '../../MessageInput'; import { useMessageInputContext, useTranslationContext } from '../../../context'; -import { Button } from '../../Button'; import clsx from 'clsx'; import { IconPaperPlane } from '../../Icons'; +import { Prompt } from '../../Dialog'; export type PollCreationDialogControlsProps = { close: () => void; @@ -18,45 +18,37 @@ export const PollCreationDialogControls = ({ const canCreatePoll = useCanCreatePoll(); return ( -
- - -
+ + + { + messageComposer.pollComposer.initState(); + close(); + }} + type='button' + > + {t('Cancel')} + + { + messageComposer + .createPoll() + .then(() => handleSubmitMessage()) + .then(() => { + messageComposer.pollComposer.initState(); + close(); + }) + .catch(console.error); + }} + type='submit' + > + + {t('Send poll')} + + + ); }; diff --git a/src/components/Poll/styling/Poll.scss b/src/components/Poll/styling/Poll.scss index 8be9da655e..252f667303 100644 --- a/src/components/Poll/styling/Poll.scss +++ b/src/components/Poll/styling/Poll.scss @@ -95,17 +95,6 @@ } } - .str-chat__poll-results-modal, - .str-chat__poll-answer-list-modal, - .str-chat__add-poll-answer-modal, - .str-chat__suggest-poll-option-modal, - .str-chat__poll-options-modal { - button { - @include utils.button-reset; - cursor: pointer; - } - } - .str-chat__poll-option-list--full { .str-chat__amount-bar { display: none; @@ -144,26 +133,6 @@ } } - .str-chat__poll-option-list--full { - .str-chat__poll-option { - display: flex; - flex-direction: row; - padding: 1rem 0.75rem; - - &:nth-of-type(1) { - padding-top: 1rem; - border-top-left-radius: var(--str-chat__border-radius-sm); - border-top-right-radius: var(--str-chat__border-radius-sm); - } - - &:last-child { - padding-bottom: 1rem; - border-bottom-left-radius: var(--str-chat__border-radius-sm); - border-bottom-right-radius: var(--str-chat__border-radius-sm); - } - } - } - .str-chat__poll-option-list:not(.str-chat__poll-option-list--full) { display: flex; flex-direction: column; @@ -229,173 +198,32 @@ } } - .str-chat__modal__inner { - $content-offset-inline: 1rem; - padding: 0 0 0.5rem; - overflow: hidden; - max-width: 400px; - - .str-chat__tooltip { - max-width: 300px; - } - - .str-chat__modal__suggest-poll-option { - .str-chat__form-field-error { - height: 1rem; - } - - .str-chat__dialog__controls { - padding-bottom: 0; - } - } - - .str-chat__modal__poll-answer-list, - .str-chat__modal__poll-option-list, - .str-chat__modal__poll-results { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-height: 400px; - } - - .str-chat__modal__poll-answer-list, - .str-chat__poll-option--full-vote-list { - .str-chat__loading-indicator-placeholder { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 40px; - } - } - - .str-chat__modal__poll-option-list__title, - .str-chat__modal__poll-results__title { - padding: 1.175rem 1rem; - } - - .str-chat__modal__poll-answer-list__body, - .str-chat__modal__poll-results__body { - display: flex; - flex-direction: column; - min-height: 0; - padding-bottom: 1rem; - } - - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body, - .str-chat__poll-answer-list, - .str-chat__modal__poll-results__option-list { - display: flex; - flex-direction: column; - flex: 1; - max-height: 100%; - min-height: 0; - } - - .str-chat__poll-answer-list { - padding-bottom: 0; - } - - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body, - .str-chat__poll-answer-list { - overflow-y: auto; - padding: 0 $content-offset-inline 1.25rem; - } - - .str-chat__poll-answer-list, - .str-chat__modal__poll-results__option-list { - gap: 0.5rem; - } - - .str-chat__modal__poll-results__body, - .str-chat__modal__poll-option-list__body { - gap: 2rem; - } - - .str-chat__poll-option__show-all-votes-button { - padding-bottom: 1rem; - } - - .str-chat__poll-answer { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 0.75rem 1rem; - - .str-chat__poll-answer__text { - margin: 0; - } - } - - .str-chat__checkmark { - margin-right: 1rem; - } - - .str-chat__poll-option__header { - display: flex; - align-items: flex-start; - gap: 0.25rem; - width: 100%; - padding: 0.75rem 1rem; + .str-chat__poll-vote-listing { + padding: 0 1rem 0.75rem; + } - .str-chat__poll-option__option-text { - flex: 1; - } - } + .str-chat__poll-option-list--full { + overflow: clip; - .str-chat__poll-vote { + .str-chat__poll-option { display: flex; - justify-content: space-between; - align-items: center; - gap: 0.5rem; - white-space: nowrap; - padding-block: 0.375rem; + flex-direction: row; + padding: 1rem 0.75rem; - .str-chat__poll-vote__author { - display: flex; - align-items: center; - gap: calc(var(--str-chat__spacing-px) * 5); - min-width: 0; - - .str-chat__poll-vote__author__name { - @include utils.ellipsis-text; - max-width: 130px; - min-width: 0; - } + &:nth-of-type(1) { + padding-top: 1rem; + border-top-left-radius: var(--str-chat__border-radius-sm); + border-top-right-radius: var(--str-chat__border-radius-sm); } - } - - .str-chat__poll-result-option-vote-counter { - display: flex; - gap: 0.375rem; - .str-chat__poll-result-winning-option-icon { - height: 1.25rem; - width: 1.25rem; - background-image: var(--str-chat__winning-poll-option-icon); + &:last-child { + padding-bottom: 1rem; + border-bottom-left-radius: var(--str-chat__border-radius-sm); + border-bottom-right-radius: var(--str-chat__border-radius-sm); } } } - .str-chat__poll-vote-listing { - padding: 0 1rem 0.75rem; - } - - .str-chat__poll-option-list--full, - .str-chat__poll-answer, - .str-chat__modal__poll-option-list__title, - .str-chat__modal__poll-results .str-chat__modal__poll-results__title, - .str-chat__modal__poll-results .str-chat__poll-option { - border-radius: 0.75rem; - } - - .str-chat__poll-option-list--full { - overflow: clip; - } - .str-chat__poll--closed { .str-chat__poll-option { &:hover { diff --git a/src/components/Poll/styling/PollCreationDialog.scss b/src/components/Poll/styling/PollCreationDialog.scss index fbda78f2db..5a9e7b349b 100644 --- a/src/components/Poll/styling/PollCreationDialog.scss +++ b/src/components/Poll/styling/PollCreationDialog.scss @@ -6,7 +6,7 @@ width: min(480px, 100vw); height: min(640px, 100vh); - .str-chat__dialog__body { + .str-chat__prompt__body { flex: 1 1; form { diff --git a/src/components/Poll/styling/PollResults.scss b/src/components/Poll/styling/PollResults.scss index 9d8ea927de..93849d1c0b 100644 --- a/src/components/Poll/styling/PollResults.scss +++ b/src/components/Poll/styling/PollResults.scss @@ -10,7 +10,7 @@ } .str-chat__modal__poll-results--option-detail { - .str-chat__modal-header__title { + .str-chat__prompt__header__title { padding-inline: 1rem; flex: 1; } diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx index 523ff9d79c..e69ae75c21 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/ReactionsListModal.tsx @@ -3,14 +3,14 @@ import clsx from 'clsx'; import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types'; -import { Modal as DefaultModal } from '../Modal'; +import type { ModalProps } from '../Modal'; +import { GlobalModal } from '../Modal'; import { useFetchReactions } from './hooks/useFetchReactions'; import { LoadingIndicator } from '../Loading'; import { Avatar } from '../Avatar'; +import type { MessageContextValue } from '../../context'; import { useComponentContext, useMessageContext } from '../../context'; import type { ReactionSort } from 'stream-chat'; -import type { ModalProps } from '../Modal'; -import type { MessageContextValue } from '../../context'; export type ReactionsListModalProps = ModalProps & Partial> & { @@ -33,7 +33,7 @@ export function ReactionsListModal({ sortReactionDetails: propSortReactionDetails, ...modalProps }: ReactionsListModalProps) { - const { Modal = DefaultModal } = useComponentContext(); + const { Modal = GlobalModal } = useComponentContext(); const selectedReaction = reactions.find( ({ reactionType }) => reactionType === selectedReactionType, ); diff --git a/src/components/Reactions/styling/ReactionsListModal.scss b/src/components/Reactions/styling/ReactionsListModal.scss new file mode 100644 index 0000000000..ca6311e1f8 --- /dev/null +++ b/src/components/Reactions/styling/ReactionsListModal.scss @@ -0,0 +1,85 @@ +.str-chat__message-reactions-details-modal { + .str-chat__modal__inner { + max-height: 80%; + min-width: 90%; + max-width: 90%; + width: min(480px, 90vw); + height: min(640px, 90vh); + flex-basis: min-content; + + @media only screen and (min-device-width: 768px) { + min-width: 40vh; + max-width: 60vh; + width: min-content; + } + } +} + +.str-chat__message-reactions-details { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-4); + max-height: 100%; + height: 100%; + min-height: 0; + + .str-chat__message-reactions-details-reaction-types { + display: flex; + max-width: 100%; + width: 100%; + min-width: 0; + overflow-x: auto; + gap: var(--str-chat__spacing-4); + align-items: center; + flex-shrink: 0; + + .str-chat__message-reactions-details-reaction-type { + display: flex; + align-items: center; + padding: var(--str-chat__spacing-1) 0; + flex-shrink: 0; + cursor: pointer; + border-block-end: solid transparent; + + .str-chat__message-reaction-emoji--with-fallback { + width: 18px; + line-height: 18px; + } + } + + .str-chat__message-reactions-details-reaction-type--selected { + border-block-end: var(--str-chat__messsage-reactions-details--selected-color); + } + } + + .str-chat__message-reaction-emoji-big { + --str-chat__stream-emoji-size: 1em; + align-self: center; + font-size: 2rem; + } + + .str-chat__message-reaction-emoji-big.str-chat__message-reaction-emoji--with-fallback { + line-height: 2rem; + } + + .str-chat__message-reactions-details-reacting-users { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-3); + max-height: 100%; + overflow-y: auto; + min-height: 30vh; + + .str-chat__loading-indicator { + margin: auto; + } + + .str-chat__message-reactions-details-reacting-user { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-2); + font: var(--str-chat__subtitle-text); + } + } +} \ No newline at end of file diff --git a/src/components/Reactions/styling/index.scss b/src/components/Reactions/styling/index.scss new file mode 100644 index 0000000000..f7dbd0be0b --- /dev/null +++ b/src/components/Reactions/styling/index.scss @@ -0,0 +1,3 @@ +@use 'ReactionList'; +@use 'ReactionSelector'; +@use 'ReactionsListModal'; \ No newline at end of file diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index a028c8f96d..36766699b3 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -37,10 +37,7 @@ export type MessageContextValue = { /** Function to send an action in a Channel */ handleAction: ActionHandlerReturnType; /** Function to delete a message in a Channel */ - handleDelete: ( - event: BaseSyntheticEvent, - options?: DeleteMessageOptions, - ) => Promise | void; + handleDelete: (options?: DeleteMessageOptions) => Promise | void; /** Function to fetch the message reactions */ handleFetchReactions: ( reactionType?: ReactionType, diff --git a/src/i18n/de.json b/src/i18n/de.json index bff3ca8202..2995a44db1 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -40,6 +40,7 @@ "Anonymous": "Anonym", "Anonymous poll": "Anonyme Umfrage", "Archive": "Archivieren", + "Are you sure you want to delete this message?": "Sind Sie sicher, dass Sie diese Nachricht löschen möchten?", "aria/Attachment": "Anhang", "aria/Block User": "Benutzer blockieren", "aria/Bookmark Message": "Nachricht für später speichern", @@ -111,6 +112,7 @@ "Current location": "Aktueller Standort", "Delete": "Löschen", "Delete for me": "Für mich löschen", + "Delete message": "Nachricht löschen", "Delivered": "Zugestellt", "Download attachment {{ name }}": "Anhang {{ name }} herunterladen", "Drag your files here": "Ziehen Sie Ihre Dateien hierher", diff --git a/src/i18n/en.json b/src/i18n/en.json index 760e55dd2d..449e320a25 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -40,6 +40,7 @@ "Anonymous": "Anonymous", "Anonymous poll": "Anonymous poll", "Archive": "Archive", + "Are you sure you want to delete this message?": "Are you sure you want to delete this message?", "aria/Attachment": "Attachment", "aria/Block User": "Block User", "aria/Bookmark Message": "Bookmark Message", @@ -111,6 +112,7 @@ "Current location": "Current location", "Delete": "Delete", "Delete for me": "Delete for me", + "Delete message": "Delete message", "Delivered": "Delivered", "Download attachment {{ name }}": "Download attachment {{ name }}", "Drag your files here": "Drag your files here", diff --git a/src/i18n/es.json b/src/i18n/es.json index ac48a76580..742831c68d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -45,6 +45,7 @@ "Anonymous": "Anónimo", "Anonymous poll": "Encuesta anónima", "Archive": "Archivo", + "Are you sure you want to delete this message?": "¿Estás seguro de que quieres eliminar este mensaje?", "aria/Attachment": "Adjunto", "aria/Block User": "Bloquear usuario", "aria/Bookmark Message": "Guardar mensaje", @@ -116,6 +117,7 @@ "Current location": "Ubicación actual", "Delete": "Borrar", "Delete for me": "Eliminar para mí", + "Delete message": "Eliminar mensaje", "Delivered": "Entregado", "Download attachment {{ name }}": "Descargar adjunto {{ name }}", "Drag your files here": "Arrastra tus archivos aquí", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6973b91e82..f31621c2c0 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -45,6 +45,7 @@ "Anonymous": "Anonyme", "Anonymous poll": "Sondage anonyme", "Archive": "Archiver", + "Are you sure you want to delete this message?": "Êtes-vous sûr de vouloir supprimer ce message ?", "aria/Attachment": "Pièce jointe", "aria/Block User": "Bloquer l'utilisateur", "aria/Bookmark Message": "Enregistrer le message", @@ -116,6 +117,7 @@ "Current location": "Emplacement actuel", "Delete": "Supprimer", "Delete for me": "Supprimer pour moi", + "Delete message": "Supprimer le message", "Delivered": "Publié", "Download attachment {{ name }}": "Télécharger la pièce jointe {{ name }}", "Drag your files here": "Glissez vos fichiers ici", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 90f81f7084..70c85f70a3 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -40,6 +40,7 @@ "Anonymous": "गुमनाम", "Anonymous poll": "गुमनाम मतदान", "Archive": "आर्काइव", + "Are you sure you want to delete this message?": "क्या आप वाकई इस संदेश को हटाना चाहते हैं?", "aria/Attachment": "अनुलग्नक", "aria/Block User": "उपयोगकर्ता को ब्लॉक करें", "aria/Bookmark Message": "संदेश बुकमार्क करें", @@ -111,6 +112,7 @@ "Current location": "वर्तमान स्थान", "Delete": "डिलीट", "Delete for me": "मेरे लिए डिलीट करें", + "Delete message": "संदेश हटाएं", "Delivered": "पहुंच गया", "Download attachment {{ name }}": "अनुलग्नक {{ name }} डाउनलोड करें", "Drag your files here": "अपनी फ़ाइलें यहाँ खींचें", diff --git a/src/i18n/it.json b/src/i18n/it.json index ca2a7b95b9..3d8659da0f 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -45,6 +45,7 @@ "Anonymous": "Anonimo", "Anonymous poll": "Sondaggio anonimo", "Archive": "Archivia", + "Are you sure you want to delete this message?": "Sei sicuro di voler eliminare questo messaggio?", "aria/Attachment": "Allegato", "aria/Block User": "Blocca utente", "aria/Bookmark Message": "Salva messaggio", @@ -116,6 +117,7 @@ "Current location": "Posizione attuale", "Delete": "Elimina", "Delete for me": "Elimina per me", + "Delete message": "Elimina messaggio", "Delivered": "Consegnato", "Download attachment {{ name }}": "Scarica l'allegato {{ name }}", "Drag your files here": "Trascina i tuoi file qui", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 1ef3d7acb7..813fa5dbd6 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -40,6 +40,7 @@ "Anonymous": "匿名", "Anonymous poll": "匿名投票", "Archive": "アーカイブ", + "Are you sure you want to delete this message?": "このメッセージを削除してもよろしいですか?", "aria/Attachment": "添付ファイル", "aria/Block User": "ユーザーをブロック", "aria/Bookmark Message": "メッセージをブックマーク", @@ -111,6 +112,7 @@ "Current location": "現在の位置", "Delete": "消去", "Delete for me": "自分用に削除", + "Delete message": "メッセージを削除", "Delivered": "配信しました", "Download attachment {{ name }}": "添付ファイル {{ name }} をダウンロード", "Drag your files here": "ここにファイルをドラッグ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index f4d8338a54..929529d107 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -40,6 +40,7 @@ "Anonymous": "익명", "Anonymous poll": "익명 투표", "Archive": "아카이브", + "Are you sure you want to delete this message?": "이 메시지를 삭제하시겠습니까?", "aria/Attachment": "첨부 파일", "aria/Block User": "사용자 차단", "aria/Bookmark Message": "메시지 북마크", @@ -111,6 +112,7 @@ "Current location": "현재 위치", "Delete": "삭제", "Delete for me": "나만 삭제", + "Delete message": "메시지 삭제", "Delivered": "배달됨", "Download attachment {{ name }}": "첨부 파일 {{ name }} 다운로드", "Drag your files here": "여기로 파일을 끌어다 놓으세요", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index fbb82e69c3..689960ea87 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -40,6 +40,7 @@ "Anonymous": "Anoniem", "Anonymous poll": "Anonieme peiling", "Archive": "Archief", + "Are you sure you want to delete this message?": "Weet je zeker dat je dit bericht wilt verwijderen?", "aria/Attachment": "Bijlage", "aria/Block User": "Gebruiker blokkeren", "aria/Bookmark Message": "Bericht bookmarken", @@ -111,6 +112,7 @@ "Current location": "Huidige locatie", "Delete": "Verwijder", "Delete for me": "Voor mij verwijderen", + "Delete message": "Bericht verwijderen", "Delivered": "Afgeleverd", "Download attachment {{ name }}": "Bijlage {{ name }} downloaden", "Drag your files here": "Sleep je bestanden hier naartoe", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 79a404ec3a..80ef00bad8 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -45,6 +45,7 @@ "Anonymous": "Anônimo", "Anonymous poll": "Enquete anônima", "Archive": "Arquivar", + "Are you sure you want to delete this message?": "Tem certeza de que deseja excluir esta mensagem?", "aria/Attachment": "Anexo", "aria/Block User": "Bloquear usuário", "aria/Bookmark Message": "Marcar mensagem", @@ -116,6 +117,7 @@ "Current location": "Localização atual", "Delete": "Excluir", "Delete for me": "Excluir para mim", + "Delete message": "Excluir mensagem", "Delivered": "Entregue", "Download attachment {{ name }}": "Baixar anexo {{ name }}", "Drag your files here": "Arraste seus arquivos aqui", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index b8a43f06ee..c276654139 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -50,6 +50,7 @@ "Anonymous": "Аноним", "Anonymous poll": "Анонимный опрос", "Archive": "Aрхивировать", + "Are you sure you want to delete this message?": "Вы уверены, что хотите удалить это сообщение?", "aria/Attachment": "Вложение", "aria/Block User": "Заблокировать пользователя", "aria/Bookmark Message": "Сохранить сообщение", @@ -121,6 +122,7 @@ "Current location": "Текущее местоположение", "Delete": "Удалить", "Delete for me": "Удалить для меня", + "Delete message": "Удалить сообщение", "Delivered": "Отправлено", "Download attachment {{ name }}": "Скачать вложение {{ name }}", "Drag your files here": "Перетащите ваши файлы сюда", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index e77c5b6f5b..63749a6793 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -40,6 +40,7 @@ "Anonymous": "Anonim", "Anonymous poll": "Anonim anket", "Archive": "Arşivle", + "Are you sure you want to delete this message?": "Bu mesajı silmek istediğinizden emin misiniz?", "aria/Attachment": "Ek", "aria/Block User": "Kullanıcıyı engelle", "aria/Bookmark Message": "Mesajı yer imi ekle", @@ -111,6 +112,7 @@ "Current location": "Mevcut konum", "Delete": "Sil", "Delete for me": "Benim için sil", + "Delete message": "Mesajı sil", "Delivered": "İletildi", "Download attachment {{ name }}": "Ek {{ name }}'i indir", "Drag your files here": "Dosyalarınızı buraya sürükleyin", diff --git a/src/styling/base.scss b/src/styling/base.scss index 166a16a762..3b5f697e32 100644 --- a/src/styling/base.scss +++ b/src/styling/base.scss @@ -10,4 +10,18 @@ outline: 2px solid var(--border-utility-focus); outline-offset: 2px; } + + // hide spin buttons form input of type number + /* Chrome, Safari, Edge, Opera */ + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + input[type='number'] { + -moz-appearance: textfield; + } } diff --git a/src/styling/index.scss b/src/styling/index.scss index a16cb65962..8f1748f55f 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -7,7 +7,6 @@ @use './variables.css'; @use 'base'; @use 'icons'; -// Fonts @use 'fonts'; // alias is necessary to allow sass create namespaces with different names (and not same name styling) @@ -37,8 +36,7 @@ @use '../components/MessageInput/styling' as MessageComposer; @use '../components/MessageList/styling' as MessageList; @use '../components/Poll/styling' as Poll; -@use '../components/Reactions/styling/ReactionList' as ReactionList; -@use '../components/Reactions/styling/ReactionSelector' as ReactionSelector; +@use '../components/Reactions/styling' as Reactions; @use '../components/TextareaComposer/styling' as TextareaComposer; @use '../components/Thread/styling' as Thread; @use '../components/VideoPlayer/styling' as VideoPlayer;