diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 24395046..0aa8aca4 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -48,7 +48,7 @@ import { BadgePressData, MessageActionData, } from './components/ChatMessage/ChatMessage'; -import { EmotePreviewSheet } from './components/EmotePreviewSheet'; +import { EmoteDetailSheet } from './components/EmoteDetailSheet'; import { EmoteSheet, EmotePickerItem, @@ -101,7 +101,6 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const debugModalRef = useRef(null); const chatInputRef = useRef(null); - const emotePreviewSheetRef = useRef(null); const badgePreviewSheetRef = useRef(null); const actionSheetRef = useRef(null); @@ -114,6 +113,7 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const [selectedEmote, setSelectedEmote] = useState( null, ); + const [isEmotePopoverVisible, setIsEmotePopoverVisible] = useState(false); const [selectedBadge, setSelectedBadge] = useState( null, ); @@ -660,9 +660,20 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { }, []); // Shared sheet handlers - lifted from per-message to single instances - const handleEmoteLongPress = useCallback((emote: EmotePressData) => { - setSelectedEmote(emote); - emotePreviewSheetRef.current?.present(); + const handleEmotePress = useCallback( + ( + emote: EmotePressData, + _position?: { x: number; y: number; width: number; height: number }, + ) => { + setSelectedEmote(emote); + setIsEmotePopoverVisible(true); + }, + [], + ); + + const handleEmotePopoverClose = useCallback(() => { + setIsEmotePopoverVisible(false); + setSelectedEmote(null); }, []); const handleBadgeLongPress = useCallback((badge: BadgePressData) => { @@ -787,7 +798,7 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { onReply={handleReply} replyDisplayName={msg.replyDisplayName} replyBody={msg.replyBody} - onEmotePress={handleEmoteLongPress} + onEmotePress={handleEmotePress} onBadgePress={handleBadgeLongPress} onMessageLongPress={handleMessageLongPress} getMentionColor={getMentionColor} @@ -799,7 +810,7 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { ), [ handleReply, - handleEmoteLongPress, + handleEmotePress, handleBadgeLongPress, handleMessageLongPress, getMentionColor, @@ -910,12 +921,11 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { /> {/* Shared bottom sheets - single instances instead of per-message */} - {selectedEmote && ( - - )} + {selectedBadge && ( & { onReply: (args: OnReply) => void; - onEmotePress?: (data: EmotePressData) => void; + onEmotePress?: ( + data: EmotePressData, + position: { x: number; y: number; width: number; height: number }, + ) => void; onBadgePress?: (data: BadgePressData) => void; onMessageLongPress?: (data: MessageActionData) => void; getMentionColor?: (username: string) => string; @@ -90,9 +93,12 @@ function ChatMessageComponent< } const handleEmotePress = useCallback( - (part: ParsedPart) => { + ( + part: ParsedPart, + position: { x: number; y: number; width: number; height: number }, + ) => { if (part.type === 'emote') { - onEmotePress?.(part); + onEmotePress?.(part, position); } }, [onEmotePress], @@ -129,7 +135,15 @@ function ChatMessageComponent< { + // Measure the emote to get position, but we don't need it for bottom sheet + handleEmotePress(emotePart, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + }} /> ); } @@ -394,7 +408,10 @@ export const ChatMessage = MemoizedChatMessage as < >( props: ChatMessageType & { onReply: (args: OnReply) => void; - onEmotePress?: (data: EmotePressData) => void; + onEmotePress?: ( + data: EmotePressData, + position: { x: number; y: number; width: number; height: number }, + ) => void; onBadgePress?: (data: BadgePressData) => void; onMessageLongPress?: (data: MessageActionData) => void; getMentionColor?: (username: string) => string; diff --git a/src/components/Chat/components/EmoteDetailSheet/EmoteDetailSheet.tsx b/src/components/Chat/components/EmoteDetailSheet/EmoteDetailSheet.tsx new file mode 100644 index 00000000..9614abd1 --- /dev/null +++ b/src/components/Chat/components/EmoteDetailSheet/EmoteDetailSheet.tsx @@ -0,0 +1,319 @@ +import { Button } from '@app/components/Button/Button'; +import { Icon } from '@app/components/Icon/Icon'; +import { Image } from '@app/components/Image/Image'; +import { Text } from '@app/components/Text/Text'; +import { openLinkInBrowser } from '@app/utils/browser/openLinkInBrowser'; +import { ParsedPart } from '@app/utils/chat/replaceTextWithEmotes'; +import { BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet'; +import * as Clipboard from 'expo-clipboard'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + View, + Dimensions, + ViewStyle, + TextStyle, + ImageStyle, + StyleProp, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { toast } from 'sonner-native'; + +interface Props { + selectedEmote: ParsedPart<'emote'> | null; + isVisible: boolean; + onClose: () => void; +} + +const { width: screenWidth } = Dimensions.get('window'); +const MAX_EMOTE_SIZE = Math.min(screenWidth * 0.25, 100); +const MIN_EMOTE_SIZE = 32; + +export function EmoteDetailSheet({ selectedEmote, isVisible, onClose }: Props) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const bottomSheetRef = useRef(null); + + useEffect(() => { + if (isVisible && selectedEmote) { + bottomSheetRef.current?.present(); + } else { + bottomSheetRef.current?.dismiss(); + } + }, [isVisible, selectedEmote]); + + const getEmoteSize = useCallback(() => { + if (!selectedEmote) return { width: 0, height: 0 }; + + const originalWidth = selectedEmote.width || 28; + const originalHeight = selectedEmote.height || 28; + const aspectRatio = originalWidth / originalHeight; + + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > MAX_EMOTE_SIZE || targetHeight > MAX_EMOTE_SIZE) { + if (aspectRatio > 1) { + targetWidth = MAX_EMOTE_SIZE; + targetHeight = MAX_EMOTE_SIZE / aspectRatio; + } else { + targetHeight = MAX_EMOTE_SIZE; + targetWidth = MAX_EMOTE_SIZE * aspectRatio; + } + } + + if (targetWidth < MIN_EMOTE_SIZE && targetHeight < MIN_EMOTE_SIZE) { + if (aspectRatio > 1) { + targetWidth = MIN_EMOTE_SIZE; + targetHeight = MIN_EMOTE_SIZE / aspectRatio; + } else { + targetHeight = MIN_EMOTE_SIZE; + targetWidth = MIN_EMOTE_SIZE * aspectRatio; + } + } + + return { + width: Math.round(targetWidth), + height: Math.round(targetHeight), + }; + }, [selectedEmote]); + + const emoteSize = getEmoteSize(); + + const handleCopy = useCallback( + (field: 'name' | 'url') => { + if (!selectedEmote) return; + void Clipboard.setStringAsync( + field === 'name' + ? selectedEmote.content + : (selectedEmote.url as string), + ).then(() => + toast.success( + `${field === 'name' ? 'Emote name' : 'Emote URL'} copied`, + ), + ); + onClose(); + }, + [selectedEmote, onClose], + ); + + const actions = useMemo( + () => [ + { + icon: 'copy' as const, + label: 'Copy emote name', + onPress: () => handleCopy('name'), + }, + { + icon: 'copy' as const, + label: 'Copy emote URL', + onPress: () => handleCopy('url'), + }, + { + icon: 'external-link' as const, + label: 'Open in Browser', + onPress: () => { + if (selectedEmote?.emote_link) { + openLinkInBrowser(selectedEmote.emote_link); + } + onClose(); + }, + }, + ], + [handleCopy, selectedEmote?.emote_link, onClose], + ); + + const snapPoints = useMemo(() => ['35%'], []); + + if (!selectedEmote) { + return null; + } + + return ( + } + handleIndicatorStyle={styles.handle as StyleProp} + enablePanDownToClose + snapPoints={snapPoints} + onDismiss={onClose} + > + , + { paddingBottom: insets.bottom + theme.spacing.xl }, + ]} + > + }> + }> + , emoteSize]} + /> + + + }> + } + numberOfLines={2} + > + {selectedEmote?.name} + + + }> + } + numberOfLines={1} + > + {selectedEmote.site} + + {selectedEmote.creator && ( + } + numberOfLines={1} + > + By {selectedEmote.creator} + + )} + {selectedEmote.original_name && + selectedEmote.original_name !== selectedEmote.name && ( + } + numberOfLines={1} + > + Original: {selectedEmote.original_name} + + )} + + + + + }> + {actions.map((action, index) => ( + + {index > 0 && ( + } /> + )} + + + ))} + + + + ); +} + +const styles = StyleSheet.create(theme => ({ + bottomSheet: { + backgroundColor: theme.colors.gray.bgAlt, + borderTopLeftRadius: theme.radii.xxl, + borderTopRightRadius: theme.radii.xxl, + borderCurve: 'continuous', + }, + handle: { + backgroundColor: theme.colors.gray.accent, + width: 36, + height: 5, + borderRadius: theme.radii.full, + opacity: 0.4, + }, + container: { + flex: 1, + paddingHorizontal: theme.spacing.xl, + paddingTop: theme.spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: theme.spacing['2xl'], + paddingTop: theme.spacing.md, + }, + emoteContainer: { + backgroundColor: theme.colors.gray.bg, + borderRadius: theme.radii.lg, + padding: theme.spacing.xl, + marginRight: theme.spacing.lg, + alignItems: 'center', + justifyContent: 'center', + width: 100, + height: 100, + }, + emoteImage: { + borderRadius: theme.radii.sm, + }, + emoteInfo: { + flex: 1, + flexShrink: 1, + justifyContent: 'center', + minHeight: 100, + minWidth: 0, + }, + emoteName: { + fontSize: theme.font.fontSize.lg, + fontWeight: 'bold' as const, + color: theme.colors.gray.text, + marginBottom: theme.spacing.md, + lineHeight: theme.font.fontSize.lg * 1.2, + flexShrink: 1, + }, + metadataContainer: { + gap: theme.spacing.xs, + flexShrink: 1, + }, + emoteMetadata: { + fontSize: theme.font.fontSize.sm, + color: theme.colors.gray.textLow, + lineHeight: theme.font.fontSize.sm * 1.4, + flexShrink: 1, + }, + actionsContainer: { + backgroundColor: theme.colors.gray.bg, + borderRadius: theme.radii.lg, + overflow: 'hidden', + }, + separator: { + height: 1, + backgroundColor: theme.colors.gray.border, + marginLeft: theme.spacing.xl, + }, + actionButton: { + backgroundColor: 'transparent', + borderRadius: 0, + paddingVertical: theme.spacing.lg, + paddingHorizontal: theme.spacing.xl, + minHeight: 56, + borderWidth: 0, + }, + actionContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + gap: theme.spacing.lg, + flexShrink: 1, + minWidth: 0, + }, + actionText: { + fontSize: theme.font.fontSize.md, + color: theme.colors.gray.text, + fontWeight: 'normal' as const, + flexShrink: 1, + }, +})); diff --git a/src/components/Chat/components/EmoteDetailSheet/index.ts b/src/components/Chat/components/EmoteDetailSheet/index.ts new file mode 100644 index 00000000..cdc2279a --- /dev/null +++ b/src/components/Chat/components/EmoteDetailSheet/index.ts @@ -0,0 +1 @@ +export { EmoteDetailSheet } from './EmoteDetailSheet';