443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
StatusBar as RNStatusBar,
|
|
Pressable,
|
|
RefreshControl,
|
|
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 { useMessages, type Message } from '@/hooks/use-messages'
|
|
import { useMessageActions } from '@/hooks/use-message-actions'
|
|
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')
|
|
}
|
|
|
|
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}`
|
|
}
|
|
|
|
export default function MessageScreen() {
|
|
const { t } = useTranslation()
|
|
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
|
|
const { markRead } = useMessageActions()
|
|
|
|
const { messages: allMessages, loading, loadingMore, error, refetch, loadMore, hasMore, execute } = useMessages({
|
|
limit: 20,
|
|
})
|
|
|
|
const messages = filterMessagesByTab(allMessages, activeTab)
|
|
|
|
useEffect(() => {
|
|
execute()
|
|
}, [])
|
|
|
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
|
|
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 50
|
|
|
|
if (isCloseToBottom && hasMore && !loadingMore && !loading) {
|
|
loadMore()
|
|
}
|
|
}
|
|
|
|
const handleMessagePress = async (message: Message) => {
|
|
if (!message.isRead) {
|
|
await markRead(message.id)
|
|
refetch()
|
|
}
|
|
}
|
|
|
|
if (loading && messages.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>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
<LoadingState color="#FFFFFF" />
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
if (error && messages.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>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
<ErrorState message={error.message} onRetry={refetch} />
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={400}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={loading && messages.length > 0}
|
|
onRefresh={refetch}
|
|
tintColor="#FFFFFF"
|
|
/>
|
|
}
|
|
>
|
|
{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>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
scrollContent: {
|
|
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: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingTop: 200,
|
|
},
|
|
emptyIcon: {
|
|
fontSize: 48,
|
|
},
|
|
emptyText: {
|
|
color: '#8A8A8A',
|
|
fontSize: 12,
|
|
marginTop: 16,
|
|
},
|
|
})
|