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}
+
+ )}
+ />
+ >
+ )
+}