hover #6
@@ -9,6 +9,7 @@ import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
|
import { useHover } from '../../hooks/useHover';
|
||||||
|
|
||||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||||
|
|
||||||
@@ -22,6 +23,51 @@ interface TabBarProps {
|
|||||||
icons?: string[];
|
icons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modern Tab 组件,支持悬停效果
|
||||||
|
interface ModernTabProps {
|
||||||
|
tab: string;
|
||||||
|
index: number;
|
||||||
|
isActive: boolean;
|
||||||
|
icon?: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModernTab: React.FC<ModernTabProps> = ({ tab, index, isActive, icon, onPress }) => {
|
||||||
|
const { isHovered, hoverProps } = useHover();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modernTab,
|
||||||
|
isActive && styles.modernTabActive,
|
||||||
|
isHovered && !isActive && styles.modernTabHovered,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
{...hoverProps}
|
||||||
|
>
|
||||||
|
<View style={styles.modernTabContent}>
|
||||||
|
{icon && (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={icon as any}
|
||||||
|
size={18}
|
||||||
|
color={isActive ? colors.primary.main : isHovered ? colors.primary.main : colors.text.secondary}
|
||||||
|
style={styles.modernTabIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
color={isActive ? colors.primary.main : isHovered ? colors.primary.main : colors.text.secondary}
|
||||||
|
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isActive && <View style={styles.modernTabIndicator} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const TabBar: React.FC<TabBarProps> = ({
|
const TabBar: React.FC<TabBarProps> = ({
|
||||||
tabs,
|
tabs,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
@@ -38,31 +84,14 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
|
|
||||||
if (variant === 'modern') {
|
if (variant === 'modern') {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<ModernTab
|
||||||
key={index}
|
key={index}
|
||||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
tab={tab}
|
||||||
|
index={index}
|
||||||
|
isActive={isActive}
|
||||||
|
icon={icon}
|
||||||
onPress={() => onTabChange(index)}
|
onPress={() => onTabChange(index)}
|
||||||
activeOpacity={0.85}
|
/>
|
||||||
>
|
|
||||||
<View style={styles.modernTabContent}>
|
|
||||||
{icon && (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={icon as any}
|
|
||||||
size={18}
|
|
||||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
||||||
style={styles.modernTabIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
||||||
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{isActive && <View style={styles.modernTabIndicator} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +346,10 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
modernTabActive: {
|
modernTabActive: {
|
||||||
backgroundColor: colors.primary.main + '15', // 10% opacity
|
backgroundColor: colors.primary.main + '20', // 12% opacity
|
||||||
|
},
|
||||||
|
modernTabHovered: {
|
||||||
|
backgroundColor: colors.primary.main + '15', // 8% opacity
|
||||||
},
|
},
|
||||||
modernTabContent: {
|
modernTabContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { User } from '../../types';
|
|||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Button from '../common/Button';
|
import Button from '../common/Button';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useHover } from '../../hooks';
|
||||||
|
|
||||||
interface UserProfileHeaderProps {
|
interface UserProfileHeaderProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -126,10 +126,15 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
label: string;
|
label: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { isHovered, hoverProps } = useHover();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.statContent}>
|
<View style={[
|
||||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
styles.statContent,
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
isHovered && styles.statContentHovered,
|
||||||
|
]}>
|
||||||
|
<Text variant="h3" style={[styles.statNumber, { color: isHovered ? colors.primary.main : undefined }]}>{value}</Text>
|
||||||
|
<Text variant="caption" color={isHovered ? colors.primary.main : colors.text.secondary} style={styles.statLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -138,9 +143,14 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
if (onPress) {
|
if (onPress) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.statItem, styles.statItemTouchable]}
|
style={[
|
||||||
|
styles.statItem,
|
||||||
|
styles.statItemTouchable,
|
||||||
|
isHovered && styles.statItemHovered,
|
||||||
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
|
{...hoverProps}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -473,11 +483,17 @@ const styles = StyleSheet.create({
|
|||||||
statItemTouchable: {
|
statItemTouchable: {
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
},
|
},
|
||||||
|
statItemHovered: {
|
||||||
|
backgroundColor: `${colors.primary.main}08`,
|
||||||
|
},
|
||||||
statContent: {
|
statContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
|
statContentHovered: {
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
},
|
||||||
statNumber: {
|
statNumber: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
|
|||||||
93
src/h
Normal file
93
src/h
Normal file
@@ -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<NodeJS.Timeout | null>(null);
|
||||||
|
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(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;
|
||||||
@@ -92,3 +92,8 @@ export type { UseConnectionStateResult } from './useConnectionState';
|
|||||||
export { useMediaCache } from './useMediaCache';
|
export { useMediaCache } from './useMediaCache';
|
||||||
|
|
||||||
export type { UseMediaCacheReturn } from './useMediaCache';
|
export type { UseMediaCacheReturn } from './useMediaCache';
|
||||||
|
|
||||||
|
// ==================== 悬停效果 Hooks ====================
|
||||||
|
export { useHover } from './useHover';
|
||||||
|
|
||||||
|
export type { UseHoverOptions, UseHoverReturn } from './useHover';
|
||||||
|
|||||||
93
src/hooks/useHover.ts
Normal file
93
src/hooks/useHover.ts
Normal file
@@ -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<NodeJS.Timeout | null>(null);
|
||||||
|
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(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;
|
||||||
@@ -19,6 +19,7 @@ import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'
|
|||||||
import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types';
|
import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types';
|
||||||
import { colors, shadows } from '../theme';
|
import { colors, shadows } from '../theme';
|
||||||
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
|
import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState';
|
||||||
|
import { useHover } from '../hooks/useHover';
|
||||||
|
|
||||||
// 导入屏幕
|
// 导入屏幕
|
||||||
import { HomeScreen } from '../screens/home';
|
import { HomeScreen } from '../screens/home';
|
||||||
@@ -41,6 +42,66 @@ interface DesktopNavigatorProps {
|
|||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单个导航项组件,支持悬停效果
|
||||||
|
interface NavItemProps {
|
||||||
|
item: NavItemConfig;
|
||||||
|
isActive: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
showBadge: boolean;
|
||||||
|
unreadCount: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavItem: React.FC<NavItemProps> = ({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
isCollapsed,
|
||||||
|
showBadge,
|
||||||
|
unreadCount,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const { isHovered, hoverProps } = useHover();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.sidebarItem,
|
||||||
|
isActive && styles.sidebarItemActive,
|
||||||
|
isHovered && !isActive && styles.sidebarItemHovered,
|
||||||
|
isCollapsed && styles.sidebarItemCollapsed,
|
||||||
|
]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
{...hoverProps}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.sidebarIconContainer,
|
||||||
|
isHovered && !isActive && styles.sidebarIconContainerHovered,
|
||||||
|
]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isActive ? (item.icon as any) : (item.iconOutline as any)}
|
||||||
|
size={24}
|
||||||
|
color={isActive ? colors.primary.main : isHovered ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
|
{showBadge && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Text style={[
|
||||||
|
styles.sidebarLabel,
|
||||||
|
isActive && styles.sidebarLabelActive,
|
||||||
|
isHovered && !isActive && styles.sidebarLabelHovered,
|
||||||
|
]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState();
|
const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState();
|
||||||
@@ -103,34 +164,15 @@ export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) {
|
|||||||
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<NavItem
|
||||||
key={item.name}
|
key={item.name}
|
||||||
style={[
|
item={item}
|
||||||
styles.sidebarItem,
|
isActive={isActive}
|
||||||
isActive && styles.sidebarItemActive,
|
isCollapsed={isCollapsed}
|
||||||
isCollapsed && styles.sidebarItemCollapsed,
|
showBadge={showBadge}
|
||||||
]}
|
unreadCount={unreadCount}
|
||||||
onPress={() => handleTabChange(item.name)}
|
onPress={() => handleTabChange(item.name)}
|
||||||
activeOpacity={0.7}
|
/>
|
||||||
>
|
|
||||||
<View style={styles.sidebarIconContainer}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isActive ? item.icon : item.iconOutline}
|
|
||||||
size={24}
|
|
||||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
||||||
/>
|
|
||||||
{showBadge && (
|
|
||||||
<View style={styles.badge}>
|
|
||||||
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -199,6 +241,9 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
sidebarItemActive: {
|
sidebarItemActive: {
|
||||||
|
backgroundColor: `${colors.primary.main}20`,
|
||||||
|
},
|
||||||
|
sidebarItemHovered: {
|
||||||
backgroundColor: `${colors.primary.main}15`,
|
backgroundColor: `${colors.primary.main}15`,
|
||||||
},
|
},
|
||||||
sidebarItemCollapsed: {
|
sidebarItemCollapsed: {
|
||||||
@@ -213,6 +258,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
|
sidebarIconContainerHovered: {
|
||||||
|
backgroundColor: `${colors.primary.main}10`,
|
||||||
|
},
|
||||||
sidebarLabel: {
|
sidebarLabel: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -224,6 +272,9 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.primary.main,
|
color: colors.primary.main,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
sidebarLabelHovered: {
|
||||||
|
color: colors.primary.main,
|
||||||
|
},
|
||||||
collapseButton: {
|
collapseButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
// 响应式布局
|
// 响应式布局
|
||||||
const isWideScreen = useBreakpointGTE('lg');
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
const isTablet = useBreakpointGTE('md');
|
const isTablet = useBreakpointGTE('md');
|
||||||
|
const { width: screenWidth } = useResponsive();
|
||||||
|
|
||||||
// 获取表情尺寸
|
// 获取表情尺寸
|
||||||
const emojiSize = useMemo(() => {
|
const emojiSize = useMemo(() => {
|
||||||
@@ -60,12 +61,14 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
return EMOJI_SIZES.mobile;
|
return EMOJI_SIZES.mobile;
|
||||||
}, [isWideScreen, isTablet]);
|
}, [isWideScreen, isTablet]);
|
||||||
|
|
||||||
// 表情网格列数
|
// 表情网格列数 - 根据实际屏幕宽度动态计算
|
||||||
const emojiColumns = useMemo(() => {
|
const emojiColumns = useMemo(() => {
|
||||||
if (isWideScreen) return 10;
|
// 根据屏幕宽度计算列数,每个表情项约44px宽(包含间距)
|
||||||
if (isTablet) return 9;
|
const itemWidth = emojiSize.itemHeight;
|
||||||
return 8;
|
const calculatedColumns = Math.floor(screenWidth / itemWidth);
|
||||||
}, [isWideScreen, isTablet]);
|
// 限制最小和最大列数
|
||||||
|
return Math.max(8, Math.min(calculatedColumns, 20));
|
||||||
|
}, [screenWidth, emojiSize.itemHeight]);
|
||||||
|
|
||||||
// 合并样式
|
// 合并样式
|
||||||
const styles = useMemo(() => {
|
const styles = useMemo(() => {
|
||||||
@@ -104,36 +107,31 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [activeTab, loadStickers]);
|
}, [activeTab, loadStickers]);
|
||||||
|
|
||||||
const renderEmojiItem: ListRenderItem<string> = useCallback(({ item }) => (
|
// 渲染单个emoji
|
||||||
|
const renderEmojiButton = (emoji: string, index: number) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.emojiItem}
|
key={`${emoji}-${index}`}
|
||||||
onPress={() => onInsertEmoji(item)}
|
style={[styles.emojiItem, { width: 40, margin: 4 }]}
|
||||||
|
onPress={() => onInsertEmoji(emoji)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.emojiText}>{item}</Text>
|
<Text style={styles.emojiText}>{emoji}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
), [styles.emojiItem, styles.emojiText, onInsertEmoji]);
|
);
|
||||||
|
|
||||||
// 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿)
|
// 渲染 Emoji 面板 - 使用ScrollView包裹实现滚动
|
||||||
const renderEmojiPanel = () => (
|
const renderEmojiPanel = () => (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={EMOJIS}
|
data={EMOJIS}
|
||||||
key={emojiColumns}
|
|
||||||
numColumns={emojiColumns}
|
|
||||||
keyExtractor={(item, index) => `${item}-${index}`}
|
keyExtractor={(item, index) => `${item}-${index}`}
|
||||||
renderItem={renderEmojiItem}
|
renderItem={({ item, index }) => renderEmojiButton(item, index)}
|
||||||
contentContainerStyle={styles.emojiGrid}
|
contentContainerStyle={[styles.emojiGrid, { flexDirection: 'row', flexWrap: 'wrap', padding: spacing.sm }]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={true}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
initialNumToRender={emojiColumns * 4}
|
initialNumToRender={100}
|
||||||
maxToRenderPerBatch={emojiColumns * 4}
|
maxToRenderPerBatch={50}
|
||||||
windowSize={7}
|
windowSize={10}
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
getItemLayout={(_, index) => ({
|
|
||||||
length: emojiSize.itemHeight,
|
|
||||||
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.messageContent}>
|
<View style={styles.messageContent}>
|
||||||
{/* 群聊模式:显示发送者昵称 */}
|
{/* 群聊模式:显示发送者昵称 - 放在气泡上方 */}
|
||||||
{isGroupChat && !isMe && senderInfo && (
|
{isGroupChat && !isMe && senderInfo && (
|
||||||
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
|||||||
import { messageService } from '../../../services';
|
import { messageService } from '../../../services';
|
||||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
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 { width: screenWidth } = Dimensions.get('window');
|
||||||
const PANEL_WIDTH = 360;
|
const PANEL_WIDTH = 360;
|
||||||
@@ -69,6 +72,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
// 回复状态
|
// 回复状态
|
||||||
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
||||||
|
|
||||||
|
// 表情面板状态
|
||||||
|
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||||
|
|
||||||
|
// 更多功能面板状态
|
||||||
|
const [showMorePanel, setShowMorePanel] = useState(false);
|
||||||
|
|
||||||
// 群信息面板动画
|
// 群信息面板动画
|
||||||
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
@@ -265,7 +274,132 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
console.error('[EmbeddedChat] 删除消息失败:', error);
|
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) => {
|
const renderMessageContent = (item: MessageResponse) => {
|
||||||
// 撤回消息
|
// 撤回消息
|
||||||
@@ -356,8 +490,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ 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 isMe = String(item.sender_id) === String(currentUser?.id);
|
||||||
|
const showTime = shouldShowTime(index);
|
||||||
|
|
||||||
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||||
const handlePointerDown = (e: any) => {
|
const handlePointerDown = (e: any) => {
|
||||||
@@ -372,31 +507,46 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View>
|
||||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
{/* 时间分隔 */}
|
||||||
// @ts-ignore - React Native for Web 支持 pointer events
|
{showTime && (
|
||||||
onPointerDown={handlePointerDown}
|
<View style={styles.timeContainer}>
|
||||||
>
|
<Text style={styles.timeText}>{formatTime(item.created_at)}</Text>
|
||||||
{!isMe && (
|
</View>
|
||||||
<Avatar
|
|
||||||
source={item.sender?.avatar}
|
|
||||||
size={36}
|
|
||||||
name={item.sender?.nickname || item.sender?.username}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
<View
|
||||||
{isGroupChat && !isMe && (
|
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
// @ts-ignore - React Native for Web 支持 pointer events
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
>
|
||||||
|
{!isMe && (
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Avatar
|
||||||
|
source={item.sender?.avatar}
|
||||||
|
size={36}
|
||||||
|
name={item.sender?.nickname || item.sender?.username}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.messageContent}>
|
||||||
|
{/* 群聊模式:显示发送者昵称 - 顶部与头像对齐 */}
|
||||||
|
{isGroupChat && !isMe && (
|
||||||
|
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||||
|
)}
|
||||||
|
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||||
|
{renderMessageContent(item)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{isMe && (
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Avatar
|
||||||
|
source={currentUser?.avatar}
|
||||||
|
size={36}
|
||||||
|
name={currentUser?.nickname || currentUser?.username}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
{renderMessageContent(item)}
|
|
||||||
</View>
|
</View>
|
||||||
{isMe && (
|
|
||||||
<Avatar
|
|
||||||
source={currentUser?.avatar}
|
|
||||||
size={36}
|
|
||||||
name={currentUser?.nickname || currentUser?.username}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -467,10 +617,56 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||||
>
|
>
|
||||||
|
{/* 表情面板 */}
|
||||||
|
{showEmojiPanel && (
|
||||||
|
<View style={styles.emojiPanelContainer}>
|
||||||
|
<EmojiPanel
|
||||||
|
onInsertEmoji={handleInsertEmoji}
|
||||||
|
onInsertSticker={handleInsertSticker}
|
||||||
|
onClose={() => setShowEmojiPanel(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 更多功能面板 */}
|
||||||
|
{showMorePanel && (
|
||||||
|
<View style={styles.morePanelContainer}>
|
||||||
|
<View style={styles.morePanelHeader}>
|
||||||
|
<Text style={styles.morePanelTitle}>更多功能</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowMorePanel(false)}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.moreGrid}>
|
||||||
|
{MORE_ACTIONS.map(action => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={action.id}
|
||||||
|
style={styles.moreItem}
|
||||||
|
onPress={() => handleMoreAction(action.id)}
|
||||||
|
>
|
||||||
|
<View style={[styles.moreIconContainer, { backgroundColor: action.color + '15' }]}>
|
||||||
|
<MaterialCommunityIcons name={action.icon as any} size={28} color={action.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.moreItemText}>{action.name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={styles.inputArea}>
|
<View style={styles.inputArea}>
|
||||||
<View style={styles.inputRow}>
|
<View style={styles.inputRow}>
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity
|
||||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color="#666" />
|
style={styles.iconButton}
|
||||||
|
onPress={() => {
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
setShowMorePanel(!showMorePanel);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={showMorePanel ? "close-circle" : "plus-circle-outline"}
|
||||||
|
size={28}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
@@ -491,8 +687,8 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity style={styles.iconButton} onPress={() => { setShowMorePanel(false); setShowEmojiPanel(!showEmojiPanel); }}>
|
||||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color="#666" />
|
<MaterialCommunityIcons name={showEmojiPanel ? "close-circle" : "emoticon-outline"} size={28} color="#666" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -664,9 +860,24 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
paddingBottom: spacing.xl,
|
paddingBottom: spacing.xl,
|
||||||
},
|
},
|
||||||
|
timeContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: spacing.md,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
color: '#8E8E93',
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: 'rgba(142, 142, 147, 0.12)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
overflow: 'hidden',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
messageRow: {
|
messageRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-start',
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
maxWidth: screenWidth * 0.7,
|
maxWidth: screenWidth * 0.7,
|
||||||
},
|
},
|
||||||
@@ -677,6 +888,9 @@ const styles = StyleSheet.create({
|
|||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
messageBubble: {
|
messageBubble: {
|
||||||
maxWidth: screenWidth * 0.5,
|
maxWidth: screenWidth * 0.5,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
@@ -693,10 +907,15 @@ const styles = StyleSheet.create({
|
|||||||
borderBottomLeftRadius: 4,
|
borderBottomLeftRadius: 4,
|
||||||
...shadows.sm,
|
...shadows.sm,
|
||||||
},
|
},
|
||||||
|
messageContent: {
|
||||||
|
maxWidth: screenWidth * 0.5,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#999',
|
color: '#999',
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
messageText: {
|
messageText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -788,4 +1007,48 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
emojiPanelContainer: {
|
||||||
|
backgroundColor: '#F5F7FA',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E8E8E8',
|
||||||
|
height: 350,
|
||||||
|
},
|
||||||
|
morePanelContainer: {
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E8E8E8',
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
morePanelHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
morePanelTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
moreGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
moreItem: {
|
||||||
|
width: '25%',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
moreIconContainer: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
moreItemText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user