feat: redesign input komentar gaya messaging app dan tampilan file lampiran

- Input komentar: tombol + di kiri, pill input flex-1, tombol send lingkaran di kanan
- Tampilan file di komentar: semua tipe file sebagai card (tidak ada preview inline)
- Grid 2 kolom untuk file, chip '+N lainnya' jika lebih dari 2
- Modal full screen saat klik '+N lainnya' dengan grid 2 kolom
- Preview gambar via ImageViewing (dalam modal dan di luar modal)
- File non-gambar dibuka via native viewer (download + intent/share)
This commit is contained in:
2026-06-09 17:35:22 +08:00
parent 9cd78dae3a
commit 3370f48238
2 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
import ImageUser from "@/components/imageNew";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import { ConstEnv } from "@/constants/ConstEnv";
import { isImageFile } from "@/constants/FileExtensions";
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
import { startActivityAsync } from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { useState } from "react";
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, View } from "react-native";
import ImageViewing from "react-native-image-viewing";
import * as mime from 'react-native-mime-types';
import Toast from "react-native-toast-message";
export type CommentFile = {
id: string
name: string
extension: string
idStorage: string
}
export type CommentItem = {
id: string
comment: string
createdAt: string
idUser: string
img: string
username: string
isEdited: boolean
updatedAt: string
files: CommentFile[]
}
type Props = {
data: CommentItem[]
loading: boolean
myId: string
canInteract: boolean
onLongPress: (id: string, comment: string, files: CommentFile[]) => void
}
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (isImageFile(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (isImageFile(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
function FileCard({ file, colors, onPress }: { file: CommentFile; colors: any; onPress: () => void }) {
const ext = file.extension.toLowerCase()
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [Styles.fileCard, {
borderColor: colors.icon + '18',
backgroundColor: pressed ? colors.icon + '10' : 'transparent'
}]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: getFileColor(ext) + '20' }]}>
<MaterialCommunityIcons name={getFileIcon(ext)} size={18} color={getFileColor(ext)} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{file.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
}
export default function DiscussionCommentList({ data, loading, myId, canInteract, onLongPress }: Props) {
const { colors } = useTheme()
const [expandedIds, setExpandedIds] = useState<string[]>([])
const [modalFiles, setModalFiles] = useState<CommentFile[]>([])
const [modalVisible, setModalVisible] = useState(false)
const [previewFile, setPreviewFile] = useState<CommentFile | null>(null)
const [modalPreviewFile, setModalPreviewFile] = useState<CommentFile | null>(null)
const [loadingOpen, setLoadingOpen] = useState(false)
const arrSkeleton = Array.from({ length: 3 }, (_, i) => i)
function toggleExpand(id: string) {
setExpandedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
async function openExternal(file: CommentFile) {
try {
setLoadingOpen(true)
const remoteUrl = `${ConstEnv.url_storage}/files/${file.idStorage}`
const fileName = `${file.name}.${file.extension}`
const localPath = `${FileSystem.documentDirectory}/${fileName}`
const dl = await FileSystem.downloadAsync(remoteUrl, localPath)
if (dl.status !== 200) throw new Error('Download failed')
const contentURL = await FileSystem.getContentUriAsync(dl.uri)
const mimeType = mime.lookup(fileName) as string
if (Platform.OS === 'android') {
await startActivityAsync('android.intent.action.VIEW', { data: contentURL, flags: 1, type: mimeType })
} else {
await Sharing.shareAsync(localPath)
}
} catch {
Toast.show({ type: 'error', text1: 'Gagal membuka file' })
} finally {
setLoadingOpen(false)
}
}
function handleFilePress(file: CommentFile) {
if (isImageFile(file.extension.toLowerCase())) {
setPreviewFile(file)
} else {
openExternal(file)
}
}
if (loading) {
return (
<View style={Styles.mt10}>
{arrSkeleton.map((_, i) => (
<Skeleton key={i} width={100} widthType="percent" height={40} borderRadius={5} />
))}
</View>
)
}
return (
<>
<View style={Styles.mt10}>
{data.map((item, i) => (
<Pressable
key={i}
onPress={() => toggleExpand(item.id)}
onLongPress={() => item.idUser === myId && canInteract && onLongPress(item.id, item.comment, item.files ?? [])}
style={({ pressed }) => [
Styles.discussionCommentCard,
{ backgroundColor: pressed ? colors.icon + '10' : colors.card, borderColor: colors.icon + '20' }
]}
>
<View style={Styles.flex1}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.mb05]}>
<View style={[Styles.rowItemsCenter, { gap: 8, flex: 1, marginRight: 8 }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textMediumSemiBold, { color: colors.text }]} numberOfLines={1}>
{item.username}
</Text>
{item.isEdited && (
<Text style={[Styles.discussionEditedText, { color: colors.dimmed }]}>diedit</Text>
)}
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed, flexShrink: 0 }]}>
{item.createdAt}
</Text>
</View>
{item.comment.length > 0 && (
<Text
style={[Styles.textDefault, { color: colors.text }]}
numberOfLines={expandedIds.includes(item.id) ? 0 : 3}
>
{item.comment}
</Text>
)}
{item.files?.length > 0 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: item.comment.length > 0 ? 8 : 0 }}>
{(item.files.length > 2 ? item.files.slice(0, 1) : item.files).map((file, idx) => (
<FileCard key={idx} file={file} colors={colors} onPress={() => handleFilePress(file)} />
))}
{item.files.length > 2 && (
<Pressable
onPress={() => { setModalFiles(item.files); setModalVisible(true) }}
style={[Styles.fileCard, { borderColor: colors.icon + '18', backgroundColor: 'transparent' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: '#868E96' + '20' }]}>
<MaterialCommunityIcons name="folder-multiple-outline" size={18} color="#868E96" />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>+{item.files.length - 1} lainnya</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>Lihat semua</Text>
</View>
</Pressable>
)}
</View>
)}
</View>
</Pressable>
))}
</View>
<Modal visible={modalVisible} animationType="slide" onRequestClose={() => setModalVisible(false)}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.ph15, Styles.pv10, { borderBottomWidth: 1, borderBottomColor: colors.icon + '20' }]}>
<Text style={Styles.textLargeSemiBold}>Lampiran ({modalFiles.length} file)</Text>
<Pressable onPress={() => setModalVisible(false)}>
<MaterialIcons name="close" size={24} color={colors.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={[Styles.ph15, Styles.pv10, { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }]}>
{modalFiles.map((file, idx) => (
<FileCard
key={idx} file={file} colors={colors}
onPress={() => {
if (isImageFile(file.extension.toLowerCase())) {
setModalPreviewFile(file)
} else {
openExternal(file)
}
}}
/>
))}
</ScrollView>
</SafeAreaView>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${modalPreviewFile?.idStorage}` }]}
imageIndex={0}
visible={modalPreviewFile !== null}
onRequestClose={() => setModalPreviewFile(null)}
doubleTapToZoomEnabled
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setModalPreviewFile(null)}>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
<Pressable onPress={() => modalPreviewFile && openExternal(modalPreviewFile)} disabled={loadingOpen}>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 26 }}></Text>
</Pressable>
</View>
)}
FooterComponent={() => (
<View style={{ paddingBottom: 20, paddingHorizontal: 16, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16 }}>{modalPreviewFile?.name}.{modalPreviewFile?.extension}</Text>
</View>
)}
/>
</Modal>
<ImageViewing
images={[{ uri: `${ConstEnv.url_storage}/files/${previewFile?.idStorage}` }]}
imageIndex={0}
visible={previewFile !== null}
onRequestClose={() => setPreviewFile(null)}
doubleTapToZoomEnabled
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreviewFile(null)}>
<Text style={{ color: 'white', fontSize: 26 }}></Text>
</Pressable>
<Pressable onPress={() => previewFile && openExternal(previewFile)} disabled={loadingOpen}>
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 26 }}></Text>
</Pressable>
</View>
)}
FooterComponent={() => (
<View style={{ paddingBottom: 20, paddingHorizontal: 16, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16 }}>{previewFile?.name}.{previewFile?.extension}</Text>
</View>
)}
/>
</>
)
}