fix: bug
This commit is contained in:
283
app/(tabs)/__tests__/_layout.test.tsx
Normal file
283
app/(tabs)/__tests__/_layout.test.tsx
Normal file
@@ -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<string, string> = {
|
||||
'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(<TabLayout />)
|
||||
|
||||
// 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(<TabLayout />)
|
||||
|
||||
// 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(<TabLayout />)
|
||||
|
||||
// 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(<TabLayout />)
|
||||
|
||||
// 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(<TabLayout />)
|
||||
|
||||
// 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(<TabLayout />)
|
||||
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(<TabLayout />)
|
||||
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(<TabLayout />)
|
||||
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(<TabLayout />)
|
||||
|
||||
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(<TabLayout />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
537
app/(tabs)/__tests__/message.test.tsx
Normal file
537
app/(tabs)/__tests__/message.test.tsx
Normal file
@@ -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<string, string> = {
|
||||
'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(<MessageScreen />)
|
||||
expect(getByText('消息')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "全部已读" button in header', () => {
|
||||
const { getByText } = render(<MessageScreen />)
|
||||
expect(getByText('全部已读')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render MessageTabBar component', () => {
|
||||
const { getByTestId } = render(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
expect(queryByTestId('announcement-banner')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Message List', () => {
|
||||
it('should render message list with MessageCard components', () => {
|
||||
const { getAllByTestId } = render(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
const messageCards = getAllByTestId('message-card')
|
||||
expect(messageCards.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should filter ACTIVITY messages when "互动" tab is clicked', async () => {
|
||||
const { getByTestId, getAllByTestId } = render(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
// 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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
|
||||
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(<MessageScreen />)
|
||||
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(<MessageScreen />)
|
||||
const errorStates = UNSAFE_queryAllByType('ErrorState' as any)
|
||||
expect(errorStates.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Initial Data Fetch', () => {
|
||||
it('should call execute on mount', () => {
|
||||
render(<MessageScreen />)
|
||||
expect(mockExecute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call announcement execute on mount', () => {
|
||||
render(<MessageScreen />)
|
||||
expect(mockAnnouncementExecute).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
@@ -96,14 +111,22 @@ export default function TabLayout() {
|
||||
tabBarIcon: ({ focused }: { focused: boolean }) => {
|
||||
if (focused) {
|
||||
return (
|
||||
<View>
|
||||
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
|
||||
<MessageIcon />
|
||||
</LinearGradient>
|
||||
{totalUnreadCount > 0 && (
|
||||
<View testID="message-unread-badge" style={styles.unreadBadge} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={{ opacity: focused ? 1 : 0.6 }}>
|
||||
<MessageIcon />
|
||||
{totalUnreadCount > 0 && (
|
||||
<View testID="message-unread-badge" style={styles.unreadBadge} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<MessageType>(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<NativeScrollEvent>) => {
|
||||
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<string, string> = {}
|
||||
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 }) => (
|
||||
<SwipeToDelete onDelete={() => handleDeleteMessage(item.id)}>
|
||||
<MessageCard
|
||||
message={item}
|
||||
onPress={handleMessagePress}
|
||||
/>
|
||||
</SwipeToDelete>
|
||||
), [handleDeleteMessage, handleMessagePress])
|
||||
|
||||
// Render list footer
|
||||
const renderFooter = useCallback(() => {
|
||||
if (loadingMore) {
|
||||
return <PaginationLoader color="#FFFFFF" />
|
||||
}
|
||||
return null
|
||||
}, [loadingMore])
|
||||
|
||||
// Render empty state
|
||||
const renderEmptyComponent = useCallback(() => (
|
||||
<MessageEmptyState message={t('message.noMessages')} />
|
||||
), [t])
|
||||
|
||||
// Loading state
|
||||
if (loading && allMessages.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
<View style={styles.segment}>
|
||||
<Pressable onPress={() => setActiveTab('all')}>
|
||||
{activeTab === 'all' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.all')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('notice')}>
|
||||
{activeTab === 'notice' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.notice')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('other')}>
|
||||
{activeTab === 'other' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.other')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
||||
)}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||
<Pressable onPress={handleMarkAllRead}>
|
||||
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
<LoadingState color="#FFFFFF" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && messages.length === 0) {
|
||||
// Error state
|
||||
if (error && allMessages.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
<View style={styles.segment}>
|
||||
<Pressable onPress={() => setActiveTab('all')}>
|
||||
{activeTab === 'all' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.all')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('notice')}>
|
||||
{activeTab === 'notice' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.notice')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('other')}>
|
||||
{activeTab === 'other' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text style={[styles.segmentText, styles.segmentTextActiveText]}>
|
||||
{t('message.other')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
||||
)}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||
<Pressable onPress={handleMarkAllRead}>
|
||||
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -200,123 +208,41 @@ export default function MessageScreen() {
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
<View style={styles.segment}>
|
||||
<Pressable onPress={() => setActiveTab('all')}>
|
||||
{activeTab === 'all' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.segmentText,
|
||||
styles.segmentTextActiveText,
|
||||
]}
|
||||
>
|
||||
{t('message.all')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.all')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('notice')}>
|
||||
{activeTab === 'notice' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.segmentText,
|
||||
styles.segmentTextActiveText,
|
||||
]}
|
||||
>
|
||||
{t('message.notice')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.notice')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setActiveTab('other')}>
|
||||
{activeTab === 'other' ? (
|
||||
<View style={styles.segmentTextActiveWrapper}>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.segmentTextActiveBg}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.segmentText,
|
||||
styles.segmentTextActiveText,
|
||||
]}
|
||||
>
|
||||
{t('message.other')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.segmentText}>{t('message.other')}</Text>
|
||||
)}
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{t('message.title')}</Text>
|
||||
<Pressable onPress={handleMarkAllRead}>
|
||||
<Text style={styles.markAllReadText}>{t('message.markAllRead')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
{/* Announcement Banner */}
|
||||
{announcements.length > 0 && (
|
||||
<AnnouncementBanner
|
||||
announcements={announcements}
|
||||
onPress={handleAnnouncementPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab Bar */}
|
||||
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
{/* Message List */}
|
||||
<FlatList
|
||||
testID="message-list"
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessageItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
filteredMessages.length === 0 && styles.emptyListContent,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<Pressable
|
||||
key={message.id}
|
||||
onPress={() => handleMessagePress(message)}
|
||||
>
|
||||
<View style={styles.cardContainer}>
|
||||
{!message.isRead && (
|
||||
<View style={styles.newMessageDotContainer}>
|
||||
<View style={styles.newMessageDot} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.cardTitle}>
|
||||
{message.title}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle} numberOfLines={2}>
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.cardBodyText}>
|
||||
{message.data || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardTime}>
|
||||
{formatTime(message.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
{loadingMore && <PaginationLoader color="#FFFFFF" />}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyIcon}>💭</Text>
|
||||
<Text style={styles.emptyText}>{t('message.noMessages')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
202
components/message/AnnouncementBanner.test.tsx
Normal file
202
components/message/AnnouncementBanner.test.tsx
Normal file
@@ -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 <View {...props}>{children}</View>
|
||||
},
|
||||
}))
|
||||
|
||||
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(
|
||||
<AnnouncementBanner announcements={[]} onPress={mockOnPress} />
|
||||
)
|
||||
expect(toJSON()).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render anything when announcements is undefined', () => {
|
||||
const { toJSON } = render(
|
||||
<AnnouncementBanner announcements={undefined as any} onPress={mockOnPress} />
|
||||
)
|
||||
expect(toJSON()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Single Announcement', () => {
|
||||
it('should render single announcement title', () => {
|
||||
const singleAnnouncement = [mockAnnouncements[0]]
|
||||
const { getByText } = render(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('系统维护通知')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render announcement banner container', () => {
|
||||
const singleAnnouncement = [mockAnnouncements[0]]
|
||||
const { getByTestId } = render(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('announcement-banner')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Announcements', () => {
|
||||
it('should render multiple announcements', () => {
|
||||
const { getByText } = render(
|
||||
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('系统维护通知')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render horizontal scroll view for multiple announcements', () => {
|
||||
const { getByTestId } = render(
|
||||
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('announcement-scroll-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render all announcement items', () => {
|
||||
const { getAllByTestId } = render(
|
||||
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||
)
|
||||
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(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
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(
|
||||
<AnnouncementBanner announcements={mockAnnouncements} onPress={mockOnPress} />
|
||||
)
|
||||
|
||||
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(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} />
|
||||
)
|
||||
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(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
const banner = getByTestId('announcement-banner')
|
||||
expect(banner).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply gradient colors', () => {
|
||||
const singleAnnouncement = [mockAnnouncements[0]]
|
||||
const { getByTestId } = render(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
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(
|
||||
<AnnouncementBanner announcements={singleAnnouncement} onPress={mockOnPress} />
|
||||
)
|
||||
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')
|
||||
})
|
||||
})
|
||||
112
components/message/AnnouncementBanner.tsx
Normal file
112
components/message/AnnouncementBanner.tsx
Normal file
@@ -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<AnnouncementBannerProps> = ({
|
||||
announcements,
|
||||
onPress,
|
||||
}) => {
|
||||
// 无公告时不渲染
|
||||
if (!announcements || announcements.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePress = (announcement: Announcement) => {
|
||||
onPress?.(announcement)
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="announcement-banner" style={styles.container}>
|
||||
<LinearGradient
|
||||
testID="gradient-container"
|
||||
colors={[...GRADIENT_COLORS]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientContainer}
|
||||
>
|
||||
<View testID="announcement-icon" style={styles.iconContainer}>
|
||||
<Text style={styles.icon}>📢</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
testID="announcement-scroll-view"
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{announcements.map((announcement) => (
|
||||
<TouchableOpacity
|
||||
key={announcement.id}
|
||||
testID={`announcement-item-${announcement.id}`}
|
||||
style={styles.announcementItem}
|
||||
onPress={() => handlePress(announcement)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
<MessageCard message={billingMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('👍')).toBeTruthy()
|
||||
expect(getByText('💰')).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('Avatar Display', () => {
|
||||
|
||||
104
components/message/MessageEmptyState.test.tsx
Normal file
104
components/message/MessageEmptyState.test.tsx
Normal file
@@ -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(<MessageEmptyState />)
|
||||
expect(getByText('暂无消息')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render default icon', () => {
|
||||
const { getByTestId } = render(<MessageEmptyState />)
|
||||
expect(getByTestId('empty-state-icon')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render container with testID', () => {
|
||||
const { getByTestId } = render(<MessageEmptyState />)
|
||||
expect(getByTestId('message-empty-state')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Props', () => {
|
||||
it('should render custom message when provided', () => {
|
||||
const customMessage = '没有互动消息'
|
||||
const { getByText, queryByText } = render(
|
||||
<MessageEmptyState message={customMessage} />
|
||||
)
|
||||
expect(getByText(customMessage)).toBeTruthy()
|
||||
expect(queryByText('暂无消息')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render custom icon when provided', () => {
|
||||
const CustomIcon = <Text testID="custom-icon">🎨</Text>
|
||||
const { getByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
|
||||
expect(getByTestId('custom-icon')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not render default icon when custom icon is provided', () => {
|
||||
const CustomIcon = <Text testID="custom-icon">🎨</Text>
|
||||
const { queryByTestId } = render(<MessageEmptyState icon={CustomIcon} />)
|
||||
// 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(<MessageEmptyState />)
|
||||
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(<MessageEmptyState />)
|
||||
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(<MessageEmptyState />)
|
||||
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(<MessageEmptyState />)
|
||||
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(<MessageEmptyState />)
|
||||
const messageText = getByTestId('empty-state-message')
|
||||
expect(messageText).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
42
components/message/MessageEmptyState.tsx
Normal file
42
components/message/MessageEmptyState.tsx
Normal file
@@ -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 (
|
||||
<View testID="message-empty-state" style={styles.container}>
|
||||
<View testID="empty-state-icon" style={styles.iconContainer}>
|
||||
{icon || <Text style={styles.defaultIcon}>📭</Text>}
|
||||
</View>
|
||||
<Text testID="empty-state-message" style={styles.message}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
defaultIcon: {
|
||||
fontSize: 48,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
color: '#8A8A8A',
|
||||
},
|
||||
})
|
||||
283
components/message/MessageTabBar.test.tsx
Normal file
283
components/message/MessageTabBar.test.tsx
Normal file
@@ -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(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
expect(getByText('全部')).toBeTruthy()
|
||||
expect(getByText('互动')).toBeTruthy()
|
||||
expect(getByText('系统')).toBeTruthy()
|
||||
expect(getByText('账单')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render with testID for each tab', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar activeTab="SYSTEM" onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar activeTab="BILLING" onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar activeTab="ACTIVITY" onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
fireEvent.press(getByTestId('tab-all'))
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should call onTabChange with ACTIVITY when "互动" tab is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
fireEvent.press(getByTestId('tab-activity'))
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith('ACTIVITY')
|
||||
})
|
||||
|
||||
it('should call onTabChange with SYSTEM when "系统" tab is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
fireEvent.press(getByTestId('tab-system'))
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith('SYSTEM')
|
||||
})
|
||||
|
||||
it('should call onTabChange with BILLING when "账单" tab is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
fireEvent.press(getByTestId('tab-billing'))
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith('BILLING')
|
||||
})
|
||||
|
||||
it('should call onTabChange even when pressing the already active tab', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ all: 5 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ activity: 3 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ system: 10 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ billing: 2 }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(getByTestId('tab-billing-badge')).toBeTruthy()
|
||||
expect(getByText('2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show multiple badges when multiple counts > 0', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ all: 15, activity: 5, system: 8, billing: 2 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ all: 150 }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(getByText('99+')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('No Badge Display', () => {
|
||||
it('should not show badge when unreadCounts is not provided', () => {
|
||||
const { queryByTestId } = render(
|
||||
<MessageTabBar activeTab={undefined} onTabChange={mockOnTabChange} />
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{ all: 0, activity: 0, system: 0, billing: 0 }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MessageTabBar
|
||||
activeTab={undefined}
|
||||
onTabChange={mockOnTabChange}
|
||||
unreadCounts={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
161
components/message/MessageTabBar.tsx
Normal file
161
components/message/MessageTabBar.tsx
Normal file
@@ -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<MessageTabBarProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
unreadCounts,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{TAB_ITEMS.map((tab) => {
|
||||
const isActive = activeTab === tab.type
|
||||
const unreadCount = getUnreadCount(tab.key, unreadCounts)
|
||||
const showBadge = unreadCount !== undefined && unreadCount > 0
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
testID={`tab-${tab.key}`}
|
||||
style={styles.tabItem}
|
||||
onPress={() => onTabChange(tab.type)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.tabContent}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive ? styles.tabLabelActive : styles.tabLabelInactive,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{showBadge && (
|
||||
<View testID={`tab-${tab.key}-badge`} style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{formatBadgeCount(unreadCount)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isActive && (
|
||||
<LinearGradient
|
||||
testID={`tab-${tab.key}-active-indicator`}
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.activeIndicator}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
75
components/message/SwipeToDelete.tsx
Normal file
75
components/message/SwipeToDelete.tsx
Normal file
@@ -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<ViewStyle>
|
||||
}
|
||||
|
||||
export function SwipeToDelete({
|
||||
children,
|
||||
onDelete,
|
||||
enabled = true,
|
||||
disabled = false,
|
||||
deleteText = '删除',
|
||||
style,
|
||||
}: SwipeToDeleteProps) {
|
||||
const isDisabled = disabled || !enabled
|
||||
|
||||
const renderRightActions = () => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="swipe-delete-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="删除"
|
||||
style={styles.deleteButton}
|
||||
onPress={() => {
|
||||
if (!isDisabled) {
|
||||
onDelete()
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.deleteText}>{deleteText}</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
testID="swipe-container"
|
||||
renderRightActions={renderRightActions}
|
||||
enabled={!isDisabled}
|
||||
overshootRight={false}
|
||||
friction={2}
|
||||
>
|
||||
<View style={style}>{children}</View>
|
||||
</Swipeable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deleteButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
width: 80,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
deleteText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
14
components/message/index.ts
Normal file
14
components/message/index.ts
Normal file
@@ -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'
|
||||
117
jest.setup.js
117
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: {},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "做同款"
|
||||
|
||||
Reference in New Issue
Block a user