diff --git a/src/components/business/TabBar.tsx b/src/components/business/TabBar.tsx index bcca877..d558ab0 100644 --- a/src/components/business/TabBar.tsx +++ b/src/components/business/TabBar.tsx @@ -9,6 +9,7 @@ import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react- import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import Text from '../common/Text'; +import { useHover } from '../../hooks/useHover'; type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern'; @@ -22,6 +23,51 @@ interface TabBarProps { icons?: string[]; } +// Modern Tab 组件,支持悬停效果 +interface ModernTabProps { + tab: string; + index: number; + isActive: boolean; + icon?: string; + onPress: () => void; +} + +const ModernTab: React.FC = ({ tab, index, isActive, icon, onPress }) => { + const { isHovered, hoverProps } = useHover(); + + return ( + + + {icon && ( + + )} + + {tab} + + + {isActive && } + + ); +}; + const TabBar: React.FC = ({ tabs, activeIndex, @@ -38,31 +84,14 @@ const TabBar: React.FC = ({ if (variant === 'modern') { return ( - onTabChange(index)} - activeOpacity={0.85} - > - - {icon && ( - - )} - - {tab} - - - {isActive && } - + /> ); } @@ -317,7 +346,10 @@ const styles = StyleSheet.create({ position: 'relative', }, modernTabActive: { - backgroundColor: colors.primary.main + '15', // 10% opacity + backgroundColor: colors.primary.main + '20', // 12% opacity + }, + modernTabHovered: { + backgroundColor: colors.primary.main + '15', // 8% opacity }, modernTabContent: { flexDirection: 'row', diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index d680ae0..7f54b5f 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -20,7 +20,7 @@ import { User } from '../../types'; import Text from '../common/Text'; import Button from '../common/Button'; import Avatar from '../common/Avatar'; -import { useResponsive } from '../../hooks'; +import { useResponsive, useHover } from '../../hooks'; interface UserProfileHeaderProps { user: User; @@ -126,10 +126,15 @@ const UserProfileHeader: React.FC = ({ label: string; onPress?: () => void; }) => { + const { isHovered, hoverProps } = useHover(); + const content = ( - - {value} - + + {value} + {label} @@ -138,9 +143,14 @@ const UserProfileHeader: React.FC = ({ if (onPress) { return ( {content} @@ -473,11 +483,17 @@ const styles = StyleSheet.create({ statItemTouchable: { borderRadius: borderRadius.md, }, + statItemHovered: { + backgroundColor: `${colors.primary.main}08`, + }, statContent: { alignItems: 'center', paddingVertical: spacing.sm, paddingHorizontal: spacing.xs, }, + statContentHovered: { + transform: [{ scale: 1.02 }], + }, statNumber: { fontWeight: '600', marginBottom: 0, diff --git a/src/h b/src/h new file mode 100644 index 0000000..4518e2a --- /dev/null +++ b/src/h @@ -0,0 +1,93 @@ +/** + * useHover Hook + * 用于检测鼠标悬停状态,仅在桌面端(Web)生效 + * 在移动端返回默认值 false + */ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { Platform } from 'react-native'; + +interface UseHoverOptions { + enterDelay?: number; + leaveDelay?: number; +} + +interface UseHoverReturn { + isHovered: boolean; + hoverProps: { + onMouseEnter: () => void; + onMouseLeave: () => void; + }; +} + +/** + * 检测当前是否在 Web 平台 + */ +const isWeb = Platform.OS === 'web'; + +/** + * useHover Hook + * @param options 配置选项 + * @returns 悬停状态和事件处理器 + */ +export function useHover(options: UseHoverOptions = {}): UseHoverReturn { + const { enterDelay = 0, leaveDelay = 0 } = options; + const [isHovered, setIsHovered] = useState(false); + const enterTimeoutRef = useRef(null); + const leaveTimeoutRef = useRef(null); + + // 清理定时器 + useEffect(() => { + return () => { + if (enterTimeoutRef.current) { + clearTimeout(enterTimeoutRef.current); + } + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = useCallback(() => { + if (!isWeb) return; + + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + leaveTimeoutRef.current = null; + } + + if (enterDelay > 0) { + enterTimeoutRef.current = setTimeout(() => { + setIsHovered(true); + }, enterDelay); + } else { + setIsHovered(true); + } + }, [enterDelay]); + + const handleMouseLeave = useCallback(() => { + if (!isWeb) return; + + if (enterTimeoutRef.current) { + clearTimeout(enterTimeoutRef.current); + enterTimeoutRef.current = null; + } + + if (leaveDelay > 0) { + leaveTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, leaveDelay); + } else { + setIsHovered(false); + } + }, [leaveDelay]); + + return { + isHovered, + hoverProps: { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }, + }; +} + +export default useHover; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88416f5..187c978 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -92,3 +92,8 @@ export type { UseConnectionStateResult } from './useConnectionState'; export { useMediaCache } from './useMediaCache'; export type { UseMediaCacheReturn } from './useMediaCache'; + +// ==================== 悬停效果 Hooks ==================== +export { useHover } from './useHover'; + +export type { UseHoverOptions, UseHoverReturn } from './useHover'; diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts new file mode 100644 index 0000000..add5472 --- /dev/null +++ b/src/hooks/useHover.ts @@ -0,0 +1,93 @@ +/** + * useHover Hook + * 用于检测鼠标悬停状态,仅在桌面端(Web)生效 + * 在移动端返回默认值 false + */ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { Platform } from 'react-native'; + +export interface UseHoverOptions { + enterDelay?: number; + leaveDelay?: number; +} + +export interface UseHoverReturn { + isHovered: boolean; + hoverProps: { + onMouseEnter: () => void; + onMouseLeave: () => void; + }; +} + +/** + * 检测当前是否在 Web 平台 + */ +const isWeb = Platform.OS === 'web'; + +/** + * useHover Hook + * @param options 配置选项 + * @returns 悬停状态和事件处理器 + */ +export function useHover(options: UseHoverOptions = {}): UseHoverReturn { + const { enterDelay = 0, leaveDelay = 0 } = options; + const [isHovered, setIsHovered] = useState(false); + const enterTimeoutRef = useRef(null); + const leaveTimeoutRef = useRef(null); + + // 清理定时器 + useEffect(() => { + return () => { + if (enterTimeoutRef.current) { + clearTimeout(enterTimeoutRef.current); + } + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = useCallback(() => { + if (!isWeb) return; + + if (leaveTimeoutRef.current) { + clearTimeout(leaveTimeoutRef.current); + leaveTimeoutRef.current = null; + } + + if (enterDelay > 0) { + enterTimeoutRef.current = setTimeout(() => { + setIsHovered(true); + }, enterDelay); + } else { + setIsHovered(true); + } + }, [enterDelay]); + + const handleMouseLeave = useCallback(() => { + if (!isWeb) return; + + if (enterTimeoutRef.current) { + clearTimeout(enterTimeoutRef.current); + enterTimeoutRef.current = null; + } + + if (leaveDelay > 0) { + leaveTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, leaveDelay); + } else { + setIsHovered(false); + } + }, [leaveDelay]); + + return { + isHovered, + hoverProps: { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }, + }; +} + +export default useHover; diff --git a/src/navigation/DesktopNavigator.tsx b/src/navigation/DesktopNavigator.tsx index fd77770..9fa4bd4 100644 --- a/src/navigation/DesktopNavigator.tsx +++ b/src/navigation/DesktopNavigator.tsx @@ -19,6 +19,7 @@ import type { TabName, NavItemConfig } from '../infrastructure/navigation/types' import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types'; import { colors, shadows } from '../theme'; import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState'; +import { useHover } from '../hooks/useHover'; // 导入屏幕 import { HomeScreen } from '../screens/home'; @@ -41,6 +42,66 @@ interface DesktopNavigatorProps { unreadCount?: number; } +// 单个导航项组件,支持悬停效果 +interface NavItemProps { + item: NavItemConfig; + isActive: boolean; + isCollapsed: boolean; + showBadge: boolean; + unreadCount: number; + onPress: () => void; +} + +const NavItem: React.FC = ({ + item, + isActive, + isCollapsed, + showBadge, + unreadCount, + onPress, +}) => { + const { isHovered, hoverProps } = useHover(); + + return ( + + + + {showBadge && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + {!isCollapsed && ( + + {item.label} + + )} + + ); +}; + export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { const insets = useSafeAreaInsets(); const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState(); @@ -103,34 +164,15 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { const showBadge = item.name === 'MessageTab' && unreadCount > 0; return ( - handleTabChange(item.name)} - activeOpacity={0.7} - > - - - {showBadge && ( - - {unreadCount > 99 ? '99+' : unreadCount} - - )} - - {!isCollapsed && ( - - {item.label} - - )} - + /> ); })} @@ -199,6 +241,9 @@ const styles = StyleSheet.create({ borderRadius: 12, }, sidebarItemActive: { + backgroundColor: `${colors.primary.main}20`, + }, + sidebarItemHovered: { backgroundColor: `${colors.primary.main}15`, }, sidebarItemCollapsed: { @@ -213,6 +258,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', borderRadius: 12, }, + sidebarIconContainerHovered: { + backgroundColor: `${colors.primary.main}10`, + }, sidebarLabel: { fontSize: 15, fontWeight: '500', @@ -224,6 +272,9 @@ const styles = StyleSheet.create({ color: colors.primary.main, fontWeight: '600', }, + sidebarLabelHovered: { + color: colors.primary.main, + }, collapseButton: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/message/components/ChatScreen/EmojiPanel.tsx b/src/screens/message/components/ChatScreen/EmojiPanel.tsx index dbad4ad..cf8ccac 100644 --- a/src/screens/message/components/ChatScreen/EmojiPanel.tsx +++ b/src/screens/message/components/ChatScreen/EmojiPanel.tsx @@ -52,6 +52,7 @@ export const EmojiPanel: React.FC = ({ // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); const isTablet = useBreakpointGTE('md'); + const { width: screenWidth } = useResponsive(); // 获取表情尺寸 const emojiSize = useMemo(() => { @@ -60,12 +61,14 @@ export const EmojiPanel: React.FC = ({ return EMOJI_SIZES.mobile; }, [isWideScreen, isTablet]); - // 表情网格列数 + // 表情网格列数 - 根据实际屏幕宽度动态计算 const emojiColumns = useMemo(() => { - if (isWideScreen) return 10; - if (isTablet) return 9; - return 8; - }, [isWideScreen, isTablet]); + // 根据屏幕宽度计算列数,每个表情项约44px宽(包含间距) + const itemWidth = emojiSize.itemHeight; + const calculatedColumns = Math.floor(screenWidth / itemWidth); + // 限制最小和最大列数 + return Math.max(8, Math.min(calculatedColumns, 20)); + }, [screenWidth, emojiSize.itemHeight]); // 合并样式 const styles = useMemo(() => { @@ -104,36 +107,31 @@ export const EmojiPanel: React.FC = ({ } }, [activeTab, loadStickers]); - const renderEmojiItem: ListRenderItem = useCallback(({ item }) => ( + // 渲染单个emoji + const renderEmojiButton = (emoji: string, index: number) => ( onInsertEmoji(item)} + key={`${emoji}-${index}`} + style={[styles.emojiItem, { width: 40, margin: 4 }]} + onPress={() => onInsertEmoji(emoji)} activeOpacity={0.7} > - {item} + {emoji} - ), [styles.emojiItem, styles.emojiText, onInsertEmoji]); + ); - // 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿) + // 渲染 Emoji 面板 - 使用ScrollView包裹实现滚动 const renderEmojiPanel = () => ( `${item}-${index}`} - renderItem={renderEmojiItem} - contentContainerStyle={styles.emojiGrid} - showsVerticalScrollIndicator={false} + renderItem={({ item, index }) => renderEmojiButton(item, index)} + contentContainerStyle={[styles.emojiGrid, { flexDirection: 'row', flexWrap: 'wrap', padding: spacing.sm }]} + showsVerticalScrollIndicator={true} keyboardShouldPersistTaps="handled" - initialNumToRender={emojiColumns * 4} - maxToRenderPerBatch={emojiColumns * 4} - windowSize={7} + initialNumToRender={100} + maxToRenderPerBatch={50} + windowSize={10} removeClippedSubviews={true} - getItemLayout={(_, index) => ({ - length: emojiSize.itemHeight, - offset: emojiSize.itemHeight * Math.floor(index / emojiColumns), - index, - })} /> ); diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 8f39a5b..fb96c44 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -408,7 +408,7 @@ export const MessageBubble: React.FC = ({ )} - {/* 群聊模式:显示发送者昵称 */} + {/* 群聊模式:显示发送者昵称 - 放在气泡上方 */} {isGroupChat && !isMe && senderInfo && ( {senderInfo.nickname} )} diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 8f7eecf..1879dd5 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -29,6 +29,9 @@ import { useMarkAsRead } from '../../../stores/messageManagerHooks'; import { messageService } from '../../../services'; import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; +import { EmojiPanel } from './ChatScreen/EmojiPanel'; +import { MORE_ACTIONS } from './ChatScreen/constants'; +import * as ImagePicker from 'expo-image-picker'; const { width: screenWidth } = Dimensions.get('window'); const PANEL_WIDTH = 360; @@ -69,6 +72,12 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 回复状态 const [replyingToMessage, setReplyingToMessage] = useState(null); + // 表情面板状态 + const [showEmojiPanel, setShowEmojiPanel] = useState(false); + + // 更多功能面板状态 + const [showMorePanel, setShowMorePanel] = useState(false); + // 群信息面板动画 const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current; const fadeAnim = useRef(new Animated.Value(0)).current; @@ -265,7 +274,132 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack console.error('[EmbeddedChat] 删除消息失败:', error); } }; + + // 处理插入表情 + const handleInsertEmoji = (emoji: string) => { + setInputText(prev => prev + emoji); + }; + + // 处理插入自定义表情 + const handleInsertSticker = async (stickerUrl: string) => { + try { + setSending(true); + const segments: MessageSegment[] = [{ + type: 'image', + data: { + url: stickerUrl, + thumbnail_url: stickerUrl, + width: 200, + height: 200 + } + }]; + await messageManager.sendMessage(String(conversation.id), segments); + setShowEmojiPanel(false); + } catch (error) { + console.error('[EmbeddedChat] 发送表情失败:', error); + } finally { + setSending(false); + } + }; + + // 处理选择图片 + const handlePickImage = async () => { + try { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.8, + allowsMultipleSelection: true, + selectionLimit: 9, + }); + + if (!result.canceled && result.assets.length > 0) { + setSending(true); + + for (const asset of result.assets) { + const segments: MessageSegment[] = [{ + type: 'image', + data: { + url: asset.uri, + thumbnail_url: asset.uri, + width: asset.width || 800, + height: asset.height || 600 + } + }]; + await messageManager.sendMessage(String(conversation.id), segments); + } + + setShowMorePanel(false); + } + } catch (error) { + console.error('[EmbeddedChat] 选择图片失败:', error); + } finally { + setSending(false); + } + }; + + // 处理更多功能项点击 + const handleMoreAction = (actionId: string) => { + switch (actionId) { + case 'image': + handlePickImage(); + break; + case 'camera': + // TODO: 实现相机拍摄 + console.log('Camera'); + break; + case 'file': + // TODO: 实现文件选择 + console.log('File'); + break; + case 'location': + // TODO: 实现位置分享 + console.log('Location'); + break; + } + }; + // 格式化时间 + const formatTime = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } else { + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } + }; + + // 判断是否需要显示时间(与上一条消息间隔超过5分钟) + const shouldShowTime = (index: number) => { + if (index === 0) return true; + const currentMsg = messages[index]; + const prevMsg = messages[index - 1]; + if (!currentMsg || !prevMsg) return true; + + const currentTime = new Date(currentMsg.created_at).getTime(); + const prevTime = new Date(prevMsg.created_at).getTime(); + const diffMinutes = (currentTime - prevTime) / (1000 * 60); + + return diffMinutes >= 5; + }; + // 渲染消息内容 const renderMessageContent = (item: MessageResponse) => { // 撤回消息 @@ -356,8 +490,9 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack }; // 渲染消息 - const renderMessage = ({ item }: { item: MessageResponse }) => { + const renderMessage = ({ item, index }: { item: MessageResponse; index: number }) => { const isMe = String(item.sender_id) === String(currentUser?.id); + const showTime = shouldShowTime(index); // 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键 const handlePointerDown = (e: any) => { @@ -372,31 +507,46 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack }; return ( - - {!isMe && ( - + + {/* 时间分隔 */} + {showTime && ( + + {formatTime(item.created_at)} + )} - - {isGroupChat && !isMe && ( - {item.sender?.nickname || item.sender?.username} + + {!isMe && ( + + + + )} + + {/* 群聊模式:显示发送者昵称 - 顶部与头像对齐 */} + {isGroupChat && !isMe && ( + {item.sender?.nickname || item.sender?.username} + )} + + {renderMessageContent(item)} + + + {isMe && ( + + + )} - {renderMessageContent(item)} - {isMe && ( - - )} ); }; @@ -467,10 +617,56 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0} > + {/* 表情面板 */} + {showEmojiPanel && ( + + setShowEmojiPanel(false)} + /> + + )} + + {/* 更多功能面板 */} + {showMorePanel && ( + + + 更多功能 + setShowMorePanel(false)}> + + + + + {MORE_ACTIONS.map(action => ( + handleMoreAction(action.id)} + > + + + + {action.name} + + ))} + + + )} - - + { + setShowEmojiPanel(false); + setShowMorePanel(!showMorePanel); + }} + > + @@ -491,8 +687,8 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack /> - - + { setShowMorePanel(false); setShowEmojiPanel(!showEmojiPanel); }}> +