expo-popcore-app/app/(tabs)/message.tsx

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