From 4ab8422808c8cb00b5f1aba11c62fff82350c766 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 9 Jun 2026 17:35:30 +0800 Subject: [PATCH] feat: tambah fitur hapus file existing saat edit komentar diskusi umum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edit mode menampilkan file existing dengan chip ✕ merah untuk tandai hapus - File yang ditandai dikirim sebagai filesToRemove ke backend saat submit - Reset removedFileIds saat cancel edit --- app/(application)/discussion/[id].tsx | 522 ++++++++------------------ lib/api/discussion.api.ts | 9 +- 2 files changed, 172 insertions(+), 359 deletions(-) diff --git a/app/(application)/discussion/[id].tsx b/app/(application)/discussion/[id].tsx index bc364bf..d083e62 100644 --- a/app/(application)/discussion/[id].tsx +++ b/app/(application)/discussion/[id].tsx @@ -1,247 +1,178 @@ import AppHeader from "@/components/AppHeader"; import BorderBottomItem2 from "@/components/borderBottomItem2"; +import DiscussionCommentInput from "@/components/discussion_general/discussionCommentInput"; +import DiscussionCommentList, { CommentFile, CommentItem } from "@/components/discussion_general/discussionCommentList"; import HeaderRightDiscussionGeneralDetail from "@/components/discussion_general/headerDiscussionDetail"; import DrawerBottom from "@/components/drawerBottom"; -import ImageUser from "@/components/imageNew"; -import { InputForm } from "@/components/inputForm"; import MenuItemRow from "@/components/menuItemRow"; import ModalConfirmation from "@/components/ModalConfirmation"; -import Skeleton from "@/components/skeleton"; import SkeletonContent from "@/components/skeletonContent"; import Text from '@/components/Text'; import { ConstEnv } from "@/constants/ConstEnv"; -import { regexOnlySpacesOrEnter } from "@/constants/OnlySpaceOrEnter"; import Styles from "@/constants/Styles"; -import { apiDeleteDiscussionGeneralCommentar, apiGetDiscussionGeneralOne, apiSendDiscussionGeneralCommentar, apiUpdateDiscussionGeneralCommentar } from "@/lib/api"; +import { apiDeleteDiscussionGeneralCommentar, apiGetDiscussionGeneralOne, apiSendDiscussionGeneralCommentar, apiSendDiscussionGeneralCommentarWithFile, apiUpdateDiscussionGeneralCommentar } from "@/lib/api"; import { getDB } from "@/lib/firebaseDatabase"; import { useAuthSession } from "@/providers/AuthProvider"; import { useTheme } from "@/providers/ThemeProvider"; -import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; +import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { ref } from '@react-native-firebase/database'; import { useHeaderHeight } from '@react-navigation/elements'; import { router, Stack, useLocalSearchParams } from "expo-router"; -import React, { useEffect, useState } from "react"; -import { KeyboardAvoidingView, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useEffect, useState } from "react"; +import { KeyboardAvoidingView, Platform, RefreshControl, ScrollView, View } from "react-native"; import Toast from "react-native-toast-message"; import { useSelector } from "react-redux"; -type Props = { - id: string - isActive: boolean - title: string - desc: string - status: number - createdAt: string +type DiscussionDetail = { + id: string; isActive: boolean; title: string + desc: string; status: number; createdAt: string } -type PropsKomentar = { - id: string - comment: string - createdAt: string - idUser: string - img: string - username: string - isEdited: boolean - updatedAt: string -} - -type PropsFile = { - id: string; - idStorage: string; - name: string; - extension: string -} +type PropsFile = { id: string; idStorage: string; name: string; extension: string } export default function DetailDiscussionGeneral() { const { token, decryptToken } = useAuthSession() - const { colors } = useTheme(); + const { colors } = useTheme() const entityUser = useSelector((state: any) => state.user) const entities = useSelector((state: any) => state.entities) - const { id } = useLocalSearchParams<{ id: string }>(); - const [data, setData] = useState() - const [dataKomentar, setDataKomentar] = useState([]) + const { id } = useLocalSearchParams<{ id: string }>() + const [data, setData] = useState() + const [dataKomentar, setDataKomentar] = useState([]) const [memberDiscussion, setMemberDiscussion] = useState(false) const [fileDiscussion, setFileDiscussion] = useState([]) const [komentar, setKomentar] = useState('') + const [commentFiles, setCommentFiles] = useState<{ uri: string; name: string }[]>([]) const update = useSelector((state: any) => state.discussionGeneralDetailUpdate) const [loading, setLoading] = useState(true) const [loadingKomentar, setLoadingKomentar] = useState(true) - const arrSkeleton = Array.from({ length: 3 }, (_, index) => index) - const reference = ref(getDB(), `/discussion-general/${id}`); - const headerHeight = useHeaderHeight(); - const [detailMore, setDetailMore] = useState([]) - const [loadingSendKomentar, setLoadingSendKomentar] = useState(false) + const [loadingSend, setLoadingSend] = useState(false) const [isVisible, setVisible] = useState(false) const [refreshing, setRefreshing] = useState(false) - const [selectKomentar, setSelectKomentar] = useState({ - id: '', - comment: '' - }) + const [selectKomentar, setSelectKomentar] = useState<{ id: string; comment: string; files: CommentFile[] }>({ id: '', comment: '', files: [] }) + const [removedFileIds, setRemovedFileIds] = useState([]) const [viewEdit, setViewEdit] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const reference = ref(getDB(), `/discussion-general/${id}`) + const headerHeight = useHeaderHeight() useEffect(() => { const onValueChange = reference.on('value', snapshot => { - if (snapshot.val() == null) { - reference.set({ trigger: true }) - } + if (snapshot.val() == null) reference.set({ trigger: true }) handleLoad('komentar', false) - }); - - // Stop listening for updates when no longer required - return () => reference.off('value', onValueChange); - }, []); + }) + return () => reference.off('value', onValueChange) + }, []) function updateTrigger() { reference.once('value', snapshot => { - const data = snapshot.val(); - reference.update({ trigger: !data.trigger }); - }); + reference.update({ trigger: !snapshot.val().trigger }) + }) } - - async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', loading: boolean) { + async function handleLoad(cat: 'detail' | 'komentar' | 'cek-anggota' | 'file', showLoading: boolean) { try { - if (cat == "detail") { - setLoading(loading) - } else if (cat == "komentar") { - setLoadingKomentar(loading) - } - + if (cat === 'detail') setLoading(showLoading) + else if (cat === 'komentar') setLoadingKomentar(showLoading) const hasil = await decryptToken(String(token?.current)) - const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat }) - - if (cat == 'detail') { - setData(response.data) - } else if (cat == 'komentar') { - setDataKomentar(response.data) - } else if (cat == 'cek-anggota') { - setMemberDiscussion(response.data) - } else if (cat == 'file') { - setFileDiscussion(response.data) - } - - } catch (error) { - console.error(error) - } finally { - setLoading(false) - setLoadingKomentar(false) - } + const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat }) + if (cat === 'detail') setData(response.data) + else if (cat === 'komentar') setDataKomentar(response.data) + else if (cat === 'cek-anggota') setMemberDiscussion(response.data) + else if (cat === 'file') setFileDiscussion(response.data) + } catch (error) { console.error(error) } + finally { setLoading(false); setLoadingKomentar(false) } } useEffect(() => { - handleLoad('detail', false) - handleLoad('komentar', false) - handleLoad('cek-anggota', false) - handleLoad('file', false) - }, [update]); + handleLoad('detail', true); handleLoad('komentar', true) + handleLoad('cek-anggota', true); handleLoad('file', true) + }, []) useEffect(() => { - handleLoad('detail', true) - handleLoad('komentar', true) - handleLoad('cek-anggota', true) - handleLoad('file', true) - }, []); + handleLoad('detail', false); handleLoad('komentar', false) + handleLoad('cek-anggota', false); handleLoad('file', false) + }, [update]) async function handleKomentar() { try { - setLoadingSendKomentar(true) - if (komentar != '') { - const hasil = await decryptToken(String(token?.current)) - const response = await apiSendDiscussionGeneralCommentar({ id: id, data: { desc: komentar, user: hasil } }) - if (response.success) { - setKomentar('') - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + setLoadingSend(true) + const hasil = await decryptToken(String(token?.current)) + let response + if (commentFiles.length > 0) { + const fd = new FormData() + commentFiles.forEach((f, i) => + fd.append(`file${i}`, { uri: f.uri, type: 'application/octet-stream', name: f.name } as any) + ) + fd.append("data", JSON.stringify({ desc: komentar, user: hasil })) + response = await apiSendDiscussionGeneralCommentarWithFile(id, fd) + } else { + response = await apiSendDiscussionGeneralCommentar({ id, data: { desc: komentar, user: hasil } }) + } + if (response.success) { + setKomentar(''); setCommentFiles([]) + updateTrigger() + } else { + Toast.show({ type: 'small', text1: response.message }) } } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal menambahkan data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setLoadingSendKomentar(false) - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" }) + } finally { setLoadingSend(false) } } async function handleEditKomentar() { try { - setLoadingSendKomentar(true) + setLoadingSend(true) const hasil = await decryptToken(String(token?.current)) - const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil } }) - if (response.success) { - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + const response = await apiUpdateDiscussionGeneralCommentar({ id: selectKomentar.id, data: { desc: selectKomentar.comment, user: hasil, filesToRemove: removedFileIds } }) + if (response.success) updateTrigger() + else Toast.show({ type: 'small', text1: response.message }) } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal mengupdate data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setLoadingSendKomentar(false) - handleViewEditKomentar() - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengupdate data" }) + } finally { setLoadingSend(false); handleViewEditKomentar() } } async function handleDeleteKomentar() { try { - setLoadingSendKomentar(true) + setLoadingSend(true) const hasil = await decryptToken(String(token?.current)) const response = await apiDeleteDiscussionGeneralCommentar({ id: selectKomentar.id, data: { user: hasil } }) - if (response.success) { - updateTrigger() - } else { - Toast.show({ type: 'small', text1: response.message }) - } + if (response.success) updateTrigger() + else Toast.show({ type: 'small', text1: response.message }) } catch (error: any) { - console.error(error); - const message = error?.response?.data?.message || "Gagal menghapus data" - - Toast.show({ type: 'small', text1: message }) - } finally { - setLoadingSendKomentar(false) - setVisible(false) - } + Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" }) + } finally { setLoadingSend(false); setVisible(false) } } - function handleMenuKomentar(id: string, comment: string) { - setSelectKomentar({ id, comment }) - setVisible(true) - } + function handleViewEditKomentar() { setVisible(false); setViewEdit(!viewEdit); setRemovedFileIds([]) } - function handleViewEditKomentar() { - setVisible(false) - setViewEdit(!viewEdit) + const isLocked = data?.status === 2 || !data?.isActive + const isMember = memberDiscussion || (entityUser.role !== "user" && entityUser.role !== "coadmin") + const canComment = !isLocked && isMember + + function lockedReason() { + if (data?.status === 2) return "Diskusi telah ditutup" + if (!data?.isActive) return "Diskusi telah diarsipkan" + return "Hanya anggota diskusi yang dapat memberikan komentar" } const handleRefresh = async () => { setRefreshing(true) - handleLoad('detail', false) - handleLoad('komentar', false) - handleLoad('cek-anggota', false) - handleLoad('file', false) - await new Promise(resolve => setTimeout(resolve, 2000)); + handleLoad('detail', false); handleLoad('komentar', false) + handleLoad('cek-anggota', false); handleLoad('file', false) + await new Promise(resolve => setTimeout(resolve, 2000)) setRefreshing(false) - }; + } return ( <> { router.back() }} />, - headerTitle: 'Diskusi', - headerTitleAlign: 'center', - // headerRight: () => , header: () => ( router.back()} - right={} + right={} /> ) }} @@ -250,219 +181,97 @@ export default function DetailDiscussionGeneral() { handleRefresh()} - tintColor={colors.icon} - /> - } + refreshControl={} > - - { - loading ? - - : - - - - } - title={data?.title} - titleShowAll={true} - subtitle={ - - - {!data?.isActive ? 'Arsip' : data?.status == 1 ? 'Buka' : 'Tutup'} - - - } - desc={data?.desc} - leftBottomInfo={ - - - {dataKomentar.length} Komentar - - } - rightBottomInfo={ - {data?.createdAt} - } - /> - } - - { - loadingKomentar ? - arrSkeleton.map((item: any, i: number) => { - return ( - - ) - }) - : - dataKomentar.map((item, i) => ( - { - setDetailMore((prev: any) => - prev.includes(item.id) - ? prev.filter((id: string) => id !== item.id) - : [...prev, item.id] - ) - }} - onLongPress={() => { - item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment) - }} - style={({ pressed }) => [ - Styles.discussionCommentCard, - { - backgroundColor: pressed ? colors.icon + '10' : colors.card, - borderColor: colors.icon + '20', - } - ]} - > - - {/* Name + time */} - - - - - {item.username} - - {item.isEdited && ( - - diedit - - )} - - - {item.createdAt} - - - - {/* Comment text */} - - {item.comment} - - - - )) - } - - - - - - { - viewEdit ? - <> - - - - Edit Komentar - - handleViewEditKomentar()}> - - + + {loading ? : ( + + - setSelectKomentar({ ...selectKomentar, comment: val })} - value={selectKomentar.comment} - multiline - focus={viewEdit} - itemRight={ - { - (!loadingSendKomentar && selectKomentar.comment != '' && !regexOnlySpacesOrEnter.test(selectKomentar.comment) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin"))) - && handleEditKomentar() - }} - style={[Platform.OS == 'android' && Styles.mb12]} - > - - - } - /> - - : - data?.status != 2 && data?.isActive && ((entityUser.role != "user" && entityUser.role != "coadmin") || memberDiscussion) - ? - { - (!loadingSendKomentar && komentar != '' && !regexOnlySpacesOrEnter.test(komentar) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin"))) - && handleKomentar() - }} - style={[Platform.OS == 'android' && Styles.mb12]} - > - - - } - /> - : - - - { - data?.status == 2 ? "Diskusi telah ditutup" : data?.isActive == false ? "Diskusi telah diarsipkan" : "Hanya anggota diskusi yang dapat memberikan komentar" - } + } + title={data?.title} + titleShowAll={true} + subtitle={ + + + {!data?.isActive ? 'Arsip' : data?.status === 1 ? 'Buka' : 'Tutup'} - } - + } + desc={data?.desc} + leftBottomInfo={ + + + {dataKomentar.length} Komentar + + } + rightBottomInfo={{data?.createdAt}} + /> + )} + { + setSelectKomentar({ id: commentId, comment, files }) + setRemovedFileIds([]) + setVisible(true) + }} + /> + + + + {canComment ? ( + setSelectKomentar({ ...selectKomentar, comment: val }) + : setKomentar + } + loading={loadingSend} + onSend={viewEdit ? handleEditKomentar : handleKomentar} + onCancelEdit={handleViewEditKomentar} + files={commentFiles} + onAddFile={(newFiles) => setCommentFiles(prev => [...prev, ...newFiles])} + onRemoveFile={(idx) => setCommentFiles(prev => prev.filter((_, i) => i !== idx))} + existingFiles={viewEdit ? selectKomentar.files.filter(f => !removedFileIds.includes(f.id)) : []} + onRemoveExistingFile={(fileId) => setRemovedFileIds(prev => [...prev, fileId])} + canSend={canComment} + /> + ) : ( + {}} loading={false} onSend={() => {}} canSend={false} + /> + )} - + } title="Edit" - onPress={() => { handleViewEditKomentar() }} + onPress={() => handleViewEditKomentar()} /> } title="Hapus" - onPress={() => { - setVisible(false) - setTimeout(() => { - setShowDeleteModal(true) - }, 600) - }} + onPress={() => { setVisible(false); setTimeout(() => setShowDeleteModal(true), 600) }} /> @@ -471,14 +280,11 @@ export default function DetailDiscussionGeneral() { visible={showDeleteModal} title="Konfirmasi" message="Apakah anda yakin ingin menghapus komentar?" - onConfirm={() => { - setShowDeleteModal(false) - handleDeleteKomentar() - }} + onConfirm={() => { setShowDeleteModal(false); handleDeleteKomentar() }} onCancel={() => setShowDeleteModal(false)} confirmText="Hapus" cancelText="Batal" /> ) -} \ No newline at end of file +} diff --git a/lib/api/discussion.api.ts b/lib/api/discussion.api.ts index ef178af..eeb42a7 100644 --- a/lib/api/discussion.api.ts +++ b/lib/api/discussion.api.ts @@ -39,7 +39,14 @@ export const apiSendDiscussionGeneralCommentar = async ({ id, data }: { id: stri return response.data; }; -export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string } }) => { +export const apiSendDiscussionGeneralCommentarWithFile = async (id: string, data: FormData) => { + const response = await api.post(`/mobile/discussion-general/${id}/comment`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data; +}; + +export const apiUpdateDiscussionGeneralCommentar = async ({ id, data }: { id: string, data: { desc: string, user: string, filesToRemove?: string[] } }) => { const response = await api.put(`/mobile/discussion-general/${id}/comment`, data) return response.data; };