This commit is contained in:
imeepos
2026-01-29 15:51:39 +08:00
parent 6279752f23
commit 7e6de68891
16 changed files with 2106 additions and 456 deletions

View 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()
})
})
})

View 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()
})
})
})

View File

@@ -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',
},
})

View File

@@ -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,
},
})

View 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')
})
})

View 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

View File

@@ -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', () => {

View 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()
})
})
})

View 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',
},
})

View 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)
})
})
})

View 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',
},
})

View 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',
},
})

View 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'

View File

@@ -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: {},
}

View File

@@ -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"

View File

@@ -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": "做同款"