diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx index 02fb61eedb..2b30bb48f0 100644 --- a/package/jest-setup.tsx +++ b/package/jest-setup.tsx @@ -109,6 +109,11 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () __esModule: true, default: require('./__mocks__/RefreshControlMock'), })); +jest.mock('react-native/Libraries/Utilities/Appearance', () => ({ + addChangeListener: () => ({ remove: () => {} }), + getColorScheme: () => 'light', + setColorScheme: () => {}, +})); jest.mock('@shopify/flash-list', () => ({ FlashList: undefined, diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 5cf58f9444..ec8cfa33b5 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -257,7 +257,7 @@ const useAudioAttachmentStyles = () => { : semantics.chatBorderOnChatIncoming, }, durationText: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontWeight: primitives.typographyFontWeightSemiBold, }, leftContainer: { diff --git a/package/src/components/Attachment/Audio/PlayPauseButton.tsx b/package/src/components/Attachment/Audio/PlayPauseButton.tsx index 1e0ac70459..a54fa0db26 100644 --- a/package/src/components/Attachment/Audio/PlayPauseButton.tsx +++ b/package/src/components/Attachment/Audio/PlayPauseButton.tsx @@ -65,7 +65,7 @@ const useStyles = () => { justifyContent: 'center', alignItems: 'center', borderWidth: 1, - borderColor: semantics.chatBorderOnChatIncoming, + borderColor: semantics.controlPlaybackToggleBorder, }, }); }, [semantics]); diff --git a/package/src/components/Attachment/Audio/__tests__/PlayPauseButton.test.tsx b/package/src/components/Attachment/Audio/__tests__/PlayPauseButton.test.tsx new file mode 100644 index 0000000000..dcd7460954 --- /dev/null +++ b/package/src/components/Attachment/Audio/__tests__/PlayPauseButton.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; + +import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { PlayPauseButton } from '../PlayPauseButton'; + +describe('PlayPauseButton', () => { + const lightTheme = mergeThemes({ scheme: 'light' }); + + const renderButton = (containerStyle?: Record) => + render( + + + , + ); + + const borderColorOf = () => { + const style = screen.getByTestId('play-pause-button').props.style; + return StyleSheet.flatten(typeof style === 'function' ? style({ pressed: false }) : style) + ?.borderColor; + }; + + // This control also renders in the composer's attachment previews, which are + // not a message bubble - so it must not reach for a chat side token. + it('defaults to the neutral playback toggle border, not a chat bubble border', () => { + renderButton(); + + expect(borderColorOf()).toBe(lightTheme.semantics.controlPlaybackToggleBorder); + expect(borderColorOf()).not.toBe(lightTheme.semantics.chatBorderOnChatIncoming); + }); + + it('lets in-bubble callers override the border with the resolved chat side', () => { + renderButton({ borderColor: lightTheme.semantics.chatBorderOnChatOutgoing }); + + expect(borderColorOf()).toBe(lightTheme.semantics.chatBorderOnChatOutgoing); + }); +}); diff --git a/package/src/components/Attachment/UrlPreview/URLPreview.tsx b/package/src/components/Attachment/UrlPreview/URLPreview.tsx index cac7956894..ef3d94c75b 100644 --- a/package/src/components/Attachment/UrlPreview/URLPreview.tsx +++ b/package/src/components/Attachment/UrlPreview/URLPreview.tsx @@ -68,10 +68,6 @@ const URLPreviewWithContext = (props: URLPreviewPropsWithContext) => { const { icons } = useComponentsContext(); - const { - theme: { semantics }, - } = useTheme(); - const { image_url, og_scrape_url, text, thumb_url, title, type } = attachment; const { @@ -156,7 +152,7 @@ const URLPreviewWithContext = (props: URLPreviewPropsWithContext) => { ) : null} - + { padding: primitives.spacingSm, }, title: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightTight, }, description: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, @@ -284,7 +280,7 @@ const useStyles = () => { gap: primitives.spacingXxs, }, linkPreviewText: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, diff --git a/package/src/components/Attachment/UrlPreview/URLPreviewCompact.tsx b/package/src/components/Attachment/UrlPreview/URLPreviewCompact.tsx index ed6bc35a91..72f6def261 100644 --- a/package/src/components/Attachment/UrlPreview/URLPreviewCompact.tsx +++ b/package/src/components/Attachment/UrlPreview/URLPreviewCompact.tsx @@ -64,10 +64,6 @@ const URLPreviewCompactWithContext = (props: URLPreviewCompactPropsWithContext) const { icons } = useComponentsContext(); - const { - theme: { semantics }, - } = useTheme(); - const { image_url, og_scrape_url, text, thumb_url, title: titleText, type } = attachment; const { @@ -158,7 +154,7 @@ const URLPreviewCompactWithContext = (props: URLPreviewCompactPropsWithContext) ) : null} - + { flexShrink: 1, }, title: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightTight, }, description: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, @@ -288,7 +284,7 @@ const useStyles = () => { gap: primitives.spacingXxs, }, linkPreviewText: { - color: semantics.chatTextIncoming, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, diff --git a/package/src/components/Attachment/UrlPreview/__tests__/URLPreview.test.tsx b/package/src/components/Attachment/UrlPreview/__tests__/URLPreview.test.tsx new file mode 100644 index 0000000000..c31e9201f1 --- /dev/null +++ b/package/src/components/Attachment/UrlPreview/__tests__/URLPreview.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; + +import type { MessageContextValue } from '../../../../contexts/messageContext/MessageContext'; +import { MessageProvider } from '../../../../contexts/messageContext/MessageContext'; +import type { MessagesContextValue } from '../../../../contexts/messagesContext/MessagesContext'; +import { MessagesProvider } from '../../../../contexts/messagesContext/MessagesContext'; +import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { generateCardAttachment } from '../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { URLPreview } from '../URLPreview'; +import { URLPreviewCompact } from '../URLPreviewCompact'; + +const lightTheme = mergeThemes({ scheme: 'light' }); + +/** + * Both URL preview variants resolve every text colour from the same + * incoming/outgoing pair, so the assertions are shared. + */ +describe.each([ + ['URLPreview', URLPreview], + ['URLPreviewCompact', URLPreviewCompact], +])('%s chat text side', (_name, Component) => { + const renderPreview = (messageContextValue: Partial = {}) => { + const attachment = generateCardAttachment({ title: 'A title', text: 'A description' }); + + return render( + + + + + + + , + ); + }; + + const colorOf = (text: string) => StyleSheet.flatten(screen.getByText(text).props.style)?.color; + + afterEach(cleanup); + + it('uses the outgoing chat text colour on our own message', async () => { + renderPreview({ isMyMessage: true }); + + await waitFor(() => { + expect(colorOf('A title')).toBe(lightTheme.semantics.chatTextOutgoing); + }); + expect(colorOf('A description')).toBe(lightTheme.semantics.chatTextOutgoing); + }); + + it("uses the incoming chat text colour on another user's message", async () => { + renderPreview({ isMyMessage: false }); + + await waitFor(() => { + expect(colorOf('A title')).toBe(lightTheme.semantics.chatTextIncoming); + }); + expect(colorOf('A description')).toBe(lightTheme.semantics.chatTextIncoming); + }); + + it('uses the outgoing attachment background on our own message', async () => { + renderPreview({ isMyMessage: true }); + + await waitFor(() => { + expect(StyleSheet.flatten(screen.getByTestId('card-attachment').props.style)).toEqual( + expect.objectContaining({ + backgroundColor: lightTheme.semantics.chatBgAttachmentOutgoing, + }), + ); + }); + }); + + it('strokes the link icon with the same colour as the link text', async () => { + renderPreview({ isMyMessage: true }); + + // The URL text and its leading icon must not disagree on the side. + await waitFor(() => { + expect(screen.getByTestId('card-attachment')).toBeTruthy(); + }); + + const linkIcon = screen.UNSAFE_getByProps({ stroke: lightTheme.semantics.chatTextOutgoing }); + expect(linkIcon).toBeTruthy(); + }); +}); diff --git a/package/src/components/Attachment/__tests__/Attachment.test.tsx b/package/src/components/Attachment/__tests__/Attachment.test.tsx index d4d1c88ae4..45070b706b 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.tsx +++ b/package/src/components/Attachment/__tests__/Attachment.test.tsx @@ -218,6 +218,54 @@ describe('Attachment', () => { isSoundPackageAvailable.mockReturnValue(false); }); + it('uses the outgoing audio player background on our own message', async () => { + const { isSoundPackageAvailable } = require('../../../native'); + isSoundPackageAvailable.mockReturnValue(true); + const attachment = generateAudioAttachment({ duration: 10, waveform_data: [0.2, 0.6] }); + const message = generateMessage({ attachments: [attachment, generateAudioAttachment()] }); + + const { getByLabelText } = render( + getAttachmentComponent( + { attachment }, + { isMyMessage: true, message, messageHasOnlySingleAttachment: false }, + ), + ); + + await waitFor(() => { + const style = StyleSheet.flatten(getByLabelText('audio-attachment-preview').props.style); + expect(style.backgroundColor).toBe(lightTheme.semantics.chatBgAttachmentOutgoing); + }); + isSoundPackageAvailable.mockReturnValue(false); + }); + + it('resolves the audio duration label colour from the message side', async () => { + const { isSoundPackageAvailable } = require('../../../native'); + isSoundPackageAvailable.mockReturnValue(true); + const attachment = generateAudioAttachment({ duration: 10, waveform_data: [0.2, 0.6] }); + const message = generateMessage({ attachments: [attachment] }); + + const { getByLabelText } = render( + getAttachmentComponent( + { attachment }, + { isMyMessage: true, message, messageHasOnlySingleAttachment: false }, + ), + ); + + // NOTE: `StableDurationLabel` applies its own `visibleStyle` last, which + // always sets `color` from the playback state - so this asserts the wiring + // reaching the label, not the final rendered pixel. See the reserve label. + await waitFor(() => { + expect(getByLabelText('Progress Duration').props.style).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ color: lightTheme.semantics.chatTextOutgoing }), + ]), + ]), + ); + }); + isSoundPackageAvailable.mockReturnValue(false); + }); + it('should render UrlPreview component if attachment has title_link or og_scrape_url', async () => { const attachment = generateImageAttachment({ og_scrape_url: uuidv4(), diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 4d9a842873..56a0c8f67e 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -171,7 +171,6 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { const { isMessageErrorType, - isMessageReceivedOrErrorType, isMessageTypeDeleted, isVeryLastMessage, messageGroupedSingleOrBottom, @@ -200,7 +199,7 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { backgroundColor = 'transparent'; } else if (hasStandaloneGiphyOrImgur) { backgroundColor = 'transparent'; - } else if (isMessageReceivedOrErrorType) { + } else if (!isMyMessage) { backgroundColor = semantics.chatBgIncoming; } diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageTextContainer.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageTextContainer.test.tsx index dc0684ef8b..e80b60e72b 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageTextContainer.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageTextContainer.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Text } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; import { cleanup, render, waitFor } from '@testing-library/react-native'; import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; -import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; @@ -22,6 +22,8 @@ import { MessageList } from '../../../MessageList/MessageList'; import { MessageTextContainer } from '../MessageTextContainer'; describe('MessageTextContainer', () => { + const lightTheme = mergeThemes({ scheme: 'light' }); + afterEach(cleanup); it('should render message text container', async () => { @@ -110,4 +112,20 @@ describe('MessageTextContainer', () => { expect(getByText(message.i18n.no_text)).toBeTruthy(); }); }); + + it('renders our own message text with the outgoing chat text colour', async () => { + const message = generateMessage({ user: generateStaticUser(1) }); + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + expect(StyleSheet.flatten(getByText(message.text as string).props.style)?.color).toBe( + lightTheme.semantics.chatTextOutgoing, + ); + }); + }); }); diff --git a/package/src/components/Message/MessageItemView/__tests__/__snapshots__/MessageAuthor.test.tsx.snap b/package/src/components/Message/MessageItemView/__tests__/__snapshots__/MessageAuthor.test.tsx.snap index 7246342308..45c2ac19e4 100644 --- a/package/src/components/Message/MessageItemView/__tests__/__snapshots__/MessageAuthor.test.tsx.snap +++ b/package/src/components/Message/MessageItemView/__tests__/__snapshots__/MessageAuthor.test.tsx.snap @@ -22,10 +22,10 @@ exports[`MessageAuthor should render message author 1`] = ` "width": 32, }, { - "backgroundColor": "#006970", + "backgroundColor": "#a9e4ea", }, { - "borderColor": "rgba(255, 255, 255, 0.2)", + "borderColor": "rgba(26, 27, 37, 0.1)", "borderWidth": 1, }, undefined, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2fd8404012..bcee871c05 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,6 +4,7 @@ import { ScrollViewProps, StyleSheet, View, + useColorScheme, ViewabilityConfig, ViewToken, } from 'react-native'; @@ -401,11 +402,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const styles = useStyles(); const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); + const scheme = useColorScheme(); const modifiedTheme = useMemo( - () => mergeThemes({ style: myMessageTheme, theme }), + () => mergeThemes({ scheme, style: myMessageTheme, theme }), // eslint-disable-next-line react-hooks/exhaustive-deps - [myMessageThemeString, theme], + [myMessageThemeString, scheme, theme], ); const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 2ef08ac23b..9e5de27721 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -7,6 +7,7 @@ import { ScrollViewProps, StyleSheet, View, + useColorScheme, ViewabilityConfig, ViewToken, } from 'react-native'; @@ -394,11 +395,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); + const scheme = useColorScheme(); const modifiedTheme = useMemo( - () => mergeThemes({ style: myMessageTheme, theme }), + () => mergeThemes({ scheme, style: myMessageTheme, theme }), // eslint-disable-next-line react-hooks/exhaustive-deps - [myMessageThemeString, theme], + [myMessageThemeString, scheme, theme], ); /** diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index 2b6b1e8a13..ca5fc14fcc 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -6,6 +6,7 @@ import { PollOption as PollOptionClass } from 'stream-chat'; import { PollOption, ShowAllOptionsButton } from './components'; import { PollUIStateProvider } from './contexts/PollUIStateContext'; +import { useIsPollCreatedByCurrentUser } from './hook/useIsPollCreatedByCurrentUser'; import { usePollState } from './hooks/usePollState'; import { @@ -105,6 +106,7 @@ const useStyles = () => { const { theme: { semantics }, } = useTheme(); + const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); return useMemo(() => { return StyleSheet.create({ container: { @@ -114,14 +116,14 @@ const useStyles = () => { }, headerContainer: { gap: primitives.spacingXxs }, headerSubtitle: { - color: semantics.chatTextIncoming, + color: isPollCreatedByClient ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, textAlign: 'left', }, headerTitle: { - color: semantics.chatTextIncoming, + color: isPollCreatedByClient ? semantics.chatTextOutgoing : semantics.chatTextIncoming, fontSize: primitives.typographyFontSizeMd, fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightNormal, @@ -131,5 +133,5 @@ const useStyles = () => { gap: primitives.spacingMd, }, }); - }, [semantics]); + }, [isPollCreatedByClient, semantics]); }; diff --git a/package/src/components/Poll/__tests__/Poll.test.tsx b/package/src/components/Poll/__tests__/Poll.test.tsx new file mode 100644 index 0000000000..3341be58de --- /dev/null +++ b/package/src/components/Poll/__tests__/Poll.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; + +import { + OwnCapabilitiesProvider, + PollContextProvider, + ThemeProvider, + TranslationProvider, +} from '../../../contexts'; +import { mergeThemes } from '../../../contexts/themeContext/ThemeContext'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { PollHeader } from '../Poll'; + +const mockIsPollCreatedByCurrentUser = jest.fn(); + +jest.mock('../hook/useIsPollCreatedByCurrentUser', () => ({ + useIsPollCreatedByCurrentUser: () => mockIsPollCreatedByCurrentUser(), +})); + +jest.mock('../hooks/usePollState', () => ({ + usePollState: () => ({ + enforceUniqueVote: true, + isClosed: false, + maxVotesAllowed: undefined, + name: 'A poll question', + }), +})); + +describe('PollHeader chat text side', () => { + const lightTheme = mergeThemes({ scheme: 'light' }); + + const renderHeader = () => + render( + + key } as never}> + + + + + + + , + ); + + const colorOf = (text: string) => StyleSheet.flatten(screen.getByText(text).props.style)?.color; + + afterEach(() => { + mockIsPollCreatedByCurrentUser.mockReset(); + }); + + it('uses the outgoing chat text colour for our own poll', () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(true); + renderHeader(); + + expect(colorOf('A poll question')).toBe(lightTheme.semantics.chatTextOutgoing); + expect(colorOf('Select one')).toBe(lightTheme.semantics.chatTextOutgoing); + }); + + it("uses the incoming chat text colour for another user's poll", () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(false); + renderHeader(); + + expect(colorOf('A poll question')).toBe(lightTheme.semantics.chatTextIncoming); + expect(colorOf('Select one')).toBe(lightTheme.semantics.chatTextIncoming); + }); +}); diff --git a/package/src/components/Poll/components/Button.tsx b/package/src/components/Poll/components/Button.tsx index b7a9a102bd..7e4b8c58d2 100644 --- a/package/src/components/Poll/components/Button.tsx +++ b/package/src/components/Poll/components/Button.tsx @@ -10,6 +10,11 @@ export type PollButtonProps = { }; export type PollVoteButtonProps = { + /** + * Render with the incoming chat tokens regardless of poll ownership. Set on + * surfaces that are not a message bubble, such as the full-options list. + */ + forceIncoming?: boolean; option: PollOption; style?: StyleProp; } & Pick; diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index cde38d1ffc..77c98ce4ea 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -247,6 +247,8 @@ const useStyles = () => { ? semantics.chatBorderOnChatOutgoing : semantics.chatBorderOnChatIncoming, }, + // NOTE: PollButtons only ever renders on the in-bubble surface (it is what + // opens the full-options modal), so it needs no `forceIncoming` handling. }); }, [semantics, isPollCreatedByClient]); }; diff --git a/package/src/components/Poll/components/PollOption.tsx b/package/src/components/Poll/components/PollOption.tsx index e47b93c70f..8cadf6d92d 100644 --- a/package/src/components/Poll/components/PollOption.tsx +++ b/package/src/components/Poll/components/PollOption.tsx @@ -92,7 +92,11 @@ export const PollAllOptions = ({ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: PollOptionProps) => { const { latestVotesByOption, voteCountsByOption, voteCount } = usePollState(); const { t } = useTranslationContext(); - const styles = useStyles(); + const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); + // `forceIncoming` is set on the full-options surface, which is a neutral card + // rather than a bubble - so it wins over poll ownership. + const isIncoming = !isPollCreatedByClient || !!forceIncoming; + const styles = useStyles({ isIncoming }); const relevantVotes = useMemo( () => latestVotesByOption?.[option.id] || [], @@ -111,21 +115,17 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po semantics, }, } = useTheme(); - const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); + const unFilledColor = isIncoming + ? semantics.chatPollProgressTrackIncoming + : semantics.chatPollProgressTrackOutgoing; - const unFilledColor = - isPollCreatedByClient && !forceIncoming - ? semantics.chatPollProgressTrackOutgoing - : semantics.chatPollProgressTrackIncoming; - - const filledColor = - isPollCreatedByClient && !forceIncoming - ? semantics.chatPollProgressFillOutgoing - : semantics.chatPollProgressFillIncoming; + const filledColor = isIncoming + ? semantics.chatPollProgressFillIncoming + : semantics.chatPollProgressFillOutgoing; return ( - + @@ -163,7 +163,7 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po ); }; -export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { +export const VoteButton = ({ forceIncoming, onPress, option }: PollVoteButtonProps) => { const { icons } = useComponentsContext(); const { message, poll } = usePollContext(); const { isClosed, ownVotesByOptionId } = usePollState(); @@ -172,7 +172,8 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { theme: { semantics }, } = useTheme(); const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); - const styles = useStyles(); + const isIncoming = !isPollCreatedByClient || !!forceIncoming; + const styles = useStyles({ isIncoming }); const { theme: { @@ -212,9 +213,9 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { { borderWidth: hasVote ? 0 : 1, backgroundColor: hasVote ? semantics.accentPrimary : 'transparent', - borderColor: isPollCreatedByClient - ? semantics.chatBorderOnChatOutgoing - : semantics.chatBorderOnChatIncoming, + borderColor: isIncoming + ? semantics.chatBorderOnChatIncoming + : semantics.chatBorderOnChatOutgoing, }, voteButtonContainer, ]} @@ -224,7 +225,7 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { ) : null; }; -const useStyles = () => { +const useStyles = ({ isIncoming }: { isIncoming: boolean }) => { const { theme: { semantics }, } = useTheme(); @@ -242,7 +243,7 @@ const useStyles = () => { minWidth: 0, }, text: { - color: semantics.chatTextIncoming, + color: isIncoming ? semantics.chatTextIncoming : semantics.chatTextOutgoing, flexGrow: 1, flexShrink: 1, fontSize: primitives.typographyFontSizeSm, @@ -264,7 +265,7 @@ const useStyles = () => { minHeight: 20, }, votesText: { - color: semantics.chatTextIncoming, + color: isIncoming ? semantics.chatTextIncoming : semantics.chatTextOutgoing, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightTight, @@ -281,7 +282,7 @@ const useStyles = () => { width: 24, }, }); - }, [semantics]); + }, [isIncoming, semantics]); }; const useAllOptionStyles = () => { diff --git a/package/src/components/Poll/components/__tests__/PollOption.test.tsx b/package/src/components/Poll/components/__tests__/PollOption.test.tsx new file mode 100644 index 0000000000..5c333a2f01 --- /dev/null +++ b/package/src/components/Poll/components/__tests__/PollOption.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; + +import { + OwnCapabilitiesProvider, + PollContextProvider, + ThemeProvider, + TranslationProvider, +} from '../../../../contexts'; +import { mergeThemes } from '../../../../contexts/themeContext/ThemeContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { PollOption } from '../PollOption'; + +const option = { id: 'option-1', text: 'An option' }; + +const mockIsPollCreatedByCurrentUser = jest.fn(); + +jest.mock('../../hook/useIsPollCreatedByCurrentUser', () => ({ + useIsPollCreatedByCurrentUser: () => mockIsPollCreatedByCurrentUser(), +})); + +jest.mock('../../hooks/usePollState', () => ({ + usePollState: () => ({ + isClosed: false, + latestVotesByOption: {}, + ownVotesByOptionId: {}, + voteCount: 4, + voteCountsByOption: { 'option-1': 2 }, + }), +})); + +jest.mock('../../hooks/usePollVoteToggle', () => ({ + usePollVoteToggle: () => jest.fn(), +})); + +describe('PollOption chat text side', () => { + const lightTheme = mergeThemes({ scheme: 'light' }); + + const renderOption = ({ forceIncoming }: { forceIncoming?: boolean } = {}) => + render( + + key } as never}> + + + + + + + , + ); + + const optionTextColor = () => + StyleSheet.flatten(screen.getByText('An option').props.style)?.color; + + const voteButtonBorderColor = () => { + const style = screen.getByLabelText('An option').props.style; + return StyleSheet.flatten(typeof style === 'function' ? style({ pressed: false }) : style) + ?.borderColor; + }; + + afterEach(() => { + mockIsPollCreatedByCurrentUser.mockReset(); + }); + + it('uses the outgoing chat text colour for our own poll', () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(true); + renderOption(); + + expect(optionTextColor()).toBe(lightTheme.semantics.chatTextOutgoing); + }); + + it("uses the incoming chat text colour for another user's poll", () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(false); + renderOption(); + + expect(optionTextColor()).toBe(lightTheme.semantics.chatTextIncoming); + }); + + // The full-options list is a neutral card, not a bubble, so `forceIncoming` + // has to win over ownership for every token - the vote button included. + it('forces incoming text and vote button border on our own poll', () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(true); + renderOption({ forceIncoming: true }); + + expect(optionTextColor()).toBe(lightTheme.semantics.chatTextIncoming); + expect(voteButtonBorderColor()).toBe(lightTheme.semantics.chatBorderOnChatIncoming); + }); + + it('uses the outgoing vote button border for our own poll in the bubble', () => { + mockIsPollCreatedByCurrentUser.mockReturnValue(true); + renderOption(); + + expect(voteButtonBorderColor()).toBe(lightTheme.semantics.chatBorderOnChatOutgoing); + }); +}); diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 6578237412..54560cd01d 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -99,6 +99,7 @@ export type ReplyPropsWithContext = { ImageComponent: React.ComponentType & Pick & { isMyMessage: boolean; + isParentMessageMine?: boolean; onDismiss?: () => void; mode: 'reply' | 'edit'; // This is temporary for the MessageContent Component to style the Reply component @@ -116,6 +117,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { const { t } = useTranslationContext(); const { isMyMessage, + isParentMessageMine = isMyMessage, ImageComponent, message: messageFromContext, mode, @@ -135,7 +137,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { }, }, } = useTheme(); - const styles = useStyles(); + const styles = useStyles({ isMyMessage: isParentMessageMine }); const title = useMemo( () => @@ -165,7 +167,8 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { {title} - + {/* `isMyMessage` here selects which side to paint, so it takes the surface. */} + @@ -187,6 +190,7 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon const { styles: prevStyles, isMyMessage: prevIsMyMessage, + isParentMessageMine: prevIsParentMessageMine, mode: prevMode, quotedMessage: prevQuotedMessage, onDismiss: prevOnDismiss, @@ -194,6 +198,7 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon const { styles: nextStyles, isMyMessage: nextIsMyMessage, + isParentMessageMine: nextIsParentMessageMine, mode: nextMode, quotedMessage: nextQuotedMessage, onDismiss: nextOnDismiss, @@ -209,6 +214,12 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon return false; } + const isParentMessageMineEqual = prevIsParentMessageMine === nextIsParentMessageMine; + + if (!isParentMessageMineEqual) { + return false; + } + const modeEqual = prevMode === nextMode; if (!modeEqual) { return false; @@ -275,7 +286,7 @@ const ReplyComposerAnnouncer = ({ }; export const Reply = (props: ReplyProps) => { - const { message: messageFromContext } = useMessageContext(); + const { isMyMessage: isMyMessageFromContext, message: messageFromContext } = useMessageContext(); const { client } = useChatContext(); const { ImageComponent } = useComponentsContext(); @@ -289,7 +300,10 @@ export const Reply = (props: ReplyProps) => { ? (messageFromContext.quoted_message as MessagesContextValue['quotedMessage']) : quotedMessageFromComposer; - const isMyMessage = client.user?.id === quotedMessage?.user?.id; + // `mode='edit'` supplies the edited message via `props.quotedMessage` and leaves + // the composer's quoted message empty, so ownership must consider both. + const isMyMessage = client.user?.id === (props.quotedMessage ?? quotedMessage)?.user?.id; + const isParentMessageMine = messageFromContext ? !!isMyMessageFromContext : undefined; // Composer header passes `onDismiss`; the in-message quoted-reply renderer // does not. Only the composer-preview path pays for announcement work. @@ -305,6 +319,7 @@ export const Reply = (props: ReplyProps) => { { ); }; -const useStyles = () => { +const useStyles = ({ isMyMessage = false }: { isMyMessage?: boolean } = {}) => { const { theme: { semantics }, } = useTheme(); - const messageComposer = useMessageComposer(); - const { quotedMessage: quotedMessageFromComposer } = useStateStore( - messageComposer.state, - messageComposerStateStoreSelector, - ); - const { client } = useChatContext(); - - const isMyMessage = client.user?.id === quotedMessageFromComposer?.user?.id; const isRTL = I18nManager.isRTL; return useMemo( diff --git a/package/src/components/Reply/__tests__/Reply.test.tsx b/package/src/components/Reply/__tests__/Reply.test.tsx index 83f408b3d2..a53cfacc01 100644 --- a/package/src/components/Reply/__tests__/Reply.test.tsx +++ b/package/src/components/Reply/__tests__/Reply.test.tsx @@ -1,13 +1,19 @@ import React from 'react'; +import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { render, waitFor } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; +import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { MessageProvider } from '../../../contexts/messageContext/MessageContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { mergeThemes } from '../../../contexts/themeContext/ThemeContext'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; @@ -49,4 +55,108 @@ describe('', () => { process.env = oldEnvironment; }); + + describe('chat text side', () => { + const lightTheme = mergeThemes({ scheme: 'light' }); + + const setup = async () => { + const chatClient = await getTestClientWithUser({ id: 'neil' }); + const mockedChannel = generateChannelResponse(); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', 'some-chat'); + await channel.watch(); + return { chatClient, channel }; + }; + + const titleColor = (title: string) => + StyleSheet.flatten(screen.getByText(title).props.style)?.color; + + // An in-message quoted reply is painted inside the parent bubble, whose + // background `MessageContent` resolves from the parent message. The colours + // must follow that surface; only the title *copy* describes the quoted author. + // The composer holds no quoted message in either case, so these also pin that + // the block never styles itself from composer state. + const renderInMessageQuotedReply = ( + { chatClient, channel }: Awaited>, + { + isMyMessage, + quotedMessageUser, + }: { isMyMessage: boolean; quotedMessageUser: { id: string; name?: string } }, + ) => { + const quotedMessage = generateMessage({ user: quotedMessageUser }); + const message = generateMessage({ + quoted_message: quotedMessage, + quoted_message_id: quotedMessage.id, + user: isMyMessage ? { id: 'neil' } : generateUser(), + }); + + render( + + + + + + + + + + + , + ); + }; + + it('styles an in-message quoted reply from the parent message, not the quoted author', async () => { + const setupResult = await setup(); + + // Our own message quoting somebody else: outgoing surface, so outgoing text + // even though the quoted author is not us. + renderInMessageQuotedReply(setupResult, { + isMyMessage: true, + quotedMessageUser: { id: 'other-user', name: 'Other User' }, + }); + + await waitFor(() => { + expect(titleColor('Reply to Other User')).toBe(lightTheme.semantics.chatTextOutgoing); + }); + }); + + it('keeps the title copy on the quoted author while the colours follow the parent', async () => { + const setupResult = await setup(); + + // Somebody else's message quoting us: incoming surface, so incoming text - + // but the title still reads "You", because that describes the quoted author. + renderInMessageQuotedReply(setupResult, { + isMyMessage: false, + quotedMessageUser: { id: 'neil' }, + }); + + await waitFor(() => { + expect(titleColor('You')).toBe(lightTheme.semantics.chatTextIncoming); + }); + }); + + it('uses outgoing colors for the edit-mode composer header', async () => { + const { chatClient, channel } = await setup(); + + // `mode='edit'` supplies the message through props and leaves the + // composer's quoted message empty - you can only ever edit your own message. + const editedMessage = generateMessage({ user: { id: 'neil' } }); + + render( + + + + + + + + + , + ); + + await waitFor(() => { + expect(titleColor('Edit Message')).toBe(lightTheme.semantics.chatTextOutgoing); + }); + }); + }); }); diff --git a/package/src/contexts/themeContext/ThemeContext.tsx b/package/src/contexts/themeContext/ThemeContext.tsx index 191fa82ee1..3751aa6e2d 100644 --- a/package/src/contexts/themeContext/ThemeContext.tsx +++ b/package/src/contexts/themeContext/ThemeContext.tsx @@ -39,7 +39,11 @@ export const mergeThemes = (params: MergedThemesParams) => { : JSON.parse(JSON.stringify(theme)) ) as Theme; - const semantics = resolveTokensTopologically(scheme === 'dark' ? darkSemantics : lightSemantics); + let semantics = scheme === 'dark' ? darkSemantics : lightSemantics; + if (theme?.semantics) { + semantics = { ...semantics, ...theme.semantics }; + } + semantics = resolveTokensTopologically(semantics); const finalTheme = { ...baseTheme, semantics };