From 3370f482386d7297031de4b976efe4ee7c8a7c4e Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 17:35:22 +0800 Subject: [PATCH] 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) --- .../discussionCommentInput.tsx | 171 +++++++++++ .../discussionCommentList.tsx | 275 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 components/discussion_general/discussionCommentInput.tsx create mode 100644 components/discussion_general/discussionCommentList.tsx diff --git a/components/discussion_general/discussionCommentInput.tsx b/components/discussion_general/discussionCommentInput.tsx new file mode 100644 index 0000000..020177f --- /dev/null +++ b/components/discussion_general/discussionCommentInput.tsx @@ -0,0 +1,171 @@ +import Text from "@/components/Text"; +import { regexOnlySpacesOrEnter } from "@/constants/OnlySpaceOrEnter"; +import Styles from "@/constants/Styles"; +import { useTheme } from "@/providers/ThemeProvider"; +import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import * as DocumentPicker from "expo-document-picker"; +import { Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import Toast from "react-native-toast-message"; + +type Props = { + mode: 'new' | 'edit' | 'locked' + lockedReason?: string + value: string + onChange: (val: string) => void + loading: boolean + onSend: () => void + onCancelEdit?: () => void + files?: { uri: string; name: string }[] + onAddFile?: (files: { uri: string; name: string }[]) => void + onRemoveFile?: (index: number) => void + existingFiles?: { id: string; name: string; extension: string }[] + onRemoveExistingFile?: (id: string) => void + canSend: boolean +} + +export default function DiscussionCommentInput({ + mode, lockedReason, value, onChange, loading, onSend, + onCancelEdit, files = [], onAddFile, onRemoveFile, + existingFiles = [], onRemoveExistingFile, canSend +}: Props) { + const { colors } = useTheme() + + async function pickFiles() { + const result = await DocumentPicker.getDocumentAsync({ type: ['*/*'], multiple: true }) + if (!result.canceled && onAddFile) { + let skipped = 0 + const newFiles: { uri: string; name: string }[] = [] + for (const asset of result.assets) { + if (!asset.uri) continue + if (files.some(f => f.name === asset.name)) { skipped++; continue } + newFiles.push({ uri: asset.uri, name: asset.name }) + } + if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' }) + if (newFiles.length > 0) onAddFile(newFiles) + } + } + + if (mode === 'locked') { + return ( + + {lockedReason} + + ) + } + + const sendDisabled = loading || value.trim() === '' || regexOnlySpacesOrEnter.test(value) || !canSend + + return ( + + {mode === 'edit' && ( + + + + Edit Komentar + + + + + + )} + + {(existingFiles.length > 0 || files.length > 0) && ( + + {existingFiles.map((f) => ( + onRemoveExistingFile?.(f.id)} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 10, paddingVertical: 6, + borderRadius: 20, borderWidth: 1, + backgroundColor: colors.card, borderColor: colors.icon + '18', + marginRight: 8 + }} + > + + + {f.name}.{f.extension} + + + + ))} + {files.map((f, idx) => ( + onRemoveFile?.(idx)} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 10, paddingVertical: 6, + borderRadius: 20, borderWidth: 1, + backgroundColor: colors.card, borderColor: colors.icon + '18', + marginRight: 8 + }} + > + + + {f.name} + + + + ))} + + )} + + + {mode === 'new' && ( + + 0 ? colors.tabActive : colors.dimmed} + /> + + )} + + + + + + !sendDisabled && onSend()} + style={{ + width: 40, height: 40, borderRadius: 20, + backgroundColor: sendDisabled ? colors.dimmed + '40' : colors.tint, + justifyContent: 'center', alignItems: 'center', + marginBottom: 0 + }} + > + + + + + ) +} diff --git a/components/discussion_general/discussionCommentList.tsx b/components/discussion_general/discussionCommentList.tsx new file mode 100644 index 0000000..d1666c6 --- /dev/null +++ b/components/discussion_general/discussionCommentList.tsx @@ -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 ( + [Styles.fileCard, { + borderColor: colors.icon + '18', + backgroundColor: pressed ? colors.icon + '10' : 'transparent' + }]} + > + + + + + {file.name} + {ext.toUpperCase()} + + + ) +} + +export default function DiscussionCommentList({ data, loading, myId, canInteract, onLongPress }: Props) { + const { colors } = useTheme() + const [expandedIds, setExpandedIds] = useState([]) + const [modalFiles, setModalFiles] = useState([]) + const [modalVisible, setModalVisible] = useState(false) + const [previewFile, setPreviewFile] = useState(null) + const [modalPreviewFile, setModalPreviewFile] = useState(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 ( + + {arrSkeleton.map((_, i) => ( + + ))} + + ) + } + + return ( + <> + + {data.map((item, i) => ( + 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' } + ]} + > + + + + + + {item.username} + + {item.isEdited && ( + diedit + )} + + + {item.createdAt} + + + + {item.comment.length > 0 && ( + + {item.comment} + + )} + + {item.files?.length > 0 && ( + 0 ? 8 : 0 }}> + {(item.files.length > 2 ? item.files.slice(0, 1) : item.files).map((file, idx) => ( + handleFilePress(file)} /> + ))} + {item.files.length > 2 && ( + { setModalFiles(item.files); setModalVisible(true) }} + style={[Styles.fileCard, { borderColor: colors.icon + '18', backgroundColor: 'transparent' }]} + > + + + + + +{item.files.length - 1} lainnya + Lihat semua + + + )} + + )} + + + ))} + + + setModalVisible(false)}> + + + Lampiran ({modalFiles.length} file) + setModalVisible(false)}> + + + + + {modalFiles.map((file, idx) => ( + { + if (isImageFile(file.extension.toLowerCase())) { + setModalPreviewFile(file) + } else { + openExternal(file) + } + }} + /> + ))} + + + setModalPreviewFile(null)} + doubleTapToZoomEnabled + HeaderComponent={() => ( + + setModalPreviewFile(null)}> + + + modalPreviewFile && openExternal(modalPreviewFile)} disabled={loadingOpen}> + + + + )} + FooterComponent={() => ( + + {modalPreviewFile?.name}.{modalPreviewFile?.extension} + + )} + /> + + + setPreviewFile(null)} + doubleTapToZoomEnabled + HeaderComponent={() => ( + + setPreviewFile(null)}> + + + previewFile && openExternal(previewFile)} disabled={loadingOpen}> + + + + )} + FooterComponent={() => ( + + {previewFile?.name}.{previewFile?.extension} + + )} + /> + + ) +}