diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap index 8c62fffa57..4733e48b26 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -19,114 +19,101 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } > - - - - - + > + + + + - - - - - + > + + + + - - - - - + > + + + + - - - - - + > + + + + - - - - - + > + + + + { NativeHandlers.triggerHaptic('impactMedium'); stopVoiceRecordingHandler(); }; + const StopIcon = useCallback( + (props: IconProps) => , + [semantics.buttonDestructiveBg], + ); + return ( - ); }; @@ -77,11 +82,12 @@ const UploadRecording = ({ }; return ( - ); @@ -97,12 +103,13 @@ const DeleteRecording = ({ deleteVoiceRecordingHandler(); }; return ( - ); }; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 6950f4370c..82f95e7385 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -11,7 +11,6 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import { IconButton } from '../../../../components/ui/IconButton'; import { useActiveAudioPlayer } from '../../../../contexts/audioPlayerContext/AudioPlayerContext'; import { MessageInputContextValue, @@ -24,6 +23,7 @@ import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { Button } from '../../../ui'; export type AudioRecordingButtonPropsWithContext = Pick< MessageInputContextValue, @@ -228,13 +228,17 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps return ( - diff --git a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx index 9f8640be40..cce437b1e1 100644 --- a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx +++ b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx @@ -9,9 +9,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { NewPlus } from '../../../../icons/NewPlus'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui/'; import { NativeAttachmentPicker } from '../NativeAttachmentPicker'; type AttachButtonPropsWithContext = Pick< @@ -35,11 +34,6 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { selectedPicker, toggleAttachmentPicker, } = props; - const { - theme: { - messageInput: { attachButton }, - }, - } = useTheme(); const onAttachButtonLayout = (event: LayoutChangeEvent) => { const layout = event.nativeEvent.layout; @@ -82,15 +76,17 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { return ( <> - {showAttachButtonPicker ? ( { const seconds = useCooldownRemaining(); - const styles = useStyles(); - const { - theme: { - messageInput: { - cooldownTimer: { text }, - }, - }, - } = useTheme(); - - const icon = useCallback(() => { - return ( - - {seconds} - - ); - }, [seconds, text, styles]); return ( - ); }; - -const useStyles = () => { - const { - theme: { semantics }, - } = useTheme(); - return useMemo( - () => - StyleSheet.create({ - text: { - color: semantics.textDisabled, - fontSize: primitives.typographyFontSizeMd, - fontWeight: primitives.typographyFontWeightSemiBold, - lineHeight: primitives.typographyLineHeightNormal, - }, - }), - [semantics.textDisabled], - ); -}; diff --git a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx index 48adc924f5..fbae9135b6 100644 --- a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx @@ -4,9 +4,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { NewTick } from '../../../../icons/NewTick'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui'; export type EditButtonProps = Partial> & { /** Disables the button */ @@ -18,12 +17,6 @@ export const EditButton = (props: EditButtonProps) => { const { sendMessage: sendMessageFromContext } = useMessageInputContext(); const sendMessage = propsSendMessage || sendMessageFromContext; - const { - theme: { - messageInput: { editButton }, - }, - } = useTheme(); - const onPressHandler = useCallback(() => { if (disabled) { return; @@ -32,13 +25,14 @@ export const EditButton = (props: EditButtonProps) => { }, [disabled, sendMessage]); return ( - ); diff --git a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx index b0ccabad26..3a9f6962b9 100644 --- a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx @@ -4,9 +4,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { SendRight } from '../../../../icons/SendRight'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui'; export type SendButtonProps = Partial> & { /** Disables the button */ @@ -18,12 +17,6 @@ export const SendButton = (props: SendButtonProps) => { const { sendMessage: sendMessageFromContext } = useMessageInputContext(); const sendMessage = propsSendMessage || sendMessageFromContext; - const { - theme: { - messageInput: { sendButton }, - }, - } = useTheme(); - const onPressHandler = useCallback(() => { if (disabled) { return; @@ -32,14 +25,15 @@ export const SendButton = (props: SendButtonProps) => { }, [disabled, sendMessage]); return ( - ); }; diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index c8614c1656..3c867275d1 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,23 +1,13 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { NewDown } from '../../icons/NewDown'; -import { BadgeNotification } from '../ui/BadgeNotification'; -import { IconButton } from '../ui/IconButton'; - -const styles = StyleSheet.create({ - unreadCountNotificationContainer: { - position: 'absolute', - right: 0, - top: 0, - }, - container: { - padding: 4, - }, -}); +import { primitives } from '../../theme'; +import { BadgeNotification } from '../ui'; +import { Button } from '../ui/Button'; export type ScrollToBottomButtonProps = { /** onPress handler */ @@ -29,13 +19,8 @@ export type ScrollToBottomButtonProps = { export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { onPress, showNotification = true, unreadCount } = props; - const { - theme: { - messageList: { - scrollToBottomButton: { container }, - }, - }, + theme: { semantics }, } = useTheme(); if (!showNotification) { @@ -43,28 +28,51 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { } return ( - - + - - + + {unreadCount ? ( ) : null} - + ); }; +const styles = StyleSheet.create({ + unreadCountNotificationContainer: { + position: 'absolute', + right: 0, + top: 0, + }, + floatingButtonContainer: { + borderRadius: primitives.radiusMax, + }, + container: { + padding: primitives.spacingXxs, + }, +}); + ScrollToBottomButton.displayName = 'ScrollToBottomButton{messageList{scrollToBottomButton}}'; diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap index ea4ebf392b..c18d5de3c1 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -2,6 +2,8 @@ exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = ` - + - - - - - + > + + + + - - - - - + > + + + + @@ -2174,137 +2180,143 @@ exports[`Thread should match thread snapshot 1`] = ` style={{}} > - - - - - + > + + + + diff --git a/package/src/components/ui/Avatar/index.ts b/package/src/components/ui/Avatar/index.ts index 17e92462dd..d4bd64f670 100644 --- a/package/src/components/ui/Avatar/index.ts +++ b/package/src/components/ui/Avatar/index.ts @@ -2,3 +2,4 @@ export * from './Avatar'; export * from './ChannelAvatar'; export * from './UserAvatar'; export * from './AvatarStack'; +export * from './AvatarGroup'; diff --git a/package/src/components/ui/Button/Button.tsx b/package/src/components/ui/Button/Button.tsx new file mode 100644 index 0000000000..c9051c3ed6 --- /dev/null +++ b/package/src/components/ui/Button/Button.tsx @@ -0,0 +1,168 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, PressableProps, StyleSheet, Text, View } from 'react-native'; + +import { buttonPadding, buttonSizes } from './constants'; +import { useButtonStyles } from './hooks/useButtonStyles'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { IconProps } from '../../../icons/utils/base'; +import { primitives } from '../../../theme'; + +type IconRenderer = (props: IconProps) => React.ReactNode; + +export type ButtonProps = PressableProps & { + /** + * The style of the button. + */ + variant: 'primary' | 'secondary' | 'destructive'; + /** + * The type of the button. + */ + type: 'solid' | 'outline' | 'ghost' | 'liquidGlass'; + /** + * The size of the button. + */ + size?: 'sm' | 'md' | 'lg'; + /** + * Whether the button is selected. + */ + selected?: boolean; + /** + * The icon to display on the leading side of the button. + */ + LeadingIcon?: IconRenderer; + /** + * The content to display on the center of the button. + */ + label?: React.ReactNode; + /** + * The icon to display on the trailing side of the button. + */ + TrailingIcon?: IconRenderer; + /** + * Whether the button is only an icon. + */ + iconOnly?: boolean; +}; + +export const Button = ({ + variant, + type, + selected = false, + size = 'md', + LeadingIcon, + TrailingIcon, + iconOnly = false, + label, + onLayout, + disabled = false, + ...rest +}: ButtonProps) => { + const { + theme: { semantics }, + } = useTheme(); + const buttonStyles = useButtonStyles({ variant, type }); + const styles = useStyles(); + + const isRTL = I18nManager.isRTL; + + const LeftIcon = isRTL ? TrailingIcon : LeadingIcon; + const RightIcon = isRTL ? LeadingIcon : TrailingIcon; + + return ( + + [ + { + backgroundColor: pressed + ? semantics.backgroundCorePressed + : selected + ? semantics.backgroundCoreSelected + : 'transparent', + }, + styles.container, + { paddingHorizontal: buttonPadding[size] }, + ]} + {...rest} + > + {LeftIcon ? ( + + ) : null} + {!iconOnly ? ( + <> + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + {RightIcon ? ( + + ) : null} + + ) : null} + + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + wrapper: { + borderRadius: primitives.radiusMax, + }, + container: { + borderRadius: primitives.radiusMax, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: primitives.spacingXs, + }, + label: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [], + ); +}; diff --git a/package/src/components/ui/Button/constants.ts b/package/src/components/ui/Button/constants.ts new file mode 100644 index 0000000000..718b576046 --- /dev/null +++ b/package/src/components/ui/Button/constants.ts @@ -0,0 +1,22 @@ +import { primitives } from '../../../theme'; + +export const buttonSizes = { + sm: { + height: 32, + width: 32, + }, + md: { + height: 40, + width: 40, + }, + lg: { + height: 48, + width: 48, + }, +}; + +export const buttonPadding = { + sm: primitives.spacingSm, + md: primitives.spacingMd, + lg: primitives.spacingLg, +}; diff --git a/package/src/components/ui/Button/hooks/useButtonStyles.ts b/package/src/components/ui/Button/hooks/useButtonStyles.ts new file mode 100644 index 0000000000..86f3bbb32c --- /dev/null +++ b/package/src/components/ui/Button/hooks/useButtonStyles.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { ColorValue } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { ButtonProps } from '../Button'; + +export type ButtonStyleCategory = + | 'primarySolid' + | 'primaryOutline' + | 'primaryGhost' + | 'secondarySolid' + | 'secondaryOutline' + | 'secondaryGhost' + | 'destructiveSolid' + | 'destructiveOutline' + | 'destructiveGhost'; + +export type ButtonStyle = { + foregroundColor?: ColorValue; + backgroundColor?: ColorValue; + borderColor?: ColorValue; + disabledForegroundColor?: ColorValue; + disabledBackgroundColor?: ColorValue; + disabledBorderColor?: ColorValue; +}; + +/** + * Returns the styles for the button based on the button style and type. + * @param buttonStyle - The style of the button. + * @param type - The type of the button. + * @returns The styles for the button. + */ +export const useButtonStyles = ({ variant, type }: Pick) => { + const { + theme: { semantics }, + } = useTheme(); + + const category = variant.concat(type[0].toUpperCase() + type.slice(1)) as ButtonStyleCategory; + + const defaultButtonStyles: Record = useMemo(() => { + return { + primarySolid: { + foregroundColor: semantics.buttonPrimaryTextOnAccent, + backgroundColor: semantics.buttonPrimaryBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + primaryOutline: { + foregroundColor: semantics.buttonPrimaryText, + backgroundColor: undefined, + borderColor: semantics.buttonPrimaryBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.borderUtilityDisabled, + }, + primaryGhost: { + foregroundColor: semantics.buttonPrimaryText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + secondarySolid: { + foregroundColor: semantics.buttonSecondaryTextOnAccent, + backgroundColor: semantics.buttonSecondaryBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + secondaryOutline: { + foregroundColor: semantics.buttonSecondaryText, + backgroundColor: undefined, + borderColor: semantics.buttonSecondaryBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.buttonSecondaryBorder, + }, + secondaryGhost: { + foregroundColor: semantics.buttonSecondaryText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + destructiveSolid: { + foregroundColor: semantics.buttonDestructiveTextOnAccent, + backgroundColor: semantics.buttonDestructiveBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + destructiveOutline: { + foregroundColor: semantics.buttonDestructiveText, + backgroundColor: undefined, + borderColor: semantics.buttonDestructiveBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.borderUtilityDisabled, + }, + destructiveGhost: { + foregroundColor: semantics.buttonDestructiveText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + }; + }, [semantics]); + + return defaultButtonStyles[category]; +}; diff --git a/package/src/components/ui/Button/index.ts b/package/src/components/ui/Button/index.ts new file mode 100644 index 0000000000..8b166a86e4 --- /dev/null +++ b/package/src/components/ui/Button/index.ts @@ -0,0 +1 @@ +export * from './Button'; diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx deleted file mode 100644 index dd6d9a6888..0000000000 --- a/package/src/components/ui/IconButton.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import { - ColorValue, - Pressable, - PressableProps, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { IconProps } from '../../icons/utils/base'; - -export type IconButtonProps = PressableProps & { - Icon: React.FC | React.ReactNode; - iconColor?: ColorValue; - onPress?: () => void; - size?: 'sm' | 'md' | 'lg'; - status?: 'disabled' | 'pressed' | 'selected' | 'enabled'; - type?: 'primary' | 'secondary' | 'destructive'; - category?: 'ghost' | 'filled' | 'outline'; -}; - -const sizes = { - lg: { borderRadius: 24, height: 48, width: 48 }, - md: { borderRadius: 20, height: 40, width: 40 }, - sm: { - borderRadius: 16, - height: 32, - width: 32, - }, -}; - -const getBackgroundColor = ({ - type, - status, -}: { - type: IconButtonProps['type']; - status: IconButtonProps['status']; -}) => { - if (type === 'primary') { - if (status === 'disabled') { - return '#E2E6EA'; - } else { - return '#005FFF'; - } - } else if (type === 'secondary') { - return '#FFFFFF'; - } - return { - destructive: '#D92F26', - primary: '#005FFF', - secondary: '#FFFFFF', - }[type ?? 'primary']; -}; - -export const IconButton = (props: IconButtonProps) => { - const { - category = 'filled', - status = 'enabled', - Icon, - iconColor, - onPress, - size = 'md', - style, - type = 'primary', - ...rest - } = props; - const { - theme: { - colors: { selected: selectedColor }, - }, - } = useTheme(); - return ( - [ - styles.container, - sizes[size], - { - backgroundColor: - status === 'selected' - ? selectedColor - : pressed - ? '#F5F6F7' - : category === 'outline' - ? 'none' - : getBackgroundColor({ status, type }), - borderColor: type === 'destructive' ? '#D92F26' : '#E2E6EA', - borderWidth: category === 'outline' || category === 'filled' ? 1 : 0, - }, - style as StyleProp, - ]} - {...rest} - > - {typeof Icon === 'function' ? ( - - ) : ( - {Icon} - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/package/src/components/ui/index.ts b/package/src/components/ui/index.ts index 6c1291a195..d9d379b842 100644 --- a/package/src/components/ui/index.ts +++ b/package/src/components/ui/index.ts @@ -1,6 +1,6 @@ export * from './Avatar'; export * from './BadgeCount'; export * from './BadgeNotification'; -export * from './IconButton'; export * from './OnlineIndicator'; export * from './VideoPlayIndicator'; +export * from './Button';