React Native mobile application for SAPIENS news platform. Consolidated all previous history into single commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
573 lines
16 KiB
TypeScript
573 lines
16 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { View, Text, ScrollView, Image, ActivityIndicator, StyleSheet, SafeAreaView, TouchableOpacity, FlatList } from 'react-native';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useArticle, useOutlet } from '@/hooks/useApi';
|
|
import { useLanguage } from '@/contexts/LanguageContext';
|
|
import ArticleCard from '@/components/ArticleCard';
|
|
import LanguageModal from '@/components/LanguageModal';
|
|
import CommentsDrawer from '@/components/CommentsDrawer';
|
|
import { API_BASE_URL } from '@/lib/config';
|
|
|
|
export default function ArticleScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const router = useRouter();
|
|
const { language, setLanguage } = useLanguage();
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [showLanguage, setShowLanguage] = useState(false);
|
|
const [showComments, setShowComments] = useState(false);
|
|
|
|
const { data: article, isLoading, error } = useArticle(id || '', language, true);
|
|
|
|
// Get outlet data from article
|
|
const articleData = article as any;
|
|
const outletId = articleData?.outletId || articleData?.outlet_id;
|
|
const { data: outlet } = useOutlet(outletId || '', language);
|
|
|
|
// Fetch latest articles for the feed
|
|
// TODO: Re-enable when news-api has a compatible feed endpoint
|
|
const { data: latestArticles } = useQuery({
|
|
queryKey: ['/api/feed', { limit: 20, exclude: id }],
|
|
queryFn: async () => {
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/${language}/articles/latest?limit=20`);
|
|
return response.json();
|
|
},
|
|
enabled: false, // Disabled until API structure matches frontend expectations
|
|
});
|
|
|
|
// Clean and process body content
|
|
const cleanedBody = useMemo(() => {
|
|
if (!article || !(article as any).body) return '';
|
|
|
|
const body = (article as any).body;
|
|
// Remove "originally published" line
|
|
const pattern = /^\s*(?:This article was\s+)?originally published\s+(?:at|on)\s+\S+\.?\s*(?:\r?\n){1,2}/i;
|
|
const cleaned = body.replace(pattern, '').trimStart();
|
|
|
|
// Truncate at 3000 characters but complete the sentence
|
|
if (cleaned.length <= 3000) return cleaned;
|
|
|
|
const truncatePoint = 3000;
|
|
const afterTruncate = cleaned.substring(truncatePoint);
|
|
const sentenceEndMatch = afterTruncate.match(/[.!?](?:\s|$)/);
|
|
|
|
if (sentenceEndMatch && sentenceEndMatch.index !== undefined) {
|
|
const endIndex = truncatePoint + sentenceEndMatch.index + 1;
|
|
return cleaned.substring(0, endIndex);
|
|
}
|
|
|
|
return cleaned.substring(0, 3000);
|
|
}, [article]);
|
|
|
|
const getInitials = (name: string) => {
|
|
return name
|
|
.split(' ')
|
|
.map(word => word[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
};
|
|
|
|
// Parse body content for rendering
|
|
const parseBodyContent = (text: string) => {
|
|
const lines = text.split('\n');
|
|
const elements: Array<{ type: 'subtitle' | 'paragraph', content: string, key: string }> = [];
|
|
let currentParagraph: string[] = [];
|
|
let keyIndex = 0;
|
|
|
|
const flushParagraph = () => {
|
|
if (currentParagraph.length > 0) {
|
|
const paragraphText = currentParagraph.join('\n').trim();
|
|
if (paragraphText) {
|
|
elements.push({
|
|
type: 'paragraph',
|
|
content: paragraphText,
|
|
key: `p-${keyIndex++}`
|
|
});
|
|
}
|
|
currentParagraph = [];
|
|
}
|
|
};
|
|
|
|
lines.forEach((line) => {
|
|
const trimmedLine = line.trim();
|
|
|
|
// Check for subtitle patterns
|
|
const fullLineSubtitle = trimmedLine.match(/^\*\*(.*?)\*\*$/);
|
|
const prefixedSubtitle = trimmedLine.match(/^\s*\*\*(.+?)\*\*\s*(.*)$/);
|
|
|
|
if (fullLineSubtitle && trimmedLine === `**${fullLineSubtitle[1]}**`) {
|
|
flushParagraph();
|
|
elements.push({
|
|
type: 'subtitle',
|
|
content: fullLineSubtitle[1],
|
|
key: `subtitle-${keyIndex++}`
|
|
});
|
|
} else if (prefixedSubtitle && prefixedSubtitle[2].trim() !== '') {
|
|
flushParagraph();
|
|
elements.push({
|
|
type: 'subtitle',
|
|
content: prefixedSubtitle[1],
|
|
key: `subtitle-${keyIndex++}`
|
|
});
|
|
if (prefixedSubtitle[2].trim()) {
|
|
currentParagraph.push(prefixedSubtitle[2].trim());
|
|
}
|
|
} else if (trimmedLine === '') {
|
|
flushParagraph();
|
|
} else {
|
|
currentParagraph.push(line);
|
|
}
|
|
});
|
|
|
|
flushParagraph();
|
|
return elements;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#007AFF" />
|
|
<Text style={styles.text}>Loading...</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !article) {
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>Article not found</Text>
|
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
|
<Text style={styles.backButtonText}>Go Back</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const bodyElements = articleData.subtopics && articleData.subtopics.length > 0
|
|
? null
|
|
: parseBodyContent(cleanedBody);
|
|
|
|
const outletData = outlet as any;
|
|
const outletName = outletData?.name || articleData?.outletName || 'Unknown Outlet';
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<View style={styles.headerLeft}>
|
|
{outletData?.avatar ? (
|
|
<Image
|
|
source={{ uri: outletData.avatar }}
|
|
style={styles.outletAvatar}
|
|
/>
|
|
) : (
|
|
<View style={[styles.avatarFallback, styles.outletAvatar]}>
|
|
<Text style={styles.avatarText}>{getInitials(outletName)}</Text>
|
|
</View>
|
|
)}
|
|
<View style={styles.outletInfo}>
|
|
<Image
|
|
source={require('@/assets/images/sapiens-logo.png')}
|
|
style={styles.miniLogo}
|
|
resizeMode="contain"
|
|
/>
|
|
<Text style={styles.outletName} numberOfLines={1}>{outletName}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.headerRight}>
|
|
<TouchableOpacity style={styles.iconButton} onPress={() => setShowLanguage(true)}>
|
|
<Ionicons name="settings-outline" size={18} color="#333" />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.iconButton}>
|
|
<Ionicons name="person-circle-outline" size={20} color="#333" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
|
{/* Hero Image */}
|
|
{articleData.thumbnail && (
|
|
<View style={styles.heroContainer}>
|
|
<Image
|
|
source={{ uri: articleData.thumbnail }}
|
|
style={[styles.heroImage, !imageLoaded && styles.heroImageHidden]}
|
|
resizeMode="cover"
|
|
onLoad={() => setImageLoaded(true)}
|
|
/>
|
|
{!imageLoaded && (
|
|
<View style={styles.imagePlaceholder}>
|
|
<ActivityIndicator size="large" color="#007AFF" />
|
|
</View>
|
|
)}
|
|
{articleData.category && (
|
|
<View style={styles.categoryBadge}>
|
|
<Text style={styles.categoryText}>{articleData.category}</Text>
|
|
</View>
|
|
)}
|
|
{articleData.timeAgo && (
|
|
<View style={styles.timeContainer}>
|
|
<Text style={styles.timeText}>{articleData.timeAgo}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Article Content Card */}
|
|
<View style={styles.contentCard}>
|
|
{/* Title */}
|
|
<Text style={styles.title}>{articleData.title}</Text>
|
|
|
|
{/* Summary */}
|
|
{articleData.summary && (
|
|
<Text style={styles.summary}>{articleData.summary}</Text>
|
|
)}
|
|
|
|
{/* Body - Subtopics or Parsed Content */}
|
|
<View style={styles.body}>
|
|
{articleData.subtopics && articleData.subtopics.length > 0 ? (
|
|
articleData.subtopics.map((topic: any, index: number) => (
|
|
<View key={index} style={styles.subtopicSection}>
|
|
<Text style={styles.subtitle}>{topic.title}</Text>
|
|
{Array.isArray(topic.content) ? (
|
|
topic.content.map((paragraph: string, pIndex: number) => (
|
|
<Text key={pIndex} style={styles.paragraph}>
|
|
{paragraph}
|
|
</Text>
|
|
))
|
|
) : (
|
|
<Text style={styles.paragraph}>{topic.content}</Text>
|
|
)}
|
|
</View>
|
|
))
|
|
) : (
|
|
bodyElements?.map((element) => (
|
|
element.type === 'subtitle' ? (
|
|
<Text key={element.key} style={styles.subtitle}>
|
|
{element.content}
|
|
</Text>
|
|
) : (
|
|
<Text key={element.key} style={styles.paragraph}>
|
|
{element.content}
|
|
</Text>
|
|
)
|
|
))
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Latest Articles Feed */}
|
|
{latestArticles?.items && latestArticles.items.length > 0 && (
|
|
<View style={styles.feedContainer}>
|
|
<Text style={styles.feedTitle}>Latest Articles</Text>
|
|
{latestArticles.items
|
|
.filter((item: any) => item.newsId && item.newsId !== id)
|
|
.slice(0, 10)
|
|
.map((item: any) => (
|
|
<View key={item.newsId} style={styles.feedItemWrapper}>
|
|
<ArticleCard
|
|
id={item.newsId}
|
|
title={item.title}
|
|
summary={item.summary}
|
|
thumbnail={item.thumbnail}
|
|
publishedAt={new Date(item.publishedAt)}
|
|
timeAgo={item.timeAgo}
|
|
outletName={item.outletName}
|
|
category={item.category}
|
|
onClick={(newsId) => {
|
|
router.push(`/article/${newsId}`);
|
|
}}
|
|
isAd={item.isAd || false}
|
|
tags={item.tags || []}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
{/* Footer Action Bar */}
|
|
<View style={styles.footer}>
|
|
<TouchableOpacity style={styles.footerButton} onPress={() => router.back()}>
|
|
<Ionicons name="chevron-back" size={24} color="#333" />
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.footerActions}>
|
|
<TouchableOpacity style={styles.actionButton} onPress={() => setShowComments(true)}>
|
|
<Ionicons name="chatbubble-outline" size={22} color="#333" />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.actionButton}>
|
|
<Ionicons name="share-outline" size={22} color="#333" />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.actionButton}>
|
|
<Ionicons name="bookmark-outline" size={22} color="#333" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TouchableOpacity style={styles.footerButton}>
|
|
<Ionicons name="chevron-forward" size={24} color="#999" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Language Modal */}
|
|
<LanguageModal
|
|
visible={showLanguage}
|
|
onClose={() => setShowLanguage(false)}
|
|
currentLanguage={language}
|
|
onSelectLanguage={setLanguage}
|
|
/>
|
|
|
|
{/* Comments Drawer */}
|
|
<CommentsDrawer
|
|
articleId={id || ''}
|
|
isOpen={showComments}
|
|
onClose={() => setShowComments(false)}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#f5f5f5',
|
|
},
|
|
header: {
|
|
backgroundColor: '#fff',
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#e0e0e0',
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
headerLeft: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flex: 1,
|
|
gap: 12,
|
|
},
|
|
outletAvatar: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
},
|
|
avatarFallback: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: '#e0e0e0',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
avatarText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#333',
|
|
},
|
|
outletInfo: {
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
gap: 3,
|
|
flex: 1,
|
|
},
|
|
miniLogo: {
|
|
height: 10,
|
|
width: 90,
|
|
resizeMode: 'contain',
|
|
marginLeft: 0,
|
|
},
|
|
outletName: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#000',
|
|
maxWidth: 150,
|
|
},
|
|
headerRight: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
iconButton: {
|
|
padding: 8,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
heroContainer: {
|
|
position: 'relative',
|
|
width: '100%',
|
|
aspectRatio: 16 / 9,
|
|
backgroundColor: '#e0e0e0',
|
|
},
|
|
heroImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
heroImageHidden: {
|
|
opacity: 0,
|
|
},
|
|
imagePlaceholder: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: '#e0e0e0',
|
|
},
|
|
categoryBadge: {
|
|
position: 'absolute',
|
|
top: 16,
|
|
left: 16,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 16,
|
|
},
|
|
categoryText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: '#333',
|
|
},
|
|
timeContainer: {
|
|
position: 'absolute',
|
|
bottom: 12,
|
|
left: 12,
|
|
},
|
|
timeText: {
|
|
fontSize: 12,
|
|
color: '#fff',
|
|
fontWeight: '600',
|
|
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
|
textShadowOffset: { width: 0, height: 1 },
|
|
textShadowRadius: 3,
|
|
},
|
|
contentCard: {
|
|
backgroundColor: '#fff',
|
|
marginTop: 16,
|
|
marginHorizontal: 16,
|
|
marginBottom: 24,
|
|
padding: 20,
|
|
borderRadius: 12,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 4,
|
|
elevation: 3,
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
fontWeight: 'bold',
|
|
color: '#000',
|
|
marginBottom: 12,
|
|
lineHeight: 32,
|
|
},
|
|
summary: {
|
|
fontSize: 16,
|
|
color: '#666',
|
|
marginBottom: 20,
|
|
lineHeight: 24,
|
|
},
|
|
body: {
|
|
marginBottom: 16,
|
|
},
|
|
subtopicSection: {
|
|
marginBottom: 20,
|
|
},
|
|
subtitle: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
color: '#000',
|
|
marginTop: 20,
|
|
marginBottom: 12,
|
|
},
|
|
paragraph: {
|
|
fontSize: 16,
|
|
color: '#333',
|
|
lineHeight: 26,
|
|
marginBottom: 16,
|
|
},
|
|
feedContainer: {
|
|
marginTop: 32,
|
|
marginHorizontal: 16,
|
|
marginBottom: 32,
|
|
},
|
|
feedTitle: {
|
|
fontSize: 20,
|
|
fontWeight: 'bold',
|
|
color: '#000',
|
|
marginBottom: 16,
|
|
},
|
|
feedItemWrapper: {
|
|
marginBottom: 12,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
errorContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 20,
|
|
},
|
|
text: {
|
|
fontSize: 14,
|
|
color: '#666',
|
|
marginTop: 10,
|
|
},
|
|
errorText: {
|
|
fontSize: 16,
|
|
color: 'red',
|
|
fontWeight: 'bold',
|
|
marginBottom: 16,
|
|
},
|
|
backButton: {
|
|
backgroundColor: '#007AFF',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
borderRadius: 8,
|
|
},
|
|
backButtonText: {
|
|
color: '#fff',
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
footer: {
|
|
backgroundColor: '#fff',
|
|
borderTopWidth: 1,
|
|
borderTopColor: '#e0e0e0',
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 8,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
footerButton: {
|
|
padding: 8,
|
|
width: 40,
|
|
alignItems: 'center',
|
|
},
|
|
footerActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
actionButton: {
|
|
padding: 8,
|
|
width: 36,
|
|
height: 36,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
});
|