Last active
April 11, 2025 08:02
-
-
Save gorkemozkan/c1bcae7deefea1d2a0f2577dcc7883ef to your computer and use it in GitHub Desktop.
ReadMore Without Library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState, useCallback, useEffect, useRef } from 'react'; | |
import { View, TouchableOpacity, StyleSheet, LayoutAnimation, Platform, UIManager, Text, LayoutChangeEvent, Animated } from 'react-native'; | |
import theme from '@/theme'; | |
import HTMLRenderer from './HTMLRenderer'; | |
import StyledText from '@/components/UI/StyledText'; | |
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { | |
UIManager.setLayoutAnimationEnabledExperimental(true); | |
} | |
interface ReadMoreProps { | |
children: string | React.ReactNode; | |
style?: { | |
typography?: { | |
fontFamily?: string; | |
fontSize?: number; | |
lineHeight?: number; | |
color?: string; | |
}; | |
container?: object; | |
content?: object; | |
readMoreButton?: object; | |
readMoreText?: object; | |
skeleton?: object; | |
}; | |
config?: { | |
maxHeight?: number; | |
readMoreText?: string; | |
readLessText?: string; | |
hideShowButton?: boolean; | |
animationDuration?: number; | |
skeletonColor?: string; | |
skeletonHighlightColor?: string; | |
skeletonLines?: number; | |
skeletonHeight?: number; | |
skeletonLineHeight?: number; | |
skeletonLineSpacing?: number; | |
}; | |
isHtml?: boolean; | |
} | |
const sanitizeHtml = (html: string): string => { | |
return html | |
// Remove script tags and their content | |
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') | |
// Remove style tags and their content | |
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '') | |
// Remove empty paragraphs | |
.replace(/<p>\s*( |\u00A0|\s)*<\/p>/gi, '') | |
// Replace <br> tags with spaces | |
.replace(/<br\s*\/?>/gi, ' ') | |
// Remove all tags except allowed ones | |
.replace(/<(?!\/?(p|b|i|em|strong|a|ul|ol|li|span|div|img|h[1-4])\b)[^>]+>/gi, '') | |
// Remove javascript: URLs | |
.replace(/javascript:/gi, '') | |
// Remove event handlers | |
.replace(/on\w+="[^"]*"/gi, '') | |
// Replace non-breaking spaces with regular spaces | |
.replace(/ | | /gi, ' ') | |
.replace(/\u00A0/g, ' ') | |
.replace(/\u2007/g, ' ') | |
.replace(/\u202F/g, ' ') | |
// Replace HTML entities | |
.replace(/&/g, '&') | |
.replace(/</g, '<') | |
.replace(/>/g, '>') | |
.replace(/"/g, '"') | |
// Normalize whitespace | |
.replace(/\s+/g, ' ') | |
.trim(); | |
}; | |
const ReadMore: React.FC<ReadMoreProps> = ({ | |
children, | |
style = {}, | |
config = {}, | |
isHtml = false, | |
}) => { | |
const { | |
maxHeight = 115, | |
readMoreText = 'Show More', | |
readLessText = 'Show Less', | |
hideShowButton = false, | |
animationDuration = 300, | |
skeletonColor = theme.colorBg.Elevated, | |
skeletonHighlightColor = theme.colorBg.Secondary, | |
skeletonLines = 3, | |
skeletonHeight, | |
skeletonLineHeight = 16, | |
skeletonLineSpacing = 8, | |
} = config; | |
const { | |
typography = {}, | |
container: containerStyle = {}, | |
content: contentStyle = {}, | |
readMoreButton: readMoreButtonStyle = {}, | |
readMoreText: readMoreTextStyle = {}, | |
skeleton: skeletonStyle = {}, | |
} = style; | |
const { | |
fontFamily = 'system-ui', | |
fontSize = 16, | |
lineHeight = 24, | |
color = theme.colorText.Secondary, | |
} = typography; | |
// State | |
const [isExpanded, setIsExpanded] = useState(false); | |
const [contentHeight, setContentHeight] = useState(0); | |
const [shouldShowButton, setShouldShowButton] = useState(false); | |
const [isReady, setIsReady] = useState(false); | |
const [skeletonAnimValue] = useState(new Animated.Value(0)); | |
const contentRef = useRef<View>(null); | |
const measureContainerRef = useRef<View>(null); | |
const fadeAnim = useRef(new Animated.Value(0)).current; | |
// Process content | |
const processedContent = React.useMemo(() => { | |
if (isHtml && typeof children === 'string') { | |
return sanitizeHtml(children); | |
} | |
return children; | |
}, [children, isHtml]); | |
const renderContent = useCallback(() => { | |
if (isHtml && typeof processedContent === 'string') { | |
return ( | |
<HTMLRenderer | |
html={processedContent} | |
style={{ | |
container: { width: '100%' }, | |
text: { fontFamily, fontSize, lineHeight, color } | |
}} | |
/> | |
); | |
} else if (typeof processedContent === 'string') { | |
return ( | |
<StyledText style={{ fontFamily, fontSize, lineHeight, color }}> | |
{processedContent} | |
</StyledText> | |
); | |
} else { | |
return processedContent; | |
} | |
}, [processedContent, isHtml, fontFamily, fontSize, lineHeight, color]); | |
const toggleExpanded = useCallback(() => { | |
if (Platform.OS === 'ios' || Platform.OS === 'android') { | |
LayoutAnimation.configureNext({ | |
duration: animationDuration, | |
update: { type: 'easeInEaseOut' }, | |
}); | |
} | |
setIsExpanded(prev => !prev); | |
}, [animationDuration]); | |
const onLayout = useCallback((event: LayoutChangeEvent) => { | |
const height = event.nativeEvent.layout.height; | |
setContentHeight(height); | |
setShouldShowButton(height > maxHeight); | |
setIsReady(true); | |
Animated.timing(fadeAnim, { | |
toValue: 1, | |
duration: 300, | |
useNativeDriver: true, | |
}).start(); | |
}, [maxHeight, fadeAnim]); | |
useEffect(() => { | |
setIsExpanded(false); | |
setIsReady(false); | |
setShouldShowButton(false); | |
fadeAnim.setValue(0); | |
Animated.loop( | |
Animated.sequence([ | |
Animated.timing(skeletonAnimValue, { | |
toValue: 1, | |
duration: 1000, | |
useNativeDriver: true, | |
}), | |
Animated.timing(skeletonAnimValue, { | |
toValue: 0, | |
duration: 1000, | |
useNativeDriver: true, | |
}), | |
]) | |
).start(); | |
}, [processedContent, fadeAnim, skeletonAnimValue]); | |
const renderSkeleton = () => { | |
const lines = []; | |
const calculatedHeight = skeletonHeight || Math.min(maxHeight, 150); | |
for (let i = 0; i < skeletonLines; i++) { | |
const width = i === skeletonLines - 1 ? '60%' : `${Math.random() * 40 + 60}%`; | |
const opacity = skeletonAnimValue.interpolate({ | |
inputRange: [0, 0.5, 1], | |
outputRange: [0.3, 0.7, 0.3], | |
}); | |
lines.push( | |
<Animated.View | |
key={i} | |
style={[ | |
styles.skeletonLine, | |
{ | |
width, | |
backgroundColor: skeletonColor, | |
height: skeletonLineHeight, | |
marginBottom: i < skeletonLines - 1 ? skeletonLineSpacing : 0, | |
opacity | |
}, | |
skeletonStyle | |
]} | |
/> | |
); | |
} | |
return ( | |
<View | |
style={[ | |
styles.skeletonContainer, | |
{ | |
minHeight: calculatedHeight, | |
paddingVertical: 10, | |
} | |
]} | |
> | |
{lines} | |
</View> | |
); | |
}; | |
if (!processedContent) return null; | |
return ( | |
<View style={[styles.container, containerStyle]}> | |
<View | |
ref={measureContainerRef} | |
style={styles.measureContainer} | |
onLayout={onLayout} | |
> | |
{renderContent()} | |
</View> | |
{!isReady && renderSkeleton()} | |
{isReady && ( | |
<Animated.View | |
ref={contentRef} | |
style={[ | |
styles.content, | |
{ maxHeight: isExpanded ? undefined : maxHeight }, | |
contentStyle, | |
{ opacity: fadeAnim } | |
]} | |
> | |
{renderContent()} | |
</Animated.View> | |
)} | |
{isReady && shouldShowButton && !hideShowButton && ( | |
<TouchableOpacity | |
onPress={toggleExpanded} | |
style={[styles.readMoreButton, readMoreButtonStyle]} | |
activeOpacity={0.7} | |
> | |
<StyledText | |
button='md' | |
colorVariable={isExpanded ? 'Secondary' : 'Active'} | |
style={[styles.readMoreText, readMoreTextStyle]} | |
> | |
{isExpanded ? readLessText : readMoreText} | |
</StyledText> | |
</TouchableOpacity> | |
)} | |
</View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
width: '100%', | |
}, | |
measureContainer: { | |
position: 'absolute', | |
opacity: 0, | |
width: '100%', | |
zIndex: -1, | |
}, | |
content: { | |
width: '100%', | |
overflow: 'hidden', | |
}, | |
readMoreButton: { | |
marginTop: 12, | |
alignItems: 'flex-start', | |
}, | |
readMoreText: { | |
color: theme.colorText.Primary, | |
}, | |
skeletonContainer: { | |
width: '100%', | |
minHeight: 50, | |
paddingVertical: 10, | |
}, | |
skeletonLine: { | |
height: 16, | |
borderRadius: 4, | |
marginBottom: 8, | |
}, | |
}); | |
export default React.memo(ReadMore); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment