345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import React, { useState, useCallback } from 'react'
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
StatusBar as RNStatusBar,
|
|
Pressable,
|
|
RefreshControl,
|
|
} from 'react-native'
|
|
import { StatusBar } from 'expo-status-bar'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useRouter, useFocusEffect } from 'expo-router'
|
|
import { PanGestureHandler } from 'react-native-gesture-handler'
|
|
|
|
import { useMessages, type Message } from '@/hooks/use-messages'
|
|
import { useMessageActions } from '@/hooks/use-message-actions'
|
|
import { useAnnouncements } from '@/hooks/use-announcements'
|
|
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
|
import LoadingState from '@/components/LoadingState'
|
|
import ErrorState from '@/components/ErrorState'
|
|
import PaginationLoader from '@/components/PaginationLoader'
|
|
|
|
import { MessageCard, type Message as MessageCardMessage } from '@/components/message/MessageCard'
|
|
import { MessageTabBar, type MessageType, TAB_ITEMS } 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',
|
|
}
|
|
|
|
// 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 router = useRouter()
|
|
const [activeTab, setActiveTab] = useState<MessageType>(undefined)
|
|
|
|
// 获取当前 Tab 索引
|
|
const activeTabIndex = TAB_ITEMS.findIndex(tab => tab.type === activeTab)
|
|
|
|
// Hooks
|
|
const {
|
|
messages: allMessages,
|
|
loading,
|
|
loadingMore,
|
|
refreshing,
|
|
error,
|
|
refetch,
|
|
loadMore,
|
|
hasMore,
|
|
} = useMessages({ limit: 20 })
|
|
|
|
const {
|
|
markRead,
|
|
batchMarkRead,
|
|
deleteMessage,
|
|
} = useMessageActions()
|
|
|
|
const {
|
|
announcements,
|
|
execute: executeAnnouncements,
|
|
} = useAnnouncements()
|
|
|
|
// Filter messages based on active tab
|
|
const filteredMessages = filterMessagesByTab(allMessages, activeTab)
|
|
|
|
// Reload data when tab gains focus
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
refetch()
|
|
executeAnnouncements()
|
|
}, [refetch, executeAnnouncements])
|
|
)
|
|
|
|
// Handle pull-to-refresh
|
|
const handleRefresh = useCallback(() => {
|
|
refetch()
|
|
executeAnnouncements()
|
|
}, [refetch, executeAnnouncements])
|
|
|
|
// Handle tab change
|
|
const handleTabChange = useCallback((type: MessageType) => {
|
|
setActiveTab(type)
|
|
}, [])
|
|
|
|
// 左右滑动切换消息类型 Tab
|
|
const handleSwipeLeft = useCallback(() => {
|
|
if (activeTabIndex < TAB_ITEMS.length - 1) {
|
|
setActiveTab(TAB_ITEMS[activeTabIndex + 1].type)
|
|
}
|
|
}, [activeTabIndex])
|
|
|
|
const handleSwipeRight = useCallback(() => {
|
|
if (activeTabIndex > 0) {
|
|
setActiveTab(TAB_ITEMS[activeTabIndex - 1].type)
|
|
}
|
|
}, [activeTabIndex])
|
|
|
|
const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({
|
|
onSwipeLeft: handleSwipeLeft,
|
|
onSwipeRight: handleSwipeRight,
|
|
canSwipeLeft: activeTabIndex < TAB_ITEMS.length - 1,
|
|
canSwipeRight: activeTabIndex > 0,
|
|
})
|
|
|
|
// Handle mark all read
|
|
const handleMarkAllRead = useCallback(async () => {
|
|
const unreadMessages = filteredMessages.filter(m => !m.isRead)
|
|
if (unreadMessages.length === 0) return
|
|
|
|
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)
|
|
}
|
|
|
|
// 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}` as any)
|
|
}
|
|
}, [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])
|
|
|
|
// Convert Message to MessageCardMessage format
|
|
const convertToMessageCardFormat = (message: Message): MessageCardMessage => ({
|
|
id: message.id,
|
|
type: message.type,
|
|
title: message.title,
|
|
content: message.content,
|
|
data: message.data,
|
|
link: message.link,
|
|
priority: message.priority,
|
|
isRead: message.isRead,
|
|
readAt: message.readAt ? (typeof message.readAt === 'string' ? message.readAt : message.readAt.toISOString()) : undefined,
|
|
createdAt: message.createdAt,
|
|
})
|
|
|
|
// Render message item
|
|
const renderMessageItem = useCallback(({ item }: { item: Message }) => (
|
|
<MessageCard
|
|
message={convertToMessageCardFormat(item)}
|
|
onPress={() => handleMessagePress(item)}
|
|
/>
|
|
), [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.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>
|
|
)
|
|
}
|
|
|
|
// Error state
|
|
if (error && allMessages.length === 0) {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
<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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<StatusBar style="light" />
|
|
<RNStatusBar barStyle="light-content" />
|
|
|
|
{/* 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>
|
|
|
|
{/* Announcement Banner */}
|
|
{announcements.length > 0 && (
|
|
<AnnouncementBanner
|
|
announcements={announcements}
|
|
onPress={handleAnnouncementPress}
|
|
/>
|
|
)}
|
|
|
|
{/* Tab Bar */}
|
|
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
|
|
|
{/* Message List */}
|
|
<PanGestureHandler
|
|
onGestureEvent={handleGestureEvent}
|
|
onHandlerStateChange={handleGestureStateChange}
|
|
activeOffsetX={[-20, 20]}
|
|
>
|
|
<View style={styles.gestureContainer}>
|
|
<FlatList
|
|
testID="message-list"
|
|
data={filteredMessages}
|
|
renderItem={renderMessageItem}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={[
|
|
styles.listContent,
|
|
filteredMessages.length === 0 && styles.emptyListContent,
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
onEndReached={handleEndReached}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={renderFooter}
|
|
ListEmptyComponent={renderEmptyComponent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor="#FFFFFF"
|
|
colors={['#FFFFFF']}
|
|
/>
|
|
}
|
|
/>
|
|
</View>
|
|
</PanGestureHandler>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
gestureContainer: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 20,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
},
|
|
markAllReadText: {
|
|
fontSize: 14,
|
|
color: '#8A8A8A',
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: 4,
|
|
paddingTop: 12,
|
|
paddingBottom: 100,
|
|
},
|
|
emptyListContent: {
|
|
flex: 1,
|
|
},
|
|
})
|