From 039c8c86c21888a0dac4088f5d1274c13cc515cb Mon Sep 17 00:00:00 2001 From: luke-h1 Date: Tue, 6 Jan 2026 21:19:20 +0000 Subject: [PATCH 1/2] feat(chat): emote popover --- src/components/Chat/Chat.tsx | 45 ++- .../components/ChatMessage/ChatMessage.tsx | 17 +- .../components/EmotePopover/EmotePopover.tsx | 363 ++++++++++++++++++ .../Chat/components/EmotePopover/index.ts | 1 + 4 files changed, 409 insertions(+), 17 deletions(-) create mode 100644 src/components/Chat/components/EmotePopover/EmotePopover.tsx create mode 100644 src/components/Chat/components/EmotePopover/index.ts diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 24395046..4a2f4e95 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 { EmotePopover } from './components/EmotePopover'; 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,13 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const [selectedEmote, setSelectedEmote] = useState( null, ); + const [emoteAnchorPosition, setEmoteAnchorPosition] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const [isEmotePopoverVisible, setIsEmotePopoverVisible] = useState(false); const [selectedBadge, setSelectedBadge] = useState( null, ); @@ -660,9 +666,22 @@ 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); + setEmoteAnchorPosition(position); + setIsEmotePopoverVisible(true); + }, + [], + ); + + const handleEmotePopoverClose = useCallback(() => { + setIsEmotePopoverVisible(false); + setSelectedEmote(null); + setEmoteAnchorPosition(null); }, []); const handleBadgeLongPress = useCallback((badge: BadgePressData) => { @@ -787,7 +806,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 +818,7 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { ), [ handleReply, - handleEmoteLongPress, + handleEmotePress, handleBadgeLongPress, handleMessageLongPress, getMentionColor, @@ -910,12 +929,12 @@ 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], @@ -394,7 +400,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/EmotePopover/EmotePopover.tsx b/src/components/Chat/components/EmotePopover/EmotePopover.tsx new file mode 100644 index 00000000..c1e68d04 --- /dev/null +++ b/src/components/Chat/components/EmotePopover/EmotePopover.tsx @@ -0,0 +1,363 @@ +import { Button } from '@app/components/Button/Button'; +import { Icon } from '@app/components/Icon/Icon'; +import { Image } from '@app/components/Image/Image'; +import { PressableArea } from '@app/components/PressableArea/PressableArea'; +import { Text } from '@app/components/Text/Text'; +import { openLinkInBrowser } from '@app/utils/browser/openLinkInBrowser'; +import { ParsedPart } from '@app/utils/chat/replaceTextWithEmotes'; +import * as Clipboard from 'expo-clipboard'; +import { useCallback, useMemo, useState } from 'react'; +import { Modal, View, Dimensions, LayoutChangeEvent } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { toast } from 'sonner-native'; + +interface Props { + selectedEmote: ParsedPart<'emote'> | null; + anchorPosition: { + x: number; + y: number; + width: number; + height: number; + } | null; + isVisible: boolean; + onClose: () => void; +} + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); +const MAX_EMOTE_SIZE = Math.min(screenWidth * 0.3, 100); +const MIN_EMOTE_SIZE = 32; +const POPOVER_PADDING = 12; +const ARROW_SIZE = 8; + +export function EmotePopover({ + selectedEmote, + anchorPosition, + isVisible, + onClose, +}: Props) { + const [popoverLayout, setPopoverLayout] = useState<{ + width: number; + height: number; + } | null>(null); + + 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 onPopoverLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setPopoverLayout({ width, height }); + }, []); + + const popoverPosition = useMemo(() => { + if (!anchorPosition || !popoverLayout) { + return { top: 0, left: 0, arrowPosition: 'bottom' as const }; + } + + const { x, y, width: anchorWidth, height: anchorHeight } = anchorPosition; + const { width: popoverWidth, height: popoverHeight } = popoverLayout; + + // Calculate center of anchor + const anchorCenterX = x + anchorWidth / 2; + + // Try to position popover above the emote (arrow pointing down) + let top = y - popoverHeight - ARROW_SIZE; + let left = anchorCenterX - popoverWidth / 2; + let arrowPosition: 'top' | 'bottom' = 'bottom'; + + // If popover would go off screen, position it below (arrow pointing up) + if (top < 0) { + top = y + anchorHeight + ARROW_SIZE; + arrowPosition = 'top'; + } + + // Adjust horizontal position to keep within screen bounds + if (left < POPOVER_PADDING) { + left = POPOVER_PADDING; + } else if (left + popoverWidth > screenWidth - POPOVER_PADDING) { + left = screenWidth - popoverWidth - POPOVER_PADDING; + } + + // Calculate arrow offset from center + const arrowOffset = anchorCenterX - left; + + return { + top: Math.max( + POPOVER_PADDING, + Math.min(top, screenHeight - popoverHeight - POPOVER_PADDING), + ), + left: Math.max( + POPOVER_PADDING, + Math.min(left, screenWidth - popoverWidth - POPOVER_PADDING), + ), + arrowPosition, + arrowOffset: Math.max(20, Math.min(arrowOffset, popoverWidth - 20)), + }; + }, [anchorPosition, popoverLayout]); + + if (!selectedEmote || !isVisible) { + return null; + } + + return ( + + + true} + > + {/* Arrow */} + + + {/* Content */} + + + + + + + + + {selectedEmote?.name} + + + + {selectedEmote.site} + {selectedEmote.creator && ( + + By {selectedEmote.creator} + + )} + {selectedEmote.original_name && + selectedEmote.original_name !== selectedEmote.name && ( + + Original: {selectedEmote.original_name} + + )} + + + + + {/* Actions */} + + {actions.map((action, index) => ( + + ))} + + + + + + ); +} + +const styles = StyleSheet.create(theme => ({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + popover: { + position: 'absolute', + backgroundColor: theme.colors.gray.bgAlt, + borderRadius: theme.radii.lg, + borderCurve: 'continuous', + boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.3)', + elevation: 12, + minWidth: 200, + maxWidth: screenWidth - POPOVER_PADDING * 2, + }, + arrow: { + position: 'absolute', + width: 0, + height: 0, + borderLeftWidth: ARROW_SIZE, + borderRightWidth: ARROW_SIZE, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + }, + arrowTop: { + top: -ARROW_SIZE, + borderBottomWidth: ARROW_SIZE, + borderBottomColor: theme.colors.gray.bgAlt, + }, + arrowBottom: { + bottom: -ARROW_SIZE, + borderTopWidth: ARROW_SIZE, + borderTopColor: theme.colors.gray.bgAlt, + }, + content: { + padding: theme.spacing.lg, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: theme.spacing.md, + }, + emoteContainer: { + backgroundColor: theme.colors.gray.bg, + borderRadius: theme.radii.md, + padding: theme.spacing.md, + marginRight: theme.spacing.md, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: theme.colors.gray.border, + }, + emoteImage: { + borderRadius: theme.radii.sm, + }, + emoteInfo: { + flex: 1, + justifyContent: 'center', + }, + emoteName: { + fontSize: theme.font.fontSize.md, + fontWeight: 'bold', + color: theme.colors.gray.text, + marginBottom: theme.spacing.xs, + }, + metadataContainer: { + gap: theme.spacing.xs / 2, + }, + emoteMetadata: { + fontSize: theme.font.fontSize.xs, + color: theme.colors.gray.textLow, + lineHeight: theme.font.fontSize.xs * 1.3, + }, + actionsContainer: { + gap: theme.spacing.xs, + }, + actionButton: { + backgroundColor: theme.colors.gray.bg, + borderRadius: theme.radii.md, + paddingVertical: theme.spacing.sm, + paddingHorizontal: theme.spacing.md, + borderWidth: 1, + borderColor: theme.colors.gray.border, + }, + actionContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + gap: theme.spacing.sm, + }, + actionText: { + fontSize: theme.font.fontSize.sm, + color: theme.colors.gray.text, + fontWeight: 'normal', + }, +})); diff --git a/src/components/Chat/components/EmotePopover/index.ts b/src/components/Chat/components/EmotePopover/index.ts new file mode 100644 index 00000000..f0756527 --- /dev/null +++ b/src/components/Chat/components/EmotePopover/index.ts @@ -0,0 +1 @@ +export { EmotePopover } from './EmotePopover'; From a6730bc7b6042daabbec933807ece6b27e358642 Mon Sep 17 00:00:00 2001 From: luke-h1 Date: Wed, 7 Jan 2026 11:36:14 +0000 Subject: [PATCH 2/2] chore: emote popover --- src/components/Chat/Chat.tsx | 15 +- .../components/ChatMessage/ChatMessage.tsx | 10 +- .../EmoteDetailSheet/EmoteDetailSheet.tsx | 319 +++++++++++++++ .../Chat/components/EmoteDetailSheet/index.ts | 1 + .../components/EmotePopover/EmotePopover.tsx | 363 ------------------ .../Chat/components/EmotePopover/index.ts | 1 - 6 files changed, 332 insertions(+), 377 deletions(-) create mode 100644 src/components/Chat/components/EmoteDetailSheet/EmoteDetailSheet.tsx create mode 100644 src/components/Chat/components/EmoteDetailSheet/index.ts delete mode 100644 src/components/Chat/components/EmotePopover/EmotePopover.tsx delete mode 100644 src/components/Chat/components/EmotePopover/index.ts diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 4a2f4e95..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 { EmotePopover } from './components/EmotePopover'; +import { EmoteDetailSheet } from './components/EmoteDetailSheet'; import { EmoteSheet, EmotePickerItem, @@ -113,12 +113,6 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const [selectedEmote, setSelectedEmote] = useState( null, ); - const [emoteAnchorPosition, setEmoteAnchorPosition] = useState<{ - x: number; - y: number; - width: number; - height: number; - } | null>(null); const [isEmotePopoverVisible, setIsEmotePopoverVisible] = useState(false); const [selectedBadge, setSelectedBadge] = useState( null, @@ -669,10 +663,9 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const handleEmotePress = useCallback( ( emote: EmotePressData, - position: { x: number; y: number; width: number; height: number }, + _position?: { x: number; y: number; width: number; height: number }, ) => { setSelectedEmote(emote); - setEmoteAnchorPosition(position); setIsEmotePopoverVisible(true); }, [], @@ -681,7 +674,6 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { const handleEmotePopoverClose = useCallback(() => { setIsEmotePopoverVisible(false); setSelectedEmote(null); - setEmoteAnchorPosition(null); }, []); const handleBadgeLongPress = useCallback((badge: BadgePressData) => { @@ -929,9 +921,8 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { /> {/* Shared bottom sheets - single instances instead of per-message */} - diff --git a/src/components/Chat/components/ChatMessage/ChatMessage.tsx b/src/components/Chat/components/ChatMessage/ChatMessage.tsx index 527d5a6f..55342dcf 100644 --- a/src/components/Chat/components/ChatMessage/ChatMessage.tsx +++ b/src/components/Chat/components/ChatMessage/ChatMessage.tsx @@ -135,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, + }); + }} /> ); } 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'; diff --git a/src/components/Chat/components/EmotePopover/EmotePopover.tsx b/src/components/Chat/components/EmotePopover/EmotePopover.tsx deleted file mode 100644 index c1e68d04..00000000 --- a/src/components/Chat/components/EmotePopover/EmotePopover.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { Button } from '@app/components/Button/Button'; -import { Icon } from '@app/components/Icon/Icon'; -import { Image } from '@app/components/Image/Image'; -import { PressableArea } from '@app/components/PressableArea/PressableArea'; -import { Text } from '@app/components/Text/Text'; -import { openLinkInBrowser } from '@app/utils/browser/openLinkInBrowser'; -import { ParsedPart } from '@app/utils/chat/replaceTextWithEmotes'; -import * as Clipboard from 'expo-clipboard'; -import { useCallback, useMemo, useState } from 'react'; -import { Modal, View, Dimensions, LayoutChangeEvent } from 'react-native'; -import { StyleSheet } from 'react-native-unistyles'; -import { toast } from 'sonner-native'; - -interface Props { - selectedEmote: ParsedPart<'emote'> | null; - anchorPosition: { - x: number; - y: number; - width: number; - height: number; - } | null; - isVisible: boolean; - onClose: () => void; -} - -const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); -const MAX_EMOTE_SIZE = Math.min(screenWidth * 0.3, 100); -const MIN_EMOTE_SIZE = 32; -const POPOVER_PADDING = 12; -const ARROW_SIZE = 8; - -export function EmotePopover({ - selectedEmote, - anchorPosition, - isVisible, - onClose, -}: Props) { - const [popoverLayout, setPopoverLayout] = useState<{ - width: number; - height: number; - } | null>(null); - - 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 onPopoverLayout = useCallback((event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - setPopoverLayout({ width, height }); - }, []); - - const popoverPosition = useMemo(() => { - if (!anchorPosition || !popoverLayout) { - return { top: 0, left: 0, arrowPosition: 'bottom' as const }; - } - - const { x, y, width: anchorWidth, height: anchorHeight } = anchorPosition; - const { width: popoverWidth, height: popoverHeight } = popoverLayout; - - // Calculate center of anchor - const anchorCenterX = x + anchorWidth / 2; - - // Try to position popover above the emote (arrow pointing down) - let top = y - popoverHeight - ARROW_SIZE; - let left = anchorCenterX - popoverWidth / 2; - let arrowPosition: 'top' | 'bottom' = 'bottom'; - - // If popover would go off screen, position it below (arrow pointing up) - if (top < 0) { - top = y + anchorHeight + ARROW_SIZE; - arrowPosition = 'top'; - } - - // Adjust horizontal position to keep within screen bounds - if (left < POPOVER_PADDING) { - left = POPOVER_PADDING; - } else if (left + popoverWidth > screenWidth - POPOVER_PADDING) { - left = screenWidth - popoverWidth - POPOVER_PADDING; - } - - // Calculate arrow offset from center - const arrowOffset = anchorCenterX - left; - - return { - top: Math.max( - POPOVER_PADDING, - Math.min(top, screenHeight - popoverHeight - POPOVER_PADDING), - ), - left: Math.max( - POPOVER_PADDING, - Math.min(left, screenWidth - popoverWidth - POPOVER_PADDING), - ), - arrowPosition, - arrowOffset: Math.max(20, Math.min(arrowOffset, popoverWidth - 20)), - }; - }, [anchorPosition, popoverLayout]); - - if (!selectedEmote || !isVisible) { - return null; - } - - return ( - - - true} - > - {/* Arrow */} - - - {/* Content */} - - - - - - - - - {selectedEmote?.name} - - - - {selectedEmote.site} - {selectedEmote.creator && ( - - By {selectedEmote.creator} - - )} - {selectedEmote.original_name && - selectedEmote.original_name !== selectedEmote.name && ( - - Original: {selectedEmote.original_name} - - )} - - - - - {/* Actions */} - - {actions.map((action, index) => ( - - ))} - - - - - - ); -} - -const styles = StyleSheet.create(theme => ({ - overlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - }, - popover: { - position: 'absolute', - backgroundColor: theme.colors.gray.bgAlt, - borderRadius: theme.radii.lg, - borderCurve: 'continuous', - boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.3)', - elevation: 12, - minWidth: 200, - maxWidth: screenWidth - POPOVER_PADDING * 2, - }, - arrow: { - position: 'absolute', - width: 0, - height: 0, - borderLeftWidth: ARROW_SIZE, - borderRightWidth: ARROW_SIZE, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - }, - arrowTop: { - top: -ARROW_SIZE, - borderBottomWidth: ARROW_SIZE, - borderBottomColor: theme.colors.gray.bgAlt, - }, - arrowBottom: { - bottom: -ARROW_SIZE, - borderTopWidth: ARROW_SIZE, - borderTopColor: theme.colors.gray.bgAlt, - }, - content: { - padding: theme.spacing.lg, - }, - header: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: theme.spacing.md, - }, - emoteContainer: { - backgroundColor: theme.colors.gray.bg, - borderRadius: theme.radii.md, - padding: theme.spacing.md, - marginRight: theme.spacing.md, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: theme.colors.gray.border, - }, - emoteImage: { - borderRadius: theme.radii.sm, - }, - emoteInfo: { - flex: 1, - justifyContent: 'center', - }, - emoteName: { - fontSize: theme.font.fontSize.md, - fontWeight: 'bold', - color: theme.colors.gray.text, - marginBottom: theme.spacing.xs, - }, - metadataContainer: { - gap: theme.spacing.xs / 2, - }, - emoteMetadata: { - fontSize: theme.font.fontSize.xs, - color: theme.colors.gray.textLow, - lineHeight: theme.font.fontSize.xs * 1.3, - }, - actionsContainer: { - gap: theme.spacing.xs, - }, - actionButton: { - backgroundColor: theme.colors.gray.bg, - borderRadius: theme.radii.md, - paddingVertical: theme.spacing.sm, - paddingHorizontal: theme.spacing.md, - borderWidth: 1, - borderColor: theme.colors.gray.border, - }, - actionContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', - gap: theme.spacing.sm, - }, - actionText: { - fontSize: theme.font.fontSize.sm, - color: theme.colors.gray.text, - fontWeight: 'normal', - }, -})); diff --git a/src/components/Chat/components/EmotePopover/index.ts b/src/components/Chat/components/EmotePopover/index.ts deleted file mode 100644 index f0756527..00000000 --- a/src/components/Chat/components/EmotePopover/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EmotePopover } from './EmotePopover';