diff --git a/packages/uikit-react-native-foundation/src/components/Text/index.tsx b/packages/uikit-react-native-foundation/src/components/Text/index.tsx index e1cc8f79e..09b4623c5 100644 --- a/packages/uikit-react-native-foundation/src/components/Text/index.tsx +++ b/packages/uikit-react-native-foundation/src/components/Text/index.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { I18nManager, Text as RNText, TextProps as RNTextProps, StyleSheet, TextStyle } from 'react-native'; +import { isStartsWithRTL } from '@sendbird/uikit-utils'; + import useUIKitTheme from '../../theme/useUIKitTheme'; import type { TypoName, UIKitTheme } from '../../types'; -import { isStartsWithRTL } from './isStartsWithRTL'; export interface RTLTextAlignSupportProps { /** @@ -33,6 +34,10 @@ const Text = ({ children, color, style, supportRTLAlign = true, originalText, .. ]) as TextStyle; const textAlign = (() => { + if (textStyle.textAlign && textStyle.textAlign !== 'left' && textStyle.textAlign !== 'right') { + return textStyle.textAlign; + } + if (I18nManager.isRTL && supportRTLAlign) { if ( (originalText && isStartsWithRTL(originalText)) || @@ -43,6 +48,7 @@ const Text = ({ children, color, style, supportRTLAlign = true, originalText, .. return I18nManager.doLeftAndRightSwapInRTL ? 'right' : 'left'; } } + if (textStyle.textAlign) return textStyle.textAlign; return undefined; })(); diff --git a/packages/uikit-react-native-foundation/src/components/TextInput/index.tsx b/packages/uikit-react-native-foundation/src/components/TextInput/index.tsx index 5841c03a8..3b797fc37 100644 --- a/packages/uikit-react-native-foundation/src/components/TextInput/index.tsx +++ b/packages/uikit-react-native-foundation/src/components/TextInput/index.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { I18nManager, TextInput as RNTextInput, StyleSheet, TextInputProps, TextStyle } from 'react-native'; +import { isStartsWithRTL } from '@sendbird/uikit-utils'; + import createStyleSheet from '../../styles/createStyleSheet'; import useUIKitTheme from '../../theme/useUIKitTheme'; import type { UIKitTheme } from '../../types'; import { RTLTextAlignSupportProps } from '../Text'; -import { isStartsWithRTL } from '../Text/isStartsWithRTL'; type Props = { variant?: keyof UIKitTheme['colors']['ui']['input']; @@ -34,6 +35,10 @@ const TextInput = React.forwardRef(function TextInput( ]) as TextStyle; const textAlign = (() => { + if (textStyle.textAlign && textStyle.textAlign !== 'left' && textStyle.textAlign !== 'right') { + return textStyle.textAlign; + } + if (I18nManager.isRTL && supportRTLAlign) { const text = originalText || props.value || props.placeholder; // Note: TextInput is not affected by doLeftAndRightSwapInRTL @@ -44,7 +49,7 @@ const TextInput = React.forwardRef(function TextInput( } } - if (textStyle.textAlign) textStyle.textAlign; + if (textStyle.textAlign) return textStyle.textAlign; return undefined; })(); diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 10cb4a888..55d16ed90 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -122,7 +122,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' let seekFinished = !shouldSeekToTime; const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { - voiceMessageStatusManager.setCurrentTime(message.channelUrl, message.messageId, currentTime); + voiceMessageStatusManager.setCurrentTime(message.channelUrl, message.messageId, stopped ? 0 : currentTime); if (seekFinished) { setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); } diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx index dc1587117..c95bd29f3 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx @@ -16,6 +16,7 @@ export type VoiceFileMessageState = { type Props = ThreadParentMessageRendererProps<{ durationMetaArrayKey?: string; onUnmount: () => void; + initialCurrentTime?: number; }>; const ThreadParentMessageFileVoice = (props: Props) => { @@ -25,6 +26,7 @@ const ThreadParentMessageFileVoice = (props: Props) => { parentMessage, durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, + initialCurrentTime, } = props; const fileMessage: SendbirdFileMessage = parentMessage as SendbirdFileMessage; @@ -38,7 +40,7 @@ const ThreadParentMessageFileVoice = (props: Props) => { const initialDuration = value ? parseInt(value, 10) : 0; return { status: 'paused', - currentTime: 0, + currentTime: initialCurrentTime || 0, duration: initialDuration, }; }); diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx index ba860fa8c..3ebde558e 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx @@ -34,7 +34,7 @@ export type ThreadParentMessageRendererProps = { const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => { const handlers = useSBUHandlers(); const playerUnsubscribes = useRef<(() => void)[]>([]); - const { sbOptions, currentUser, mentionManager } = useSendbirdChat(); + const { sbOptions, currentUser, mentionManager, voiceMessageStatusManager } = useSendbirdChat(); const { palette } = useUIKitTheme(); const { mediaService, playerService } = usePlatformService(); const parentMessage = props.parentMessage; @@ -68,6 +68,11 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => let seekFinished = !shouldSeekToTime; const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { + voiceMessageStatusManager.setCurrentTime( + parentMessage.channelUrl, + parentMessage.messageId, + stopped ? 0 : currentTime, + ); if (seekFinished) { setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); } @@ -177,6 +182,10 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => return ( { if (isVoiceMessage(parentMessage) && playerService.uri === parentMessage.url) { resetPlayer().catch((_) => {}); diff --git a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx index b954e6e44..a4325358f 100644 --- a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx +++ b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx @@ -411,7 +411,6 @@ const useConfigInstance = ({ imageCompression, userMention, voiceMessage }: Send debounceMills: userMention?.debounceMills ?? MentionConfig.DEFAULT.DEBOUNCE_MILLS, delimiter: MentionConfig.DEFAULT.DELIMITER, trigger: MentionConfig.DEFAULT.TRIGGER, - forceTriggerLeftInRTL: MentionConfig.DEFAULT.FORCE_TRIGGER_LEFT_IN_RTL, }); }, [userMention?.mentionLimit, userMention?.suggestionLimit, userMention?.debounceMills]); diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 1ed808f98..ac4733e2e 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -121,7 +121,6 @@ const createGroupChannelFragment = (initModule?: Partial): G await recorderService.reset().catch(() => {}); }; const _onPressHeaderLeft = useFreshCallback(async () => { - voiceMessageStatusManager.clear(); await onBlurFragment(); onPressHeaderLeft(); }); @@ -144,6 +143,7 @@ const createGroupChannelFragment = (initModule?: Partial): G useEffect(() => { return () => { + voiceMessageStatusManager.clear(); onBlurFragment(); }; }, []); diff --git a/packages/uikit-react-native/src/libs/MentionConfig.ts b/packages/uikit-react-native/src/libs/MentionConfig.ts index 3ca82edd3..c79e0dde9 100644 --- a/packages/uikit-react-native/src/libs/MentionConfig.ts +++ b/packages/uikit-react-native/src/libs/MentionConfig.ts @@ -4,13 +4,6 @@ export interface MentionConfigInterface { debounceMills: number; delimiter: string; trigger: string; - /** - * This configuration keeps the trigger positioned to the left in RTL mode, instead of being placed after `username@`. - * @example - * RTL: `@username` - * LTR: `@username` - */ - forceTriggerLeftInRTL: boolean; } class MentionConfig { @@ -20,7 +13,6 @@ class MentionConfig { DEBOUNCE_MILLS: 300, DELIMITER: ' ', TRIGGER: '@', - FORCE_TRIGGER_LEFT_IN_RTL: true, }; constructor(private _config: MentionConfigInterface) {} @@ -43,10 +35,6 @@ class MentionConfig { get trigger() { return this._config.trigger; } - - get forceTriggerLeftInRTL() { - return this._config.forceTriggerLeftInRTL; - } } export default MentionConfig; diff --git a/packages/uikit-react-native/src/libs/MentionManager.tsx b/packages/uikit-react-native/src/libs/MentionManager.tsx index ffb2c0f6e..2c187504f 100644 --- a/packages/uikit-react-native/src/libs/MentionManager.tsx +++ b/packages/uikit-react-native/src/libs/MentionManager.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { I18nManager } from 'react-native'; import { Text, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; import type { SendbirdFileMessage, SendbirdUser, SendbirdUserMessage } from '@sendbird/uikit-utils'; -import { createMentionTemplateRegex, replaceWithRegex } from '@sendbird/uikit-utils'; +import { createMentionTemplateRegex, isEndsWithRTL, replaceWithRegex } from '@sendbird/uikit-utils'; import type { MentionedUser, Range } from '../types'; import type { MentionConfigInterface } from './MentionConfig'; @@ -22,12 +21,18 @@ class MentionManager { this._templateRegex = createMentionTemplateRegex(this.config.trigger); } - get triggerDirPrefixForDisplay() { - if (this.config.forceTriggerLeftInRTL) { - return SPAN_DIRECTION.LRM; - } - return I18nManager.isRTL ? SPAN_DIRECTION.RLM : SPAN_DIRECTION.LRM; - } + // Note: When the input starts in LTR and the mentioned user's name is in RTL, it appears as "Hello @{cibarA}." + // If typing continues in RTL, the mention is rendered as: "Hello @{txeTlanoitiddA}{cibarA}." + // + // Conversely, if the input starts in RTL and the mentioned user's name is in LTR, it appears as "{Eng}@ cibarA." + // If typing continues, it is rendered as: "{Eng}{AdditionalText}@ cibarA." + // + // While this follows the natural text direction, it can make mentions harder to distinguish. + // To address this, we use the RLM or LRM Unicode characters to reset subsequent spans based on the last text string of the user's name. + // By applying this trick, the result will be displayed as "Hello @{cibarA} {txeTlanoitiddA}" or "{AdditionalText} {Eng}@ cibarA," ensuring the mention block remains clearly distinguishable. + getDirectionOfNextSpan = (name: string) => { + return isEndsWithRTL(name) ? SPAN_DIRECTION.LRM : SPAN_DIRECTION.RLM; + }; public rangeHelpers = { inRangeUnderOver(start: number, num: number, end: number) { @@ -131,9 +136,9 @@ class MentionManager { * @description User to @user.nickname text format * */ public asMentionedMessageText = (user: SendbirdUser, delimiter = false) => { - const prefix = this.triggerDirPrefixForDisplay; + const prefix = ''; const content = `${this.config.trigger}${user.nickname}`; - const postfix = delimiter ? this.config.delimiter : ''; + const postfix = this.getDirectionOfNextSpan(user.nickname) + (delimiter ? this.config.delimiter : ''); return prefix + content + postfix; }; diff --git a/packages/uikit-utils/src/index.ts b/packages/uikit-utils/src/index.ts index 127c465a1..beede9a67 100644 --- a/packages/uikit-utils/src/index.ts +++ b/packages/uikit-utils/src/index.ts @@ -3,6 +3,7 @@ export { default as arrayToMap, arrayToMapWithGetter } from './shared/arrayToMap export * from './shared/regex'; export * from './shared/bufferedRequest'; export * from './shared/file'; +export * from './shared/rtl'; export * from './shared'; export * from './hooks'; diff --git a/packages/uikit-react-native-foundation/src/components/Text/isStartsWithRTL.ts b/packages/uikit-utils/src/shared/rtl.ts similarity index 71% rename from packages/uikit-react-native-foundation/src/components/Text/isStartsWithRTL.ts rename to packages/uikit-utils/src/shared/rtl.ts index c942f4066..0e669c265 100644 --- a/packages/uikit-react-native-foundation/src/components/Text/isStartsWithRTL.ts +++ b/packages/uikit-utils/src/shared/rtl.ts @@ -1,4 +1,4 @@ -export const isStartsWithRTL = (str?: string): boolean => { +const isRTLString = (dir: 'start' | 'end', str?: string) => { if (!str || str.length === 0) { return false; } @@ -8,8 +8,8 @@ export const isStartsWithRTL = (str?: string): boolean => { return false; } - const firstChar = Array.from(trimmedStr)[0]; - const point = firstChar.codePointAt(0); + const char = dir === 'start' ? Array.from(trimmedStr)[0] : Array.from(trimmedStr).pop(); + const point = char?.codePointAt(0); if (point === undefined) { return false; } @@ -17,6 +17,14 @@ export const isStartsWithRTL = (str?: string): boolean => { return isRTLCodePoint(point); }; +export const isStartsWithRTL = (str?: string): boolean => { + return isRTLString('start', str); +}; + +export const isEndsWithRTL = (str?: string): boolean => { + return isRTLString('end', str); +}; + const isRTLCodePoint = (codePoint: number) => { for (const [start, end] of rtlCodePointRanges) { if (codePoint >= start && codePoint <= end) { diff --git a/sample/src/screens/uikit/openChannel/OpenChannelTabs/OpenChannelListLiveStreamsScreen.tsx b/sample/src/screens/uikit/openChannel/OpenChannelTabs/OpenChannelListLiveStreamsScreen.tsx index fbf1897c5..569ff2f97 100644 --- a/sample/src/screens/uikit/openChannel/OpenChannelTabs/OpenChannelListLiveStreamsScreen.tsx +++ b/sample/src/screens/uikit/openChannel/OpenChannelTabs/OpenChannelListLiveStreamsScreen.tsx @@ -30,7 +30,7 @@ const OpenChannelListFragment = createOpenChannelListFragment({ List: (props) => { const { colors } = useUIKitTheme(); return ( - + {'Preset channels developed by UIKit'}