diff --git a/app/(tabs)/__tests__/_layout.test.tsx b/app/(tabs)/__tests__/_layout.test.tsx new file mode 100644 index 0000000..4a08f91 --- /dev/null +++ b/app/(tabs)/__tests__/_layout.test.tsx @@ -0,0 +1,283 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import TabLayout from '../_layout' + +// Mock expo-router Tabs +jest.mock('expo-router', () => { + const React = require('react') + const { View, Text } = require('react-native') + + // Mock Tabs component that renders children and captures screen options + const MockTabs = ({ children, screenOptions }: any) => { + return React.createElement(View, { testID: 'tabs-container' }, children) + } + + // Mock Tabs.Screen that renders the icon + MockTabs.Screen = ({ name, options }: any) => { + const React = require('react') + const { View } = require('react-native') + + // Render the icon to test badge display + const icon = options?.tabBarIcon?.({ focused: false }) + + return React.createElement( + View, + { testID: `tab-screen-${name}` }, + icon + ) + } + + return { + Tabs: MockTabs, + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + }), + useLocalSearchParams: () => ({}), + } +}) + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'tabs.home': '首页', + 'tabs.video': '视频', + 'tabs.message': '消息', + 'tabs.my': '我的', + } + return translations[key] || key + }, + }), +})) + +// Mock expo-linear-gradient +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})) + +// Mock react-native-safe-area-context +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) + +// Mock icon components +jest.mock('@/components/icon', () => ({ + HomeIcon: () => null, + MessageIcon: () => null, + MyIcon: () => null, + VideoIcon: () => null, +})) + +// Mock unread count hooks +const mockMessageUnreadCount = jest.fn() +const mockAnnouncementUnreadCount = jest.fn() + +jest.mock('@/hooks/use-message-unread-count', () => ({ + useMessageUnreadCount: () => mockMessageUnreadCount(), +})) + +jest.mock('@/hooks/use-announcement-unread-count', () => ({ + useAnnouncementUnreadCount: () => mockAnnouncementUnreadCount(), +})) + +describe('TabLayout - Message Unread Badge', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default: no unread messages + mockMessageUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + mockAnnouncementUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + }) + + describe('Badge Visibility', () => { + it('should show unread badge when there are unread messages', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 5 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + + // Badge should be visible + expect(getByTestId('message-unread-badge')).toBeTruthy() + }) + + it('should show unread badge when there are unread announcements', () => { + mockAnnouncementUnreadCount.mockReturnValue({ + data: { count: 3 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + + // Badge should be visible + expect(getByTestId('message-unread-badge')).toBeTruthy() + }) + + it('should show unread badge when there are both unread messages and announcements', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 2 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + mockAnnouncementUnreadCount.mockReturnValue({ + data: { count: 1 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + + // Badge should be visible + expect(getByTestId('message-unread-badge')).toBeTruthy() + }) + + it('should NOT show unread badge when there are no unread messages or announcements', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + mockAnnouncementUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { queryByTestId } = render() + + // Badge should NOT be visible + expect(queryByTestId('message-unread-badge')).toBeNull() + }) + + it('should NOT show unread badge when data is undefined', () => { + mockMessageUnreadCount.mockReturnValue({ + data: undefined, + loading: false, + error: null, + refetch: jest.fn(), + }) + mockAnnouncementUnreadCount.mockReturnValue({ + data: undefined, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { queryByTestId } = render() + + // Badge should NOT be visible + expect(queryByTestId('message-unread-badge')).toBeNull() + }) + }) + + describe('Badge Styling', () => { + it('should have correct badge color (#00FF66)', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 5 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + const badge = getByTestId('message-unread-badge') + + // Check badge has correct background color + expect(badge.props.style).toEqual( + expect.objectContaining({ + backgroundColor: '#00FF66', + }) + ) + }) + + it('should have correct badge size (8px width and height)', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 5 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + const badge = getByTestId('message-unread-badge') + + // Check badge has correct size + expect(badge.props.style).toEqual( + expect.objectContaining({ + width: 8, + height: 8, + }) + ) + }) + + it('should have circular shape (borderRadius: 4)', () => { + mockMessageUnreadCount.mockReturnValue({ + data: { count: 5 }, + loading: false, + error: null, + refetch: jest.fn(), + }) + + const { getByTestId } = render() + const badge = getByTestId('message-unread-badge') + + // Check badge is circular (borderRadius = width/2 = 4) + expect(badge.props.style).toEqual( + expect.objectContaining({ + borderRadius: 4, + }) + ) + }) + }) + + describe('Data Fetching', () => { + it('should call refetch on mount for message unread count', () => { + const mockRefetch = jest.fn() + mockMessageUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: mockRefetch, + }) + + render() + + expect(mockRefetch).toHaveBeenCalled() + }) + + it('should call refetch on mount for announcement unread count', () => { + const mockRefetch = jest.fn() + mockAnnouncementUnreadCount.mockReturnValue({ + data: { count: 0 }, + loading: false, + error: null, + refetch: mockRefetch, + }) + + render() + + expect(mockRefetch).toHaveBeenCalled() + }) + }) +}) diff --git a/app/(tabs)/__tests__/message.test.tsx b/app/(tabs)/__tests__/message.test.tsx new file mode 100644 index 0000000..4a6722d --- /dev/null +++ b/app/(tabs)/__tests__/message.test.tsx @@ -0,0 +1,537 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react-native' +import MessageScreen from '../message' + +// Mock expo-status-bar +jest.mock('expo-status-bar', () => ({ + StatusBar: 'StatusBar', +})) + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'message.title': '消息', + 'message.markAllRead': '全部已读', + 'message.noMessages': '暂无消息', + } + return translations[key] || key + }, + }), +})) + +// Mock expo-router +const mockPush = jest.fn() +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: mockPush, + replace: jest.fn(), + back: jest.fn(), + }), + useLocalSearchParams: () => ({}), +})) + +// Mock hooks +const mockExecute = jest.fn() +const mockLoadMore = jest.fn() +const mockRefetch = jest.fn() +const mockMarkRead = jest.fn() +const mockBatchMarkRead = jest.fn() +const mockDeleteMessage = jest.fn() +const mockAnnouncementExecute = jest.fn() + +// Default mock data +const mockMessages = [ + { + id: '1', + type: 'ACTIVITY' as const, + title: '有人点赞了你的作品', + content: '用户A点赞了你的模板', + isRead: false, + createdAt: new Date('2026-01-29T10:00:00'), + data: { subType: 'TEMPLATE_LIKED', templateId: 'template-1' }, + }, + { + id: '2', + type: 'SYSTEM' as const, + title: '生成成功', + content: '你的模板已生成完成', + isRead: true, + createdAt: new Date('2026-01-28T10:00:00'), + data: { subType: 'TEMPLATE_GENERATION_SUCCESS', generationId: 'gen-1' }, + }, + { + id: '3', + type: 'BILLING' as const, + title: '积分扣除', + content: '消耗10积分', + isRead: false, + createdAt: new Date('2026-01-27T10:00:00'), + data: { subType: 'CREDITS_DEDUCTED', credits: 10 }, + }, +] + +const mockAnnouncements = [ + { + id: 'ann-1', + title: '系统公告:新功能上线', + content: '我们上线了新功能', + link: 'https://example.com/announcement', + createdAt: new Date('2026-01-29T08:00:00'), + }, +] + +jest.mock('@/hooks/use-messages', () => ({ + useMessages: jest.fn(() => ({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + })), +})) + +jest.mock('@/hooks/use-message-actions', () => ({ + useMessageActions: jest.fn(() => ({ + markRead: mockMarkRead, + markReadLoading: false, + markReadError: null, + batchMarkRead: mockBatchMarkRead, + batchMarkReadLoading: false, + batchMarkReadError: null, + deleteMessage: mockDeleteMessage, + deleteLoading: false, + deleteError: null, + })), +})) + +jest.mock('@/hooks/use-announcements', () => ({ + useAnnouncements: jest.fn(() => ({ + announcements: mockAnnouncements, + loading: false, + error: null, + execute: mockAnnouncementExecute, + refetch: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + })), +})) + +jest.mock('@/hooks/use-message-unread-count', () => ({ + useMessageUnreadCount: jest.fn(() => ({ + count: 2, + loading: false, + error: null, + execute: jest.fn(), + })), +})) + +// Mock components +jest.mock('@/components/LoadingState', () => 'LoadingState') +jest.mock('@/components/ErrorState', () => 'ErrorState') +jest.mock('@/components/PaginationLoader', () => 'PaginationLoader') + +describe('MessageScreen', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Page Structure', () => { + it('should render page header with title "消息"', () => { + const { getByText } = render() + expect(getByText('消息')).toBeTruthy() + }) + + it('should render "全部已读" button in header', () => { + const { getByText } = render() + expect(getByText('全部已读')).toBeTruthy() + }) + + it('should render MessageTabBar component', () => { + const { getByTestId } = render() + expect(getByTestId('tab-all')).toBeTruthy() + expect(getByTestId('tab-activity')).toBeTruthy() + expect(getByTestId('tab-system')).toBeTruthy() + expect(getByTestId('tab-billing')).toBeTruthy() + }) + }) + + describe('Announcement Banner', () => { + it('should render AnnouncementBanner when announcements exist', () => { + const { getByTestId } = render() + expect(getByTestId('announcement-banner')).toBeTruthy() + }) + + it('should not render AnnouncementBanner when no announcements', () => { + const { useAnnouncements } = require('@/hooks/use-announcements') + useAnnouncements.mockReturnValue({ + announcements: [], + loading: false, + error: null, + execute: mockAnnouncementExecute, + }) + + const { queryByTestId } = render() + expect(queryByTestId('announcement-banner')).toBeNull() + }) + }) + + describe('Message List', () => { + it('should render message list with MessageCard components', () => { + const { getAllByTestId } = render() + const messageCards = getAllByTestId('message-card') + expect(messageCards.length).toBe(3) + }) + + it('should render MessageEmptyState when no messages', () => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: [], + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + }) + + const { getByTestId } = render() + expect(getByTestId('message-empty-state')).toBeTruthy() + }) + + it('should wrap MessageCard with SwipeToDelete', () => { + // Reset mock to default with messages + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + const { getAllByTestId } = render() + const swipeContainers = getAllByTestId('swipe-container') + expect(swipeContainers.length).toBe(3) + }) + }) + + describe('Tab Switching', () => { + beforeEach(() => { + // Reset to default mock + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + const { useAnnouncements } = require('@/hooks/use-announcements') + useAnnouncements.mockReturnValue({ + announcements: mockAnnouncements, + loading: false, + error: null, + execute: mockAnnouncementExecute, + }) + }) + + it('should show all messages when "全部" tab is active', () => { + const { getAllByTestId } = render() + const messageCards = getAllByTestId('message-card') + expect(messageCards.length).toBe(3) + }) + + it('should filter ACTIVITY messages when "互动" tab is clicked', async () => { + const { getByTestId, getAllByTestId } = render() + + fireEvent.press(getByTestId('tab-activity')) + + await waitFor(() => { + const messageCards = getAllByTestId('message-card') + expect(messageCards.length).toBe(1) + }) + }) + + it('should filter SYSTEM messages when "系统" tab is clicked', async () => { + const { getByTestId, getAllByTestId } = render() + + fireEvent.press(getByTestId('tab-system')) + + await waitFor(() => { + const messageCards = getAllByTestId('message-card') + expect(messageCards.length).toBe(1) + }) + }) + + it('should filter BILLING messages when "账单" tab is clicked', async () => { + const { getByTestId, getAllByTestId } = render() + + fireEvent.press(getByTestId('tab-billing')) + + await waitFor(() => { + const messageCards = getAllByTestId('message-card') + expect(messageCards.length).toBe(1) + }) + }) + + it('should show empty state when filtered tab has no messages', async () => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: [mockMessages[0]], // Only ACTIVITY message + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + }) + + const { getByTestId, queryByTestId } = render() + + fireEvent.press(getByTestId('tab-billing')) + + await waitFor(() => { + expect(getByTestId('message-empty-state')).toBeTruthy() + }) + }) + }) + + describe('Mark All Read', () => { + beforeEach(() => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + }) + + it('should call batchMarkRead with unread message ids when "全部已读" is clicked', async () => { + const { getByText } = render() + + fireEvent.press(getByText('全部已读')) + + await waitFor(() => { + expect(mockBatchMarkRead).toHaveBeenCalledWith(['1', '3']) + }) + }) + + it('should only mark unread messages in current tab as read', async () => { + const { getByText, getByTestId } = render() + + // Switch to ACTIVITY tab + fireEvent.press(getByTestId('tab-activity')) + + // Click mark all read + fireEvent.press(getByText('全部已读')) + + await waitFor(() => { + expect(mockBatchMarkRead).toHaveBeenCalledWith(['1']) + }) + }) + + it('should refetch messages after marking all as read', async () => { + const { getByText } = render() + + fireEvent.press(getByText('全部已读')) + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + describe('Swipe to Delete', () => { + beforeEach(() => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + }) + + it('should call deleteMessage when swipe delete button is pressed', async () => { + const { getAllByTestId } = render() + + const deleteButtons = getAllByTestId('swipe-delete-button') + fireEvent.press(deleteButtons[0]) + + await waitFor(() => { + expect(mockDeleteMessage).toHaveBeenCalledWith('1') + }) + }) + + it('should refetch messages after deletion', async () => { + const { getAllByTestId } = render() + + const deleteButtons = getAllByTestId('swipe-delete-button') + fireEvent.press(deleteButtons[0]) + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled() + }) + }) + }) + + describe('Message Click Navigation', () => { + beforeEach(() => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: mockMessages, + loading: false, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + }) + + it('should navigate to /templateDetail when TEMPLATE_LIKED message is clicked', async () => { + const { getAllByTestId } = render() + + const messageCards = getAllByTestId('message-card') + fireEvent.press(messageCards[0]) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/templateDetail')) + }) + }) + + it('should navigate to /generationRecord when TEMPLATE_GENERATION_SUCCESS message is clicked', async () => { + const { getAllByTestId } = render() + + const messageCards = getAllByTestId('message-card') + fireEvent.press(messageCards[1]) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/generationRecord')) + }) + }) + + it('should navigate to /membership when CREDITS_DEDUCTED message is clicked', async () => { + const { getAllByTestId } = render() + + const messageCards = getAllByTestId('message-card') + fireEvent.press(messageCards[2]) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/membership')) + }) + }) + + it('should mark message as read when clicked', async () => { + const { getAllByTestId } = render() + + const messageCards = getAllByTestId('message-card') + fireEvent.press(messageCards[0]) + + await waitFor(() => { + expect(mockMarkRead).toHaveBeenCalledWith('1') + }) + }) + + it('should not call markRead for already read messages', async () => { + const { getAllByTestId } = render() + + const messageCards = getAllByTestId('message-card') + fireEvent.press(messageCards[1]) // This is the read message + + await waitFor(() => { + expect(mockMarkRead).not.toHaveBeenCalledWith('2') + }) + }) + }) + + describe('Pagination', () => { + it('should call loadMore when scrolling to bottom', async () => { + const { getByTestId } = render() + + const flatList = getByTestId('message-list') + + fireEvent(flatList, 'onEndReached') + + await waitFor(() => { + expect(mockLoadMore).toHaveBeenCalled() + }) + }) + }) + + describe('Loading State', () => { + it('should show loading state when loading is true and no messages', () => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: [], + loading: true, + loadingMore: false, + error: null, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: true, + }) + + const { UNSAFE_queryAllByType } = render() + const loadingStates = UNSAFE_queryAllByType('LoadingState' as any) + expect(loadingStates.length).toBeGreaterThan(0) + }) + }) + + describe('Error State', () => { + it('should show error state when error exists and no messages', () => { + const { useMessages } = require('@/hooks/use-messages') + useMessages.mockReturnValue({ + messages: [], + loading: false, + loadingMore: false, + error: { message: 'Network error' }, + execute: mockExecute, + refetch: mockRefetch, + loadMore: mockLoadMore, + hasMore: false, + }) + + const { UNSAFE_queryAllByType } = render() + const errorStates = UNSAFE_queryAllByType('ErrorState' as any) + expect(errorStates.length).toBeGreaterThan(0) + }) + }) + + describe('Initial Data Fetch', () => { + it('should call execute on mount', () => { + render() + expect(mockExecute).toHaveBeenCalled() + }) + + it('should call announcement execute on mount', () => { + render() + expect(mockAnnouncementExecute).toHaveBeenCalled() + }) + }) +}) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index acd0096..47f6025 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,11 +1,13 @@ import { Tabs } from 'expo-router' -import React from 'react' +import React, { useEffect } from 'react' import { StyleSheet, View, Text, Platform } from 'react-native' import { LinearGradient } from 'expo-linear-gradient' import { useTranslation } from 'react-i18next' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { HomeIcon, MessageIcon, MyIcon, VideoIcon } from '@/components/icon' +import { useMessageUnreadCount } from '@/hooks/use-message-unread-count' +import { useAnnouncementUnreadCount } from '@/hooks/use-announcement-unread-count' const TAB_BAR_HEIGHT = 80 @@ -15,6 +17,19 @@ export default function TabLayout() { // Android 不需要额外的底部安全区域,iOS 和 Web 需要 const bottomInset = Platform.OS === 'android' ? 0 : insets.bottom + // 获取未读消息数和公告数 + const { data: messageData, refetch: refetchMessages } = useMessageUnreadCount() + const { data: announcementData, refetch: refetchAnnouncements } = useAnnouncementUnreadCount() + + // 计算总未读数 + const totalUnreadCount = (messageData?.count || 0) + (announcementData?.count || 0) + + // 组件挂载时获取未读数 + useEffect(() => { + refetchMessages() + refetchAnnouncements() + }, []) + return ( { if (focused) { return ( - - - + + + + + {totalUnreadCount > 0 && ( + + )} + ) } return ( + {totalUnreadCount > 0 && ( + + )} ) }, @@ -147,4 +170,13 @@ const styles = StyleSheet.create({ color: '#FFFFFF', textAlign: 'center', }, + unreadBadge: { + position: 'absolute', + top: 0, + right: 0, + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#00FF66', + }, }) diff --git a/app/(tabs)/message.tsx b/app/(tabs)/message.tsx index 5f6c046..ab2e86e 100644 --- a/app/(tabs)/message.tsx +++ b/app/(tabs)/message.tsx @@ -1,195 +1,203 @@ -import { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { View, Text, StyleSheet, - ScrollView, + FlatList, StatusBar as RNStatusBar, Pressable, - NativeScrollEvent, - NativeSyntheticEvent, } from 'react-native' -import { LinearGradient } from 'expo-linear-gradient' import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' +import { useRouter } from 'expo-router' + import { useMessages, type Message } from '@/hooks/use-messages' import { useMessageActions } from '@/hooks/use-message-actions' +import { useAnnouncements } from '@/hooks/use-announcements' import LoadingState from '@/components/LoadingState' import ErrorState from '@/components/ErrorState' import PaginationLoader from '@/components/PaginationLoader' -const filterMessagesByTab = (messages: Message[], tab: 'all' | 'notice' | 'other') => { - if (tab === 'all') return messages - if (tab === 'notice') { - return messages.filter(m => m.type === 'SYSTEM' || m.type === 'ACTIVITY') - } - return messages.filter(m => m.type === 'BILLING' || m.type === 'MARKETING') +import { MessageCard } from '@/components/message/MessageCard' +import { MessageTabBar, type MessageType } from '@/components/message/MessageTabBar' +import { AnnouncementBanner } from '@/components/message/AnnouncementBanner' +import { SwipeToDelete } from '@/components/message/SwipeToDelete' +import { MessageEmptyState } from '@/components/message/MessageEmptyState' + +// Navigation map for message click routing +const NAVIGATION_MAP: Record = { + TEMPLATE_GENERATION_SUCCESS: '/generationRecord', + TEMPLATE_GENERATION_FAILED: '/generationRecord', + TEMPLATE_LIKED: '/templateDetail', + TEMPLATE_FAVORITED: '/templateDetail', + TEMPLATE_COMMENTED: '/templateDetail', + COMMENT_REPLIED: '/templateDetail', + CREDITS_DEDUCTED: '/membership', + CREDITS_REFUNDED: '/membership', + CREDITS_RECHARGED: '/membership', } -const formatTime = (date: Date) => { - const d = new Date(date) - const year = d.getFullYear() - const month = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - const hours = String(d.getHours()).padStart(2, '0') - const minutes = String(d.getMinutes()).padStart(2, '0') - const seconds = String(d.getSeconds()).padStart(2, '0') - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +// Filter messages by tab type +const filterMessagesByTab = (messages: Message[], tabType: MessageType): Message[] => { + if (tabType === undefined) return messages + return messages.filter(m => m.type === tabType) } export default function MessageScreen() { const { t } = useTranslation() - const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all') - const { markRead } = useMessageActions() + const router = useRouter() + const [activeTab, setActiveTab] = useState(undefined) - const { messages: allMessages, loading, loadingMore, error, refetch, loadMore, hasMore, execute } = useMessages({ - limit: 20, - }) + // Hooks + const { + messages: allMessages, + loading, + loadingMore, + error, + refetch, + loadMore, + hasMore, + execute, + } = useMessages({ limit: 20 }) - const messages = filterMessagesByTab(allMessages, activeTab) + const { + markRead, + batchMarkRead, + deleteMessage, + } = useMessageActions() + const { + announcements, + execute: executeAnnouncements, + } = useAnnouncements({ limit: 10 }) + + // Filter messages based on active tab + const filteredMessages = filterMessagesByTab(allMessages, activeTab) + + // Initial data fetch useEffect(() => { execute() + executeAnnouncements() }, []) - const handleScroll = (event: NativeSyntheticEvent) => { - const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent - const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 50 + // Handle tab change + const handleTabChange = useCallback((type: MessageType) => { + setActiveTab(type) + }, []) - if (isCloseToBottom && hasMore && !loadingMore && !loading) { - loadMore() - } - } + // Handle mark all read + const handleMarkAllRead = useCallback(async () => { + const unreadMessages = filteredMessages.filter(m => !m.isRead) + if (unreadMessages.length === 0) return - const handleMessagePress = async (message: Message) => { + const unreadIds = unreadMessages.map(m => m.id) + await batchMarkRead(unreadIds) + refetch() + }, [filteredMessages, batchMarkRead, refetch]) + + // Handle message press - navigate and mark as read + const handleMessagePress = useCallback(async (message: Message) => { + // Mark as read if unread if (!message.isRead) { await markRead(message.id) - refetch() } - } - if (loading && messages.length === 0) { + // Navigate based on message subType + const subType = message.data?.subType + if (subType && NAVIGATION_MAP[subType]) { + const route = NAVIGATION_MAP[subType] + // Add relevant params based on message data + const params: Record = {} + if (message.data?.templateId) { + params.id = message.data.templateId + } + if (message.data?.generationId) { + params.id = message.data.generationId + } + + const queryString = Object.keys(params).length > 0 + ? `?${new URLSearchParams(params).toString()}` + : '' + router.push(`${route}${queryString}`) + } + }, [markRead, router]) + + // Handle delete message + const handleDeleteMessage = useCallback(async (id: string) => { + await deleteMessage(id) + refetch() + }, [deleteMessage, refetch]) + + // Handle announcement press + const handleAnnouncementPress = useCallback((announcement: { id: string; title: string; link?: string }) => { + if (announcement.link) { + // Navigate to external link or internal route + router.push(announcement.link as any) + } + }, [router]) + + // Handle load more + const handleEndReached = useCallback(() => { + if (hasMore && !loadingMore && !loading) { + loadMore() + } + }, [hasMore, loadingMore, loading, loadMore]) + + // Render message item + const renderMessageItem = useCallback(({ item }: { item: Message }) => ( + handleDeleteMessage(item.id)}> + + + ), [handleDeleteMessage, handleMessagePress]) + + // Render list footer + const renderFooter = useCallback(() => { + if (loadingMore) { + return + } + return null + }, [loadingMore]) + + // Render empty state + const renderEmptyComponent = useCallback(() => ( + + ), [t]) + + // Loading state + if (loading && allMessages.length === 0) { return ( - - setActiveTab('all')}> - {activeTab === 'all' ? ( - - - - {t('message.all')} - - - ) : ( - {t('message.all')} - )} - - setActiveTab('notice')}> - {activeTab === 'notice' ? ( - - - - {t('message.notice')} - - - ) : ( - {t('message.notice')} - )} - - setActiveTab('other')}> - {activeTab === 'other' ? ( - - - - {t('message.other')} - - - ) : ( - {t('message.other')} - )} + + {t('message.title')} + + {t('message.markAllRead')} + ) } - if (error && messages.length === 0) { + // Error state + if (error && allMessages.length === 0) { return ( - - setActiveTab('all')}> - {activeTab === 'all' ? ( - - - - {t('message.all')} - - - ) : ( - {t('message.all')} - )} - - setActiveTab('notice')}> - {activeTab === 'notice' ? ( - - - - {t('message.notice')} - - - ) : ( - {t('message.notice')} - )} - - setActiveTab('other')}> - {activeTab === 'other' ? ( - - - - {t('message.other')} - - - ) : ( - {t('message.other')} - )} + + {t('message.title')} + + {t('message.markAllRead')} + ) @@ -200,123 +208,41 @@ export default function MessageScreen() { - - setActiveTab('all')}> - {activeTab === 'all' ? ( - - - - {t('message.all')} - - - ) : ( - {t('message.all')} - )} - - setActiveTab('notice')}> - {activeTab === 'notice' ? ( - - - - {t('message.notice')} - - - ) : ( - {t('message.notice')} - )} - - setActiveTab('other')}> - {activeTab === 'other' ? ( - - - - {t('message.other')} - - - ) : ( - {t('message.other')} - )} + {/* Header */} + + {t('message.title')} + + {t('message.markAllRead')} - 0 && ( + + )} + + {/* Tab Bar */} + + + {/* Message List */} + item.id} + contentContainerStyle={[ + styles.listContent, + filteredMessages.length === 0 && styles.emptyListContent, + ]} showsVerticalScrollIndicator={false} - onScroll={handleScroll} - scrollEventThrottle={400} - > - {messages.length > 0 ? ( - <> - {messages.map((message) => ( - handleMessagePress(message)} - > - - {!message.isRead && ( - - - - )} - - {message.title} - - - {message.content} - - - - - {message.data || ''} - - - - - {formatTime(message.createdAt)} - - - - ))} - {loadingMore && } - - ) : ( - - 💭 - {t('message.noMessages')} - - )} - + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + ListEmptyComponent={renderEmptyComponent} + /> ) } @@ -326,109 +252,27 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#090A0B', }, - scrollView: { - flex: 1, - backgroundColor: '#090A0B', + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, }, - scrollContent: { + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#FFFFFF', + }, + markAllReadText: { + fontSize: 14, + color: '#8A8A8A', + }, + listContent: { paddingHorizontal: 4, paddingTop: 12, }, - segment: { - flexDirection: 'row', - paddingHorizontal: 8, - paddingTop: 19, - paddingBottom: 12, - backgroundColor: '#090A0B', - gap: 16, - }, - segmentText: { - fontSize: 14, - color: '#FFFFFF', - }, - segmentTextActiveWrapper: { - position: 'relative', - paddingBottom: 2, - justifyContent: 'flex-end', - alignSelf: 'flex-start', - }, - segmentTextActiveBg: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - height: 10, - backgroundColor: '#FF9966', - }, - segmentTextActiveText: { - zIndex: 1, - }, - cardContainer: { - backgroundColor: '#16181B', - borderRadius: 16, - padding: 16, - marginBottom: 12, - marginHorizontal: 4, - position: 'relative', - }, - newMessageDotContainer: { - position: 'absolute', - top: -4, - right: -2, - width: 16, - height: 16, - borderWidth: 4, - borderColor: '#090A0B', - borderRadius: 8, - justifyContent: 'center', - alignItems: 'center', - }, - newMessageDot: { - width: 6, - height: 6, - borderRadius: 4, - backgroundColor: '#00FF66', - zIndex: 1, - }, - cardTitle: { - color: '#FFFFFF', - fontSize: 15, - fontWeight: '600', - marginBottom: 8, - }, - cardSubtitle: { - color: '#ABABAB', - fontSize: 11, - marginBottom: 16, - }, - cardBody: { - backgroundColor: '#26292E', - borderRadius: 12, - height: 100, - marginBottom: 12, - padding: 8, - }, - cardBodyText: { - color: '#FFFFFF', - fontSize: 12, - opacity: 0.8, - }, - cardTime: { - color: '#8A8A8A', - fontSize: 10, - }, - emptyContainer: { + emptyListContent: { flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingTop: 200, - }, - emptyIcon: { - fontSize: 48, - }, - emptyText: { - color: '#8A8A8A', - fontSize: 12, - marginTop: 16, }, }) diff --git a/components/message/AnnouncementBanner.test.tsx b/components/message/AnnouncementBanner.test.tsx new file mode 100644 index 0000000..3c67abb --- /dev/null +++ b/components/message/AnnouncementBanner.test.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react-native' + +import { AnnouncementBanner, type Announcement } from './AnnouncementBanner' + +// Mock expo-linear-gradient +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: ({ children, ...props }: any) => { + const { View } = require('react-native') + return {children} + }, +})) + +describe('AnnouncementBanner', () => { + const mockAnnouncements: Announcement[] = [ + { + id: 'ann-1', + title: '系统维护通知', + content: '系统将于今晚进行维护', + link: 'https://example.com/maintenance', + createdAt: new Date('2026-01-29T10:00:00Z'), + }, + { + id: 'ann-2', + title: '新功能上线', + content: '我们推出了全新的AI生成功能', + createdAt: new Date('2026-01-28T10:00:00Z'), + }, + { + id: 'ann-3', + title: '活动公告', + content: '春节活动即将开始', + link: 'https://example.com/event', + createdAt: new Date('2026-01-27T10:00:00Z'), + }, + ] + + const mockOnPress = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Empty State', () => { + it('should not render anything when announcements array is empty', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toBeNull() + }) + + it('should not render anything when announcements is undefined', () => { + const { toJSON } = render( + + ) + expect(toJSON()).toBeNull() + }) + }) + + describe('Single Announcement', () => { + it('should render single announcement title', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByText } = render( + + ) + expect(getByText('系统维护通知')).toBeTruthy() + }) + + it('should render announcement banner container', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + expect(getByTestId('announcement-banner')).toBeTruthy() + }) + }) + + describe('Multiple Announcements', () => { + it('should render multiple announcements', () => { + const { getByText } = render( + + ) + expect(getByText('系统维护通知')).toBeTruthy() + }) + + it('should render horizontal scroll view for multiple announcements', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('announcement-scroll-view')).toBeTruthy() + }) + + it('should render all announcement items', () => { + const { getAllByTestId } = render( + + ) + const items = getAllByTestId(/^announcement-item-/) + expect(items.length).toBe(3) + }) + }) + + describe('Press Interaction', () => { + it('should call onPress with correct announcement when pressed', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + fireEvent.press(getByTestId('announcement-item-ann-1')) + expect(mockOnPress).toHaveBeenCalledTimes(1) + expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[0]) + }) + + it('should call onPress with correct announcement for each item', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('announcement-item-ann-2')) + expect(mockOnPress).toHaveBeenCalledWith(mockAnnouncements[1]) + }) + + it('should not crash when onPress is not provided', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + expect(() => { + fireEvent.press(getByTestId('announcement-item-ann-1')) + }).not.toThrow() + }) + }) + + describe('Gradient Background Style', () => { + it('should render with gradient background', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + const banner = getByTestId('announcement-banner') + expect(banner).toBeTruthy() + }) + + it('should apply gradient colors', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + const gradientContainer = getByTestId('gradient-container') + expect(gradientContainer.props.colors).toEqual(['#FF9966', '#FF6699', '#9966FF']) + }) + }) + + describe('Announcement Icon', () => { + it('should render announcement icon', () => { + const singleAnnouncement = [mockAnnouncements[0]] + const { getByTestId } = render( + + ) + expect(getByTestId('announcement-icon')).toBeTruthy() + }) + }) +}) + +describe('Announcement Type', () => { + it('should have required id field', () => { + const announcement: Announcement = { + id: 'test-id', + title: 'Test Title', + createdAt: new Date(), + } + expect(announcement.id).toBeDefined() + }) + + it('should have required title field', () => { + const announcement: Announcement = { + id: 'test-id', + title: 'Test Title', + createdAt: new Date(), + } + expect(announcement.title).toBeDefined() + }) + + it('should have optional content field', () => { + const announcement: Announcement = { + id: 'test-id', + title: 'Test Title', + content: 'Test Content', + createdAt: new Date(), + } + expect(announcement.content).toBe('Test Content') + }) + + it('should have optional link field', () => { + const announcement: Announcement = { + id: 'test-id', + title: 'Test Title', + link: 'https://example.com', + createdAt: new Date(), + } + expect(announcement.link).toBe('https://example.com') + }) +}) diff --git a/components/message/AnnouncementBanner.tsx b/components/message/AnnouncementBanner.tsx new file mode 100644 index 0000000..30a5ac9 --- /dev/null +++ b/components/message/AnnouncementBanner.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, +} from 'react-native' +import { LinearGradient } from 'expo-linear-gradient' + +export interface Announcement { + id: string + title: string + content?: string + link?: string + createdAt: Date +} + +export interface AnnouncementBannerProps { + announcements: Announcement[] + onPress?: (announcement: Announcement) => void +} + +const GRADIENT_COLORS = ['#FF9966', '#FF6699', '#9966FF'] as const + +export const AnnouncementBanner: React.FC = ({ + announcements, + onPress, +}) => { + // 无公告时不渲染 + if (!announcements || announcements.length === 0) { + return null + } + + const handlePress = (announcement: Announcement) => { + onPress?.(announcement) + } + + return ( + + + + 📢 + + + {announcements.map((announcement) => ( + handlePress(announcement)} + activeOpacity={0.7} + > + + {announcement.title} + + + ))} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 16, + marginVertical: 8, + borderRadius: 12, + overflow: 'hidden', + }, + gradientContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + }, + iconContainer: { + marginRight: 8, + }, + icon: { + fontSize: 18, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + alignItems: 'center', + }, + announcementItem: { + marginRight: 16, + }, + title: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '500', + }, +}) + +export default AnnouncementBanner diff --git a/components/message/MessageCard.test.tsx b/components/message/MessageCard.test.tsx index 00449cf..edf0087 100644 --- a/components/message/MessageCard.test.tsx +++ b/components/message/MessageCard.test.tsx @@ -223,10 +223,16 @@ describe('MessageCard Component', () => { }) it('should render message icon based on subType', () => { + // Use a message without avatar to test icon display + const billingMessage = { + ...mockMessage, + type: 'BILLING' as const, + data: { subType: 'CREDITS_RECHARGED', credits: 1000 }, + } const { getByText } = render( - + ) - expect(getByText('👍')).toBeTruthy() + expect(getByText('💰')).toBeTruthy() }) describe('Avatar Display', () => { diff --git a/components/message/MessageEmptyState.test.tsx b/components/message/MessageEmptyState.test.tsx new file mode 100644 index 0000000..287a288 --- /dev/null +++ b/components/message/MessageEmptyState.test.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { render } from '@testing-library/react-native' +import { Text } from 'react-native' + +import { MessageEmptyState } from './MessageEmptyState' + +describe('MessageEmptyState Component', () => { + describe('Default Rendering', () => { + it('should render default message "暂无消息"', () => { + const { getByText } = render() + expect(getByText('暂无消息')).toBeTruthy() + }) + + it('should render default icon', () => { + const { getByTestId } = render() + expect(getByTestId('empty-state-icon')).toBeTruthy() + }) + + it('should render container with testID', () => { + const { getByTestId } = render() + expect(getByTestId('message-empty-state')).toBeTruthy() + }) + }) + + describe('Custom Props', () => { + it('should render custom message when provided', () => { + const customMessage = '没有互动消息' + const { getByText, queryByText } = render( + + ) + expect(getByText(customMessage)).toBeTruthy() + expect(queryByText('暂无消息')).toBeNull() + }) + + it('should render custom icon when provided', () => { + const CustomIcon = 🎨 + const { getByTestId } = render() + expect(getByTestId('custom-icon')).toBeTruthy() + }) + + it('should not render default icon when custom icon is provided', () => { + const CustomIcon = 🎨 + const { queryByTestId } = render() + // Custom icon container should exist, but default icon should be replaced + expect(queryByTestId('custom-icon')).toBeTruthy() + }) + }) + + describe('Styling', () => { + it('should have centered content', () => { + const { getByTestId } = render() + const container = getByTestId('message-empty-state') + + // Check that container has centered alignment styles + expect(container.props.style).toEqual( + expect.objectContaining({ + alignItems: 'center', + justifyContent: 'center', + }) + ) + }) + + it('should have transparent background', () => { + const { getByTestId } = render() + const container = getByTestId('message-empty-state') + + expect(container.props.style).toEqual( + expect.objectContaining({ + backgroundColor: 'transparent', + }) + ) + }) + + it('should have correct text color #8A8A8A', () => { + const { getByTestId } = render() + const messageText = getByTestId('empty-state-message') + + expect(messageText.props.style).toEqual( + expect.objectContaining({ + color: '#8A8A8A', + }) + ) + }) + + it('should have flex: 1 to fill available space', () => { + const { getByTestId } = render() + const container = getByTestId('message-empty-state') + + expect(container.props.style).toEqual( + expect.objectContaining({ + flex: 1, + }) + ) + }) + }) + + describe('Accessibility', () => { + it('should have accessible message text', () => { + const { getByTestId } = render() + const messageText = getByTestId('empty-state-message') + expect(messageText).toBeTruthy() + }) + }) +}) diff --git a/components/message/MessageEmptyState.tsx b/components/message/MessageEmptyState.tsx new file mode 100644 index 0000000..e16e840 --- /dev/null +++ b/components/message/MessageEmptyState.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { View, Text, StyleSheet } from 'react-native' + +export interface MessageEmptyStateProps { + message?: string + icon?: React.ReactNode +} + +export function MessageEmptyState({ + message = '暂无消息', + icon, +}: MessageEmptyStateProps) { + return ( + + + {icon || 📭} + + + {message} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + }, + iconContainer: { + marginBottom: 12, + }, + defaultIcon: { + fontSize: 48, + }, + message: { + fontSize: 14, + color: '#8A8A8A', + }, +}) diff --git a/components/message/MessageTabBar.test.tsx b/components/message/MessageTabBar.test.tsx new file mode 100644 index 0000000..a8e9f15 --- /dev/null +++ b/components/message/MessageTabBar.test.tsx @@ -0,0 +1,283 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react-native' + +import { MessageTabBar, TAB_ITEMS } from './MessageTabBar' +import type { MessageType } from './MessageTabBar' + +describe('MessageTabBar Constants', () => { + describe('TAB_ITEMS', () => { + it('should have 4 tabs', () => { + expect(TAB_ITEMS).toHaveLength(4) + }) + + it('should have correct tab keys', () => { + const keys = TAB_ITEMS.map((tab) => tab.key) + expect(keys).toEqual(['all', 'activity', 'system', 'billing']) + }) + + it('should have correct tab labels', () => { + const labels = TAB_ITEMS.map((tab) => tab.label) + expect(labels).toEqual(['全部', '互动', '系统', '账单']) + }) + + it('should have correct tab types', () => { + const types = TAB_ITEMS.map((tab) => tab.type) + expect(types).toEqual([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']) + }) + }) +}) + +describe('MessageTabBar Component', () => { + const mockOnTabChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render all 4 tabs', () => { + const { getByText } = render( + + ) + + expect(getByText('全部')).toBeTruthy() + expect(getByText('互动')).toBeTruthy() + expect(getByText('系统')).toBeTruthy() + expect(getByText('账单')).toBeTruthy() + }) + + it('should render with testID for each tab', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('tab-all')).toBeTruthy() + expect(getByTestId('tab-activity')).toBeTruthy() + expect(getByTestId('tab-system')).toBeTruthy() + expect(getByTestId('tab-billing')).toBeTruthy() + }) + }) + + describe('Active Tab Highlighting', () => { + it('should highlight "全部" tab when activeTab is undefined', () => { + const { getByTestId } = render( + + ) + + const allTab = getByTestId('tab-all') + const activityTab = getByTestId('tab-activity') + + // Check that all tab has active indicator + expect(getByTestId('tab-all-active-indicator')).toBeTruthy() + // Check that activity tab does not have active indicator + expect(() => getByTestId('tab-activity-active-indicator')).toThrow() + }) + + it('should highlight "互动" tab when activeTab is ACTIVITY', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('tab-activity-active-indicator')).toBeTruthy() + expect(() => getByTestId('tab-all-active-indicator')).toThrow() + }) + + it('should highlight "系统" tab when activeTab is SYSTEM', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('tab-system-active-indicator')).toBeTruthy() + expect(() => getByTestId('tab-all-active-indicator')).toThrow() + }) + + it('should highlight "账单" tab when activeTab is BILLING', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('tab-billing-active-indicator')).toBeTruthy() + expect(() => getByTestId('tab-all-active-indicator')).toThrow() + }) + }) + + describe('Tab Press Interaction', () => { + it('should call onTabChange with undefined when "全部" tab is pressed', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('tab-all')) + expect(mockOnTabChange).toHaveBeenCalledWith(undefined) + }) + + it('should call onTabChange with ACTIVITY when "互动" tab is pressed', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('tab-activity')) + expect(mockOnTabChange).toHaveBeenCalledWith('ACTIVITY') + }) + + it('should call onTabChange with SYSTEM when "系统" tab is pressed', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('tab-system')) + expect(mockOnTabChange).toHaveBeenCalledWith('SYSTEM') + }) + + it('should call onTabChange with BILLING when "账单" tab is pressed', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('tab-billing')) + expect(mockOnTabChange).toHaveBeenCalledWith('BILLING') + }) + + it('should call onTabChange even when pressing the already active tab', () => { + const { getByTestId } = render( + + ) + + fireEvent.press(getByTestId('tab-all')) + expect(mockOnTabChange).toHaveBeenCalledWith(undefined) + }) + }) + + describe('Unread Badge Display', () => { + it('should show unread badge on "全部" tab when all count > 0', () => { + const { getByTestId, getByText } = render( + + ) + + expect(getByTestId('tab-all-badge')).toBeTruthy() + expect(getByText('5')).toBeTruthy() + }) + + it('should show unread badge on "互动" tab when activity count > 0', () => { + const { getByTestId, getByText } = render( + + ) + + expect(getByTestId('tab-activity-badge')).toBeTruthy() + expect(getByText('3')).toBeTruthy() + }) + + it('should show unread badge on "系统" tab when system count > 0', () => { + const { getByTestId, getByText } = render( + + ) + + expect(getByTestId('tab-system-badge')).toBeTruthy() + expect(getByText('10')).toBeTruthy() + }) + + it('should show unread badge on "账单" tab when billing count > 0', () => { + const { getByTestId, getByText } = render( + + ) + + expect(getByTestId('tab-billing-badge')).toBeTruthy() + expect(getByText('2')).toBeTruthy() + }) + + it('should show multiple badges when multiple counts > 0', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('tab-all-badge')).toBeTruthy() + expect(getByTestId('tab-activity-badge')).toBeTruthy() + expect(getByTestId('tab-system-badge')).toBeTruthy() + expect(getByTestId('tab-billing-badge')).toBeTruthy() + }) + + it('should show "99+" when count exceeds 99', () => { + const { getByText } = render( + + ) + + expect(getByText('99+')).toBeTruthy() + }) + }) + + describe('No Badge Display', () => { + it('should not show badge when unreadCounts is not provided', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('tab-all-badge')).toBeNull() + expect(queryByTestId('tab-activity-badge')).toBeNull() + expect(queryByTestId('tab-system-badge')).toBeNull() + expect(queryByTestId('tab-billing-badge')).toBeNull() + }) + + it('should not show badge when count is 0', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('tab-all-badge')).toBeNull() + expect(queryByTestId('tab-activity-badge')).toBeNull() + expect(queryByTestId('tab-system-badge')).toBeNull() + expect(queryByTestId('tab-billing-badge')).toBeNull() + }) + + it('should not show badge when count is undefined', () => { + const { queryByTestId } = render( + + ) + + expect(queryByTestId('tab-all-badge')).toBeNull() + expect(queryByTestId('tab-activity-badge')).toBeNull() + expect(queryByTestId('tab-system-badge')).toBeNull() + expect(queryByTestId('tab-billing-badge')).toBeNull() + }) + }) +}) + +describe('MessageTabBar Types', () => { + it('should accept valid MessageType values', () => { + const types: MessageType[] = [undefined, 'ACTIVITY', 'SYSTEM', 'BILLING'] + types.forEach((type) => { + expect([undefined, 'ACTIVITY', 'SYSTEM', 'BILLING']).toContain(type) + }) + }) +}) diff --git a/components/message/MessageTabBar.tsx b/components/message/MessageTabBar.tsx new file mode 100644 index 0000000..eb2bf56 --- /dev/null +++ b/components/message/MessageTabBar.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' +import { LinearGradient } from 'expo-linear-gradient' + +// Types +export type MessageType = 'ACTIVITY' | 'SYSTEM' | 'BILLING' | undefined + +export interface TabItem { + key: string + label: string + type: MessageType +} + +export interface MessageTabBarProps { + activeTab: MessageType + onTabChange: (type: MessageType) => void + unreadCounts?: { + all?: number + activity?: number + system?: number + billing?: number + } +} + +// Constants +export const TAB_ITEMS: TabItem[] = [ + { key: 'all', label: '全部', type: undefined }, + { key: 'activity', label: '互动', type: 'ACTIVITY' }, + { key: 'system', label: '系统', type: 'SYSTEM' }, + { key: 'billing', label: '账单', type: 'BILLING' }, +] + +// Helper function to format badge count +const formatBadgeCount = (count: number): string => { + if (count > 99) { + return '99+' + } + return String(count) +} + +// Helper function to get unread count for a tab +const getUnreadCount = ( + key: string, + unreadCounts?: MessageTabBarProps['unreadCounts'] +): number | undefined => { + if (!unreadCounts) return undefined + switch (key) { + case 'all': + return unreadCounts.all + case 'activity': + return unreadCounts.activity + case 'system': + return unreadCounts.system + case 'billing': + return unreadCounts.billing + default: + return undefined + } +} + +export const MessageTabBar: React.FC = ({ + activeTab, + onTabChange, + unreadCounts, +}) => { + return ( + + {TAB_ITEMS.map((tab) => { + const isActive = activeTab === tab.type + const unreadCount = getUnreadCount(tab.key, unreadCounts) + const showBadge = unreadCount !== undefined && unreadCount > 0 + + return ( + onTabChange(tab.type)} + activeOpacity={0.7} + > + + + {tab.label} + + {showBadge && ( + + + {formatBadgeCount(unreadCount)} + + + )} + + {isActive && ( + + )} + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: '#090A0B', + paddingHorizontal: 16, + paddingTop: 12, + }, + tabItem: { + marginRight: 24, + paddingBottom: 8, + alignItems: 'center', + }, + tabContent: { + flexDirection: 'row', + alignItems: 'center', + }, + tabLabel: { + fontSize: 16, + fontWeight: '500', + }, + tabLabelActive: { + color: '#FFFFFF', + }, + tabLabelInactive: { + color: '#8A8A8A', + }, + activeIndicator: { + height: 2, + width: '100%', + marginTop: 6, + borderRadius: 1, + }, + badge: { + backgroundColor: '#FF3B30', + borderRadius: 10, + minWidth: 18, + height: 18, + paddingHorizontal: 5, + marginLeft: 4, + justifyContent: 'center', + alignItems: 'center', + }, + badgeText: { + color: '#FFFFFF', + fontSize: 11, + fontWeight: '600', + }, +}) diff --git a/components/message/SwipeToDelete.tsx b/components/message/SwipeToDelete.tsx new file mode 100644 index 0000000..0e6832d --- /dev/null +++ b/components/message/SwipeToDelete.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { + View, + Text, + TouchableOpacity, + StyleSheet, + ViewStyle, + StyleProp, +} from 'react-native' +import { Swipeable } from 'react-native-gesture-handler' + +export interface SwipeToDeleteProps { + children: React.ReactNode + onDelete: () => void + enabled?: boolean + disabled?: boolean + deleteText?: string + style?: StyleProp +} + +export function SwipeToDelete({ + children, + onDelete, + enabled = true, + disabled = false, + deleteText = '删除', + style, +}: SwipeToDeleteProps) { + const isDisabled = disabled || !enabled + + const renderRightActions = () => { + return ( + { + if (!isDisabled) { + onDelete() + } + }} + activeOpacity={0.8} + > + {deleteText} + + ) + } + + return ( + + {children} + + ) +} + +const styles = StyleSheet.create({ + deleteButton: { + backgroundColor: '#FF3B30', + width: 80, + justifyContent: 'center', + alignItems: 'center', + }, + deleteText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}) diff --git a/components/message/index.ts b/components/message/index.ts new file mode 100644 index 0000000..d3f0c41 --- /dev/null +++ b/components/message/index.ts @@ -0,0 +1,14 @@ +export { MessageCard, getMessageIcon, getMessageConfig, formatRelativeTime } from './MessageCard' +export type { Message, MessageData } from './MessageCard' + +export { AnnouncementBanner } from './AnnouncementBanner' +export type { Announcement, AnnouncementBannerProps } from './AnnouncementBanner' + +export { SwipeToDelete } from './SwipeToDelete' +export type { SwipeToDeleteProps } from './SwipeToDelete' + +export { MessageTabBar, TAB_ITEMS } from './MessageTabBar' +export type { MessageType, TabItem, MessageTabBarProps } from './MessageTabBar' + +export { MessageEmptyState } from './MessageEmptyState' +export type { MessageEmptyStateProps } from './MessageEmptyState' diff --git a/jest.setup.js b/jest.setup.js index 2cb1b12..ccb0cd7 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -3,95 +3,8 @@ jest.mock('react-native-css-interop', () => ({ __esModule: true, })) -// Mock react-native BEFORE any other imports to avoid flow type issues -jest.mock('react-native', () => { - const mockReact = require('react') - - // Helper to create mock components that render as actual React elements - const mockCreateMockComponent = (mockName) => { - const MockComponent = mockReact.forwardRef(({ children, ...mockProps }, mockRef) => { - return mockReact.createElement(mockName, { ...mockProps, ref: mockRef }, children) - }) - MockComponent.displayName = mockName - return MockComponent - } - - // Create Pressable with onPress support - const MockPressable = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => { - return mockReact.createElement('Pressable', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children) - }) - MockPressable.displayName = 'Pressable' - - // Create TouchableOpacity with onPress support - const MockTouchableOpacity = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => { - return mockReact.createElement('TouchableOpacity', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children) - }) - MockTouchableOpacity.displayName = 'TouchableOpacity' - - return { - RefreshControl: mockCreateMockComponent('RefreshControl'), - ScrollView: mockCreateMockComponent('ScrollView'), - FlatList: mockCreateMockComponent('FlatList'), - View: mockCreateMockComponent('View'), - Text: mockCreateMockComponent('Text'), - Image: mockCreateMockComponent('Image'), - Pressable: MockPressable, - TouchableOpacity: MockTouchableOpacity, - TextInput: mockCreateMockComponent('TextInput'), - ActivityIndicator: mockCreateMockComponent('ActivityIndicator'), - Platform: { - OS: 'web', - select: (mockObj) => mockObj.web || mockObj.default, - }, - Animated: { - View: mockCreateMockComponent('Animated.View'), - Text: mockCreateMockComponent('Animated.Text'), - Image: mockCreateMockComponent('Animated.Image'), - Value: class MockValue { - constructor(mockV) { this._value = mockV } - setValue(mockV) { this._value = mockV } - __getValue() { return this._value } - interpolate() { return this } - }, - timing: () => ({ start: jest.fn() }), - spring: () => ({ start: jest.fn() }), - event: () => jest.fn(), - }, - PanResponder: { - create: () => ({ panHandlers: {} }), - }, - StyleSheet: { create: (mockStyles) => mockStyles }, - Dimensions: { get: () => ({ width: 375, height: 812 }) }, - StatusBar: mockCreateMockComponent('StatusBar'), - SafeAreaView: mockCreateMockComponent('SafeAreaView'), - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), - NativeModules: { - DevMenu: {}, - SettingsManager: {}, - }, - Settings: { - get: jest.fn(), - set: jest.fn(), - }, - Alert: { - alert: jest.fn(), - }, - Linking: { - openURL: jest.fn(), - }, - Appearance: { - getColorScheme: jest.fn(() => 'light'), - addChangeListener: jest.fn(), - removeChangeListener: jest.fn(), - }, - BackHandler: { - addEventListener: jest.fn(() => ({ remove: jest.fn() })), - removeEventListener: jest.fn(), - }, - } -}) - -require('@testing-library/jest-native/extend-expect') +// Import extend-expect for jest matchers +import '@testing-library/react-native/extend-expect' // Mock global Appearance for react-native-css-interop global.Appearance = { @@ -168,9 +81,20 @@ jest.mock('react-native-safe-area-context', () => ({ // Mock react-native-gesture-handler jest.mock('react-native-gesture-handler', () => { - const { View } = require('react-native') + const mockReact = require('react') + const mockRN = require('react-native') + + // Mock Swipeable component that renders children and right actions + const MockSwipeable = mockReact.forwardRef(({ children, renderRightActions, testID, enabled, ...props }, ref) => { + return mockReact.createElement(mockRN.View, { testID, ref, ...props }, [ + children, + renderRightActions && renderRightActions(), + ]) + }) + MockSwipeable.displayName = 'Swipeable' + return { - GestureDetector: View, + GestureDetector: mockRN.View, Gesture: { Tap: () => ({}), Pan: () => ({}), @@ -182,11 +106,12 @@ jest.mock('react-native-gesture-handler', () => { ForceTouch: () => ({}), ManualGesture: () => ({}), }, - GestureHandlerRootView: View, - RawButton: View, - BaseButton: View, - RectButton: View, - BorderlessButton: View, + GestureHandlerRootView: mockRN.View, + RawButton: mockRN.View, + BaseButton: mockRN.View, + RectButton: mockRN.View, + BorderlessButton: mockRN.View, + Swipeable: MockSwipeable, State: {}, Directions: {}, } diff --git a/locales/en-US.json b/locales/en-US.json index 16c4f39..2f050b3 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -108,10 +108,25 @@ } }, "message": { + "title": "Messages", "all": "All", "notice": "Activity Notice", "other": "Other", - "noMessages": "No messages" + "noMessages": "No messages", + "activity": "Activity", + "system": "System", + "billing": "Billing", + "markAllRead": "Mark all read", + "delete": "Delete", + "templateLiked": "liked your work", + "templateFavorited": "favorited your work", + "templateCommented": "commented on your work", + "commentReplied": "replied to your comment", + "generationSuccess": "Work generated successfully", + "generationFailed": "Work generation failed", + "creditsDeducted": "Credits deducted", + "creditsRefunded": "Credits refunded", + "creditsRecharged": "Credits recharged" }, "video": { "makeSame": "Make Same Style" diff --git a/locales/zh-CN.json b/locales/zh-CN.json index cfaa01b..77f89fc 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -108,10 +108,25 @@ } }, "message": { + "title": "消息", "all": "全部", "notice": "活动通知", "other": "其他", - "noMessages": "暂无消息" + "noMessages": "暂无消息", + "activity": "互动", + "system": "系统", + "billing": "账单", + "markAllRead": "全部已读", + "delete": "删除", + "templateLiked": "赞了你的作品", + "templateFavorited": "收藏了你的作品", + "templateCommented": "评论了你的作品", + "commentReplied": "回复了你的评论", + "generationSuccess": "作品生成成功", + "generationFailed": "作品生成失败", + "creditsDeducted": "积分已扣除", + "creditsRefunded": "积分已退还", + "creditsRecharged": "积分充值成功" }, "video": { "makeSame": "做同款"