From 7c3b3f3fa8bebe46fdd373aa18938c7b49e9b70c Mon Sep 17 00:00:00 2001 From: Adithya Date: Mon, 16 Mar 2026 18:44:09 -0700 Subject: [PATCH 1/8] feat: add issue reporting feature - UI changes : All the page will hav the icon bug report. Clicking the icon will open the popup which will take summary and description of the issue. - Optinal zip the folder check box is provided. which will zip the entire folder and send it to PulseVault. --- app/_layout.tsx | 10 +- components/ReportIssueFab.tsx | 59 +++++++ components/ReportIssueModal.tsx | 276 ++++++++++++++++++++++++++++++ components/TimeSelectorButton.tsx | 12 +- package-lock.json | 94 ++++++++++ package.json | 1 + utils/reportIssue.ts | 69 ++++++++ utils/reportIssueAttachment.ts | 73 ++++++++ 8 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 components/ReportIssueFab.tsx create mode 100644 components/ReportIssueModal.tsx create mode 100644 utils/reportIssue.ts create mode 100644 utils/reportIssueAttachment.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index d692309..a0d822f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,11 +8,13 @@ import { useRouter } from "expo-router"; import { Stack } from "expo-router"; import * as Linking from "expo-linking"; import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { PermissionMonitor } from "@/components/PermissionMonitor"; +import { ReportIssueFab } from "@/components/ReportIssueFab"; +import { ReportIssueModal } from "@/components/ReportIssueModal"; import { useColorScheme } from "@/hooks/useColorScheme"; import { storeUploadConfigForDraft } from "@/utils/uploadConfig"; import { addDestination } from "@/utils/uploadDestinations"; @@ -23,6 +25,7 @@ const isUUIDv4 = (uuid: string) => export default function RootLayout() { const colorScheme = useColorScheme(); const router = useRouter(); + const [isReportIssueModalVisible, setIsReportIssueModalVisible] = useState(false); const [loaded] = useFonts({ "Roboto-Regular": require("../assets/fonts/Roboto-Regular.ttf"), "Roboto-Bold": require("../assets/fonts/Roboto-Bold.ttf"), @@ -184,6 +187,11 @@ export default function RootLayout() { /> + setIsReportIssueModalVisible(true)} /> + setIsReportIssueModalVisible(false)} + /> diff --git a/components/ReportIssueFab.tsx b/components/ReportIssueFab.tsx new file mode 100644 index 0000000..23222e4 --- /dev/null +++ b/components/ReportIssueFab.tsx @@ -0,0 +1,59 @@ +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { usePathname } from "expo-router"; +import * as React from "react"; +import { Pressable, PressableStateCallbackType, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface ReportIssueFabProps { + onPress: () => void; +} + +export function ReportIssueFab({ onPress }: ReportIssueFabProps) { + const pathname = usePathname(); + const insets = useSafeAreaInsets(); + const keepLowerOffset = + pathname.includes("shorts") || pathname.includes("trim-segment"); + const topOffset = keepLowerOffset + ? insets.top + 104 + : insets.top + 15; + + return ( + [ + styles.button, + { + top: topOffset, + opacity: pressed ? 0.85 : 1, + transform: [{ scale: pressed ? 0.97 : 1 }], + }, + ]} + > + + + ); +} + +const styles = StyleSheet.create({ + button: { + position: "absolute", + right: 25, + width: 40, + height: 40, + borderRadius: 25, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.78)", + borderWidth: 1.5, + borderColor: "rgba(255, 255, 255, 0.65)", + zIndex: 950, + elevation: 10, + shadowColor: "#000", + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 5, + }, +}); diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx new file mode 100644 index 0000000..12cbfad --- /dev/null +++ b/components/ReportIssueModal.tsx @@ -0,0 +1,276 @@ +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import React, { useMemo, useState } from "react"; +import { + ActivityIndicator, + Alert, + GestureResponderEvent, + Modal, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; + +import { ThemedText } from "@/components/ThemedText"; +import { Colors } from "@/constants/Colors"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { submitIssueReport } from "@/utils/reportIssue"; + +interface ReportIssueModalProps { + visible: boolean; + onClose: () => void; +} + +export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { + const colorScheme = useColorScheme(); + const colors = colorScheme === "dark" ? Colors.dark : Colors.light; + + const [summary, setSummary] = useState(""); + const [description, setDescription] = useState(""); + const [includeDraftFolder, setIncludeDraftFolder] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const canSubmit = useMemo( + () => !!summary.trim() && !!description.trim() && !isSubmitting, + [description, isSubmitting, summary] + ); + + const resetAndClose = () => { + setSummary(""); + setDescription(""); + setIncludeDraftFolder(false); + setErrorMessage(null); + onClose(); + }; + + const handleSubmit = async () => { + if (!canSubmit) { + setErrorMessage("Summary and description are required."); + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + + try { + await submitIssueReport({ + summary, + description, + includeDraftFolder, + }); + + Alert.alert("Issue reported", "Thanks. Your report was sent to Pulse Vault.", [ + { text: "OK", onPress: resetAndClose }, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to submit issue report."; + console.error(error) + setErrorMessage(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + event.stopPropagation()} + > + + + Report an Issue + + + + + + + Summary + + + Description + + + setIncludeDraftFolder((prev: boolean) => !prev)} + style={styles.checkboxRow} + > + + {includeDraftFolder ? ( + + ) : null} + + Include draft folder + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + Submit + )} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.55)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 16, + }, + modalCard: { + width: "100%", + borderRadius: 14, + borderWidth: 1, + padding: 16, + maxWidth: 460, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, + }, + title: { + fontSize: 20, + }, + closeButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + }, + label: { + marginTop: 10, + marginBottom: 6, + fontFamily: "Roboto-Bold", + }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontFamily: "Roboto-Regular", + fontSize: 15, + }, + descriptionInput: { + minHeight: 110, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + marginTop: 14, + }, + checkbox: { + width: 22, + height: 22, + borderRadius: 6, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + checkboxLabel: { + marginLeft: 10, + fontSize: 14, + }, + errorText: { + marginTop: 12, + fontFamily: "Roboto-Regular", + }, + submitButton: { + marginTop: 14, + height: 46, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + submitButtonText: { + color: "#FFFFFF", + fontFamily: "Roboto-Bold", + fontSize: 16, + }, +}); diff --git a/components/TimeSelectorButton.tsx b/components/TimeSelectorButton.tsx index 2b55a12..18f7882 100644 --- a/components/TimeSelectorButton.tsx +++ b/components/TimeSelectorButton.tsx @@ -44,6 +44,9 @@ export default function TimeSelectorButton({ <> setIsModalVisible(true)} > @@ -90,12 +93,19 @@ export default function TimeSelectorButton({ const styles = StyleSheet.create({ selectorButton: { - backgroundColor: "rgba(0, 0, 0, 0.6)", + backgroundColor: "rgba(0, 0, 0, 0.78)", width: 40, height: 40, borderRadius: 25, + borderWidth: 1.5, + borderColor: "rgba(255, 255, 255, 0.65)", justifyContent: "center", alignItems: "center", + elevation: 10, + shadowColor: "#000", + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 5, }, selectorText: { color: "#ffffff", diff --git a/package-lock.json b/package-lock.json index f478dcc..2ca16f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", + "jszip": "^3.10.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.6", @@ -5064,6 +5065,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", @@ -7286,6 +7293,12 @@ "node": ">=16.x" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8201,6 +8214,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8252,6 +8277,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -9692,6 +9726,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9951,6 +9991,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -10491,6 +10537,33 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11346,6 +11419,21 @@ "node": ">=4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -12226,6 +12314,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index dc41358..a2103fd 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", + "jszip": "^3.10.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.6", diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts new file mode 100644 index 0000000..ddfc10c --- /dev/null +++ b/utils/reportIssue.ts @@ -0,0 +1,69 @@ +import { + buildDraftsAttachment, + ReportIssueAttachment, +} from "@/utils/reportIssueAttachment"; + +const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; + +export interface SubmitIssueReportInput { + summary: string; + description: string; + includeDraftFolder: boolean; +} + +export interface SubmitIssueReportResult { + success: boolean; + attachedFileName?: string; +} + +function appendAttachment(formData: FormData, attachment: ReportIssueAttachment) { + formData.append("attachment", { + uri: attachment.uri, + name: attachment.name, + type: attachment.type, + } as unknown as Blob); +} + +export async function submitIssueReport( + input: SubmitIssueReportInput +): Promise { + const summary = input.summary.trim(); + const description = input.description.trim(); + + if (!summary) { + throw new Error("Summary is required."); + } + + if (!description) { + throw new Error("Description is required."); + } + + const formData = new FormData(); + formData.append("summary", summary); + formData.append("description", description); + + let attachment: ReportIssueAttachment | null = null; + if (input.includeDraftFolder) { + attachment = await buildDraftsAttachment(); + if (attachment) { + appendAttachment(formData, attachment); + } + } + + const response = await fetch(REPORT_ISSUE_ENDPOINT, { + method: "POST", + body: formData, + }); + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const message = errorText + ? `Failed to report issue (${response.status}): ${errorText}` + : `Failed to report issue (${response.status}).`; + throw new Error(message); + } + + return { + success: true, + attachedFileName: attachment?.name, + }; +} diff --git a/utils/reportIssueAttachment.ts b/utils/reportIssueAttachment.ts new file mode 100644 index 0000000..d632b70 --- /dev/null +++ b/utils/reportIssueAttachment.ts @@ -0,0 +1,73 @@ +import { DraftStorage } from "@/utils/draftStorage"; +import { fileStore } from "@/utils/fileStore"; +import * as FileSystem from "expo-file-system"; +import JSZip from "jszip"; + +export interface ReportIssueAttachment { + uri: string; + name: string; + type: string; +} + +export async function buildDraftsAttachment(): Promise { + const drafts = await DraftStorage.getAllDrafts(); + if (drafts.length === 0) { + return null; + } + + const zip = new JSZip(); + const metadata = drafts + .slice() + .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()) + .map((draft) => ({ + id: draft.id, + mode: draft.mode, + name: draft.name ?? null, + createdAt: draft.createdAt.toISOString(), + lastModified: draft.lastModified.toISOString(), + maxDurationLimitSeconds: draft.maxDurationLimitSeconds, + segmentCount: draft.segments.length, + })); + + zip.file("metadata.json", JSON.stringify({ drafts: metadata }, null, 2)); + + for (const draft of drafts) { + const absoluteFiles = await fileStore.getDraftFiles(draft.id); + for (const absolutePath of absoluteFiles) { + const info = await FileSystem.getInfoAsync(absolutePath); + if (!info.exists) { + continue; + } + + const base64Content = await FileSystem.readAsStringAsync(absolutePath, { + encoding: FileSystem.EncodingType.Base64, + }); + const relativePath = fileStore.toRelativePath(absolutePath); + zip.file(relativePath, base64Content, { base64: true }); + } + } + + const zipBase64 = await zip.generateAsync({ + type: "base64", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }); + + const reportsDir = `${FileSystem.cacheDirectory}reports/`; + const reportsDirInfo = await FileSystem.getInfoAsync(reportsDir); + if (!reportsDirInfo.exists) { + await FileSystem.makeDirectoryAsync(reportsDir, { intermediates: true }); + } + + const zipFileName = `pulse-report-drafts-${Date.now()}.zip`; + const attachmentUri = `${reportsDir}${zipFileName}`; + await FileSystem.writeAsStringAsync(attachmentUri, zipBase64, { + encoding: FileSystem.EncodingType.Base64, + }); + + return { + uri: attachmentUri, + name: zipFileName, + type: "application/zip", + }; +} From 781c2b6db308e05ac125de1ecff2b93874095ac7 Mon Sep 17 00:00:00 2001 From: Adithya Date: Wed, 18 Mar 2026 19:52:56 -0700 Subject: [PATCH 2/8] feat: enhance issue reporting functionality with submission result handling and improved error management --- components/ReportIssueModal.tsx | 254 ++++++++++++++++++++------------ utils/reportIssue.ts | 33 ++++- utils/reportIssueAttachment.ts | 61 +++++++- 3 files changed, 245 insertions(+), 103 deletions(-) diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx index 12cbfad..271ca59 100644 --- a/components/ReportIssueModal.tsx +++ b/components/ReportIssueModal.tsx @@ -1,9 +1,9 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; -import React, { useMemo, useState } from "react"; +import * as React from "react"; import { ActivityIndicator, - Alert, GestureResponderEvent, + Linking, Modal, Pressable, StyleSheet, @@ -14,7 +14,7 @@ import { import { ThemedText } from "@/components/ThemedText"; import { Colors } from "@/constants/Colors"; import { useColorScheme } from "@/hooks/useColorScheme"; -import { submitIssueReport } from "@/utils/reportIssue"; +import { SubmitIssueReportResult, submitIssueReport } from "@/utils/reportIssue"; interface ReportIssueModalProps { visible: boolean; @@ -25,13 +25,14 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { const colorScheme = useColorScheme(); const colors = colorScheme === "dark" ? Colors.dark : Colors.light; - const [summary, setSummary] = useState(""); - const [description, setDescription] = useState(""); - const [includeDraftFolder, setIncludeDraftFolder] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + const [summary, setSummary] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [includeDraftFolder, setIncludeDraftFolder] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [submittedIssue, setSubmittedIssue] = React.useState(null); - const canSubmit = useMemo( + const canSubmit = React.useMemo( () => !!summary.trim() && !!description.trim() && !isSubmitting, [description, isSubmitting, summary] ); @@ -41,9 +42,24 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setDescription(""); setIncludeDraftFolder(false); setErrorMessage(null); + setSubmittedIssue(null); onClose(); }; + const handleOpenIssueUrl = async () => { + if (!submittedIssue?.issueUrl) return; + try { + const supported = await Linking.canOpenURL(submittedIssue.issueUrl); + if (!supported) { + setErrorMessage("Unable to open issue URL on this device."); + return; + } + await Linking.openURL(submittedIssue.issueUrl); + } catch { + setErrorMessage("Unable to open issue URL on this device."); + } + }; + const handleSubmit = async () => { if (!canSubmit) { setErrorMessage("Summary and description are required."); @@ -54,18 +70,16 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setErrorMessage(null); try { - await submitIssueReport({ + const result = await submitIssueReport({ summary, description, includeDraftFolder, }); - Alert.alert("Issue reported", "Thanks. Your report was sent to Pulse Vault.", [ - { text: "OK", onPress: resetAndClose }, - ]); + setSubmittedIssue(result); } catch (error) { const message = error instanceof Error ? error.message : "Failed to submit issue report."; - console.error(error) + console.error(error); setErrorMessage(message); } finally { setIsSubmitting(false); @@ -104,89 +118,126 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { - Summary - + {submittedIssue ? ( + + + {`Success message and issue created : ${submittedIssue.issueNumber ?? "-"}. Thank you for the feedback. We will work on it.`} + - Description - + + Open GitHub URL + - setIncludeDraftFolder((prev: boolean) => !prev)} - style={styles.checkboxRow} - > - - {includeDraftFolder ? ( - - ) : null} + + Close + - Include draft folder - + ) : ( + <> + Summary + - {errorMessage ? ( - - {errorMessage} - - ) : null} + Description + + + setIncludeDraftFolder((prev: boolean) => !prev)} + style={styles.checkboxRow} + > + + {includeDraftFolder ? ( + + ) : null} + + Include draft folder + - - {isSubmitting ? ( - - ) : ( - Submit - )} - + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {isSubmitting ? ( + + ) : ( + Submit + )} + + + )} @@ -261,6 +312,25 @@ const styles = StyleSheet.create({ marginTop: 12, fontFamily: "Roboto-Regular", }, + successText: { + marginTop: 10, + marginBottom: 16, + fontFamily: "Roboto-Regular", + fontSize: 15, + lineHeight: 22, + }, + secondaryButton: { + borderWidth: 1, + height: 44, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + marginBottom: 10, + }, + secondaryButtonText: { + fontFamily: "Roboto-Bold", + fontSize: 15, + }, submitButton: { marginTop: 14, height: 46, diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts index ddfc10c..432a109 100644 --- a/utils/reportIssue.ts +++ b/utils/reportIssue.ts @@ -14,10 +14,22 @@ export interface SubmitIssueReportInput { export interface SubmitIssueReportResult { success: boolean; attachedFileName?: string; + issueNumber?: number; + issueUrl?: string; + uploadId?: string | null; + downloadUrl?: string | null; +} + +interface ReportIssueApiResponse { + success?: boolean; + issueNumber?: number; + issueUrl?: string; + uploadId?: string | null; + downloadUrl?: string | null; } function appendAttachment(formData: FormData, attachment: ReportIssueAttachment) { - formData.append("attachment", { + formData.append("zip", { uri: attachment.uri, name: attachment.name, type: attachment.type, @@ -45,9 +57,10 @@ export async function submitIssueReport( let attachment: ReportIssueAttachment | null = null; if (input.includeDraftFolder) { attachment = await buildDraftsAttachment(); - if (attachment) { - appendAttachment(formData, attachment); + if (!attachment) { + throw new Error("Failed to create draft attachment."); } + appendAttachment(formData, attachment); } const response = await fetch(REPORT_ISSUE_ENDPOINT, { @@ -62,8 +75,20 @@ export async function submitIssueReport( throw new Error(message); } + const responseJson = (await response.json().catch(() => null)) as + | ReportIssueApiResponse + | null; + + if (responseJson && responseJson.success === false) { + throw new Error("Issue report was not accepted by the server."); + } + return { - success: true, + success: responseJson?.success ?? true, attachedFileName: attachment?.name, + issueNumber: responseJson?.issueNumber, + issueUrl: responseJson?.issueUrl, + uploadId: responseJson?.uploadId ?? null, + downloadUrl: responseJson?.downloadUrl ?? null, }; } diff --git a/utils/reportIssueAttachment.ts b/utils/reportIssueAttachment.ts index d632b70..557162a 100644 --- a/utils/reportIssueAttachment.ts +++ b/utils/reportIssueAttachment.ts @@ -1,7 +1,7 @@ import { DraftStorage } from "@/utils/draftStorage"; import { fileStore } from "@/utils/fileStore"; import * as FileSystem from "expo-file-system"; -import JSZip from "jszip"; +import JSZip = require("jszip"); export interface ReportIssueAttachment { uri: string; @@ -9,11 +9,41 @@ export interface ReportIssueAttachment { type: string; } +const DRAFTS_DIR_RELATIVE = "pulse/drafts/"; + +function hasZipMagic(base64: string): boolean { + return base64.startsWith("UEsDB") || base64.startsWith("UEsFB") || base64.startsWith("UEsBA"); +} + +async function validateZipBase64(zipBase64: string): Promise { + if (!hasZipMagic(zipBase64)) { + throw new Error("Generated draft archive is not a valid ZIP (invalid file signature)."); + } + + await JSZip.loadAsync(zipBase64, { base64: true }); +} + +async function getDraftIdsFromFileSystem(): Promise { + const draftsRoot = `${FileSystem.documentDirectory}${DRAFTS_DIR_RELATIVE}`; + const draftsRootInfo = await FileSystem.getInfoAsync(draftsRoot); + if (!draftsRootInfo.exists) { + return []; + } + + const entries = await FileSystem.readDirectoryAsync(draftsRoot); + const ids = entries + .map((entry) => entry.replace(/\/$/, "")) + .filter((entry) => entry.length > 0); + + return Array.from(new Set(ids)); +} + export async function buildDraftsAttachment(): Promise { const drafts = await DraftStorage.getAllDrafts(); - if (drafts.length === 0) { - return null; - } + const fileSystemDraftIds = await getDraftIdsFromFileSystem(); + const draftIds = Array.from( + new Set([...drafts.map((draft) => draft.id), ...fileSystemDraftIds]) + ); const zip = new JSZip(); const metadata = drafts @@ -29,10 +59,20 @@ export async function buildDraftsAttachment(): Promise Date: Fri, 20 Mar 2026 12:12:08 -0700 Subject: [PATCH 3/8] feat: implement progress tracking for issue report submission and integrate zip functionality --- components/ReportIssueModal.tsx | 82 ++++++++++++++- package-lock.json | 107 +++---------------- package.json | 2 +- react-native-zip-archive.d.ts | 3 + tsconfig.json | 1 + utils/reportIssue.ts | 160 +++++++++++++++++++++++++++-- utils/reportIssueAttachment.ts | 177 +++++++++++++++++++++++--------- 7 files changed, 374 insertions(+), 158 deletions(-) create mode 100644 react-native-zip-archive.d.ts diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx index 271ca59..c387046 100644 --- a/components/ReportIssueModal.tsx +++ b/components/ReportIssueModal.tsx @@ -14,7 +14,11 @@ import { import { ThemedText } from "@/components/ThemedText"; import { Colors } from "@/constants/Colors"; import { useColorScheme } from "@/hooks/useColorScheme"; -import { SubmitIssueReportResult, submitIssueReport } from "@/utils/reportIssue"; +import { + SubmitIssueReportProgress, + SubmitIssueReportResult, + submitIssueReport, +} from "@/utils/reportIssue"; interface ReportIssueModalProps { visible: boolean; @@ -31,6 +35,8 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { const [isSubmitting, setIsSubmitting] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(null); const [submittedIssue, setSubmittedIssue] = React.useState(null); + const [submitProgress, setSubmitProgress] = React.useState(0); + const [submitProgressMessage, setSubmitProgressMessage] = React.useState(""); const canSubmit = React.useMemo( () => !!summary.trim() && !!description.trim() && !isSubmitting, @@ -43,9 +49,31 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setIncludeDraftFolder(false); setErrorMessage(null); setSubmittedIssue(null); + setSubmitProgress(0); + setSubmitProgressMessage(""); onClose(); }; + const handleSubmitProgress = React.useCallback( + (progressUpdate: SubmitIssueReportProgress) => { + setSubmitProgress(progressUpdate.progress); + if (progressUpdate.message) { + setSubmitProgressMessage(progressUpdate.message); + return; + } + + if (progressUpdate.phase === "uploading") { + const pct = Math.round(progressUpdate.progress * 100); + setSubmitProgressMessage(`Uploading report... ${pct}%`); + } else if (progressUpdate.phase === "finalizing") { + setSubmitProgressMessage("Finalizing issue report..."); + } else { + setSubmitProgressMessage("Preparing payload..."); + } + }, + [] + ); + const handleOpenIssueUrl = async () => { if (!submittedIssue?.issueUrl) return; try { @@ -68,13 +96,15 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setIsSubmitting(true); setErrorMessage(null); + setSubmitProgress(0); + setSubmitProgressMessage("Preparing payload..."); try { const result = await submitIssueReport({ summary, description, includeDraftFolder, - }); + }, handleSubmitProgress); setSubmittedIssue(result); } catch (error) { @@ -214,6 +244,25 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { Include draft folder + {isSubmitting ? ( + + + + + + {submitProgressMessage || "Uploading report..."} + + + ) : null} + {errorMessage ? ( {errorMessage} @@ -231,7 +280,12 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { ]} > {isSubmitting ? ( - + + + + {`${Math.round(submitProgress * 100)}%`} + + ) : ( Submit )} @@ -308,6 +362,23 @@ const styles = StyleSheet.create({ marginLeft: 10, fontSize: 14, }, + progressContainer: { + marginTop: 12, + }, + progressTrack: { + height: 8, + borderRadius: 999, + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + }, + progressText: { + marginTop: 6, + fontSize: 13, + fontFamily: "Roboto-Regular", + }, errorText: { marginTop: 12, fontFamily: "Roboto-Regular", @@ -343,4 +414,9 @@ const styles = StyleSheet.create({ fontFamily: "Roboto-Bold", fontSize: 16, }, + submitProgressInline: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, }); diff --git a/package-lock.json b/package-lock.json index 2ca16f5..1f18d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", - "jszip": "^3.10.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.6", @@ -46,7 +45,8 @@ "react-native-video": "^6.18.0", "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-native-zip-archive": "^7.0.2" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -5065,12 +5065,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cosmiconfig": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", @@ -7293,12 +7287,6 @@ "node": ">=16.x" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8214,18 +8202,6 @@ "node": ">=4.0" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8277,15 +8253,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -9726,12 +9693,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9991,12 +9952,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -10454,6 +10409,16 @@ "react-native": "*" } }, + "node_modules/react-native-zip-archive": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-7.0.2.tgz", + "integrity": "sha512-msCRJMcwH6NVZ2/zoC+1nvA0wlpYRnMxteQywS9nt4BzXn48tZpaVtE519QEZn0xe3ygvgsWx5cdPoE9Jx3bsg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.60.0" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.79.6", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.6.tgz", @@ -10537,33 +10502,6 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11419,21 +11357,6 @@ "node": ">=4" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -12314,12 +12237,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index a2103fd..1d7378c 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", - "jszip": "^3.10.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.6", @@ -46,6 +45,7 @@ "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-sortables": "^1.9.2", + "react-native-zip-archive": "^7.0.2", "react-native-video": "^6.18.0", "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", diff --git a/react-native-zip-archive.d.ts b/react-native-zip-archive.d.ts new file mode 100644 index 0000000..1a99bc3 --- /dev/null +++ b/react-native-zip-archive.d.ts @@ -0,0 +1,3 @@ +declare module "react-native-zip-archive" { + export function zip(source: string, target: string): Promise; +} diff --git a/tsconfig.json b/tsconfig.json index b29ddea..37f4b5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "**/*.ts", "**/*.tsx", + "**/*.d.ts", ".expo/types/**/*.ts", "expo-env.d.ts" ], diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts index 432a109..602134f 100644 --- a/utils/reportIssue.ts +++ b/utils/reportIssue.ts @@ -5,6 +5,13 @@ import { const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +const PREPARING_START_PROGRESS = 0; +const PREPARING_DONE_PROGRESS = 0.35; +const UPLOAD_START_PROGRESS = 0.35; +const UPLOAD_DONE_PROGRESS = 0.95; +const FINALIZING_START_PROGRESS = 0.96; +const FINALIZING_DONE_PROGRESS = 1; + export interface SubmitIssueReportInput { summary: string; description: string; @@ -20,6 +27,23 @@ export interface SubmitIssueReportResult { downloadUrl?: string | null; } +export type SubmitIssueReportProgressPhase = + | "preparing-payload" + | "uploading" + | "finalizing"; + +export interface SubmitIssueReportProgress { + phase: SubmitIssueReportProgressPhase; + progress: number; + loaded?: number; + total?: number; + message?: string; +} + +export type SubmitIssueReportProgressCallback = ( + progress: SubmitIssueReportProgress +) => void; + interface ReportIssueApiResponse { success?: boolean; issueNumber?: number; @@ -28,6 +52,13 @@ interface ReportIssueApiResponse { downloadUrl?: string | null; } +function clampProgress(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +} + function appendAttachment(formData: FormData, attachment: ReportIssueAttachment) { formData.append("zip", { uri: attachment.uri, @@ -36,8 +67,54 @@ function appendAttachment(formData: FormData, attachment: ReportIssueAttachment) } as unknown as Blob); } +function emitProgress( + onProgress: SubmitIssueReportProgressCallback | undefined, + progress: SubmitIssueReportProgress +): void { + if (!onProgress) return; + onProgress({ + ...progress, + progress: clampProgress(progress.progress), + }); +} + +function postFormDataWithProgress( + url: string, + formData: FormData, + onProgress?: (loaded: number, total?: number) => void +): Promise<{ status: number; responseText: string }> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open("POST", url); + + xhr.onload = () => { + resolve({ + status: xhr.status, + responseText: xhr.responseText ?? "", + }); + }; + + xhr.onerror = () => { + reject(new Error("Network error while submitting issue report.")); + }; + + xhr.onabort = () => { + reject(new Error("Issue report upload was aborted.")); + }; + + xhr.upload.onprogress = (event: ProgressEvent) => { + const total = event.lengthComputable ? event.total : undefined; + onProgress?.(event.loaded, total); + }; + + xhr.send(formData); + }); +} + export async function submitIssueReport( - input: SubmitIssueReportInput + input: SubmitIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback ): Promise { const summary = input.summary.trim(); const description = input.description.trim(); @@ -50,39 +127,100 @@ export async function submitIssueReport( throw new Error("Description is required."); } + emitProgress(onProgress, { + phase: "preparing-payload", + progress: PREPARING_START_PROGRESS, + message: "Preparing payload.", + }); + const formData = new FormData(); formData.append("summary", summary); formData.append("description", description); let attachment: ReportIssueAttachment | null = null; if (input.includeDraftFolder) { + emitProgress(onProgress, { + phase: "preparing-payload", + progress: 0.15, + message: "Creating draft attachment.", + }); + console.log("1 :::", new Date().toLocaleTimeString()); attachment = await buildDraftsAttachment(); + console.log("2 :::", new Date().toLocaleTimeString()); if (!attachment) { throw new Error("Failed to create draft attachment."); } + console.log("3 :::", new Date().toLocaleTimeString()); appendAttachment(formData, attachment); + console.log("4 :::", new Date().toLocaleTimeString()); } - const response = await fetch(REPORT_ISSUE_ENDPOINT, { - method: "POST", - body: formData, + emitProgress(onProgress, { + phase: "preparing-payload", + progress: PREPARING_DONE_PROGRESS, + message: "Payload ready.", + }); + + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_START_PROGRESS, + loaded: 0, + message: "Uploading issue report.", + }); + + const { status, responseText } = await postFormDataWithProgress( + REPORT_ISSUE_ENDPOINT, + formData, + (loaded, total) => { + const uploadProgress = + typeof total === "number" && total > 0 ? loaded / total : 0; + const globalProgress = + UPLOAD_START_PROGRESS + + (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * + clampProgress(uploadProgress); + + emitProgress(onProgress, { + phase: "uploading", + progress: globalProgress, + loaded, + total, + }); + } + ); + + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_START_PROGRESS, + message: "Finalizing issue report.", }); - if (!response.ok) { - const errorText = await response.text().catch(() => ""); + + if (status < 200 || status >= 300) { + const errorText = responseText || ""; const message = errorText - ? `Failed to report issue (${response.status}): ${errorText}` - : `Failed to report issue (${response.status}).`; + ? `Failed to report issue (${status}): ${errorText}` + : `Failed to report issue (${status}).`; throw new Error(message); } - const responseJson = (await response.json().catch(() => null)) as - | ReportIssueApiResponse - | null; + let responseJson: ReportIssueApiResponse | null = null; + if (responseText) { + try { + responseJson = JSON.parse(responseText) as ReportIssueApiResponse; + } catch { + responseJson = null; + } + } if (responseJson && responseJson.success === false) { throw new Error("Issue report was not accepted by the server."); } + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_DONE_PROGRESS, + message: "Issue report submitted.", + }); + return { success: responseJson?.success ?? true, attachedFileName: attachment?.name, diff --git a/utils/reportIssueAttachment.ts b/utils/reportIssueAttachment.ts index 557162a..551f3eb 100644 --- a/utils/reportIssueAttachment.ts +++ b/utils/reportIssueAttachment.ts @@ -1,7 +1,7 @@ import { DraftStorage } from "@/utils/draftStorage"; import { fileStore } from "@/utils/fileStore"; import * as FileSystem from "expo-file-system"; -import JSZip = require("jszip"); +import { zip } from "react-native-zip-archive"; export interface ReportIssueAttachment { uri: string; @@ -11,16 +11,22 @@ export interface ReportIssueAttachment { const DRAFTS_DIR_RELATIVE = "pulse/drafts/"; -function hasZipMagic(base64: string): boolean { - return base64.startsWith("UEsDB") || base64.startsWith("UEsFB") || base64.startsWith("UEsBA"); +function normalizeToFileUri(path: string): string { + if (!path) return path; + if (path.startsWith("file://")) return path; + if (path.startsWith("/")) return `file://${path}`; + return path; } -async function validateZipBase64(zipBase64: string): Promise { - if (!hasZipMagic(zipBase64)) { - throw new Error("Generated draft archive is not a valid ZIP (invalid file signature)."); +async function ensureDir(path: string): Promise { + const info = await FileSystem.getInfoAsync(path); + if (!info.exists) { + await FileSystem.makeDirectoryAsync(path, { intermediates: true }); } +} - await JSZip.loadAsync(zipBase64, { base64: true }); +function randomSuffix(): string { + return `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; } async function getDraftIdsFromFileSystem(): Promise { @@ -39,13 +45,43 @@ async function getDraftIdsFromFileSystem(): Promise { } export async function buildDraftsAttachment(): Promise { + const startTs = Date.now(); + console.log("[ReportIssueAttachment] Building draft zip attachment..."); + const drafts = await DraftStorage.getAllDrafts(); const fileSystemDraftIds = await getDraftIdsFromFileSystem(); const draftIds = Array.from( new Set([...drafts.map((draft) => draft.id), ...fileSystemDraftIds]) ); - const zip = new JSZip(); + console.log( + `[ReportIssueAttachment] Drafts from metadata=${drafts.length}, filesystem=${fileSystemDraftIds.length}, merged=${draftIds.length}` + ); + + if (draftIds.length === 0) { + console.log("[ReportIssueAttachment] No drafts found. Skipping attachment."); + return null; + } + + const reportsDir = `${FileSystem.cacheDirectory}reports/`; + await ensureDir(reportsDir); + + const buildId = randomSuffix(); + const stagingDir = `${reportsDir}report-build-${buildId}/`; + await ensureDir(stagingDir); + + const cleanupPaths = [stagingDir]; + + const zipFileName = `pulse-report-drafts-${Date.now()}.zip`; + const zipOutputPath = `${reportsDir}${zipFileName}`; + const normalizedZipOutputPath = normalizeToFileUri(zipOutputPath); + + const outputInfo = await FileSystem.getInfoAsync(zipOutputPath); + if (outputInfo.exists) { + await FileSystem.deleteAsync(zipOutputPath, { idempotent: true }); + } + + try { const metadata = drafts .slice() .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()) @@ -59,62 +95,107 @@ export async function buildDraftsAttachment(): Promise ${normalizedZipOutputPath}` + ); + + let zipUri: string; + try { + zipUri = await zip(stagingDir, normalizedZipOutputPath); + } catch (error) { + console.error("[ReportIssueAttachment] Native zip operation failed", { + stagingDir, + normalizedZipOutputPath, + error, + }); + throw new Error("Failed to create draft zip attachment (native zip failed)."); + } - const reportsDir = `${FileSystem.cacheDirectory}reports/`; - const reportsDirInfo = await FileSystem.getInfoAsync(reportsDir); - if (!reportsDirInfo.exists) { - await FileSystem.makeDirectoryAsync(reportsDir, { intermediates: true }); - } + const finalUriCandidates = Array.from( + new Set([ + zipUri, + normalizeToFileUri(zipUri), + zipOutputPath, + normalizedZipOutputPath, + ]) + ).filter((candidate) => !!candidate); + + let finalUri: string | null = null; + for (const candidate of finalUriCandidates) { + const info = await FileSystem.getInfoAsync(candidate); + console.log( + `[ReportIssueAttachment] Zip candidate check exists=${info.exists} path=${candidate}` + ); + if (info.exists) { + finalUri = candidate; + break; + } + } - const zipFileName = `pulse-report-drafts-${Date.now()}.zip`; - const attachmentUri = `${reportsDir}${zipFileName}`; - await FileSystem.writeAsStringAsync(attachmentUri, zipBase64, { - encoding: FileSystem.EncodingType.Base64, - }); + if (!finalUri) { + throw new Error( + `Failed to create draft zip attachment. zip() returned: ${zipUri}` + ); + } - const persistedZipBase64 = await FileSystem.readAsStringAsync(attachmentUri, { - encoding: FileSystem.EncodingType.Base64, - }); - await validateZipBase64(persistedZipBase64); + const zipInfo = await FileSystem.getInfoAsync(finalUri); + const zipSize = "size" in zipInfo && typeof zipInfo.size === "number" ? zipInfo.size : 0; + console.log( + `[ReportIssueAttachment] Zip ready (${zipSize} bytes) at ${finalUri} in ${Date.now() - startTs}ms` + ); return { - uri: attachmentUri, - name: zipFileName, - type: "application/zip", - }; + uri: finalUri, + name: zipFileName, + type: "application/zip", + }; + } finally { + await Promise.all( + cleanupPaths.map((path) => + FileSystem.deleteAsync(path, { idempotent: true }).catch(() => undefined) + ) + ); + } } From 86e6aed03b212755618fea5edfcf423123130c6d Mon Sep 17 00:00:00 2001 From: Adithya Date: Wed, 25 Mar 2026 13:35:30 -0700 Subject: [PATCH 4/8] feat: improve issue report submission feedback and add note for draft folder accessibility --- components/ReportIssueModal.tsx | 14 +++++++++++++- utils/reportIssue.ts | 17 ++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx index c387046..76f53c1 100644 --- a/components/ReportIssueModal.tsx +++ b/components/ReportIssueModal.tsx @@ -151,7 +151,7 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { {submittedIssue ? ( - {`Success message and issue created : ${submittedIssue.issueNumber ?? "-"}. Thank you for the feedback. We will work on it.`} + {`Issue created successfully. Thank you for the feedback. We will work on it.`} Include draft folder + {includeDraftFolder ? ( + + Note: This draft folder will become publicly accessible on GitHub once you upload it. + + ) : null} + {isSubmitting ? ( @@ -362,6 +368,12 @@ const styles = StyleSheet.create({ marginLeft: 10, fontSize: 14, }, + noteText: { + marginTop: 6, + fontSize: 12, + lineHeight: 17, + fontFamily: "Roboto-Regular", + }, progressContainer: { marginTop: 12, }, diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts index 602134f..d71939c 100644 --- a/utils/reportIssue.ts +++ b/utils/reportIssue.ts @@ -3,7 +3,8 @@ import { ReportIssueAttachment, } from "@/utils/reportIssueAttachment"; -const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +// const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +const REPORT_ISSUE_ENDPOINT = "https://pulse-vault.opensource.mieweb.org/api/report_issue"; const PREPARING_START_PROGRESS = 0; const PREPARING_DONE_PROGRESS = 0.35; @@ -148,11 +149,17 @@ export async function submitIssueReport( attachment = await buildDraftsAttachment(); console.log("2 :::", new Date().toLocaleTimeString()); if (!attachment) { - throw new Error("Failed to create draft attachment."); + emitProgress(onProgress, { + phase: "preparing-payload", + progress: 0.22, + message: "No drafts found. Continuing without draft attachment.", + }); + console.log("[ReportIssue] No drafts available; submitting without draft attachment."); + } else { + console.log("3 :::", new Date().toLocaleTimeString()); + appendAttachment(formData, attachment); + console.log("4 :::", new Date().toLocaleTimeString()); } - console.log("3 :::", new Date().toLocaleTimeString()); - appendAttachment(formData, attachment); - console.log("4 :::", new Date().toLocaleTimeString()); } emitProgress(onProgress, { From e85d6d655be139a02e6ad09b939c9df970d12563 Mon Sep 17 00:00:00 2001 From: Adithya Date: Mon, 30 Mar 2026 22:14:32 -0700 Subject: [PATCH 5/8] feat: add TUS upload functionality for issue report attachments and improve report submission process --- package-lock.json | 158 ++++++++++++- package.json | 7 +- utils/reportIssue.ts | 125 +++++++---- utils/reportIssueTusUpload.ts | 411 ++++++++++++++++++++++++++++++++++ 4 files changed, 659 insertions(+), 42 deletions(-) create mode 100644 utils/reportIssueTusUpload.ts diff --git a/package-lock.json b/package-lock.json index 1f18d6d..6937c63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,8 @@ "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", - "react-native-zip-archive": "^7.0.2" + "react-native-zip-archive": "^7.0.2", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -4947,6 +4948,15 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combine-errors": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", + "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==", + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -5172,6 +5182,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", + "license": "ISC" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7743,6 +7759,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -8111,6 +8139,12 @@ "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", "license": "MIT" }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8527,6 +8561,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "license": "MIT", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==", + "license": "MIT" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "license": "MIT", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==", + "license": "MIT" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==", + "license": "MIT" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "license": "MIT", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8546,6 +8626,16 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "license": "MIT", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, "node_modules/log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -10002,6 +10092,23 @@ "dev": true, "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10037,6 +10144,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -10645,6 +10758,12 @@ "path-parse": "^1.0.5" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10718,6 +10837,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -11900,6 +12028,24 @@ "license": "0BSD", "optional": true }, + "node_modules/tus-js-client": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12219,6 +12365,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-latest-callback": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", diff --git a/package.json b/package.json index 1d7378c..0292170 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,12 @@ "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-sortables": "^1.9.2", - "react-native-zip-archive": "^7.0.2", "react-native-video": "^6.18.0", "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-native-zip-archive": "^7.0.2", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -59,4 +60,4 @@ "typescript": "~5.8.3" }, "private": true -} \ No newline at end of file +} diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts index d71939c..dfb9fe8 100644 --- a/utils/reportIssue.ts +++ b/utils/reportIssue.ts @@ -2,9 +2,13 @@ import { buildDraftsAttachment, ReportIssueAttachment, } from "@/utils/reportIssueAttachment"; +import { uploadIssueZipViaTus } from "@/utils/reportIssueTusUpload"; // const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; const REPORT_ISSUE_ENDPOINT = "https://pulse-vault.opensource.mieweb.org/api/report_issue"; +const REPORT_ISSUE_TUS_ENDPOINT = "https://pulse-vault.opensource.mieweb.org/api/report_issue/tus"; +// const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +// const REPORT_ISSUE_TUS_ENDPOINT = "http://localhost:3000/api/report_issue/tus"; const PREPARING_START_PROGRESS = 0; const PREPARING_DONE_PROGRESS = 0.35; @@ -60,14 +64,6 @@ function clampProgress(value: number): number { return value; } -function appendAttachment(formData: FormData, attachment: ReportIssueAttachment) { - formData.append("zip", { - uri: attachment.uri, - name: attachment.name, - type: attachment.type, - } as unknown as Blob); -} - function emitProgress( onProgress: SubmitIssueReportProgressCallback | undefined, progress: SubmitIssueReportProgress @@ -79,10 +75,9 @@ function emitProgress( }); } -function postFormDataWithProgress( +function postFormData( url: string, - formData: FormData, - onProgress?: (loaded: number, total?: number) => void + formData: FormData ): Promise<{ status: number; responseText: string }> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -104,15 +99,25 @@ function postFormDataWithProgress( reject(new Error("Issue report upload was aborted.")); }; - xhr.upload.onprogress = (event: ProgressEvent) => { - const total = event.lengthComputable ? event.total : undefined; - onProgress?.(event.loaded, total); - }; - xhr.send(formData); }); } +function createReportId(): string { + return `report-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function cleanupAttachment(attachment: ReportIssueAttachment | null): Promise { + if (!attachment) return; + + try { + const fileSystem = await import("expo-file-system"); + await fileSystem.deleteAsync(attachment.uri, { idempotent: true }); + } catch { + // non-blocking cleanup + } +} + export async function submitIssueReport( input: SubmitIssueReportInput, onProgress?: SubmitIssueReportProgressCallback @@ -137,6 +142,8 @@ export async function submitIssueReport( const formData = new FormData(); formData.append("summary", summary); formData.append("description", description); + const reportId = createReportId(); + formData.append("reportId", reportId); let attachment: ReportIssueAttachment | null = null; if (input.includeDraftFolder) { @@ -155,10 +162,6 @@ export async function submitIssueReport( message: "No drafts found. Continuing without draft attachment.", }); console.log("[ReportIssue] No drafts available; submitting without draft attachment."); - } else { - console.log("3 :::", new Date().toLocaleTimeString()); - appendAttachment(formData, attachment); - console.log("4 :::", new Date().toLocaleTimeString()); } } @@ -175,25 +178,69 @@ export async function submitIssueReport( message: "Uploading issue report.", }); - const { status, responseText } = await postFormDataWithProgress( - REPORT_ISSUE_ENDPOINT, - formData, - (loaded, total) => { - const uploadProgress = - typeof total === "number" && total > 0 ? loaded / total : 0; - const globalProgress = - UPLOAD_START_PROGRESS + - (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * - clampProgress(uploadProgress); + if (attachment) { + console.log("3 :::", new Date().toLocaleTimeString()); + const attachmentType = attachment.type || "application/zip"; + const attachmentName = attachment.name || `issue-report-${Date.now()}.zip`; + + const tusUploadResult = await uploadIssueZipViaTus({ + endpoint: REPORT_ISSUE_TUS_ENDPOINT, + fileUri: attachment.uri, + fileName: attachmentName, + fileType: attachmentType, + reportId, + onProgress: ({ bytesUploaded, bytesTotal }) => { + const uploadProgress = + typeof bytesTotal === "number" && bytesTotal > 0 + ? bytesUploaded / bytesTotal + : 0; + + const globalProgress = + UPLOAD_START_PROGRESS + + (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * + clampProgress(uploadProgress); + + emitProgress(onProgress, { + phase: "uploading", + progress: globalProgress, + loaded: bytesUploaded, + total: bytesTotal, + }); + }, + onNotice: (message) => { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_START_PROGRESS, + message, + }); + }, + }); - emitProgress(onProgress, { - phase: "uploading", - progress: globalProgress, - loaded, - total, - }); - } - ); + formData.append("zipUploadMode", "tus"); + formData.append("zipFileName", attachmentName); + formData.append("zipContentType", attachmentType); + formData.append("zipTusUploadId", tusUploadResult.uploadId); + formData.append("zipTusUploadUrl", tusUploadResult.uploadUrl); + + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Upload complete. Submitting report details.", + }); + console.log("4 :::", new Date().toLocaleTimeString()); + } else { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Submitting report details.", + }); + } + + const { status, responseText } = await postFormData(REPORT_ISSUE_ENDPOINT, formData); emitProgress(onProgress, { phase: "finalizing", @@ -228,6 +275,8 @@ export async function submitIssueReport( message: "Issue report submitted.", }); + await cleanupAttachment(attachment); + return { success: responseJson?.success ?? true, attachedFileName: attachment?.name, diff --git a/utils/reportIssueTusUpload.ts b/utils/reportIssueTusUpload.ts new file mode 100644 index 0000000..0d30b3a --- /dev/null +++ b/utils/reportIssueTusUpload.ts @@ -0,0 +1,411 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as FileSystem from "expo-file-system"; +import * as tus from "tus-js-client"; + +export interface ReportIssueTusProgress { + bytesUploaded: number; + bytesTotal: number; +} + +export interface ReportIssueTusUploadInput { + fileUri: string; + fileName: string; + fileType: string; + endpoint: string; + reportId: string; + onProgress?: (progress: ReportIssueTusProgress) => void; + onNotice?: (message: string) => void; +} + +export interface ReportIssueTusUploadResult { + uploadUrl: string; + uploadId: string; +} + +const URL_STORAGE_PREFIX = "issue-report-tus:"; +const UPLOAD_ACTIVITY_TIMEOUT_MS = 30_000; +const TUS_RESUMABLE_VERSION = "1.0.0"; + +interface StoredUploadEntry { + fingerprint: string; + upload: { + size?: number; + metadata?: Record; + creationTime?: string; + uploadUrl?: string; + urlStorageKey?: string; + }; +} + +class AsyncStorageUrlStorage { + async addUpload(fingerprint: string, upload: StoredUploadEntry["upload"]): Promise { + const storageKey = `${URL_STORAGE_PREFIX}${Date.now()}:${Math.random().toString(16).slice(2)}`; + const payload: StoredUploadEntry = { + fingerprint, + upload: { + ...upload, + urlStorageKey: storageKey, + }, + }; + await AsyncStorage.setItem(storageKey, JSON.stringify(payload)); + return storageKey; + } + + async findUploadsByFingerprint(fingerprint: string): Promise { + const keys = await AsyncStorage.getAllKeys(); + const tusKeys = keys.filter((key) => key.startsWith(URL_STORAGE_PREFIX)); + if (tusKeys.length === 0) return []; + + const records = await AsyncStorage.multiGet(tusKeys); + const matches: StoredUploadEntry["upload"][] = []; + + for (const [key, raw] of records) { + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as StoredUploadEntry; + if (parsed.fingerprint !== fingerprint) continue; + matches.push({ + ...parsed.upload, + urlStorageKey: parsed.upload.urlStorageKey ?? key, + }); + } catch { + continue; + } + } + + return matches; + } + + async removeUpload(urlStorageKey: string): Promise { + await AsyncStorage.removeItem(urlStorageKey); + } +} + +function parseUploadId(uploadUrl: string): string { + try { + const url = new URL(uploadUrl); + const segments = url.pathname.split("/").filter(Boolean); + return segments[segments.length - 1] ?? uploadUrl; + } catch { + const segments = uploadUrl.split("/").filter(Boolean); + return segments[segments.length - 1] ?? uploadUrl; + } +} + +function getHttpStatus(error: unknown): number | undefined { + const originalResponse = (error as { originalResponse?: { getStatus?: () => number } }) + ?.originalResponse; + + const maybeStatus = originalResponse?.getStatus; + if (typeof maybeStatus === "function") { + try { + const value = maybeStatus.call(originalResponse); + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } catch { + // continue with fallback parsing + } + } + + const message = error instanceof Error ? error.message : ""; + const statusMatch = message.match(/response code:\s*(\d{3})/i) ?? message.match(/\b(\d{3})\b/); + if (statusMatch) { + const parsed = Number(statusMatch[1]); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return undefined; +} + +function isLikelyNetworkError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return ( + message.includes("network") || + message.includes("offline") || + message.includes("failed to fetch") || + message.includes("connection") + ); +} + +function isRetryableTusError(error: unknown): boolean { + const maybeRetryable = ( + tus.Upload as unknown as { + isRetryableError?: (inputError: unknown) => boolean; + } + ).isRetryableError; + + if (typeof maybeRetryable === "function") { + return maybeRetryable(error); + } + + const status = getHttpStatus(error); + if (typeof status !== "number") { + return false; + } + + return status >= 500 || status === 409 || status === 423 || status === 429; +} + +function toUploadError(error: unknown): Error { + if (!(error instanceof Error)) { + return new Error("Issue attachment upload failed."); + } + + const status = getHttpStatus(error); + if (status === 401 || status === 403) { + return new Error( + "Issue attachment upload was rejected by the server (403). Please verify report-upload authorization on the TUS endpoint." + ); + } + + if (typeof status === "number" && status >= 400 && status < 500) { + return new Error(`Issue attachment upload failed with status ${status}.`); + } + + return error; +} + +function createFingerprint(params: { + fileUri: string; + fileName: string; + fileSize: number; + reportId: string; +}): string { + return [ + "issue-report", + params.reportId, + params.fileName, + params.fileSize, + params.fileUri, + ].join(":"); +} + +function normalizeTusEndpoint(endpoint: string): string { + return endpoint.replace(/\/$/, ""); +} + +async function startTusUpload( + upload: tus.Upload, + fingerprintStorage: AsyncStorageUrlStorage, + allowResume: boolean +): Promise<{ uploadUrl: string; resumed: boolean }> { + let resumed = false; + let resumedStorageKey: string | null = null; + + if (allowResume) { + const previousUploads = await upload.findPreviousUploads(); + if (previousUploads.length > 0) { + const previousUpload = previousUploads[0]; + resumed = true; + resumedStorageKey = previousUpload.urlStorageKey ?? null; + upload.resumeFromPreviousUpload(previousUpload); + } + } + + return new Promise((resolve, reject) => { + let isSettled = false; + let activityTimeout: ReturnType | null = null; + + const clearActivityTimeout = () => { + if (activityTimeout) { + clearTimeout(activityTimeout); + activityTimeout = null; + } + }; + + const touchActivity = () => { + clearActivityTimeout(); + activityTimeout = setTimeout(() => { + if (isSettled) return; + isSettled = true; + upload.abort().catch(() => undefined); + reject( + new Error( + "Issue attachment upload timed out due to no network activity. Please check connection and try again." + ) + ); + }, UPLOAD_ACTIVITY_TIMEOUT_MS); + }; + + touchActivity(); + + upload.options.onSuccess = () => { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + + if (!upload.url) { + reject(new Error("TUS upload completed without an upload URL.")); + return; + } + + resolve({ + uploadUrl: upload.url, + resumed, + }); + }; + + upload.options.onError = async (error: Error) => { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + + const status = getHttpStatus(error); + if (status === 404 && resumed && resumedStorageKey) { + await fingerprintStorage.removeUpload(resumedStorageKey); + } + reject(error); + }; + + const previousProgressHandler = upload.options.onProgress; + upload.options.onProgress = (bytesUploaded, bytesTotal) => { + touchActivity(); + previousProgressHandler?.(bytesUploaded, bytesTotal); + }; + + const previousBeforeRequest = upload.options.onBeforeRequest; + upload.options.onBeforeRequest = async (req) => { + touchActivity(); + req.setHeader("Tus-Resumable", TUS_RESUMABLE_VERSION); + console.log( + `[ReportIssueTUS] ${req.getMethod()} ${req.getURL()} starting (Tus-Resumable=${req.getHeader("Tus-Resumable") ?? "missing"})` + ); + if (previousBeforeRequest) { + await previousBeforeRequest(req); + } + }; + + const previousAfterResponse = upload.options.onAfterResponse; + upload.options.onAfterResponse = async (req, res) => { + touchActivity(); + console.log( + `[ReportIssueTUS] ${req.getMethod()} ${req.getURL()} -> ${res.getStatus()}` + ); + if (previousAfterResponse) { + await previousAfterResponse(req, res); + } + }; + + try { + upload.start(); + } catch (error) { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + reject(error instanceof Error ? error : new Error("Failed to start TUS upload.")); + } + }); +} + +export async function uploadIssueZipViaTus( + input: ReportIssueTusUploadInput +): Promise { + console.log("[ReportIssueTUS] Starting issue attachment upload..."); + const fileInfo = await FileSystem.getInfoAsync(input.fileUri); + if (!fileInfo.exists) { + throw new Error("Issue attachment file not found."); + } + + const fileSize = "size" in fileInfo && typeof fileInfo.size === "number" ? fileInfo.size : 0; + if (fileSize <= 0) { + throw new Error("Issue attachment file is empty."); + } + console.log(`[ReportIssueTUS] File size: ${fileSize} bytes`); + + const endpoint = normalizeTusEndpoint(input.endpoint); + console.log(`[ReportIssueTUS] Endpoint: ${endpoint}`); + const urlStorage = new AsyncStorageUrlStorage(); + const fingerprint = createFingerprint({ + fileUri: input.fileUri, + fileName: input.fileName, + fileSize, + reportId: input.reportId, + }); + + const metadata: Record = { + filename: input.fileName, + reportId: input.reportId, + contentType: input.fileType, + }; + + const makeUpload = () => + new tus.Upload( + { + uri: input.fileUri, + name: input.fileName, + type: input.fileType, + } as unknown as Blob, + { + endpoint, + headers: { + "tus-resumable": TUS_RESUMABLE_VERSION, + }, + metadata, + uploadSize: fileSize, + storeFingerprintForResuming: true, + removeFingerprintOnSuccess: true, + retryDelays: [0, 500, 1000, 2000, 4000, 8000, 16000], + urlStorage: urlStorage as unknown as tus.UrlStorage, + fingerprint: () => Promise.resolve(fingerprint), + onProgress: (bytesUploaded, bytesTotal) => { + input.onProgress?.({ + bytesUploaded, + bytesTotal, + }); + }, + onShouldRetry: (error, retryAttempt) => { + const status = getHttpStatus(error); + + if (typeof status === "number" && status >= 400 && status < 500) { + return false; + } + + if (retryAttempt > 10) { + return false; + } + + if (status === 404) { + return false; + } + + return isLikelyNetworkError(error) || isRetryableTusError(error); + }, + } + ); + + try { + const initialUpload = makeUpload(); + console.log("[ReportIssueTUS] Attempting resume or fresh upload..."); + const result = await startTusUpload(initialUpload, urlStorage, true); + console.log(`[ReportIssueTUS] Upload completed. URL: ${result.uploadUrl}`); + return { + uploadUrl: result.uploadUrl, + uploadId: parseUploadId(result.uploadUrl), + }; + } catch (error) { + if (getHttpStatus(error) !== 404) { + throw toUploadError(error); + } + + input.onNotice?.("Previous upload expired. Retrying from start..."); + console.log("[ReportIssueTUS] Resume upload returned 404. Restarting from zero."); + + const staleUploads = await urlStorage.findUploadsByFingerprint(fingerprint); + await Promise.all( + staleUploads.map((entry) => + entry.urlStorageKey ? urlStorage.removeUpload(entry.urlStorageKey) : Promise.resolve() + ) + ); + + const retryUpload = makeUpload(); + const retryResult = await startTusUpload(retryUpload, urlStorage, false); + console.log(`[ReportIssueTUS] Retry upload completed. URL: ${retryResult.uploadUrl}`); + return { + uploadUrl: retryResult.uploadUrl, + uploadId: parseUploadId(retryResult.uploadUrl), + }; + } +} From 1fe3da1b7de3a95011a67bba9e99accaba5a6cb9 Mon Sep 17 00:00:00 2001 From: Adithya Date: Thu, 2 Apr 2026 10:42:41 -0700 Subject: [PATCH 6/8] feat: add expo-notifications for local notifications and implement bug report notification system - Added expo-notifications dependency to package.json and package-lock.json. - Created localNotification.ts to handle local notifications for bug report uploads. - Implemented notification setup, permission requests, and scheduling notifications based on bug report results. - Updated reportIssue.ts to support a two-phase flow for issue reporting, separating payload preparation and upload processes. - Added validation for issue report input and cleanup for attachments after submission. --- app/_layout.tsx | 54 ++++++ components/ReportIssueModal.tsx | 165 ++++++++++++++---- package-lock.json | 145 ++++++++++++---- package.json | 1 + utils/localNotification.ts | 135 +++++++++++++++ utils/reportIssue.ts | 294 +++++++++++++++++++------------- 6 files changed, 616 insertions(+), 178 deletions(-) create mode 100644 utils/localNotification.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index a0d822f..b09d2cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,10 @@ import { PermissionMonitor } from "@/components/PermissionMonitor"; import { ReportIssueFab } from "@/components/ReportIssueFab"; import { ReportIssueModal } from "@/components/ReportIssueModal"; import { useColorScheme } from "@/hooks/useColorScheme"; +import { + BUG_REPORT_NOTIFICATION_OPEN_ACTION, + openBugReportNotificationUrl, +} from "@/utils/localNotification"; import { storeUploadConfigForDraft } from "@/utils/uploadConfig"; import { addDestination } from "@/utils/uploadDestinations"; @@ -115,6 +119,56 @@ export default function RootLayout() { }; }, [router]); + useEffect(() => { + let cancelled = false; + let subscription: { remove: () => void } | null = null; + + const setupNotifications = async () => { + const Notifications = await import("expo-notifications"); + + const handleResponse = async (response: { + actionIdentifier: string; + notification: { + request: { + content: { + data?: { + issueUrl?: string | null; + }; + }; + }; + }; + }) => { + if (cancelled) return; + + const issueUrl = response.notification.request.content.data?.issueUrl; + if (!issueUrl) return; + + if ( + response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER || + response.actionIdentifier === BUG_REPORT_NOTIFICATION_OPEN_ACTION + ) { + await openBugReportNotificationUrl(issueUrl); + } + }; + + subscription = Notifications.addNotificationResponseReceivedListener((response) => { + void handleResponse(response); + }); + + const lastResponse = await Notifications.getLastNotificationResponseAsync(); + if (lastResponse) { + await handleResponse(lastResponse as never); + } + }; + + void setupNotifications(); + + return () => { + cancelled = true; + subscription?.remove(); + }; + }, []); + if (!loaded) { // Async font loading only occurs in development. return null; diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx index 76f53c1..bd58bb7 100644 --- a/components/ReportIssueModal.tsx +++ b/components/ReportIssueModal.tsx @@ -19,6 +19,9 @@ import { SubmitIssueReportResult, submitIssueReport, } from "@/utils/reportIssue"; +import { + notifyBackgroundBugReportResult, +} from "@/utils/localNotification"; interface ReportIssueModalProps { visible: boolean; @@ -37,13 +40,22 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { const [submittedIssue, setSubmittedIssue] = React.useState(null); const [submitProgress, setSubmitProgress] = React.useState(0); const [submitProgressMessage, setSubmitProgressMessage] = React.useState(""); + const [showBackgroundUploadButton, setShowBackgroundUploadButton] = React.useState(false); + const [activeSubmissionToken, setActiveSubmissionToken] = React.useState(null); + const backgroundEnabledByTokenRef = React.useRef>(new Map()); + + const normalizedSummary = summary.trim(); + const normalizedDescription = description.trim(); + + const canUploadInBackground = showBackgroundUploadButton && isSubmitting; + const isBusy = isSubmitting; const canSubmit = React.useMemo( - () => !!summary.trim() && !!description.trim() && !isSubmitting, - [description, isSubmitting, summary] + () => !!normalizedSummary && !!normalizedDescription && !isSubmitting, + [isSubmitting, normalizedDescription, normalizedSummary] ); - const resetAndClose = () => { + const resetAndClose = React.useCallback(() => { setSummary(""); setDescription(""); setIncludeDraftFolder(false); @@ -51,8 +63,10 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setSubmittedIssue(null); setSubmitProgress(0); setSubmitProgressMessage(""); + setShowBackgroundUploadButton(false); + setActiveSubmissionToken(null); onClose(); - }; + }, [onClose]); const handleSubmitProgress = React.useCallback( (progressUpdate: SubmitIssueReportProgress) => { @@ -99,31 +113,75 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { setSubmitProgress(0); setSubmitProgressMessage("Preparing payload..."); + const currentSubmissionToken = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + backgroundEnabledByTokenRef.current.set(currentSubmissionToken, false); + setActiveSubmissionToken(currentSubmissionToken); + setShowBackgroundUploadButton(includeDraftFolder); + try { - const result = await submitIssueReport({ - summary, - description, - includeDraftFolder, - }, handleSubmitProgress); + const result = await submitIssueReport( + { + summary, + description, + includeDraftFolder, + }, + handleSubmitProgress + ); + + const shouldNotifyInBackground = + backgroundEnabledByTokenRef.current.get(currentSubmissionToken) === true; - setSubmittedIssue(result); + if (shouldNotifyInBackground) { + void notifyBackgroundBugReportResult(true, result.issueUrl); + } else { + setSubmittedIssue(result); + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to submit issue report."; console.error(error); - setErrorMessage(message); + + const shouldNotifyInBackground = + backgroundEnabledByTokenRef.current.get(currentSubmissionToken) === true; + + if (shouldNotifyInBackground) { + void notifyBackgroundBugReportResult(false); + } else { + setErrorMessage(message); + } } finally { + backgroundEnabledByTokenRef.current.delete(currentSubmissionToken); + setShowBackgroundUploadButton(false); + setActiveSubmissionToken(null); setIsSubmitting(false); } }; + const handleUploadInBackground = React.useCallback(() => { + if (!canUploadInBackground || !activeSubmissionToken) { + return; + } + + backgroundEnabledByTokenRef.current.set(activeSubmissionToken, true); + resetAndClose(); + }, [activeSubmissionToken, canUploadInBackground, resetAndClose]); + return ( { + if (isBusy) return; + resetAndClose(); + }} > - + { + if (isBusy) return; + resetAndClose(); + }} + > @@ -275,27 +333,47 @@ export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { ) : null} - - {isSubmitting ? ( - - - - {`${Math.round(submitProgress * 100)}%`} - - - ) : ( - Submit - )} - + + {showBackgroundUploadButton ? ( + + Upload in Background + + ) : null} + + + {isSubmitting ? ( + + + + {`${Math.round(submitProgress * 100)}%`} + + + ) : ( + Submit + )} + + )} @@ -414,6 +492,23 @@ const styles = StyleSheet.create({ fontFamily: "Roboto-Bold", fontSize: 15, }, + footerButtons: { + marginTop: 14, + flexDirection: "row", + gap: 10, + }, + secondaryFooterButton: { + flex: 1, + borderWidth: 1, + height: 46, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + footerButtonHalf: { + flex: 1, + marginTop: 0, + }, submitButton: { marginTop: 14, height: 46, diff --git a/package-lock.json b/package-lock.json index 6937c63..ade88f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linking": "~7.1.7", + "expo-notifications": "~0.31.5", "expo-router": "~5.1.7", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.10", @@ -2344,6 +2345,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4205,6 +4212,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4225,7 +4245,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4431,6 +4450,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4632,7 +4657,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4651,7 +4675,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4665,7 +4688,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5309,7 +5331,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5336,7 +5357,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -5425,7 +5445,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5569,7 +5588,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5579,7 +5597,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5617,7 +5634,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6183,6 +6199,15 @@ } } }, + "node_modules/expo-application": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.1.5.tgz", + "integrity": "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz", @@ -6385,6 +6410,26 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.31.5.tgz", + "integrity": "sha512-HsitfTrSESFDWwaX0Y+6GQlWEooQqZKdGbNTwTPHfp5PNCr02tVPwwya9j1tdg3Awj8/vmfXmSxzNhULfmgJhQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.7.6", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~6.1.5", + "expo-constants": "~17.1.8" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "5.1.10", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.10.tgz", @@ -6743,7 +6788,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -6853,7 +6897,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6881,7 +6924,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6915,7 +6957,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7056,7 +7097,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7097,7 +7137,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7126,7 +7165,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7139,7 +7177,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7395,6 +7432,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7499,7 +7552,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7621,7 +7673,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -7663,6 +7714,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -7715,7 +7782,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7810,7 +7876,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -8759,7 +8824,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9435,11 +9499,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9449,7 +9528,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9939,7 +10017,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10944,7 +11021,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11091,7 +11167,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -12393,6 +12468,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12590,7 +12678,6 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index 0292170..2e7fdbc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linking": "~7.1.7", + "expo-notifications": "~0.31.5", "expo-router": "~5.1.7", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.10", diff --git a/utils/localNotification.ts b/utils/localNotification.ts new file mode 100644 index 0000000..24aebf3 --- /dev/null +++ b/utils/localNotification.ts @@ -0,0 +1,135 @@ +import { Alert, Platform } from "react-native"; + +type NotificationsModule = typeof import("expo-notifications"); + +let notificationSetupDone = false; +let notificationsModuleCache: NotificationsModule | null | undefined; + +const BUG_REPORT_NOTIFICATION_CATEGORY = "bug-report-upload"; +const BUG_REPORT_OPEN_ACTION = "open-github-ticket"; + +async function getNotificationsModule(): Promise { + if (notificationsModuleCache !== undefined) { + return notificationsModuleCache; + } + + try { + notificationsModuleCache = await import("expo-notifications"); + } catch (error) { + notificationsModuleCache = null; + console.warn( + "[BugReportNotification] expo-notifications native module unavailable. Rebuild app to enable local notifications.", + error + ); + } + + return notificationsModuleCache; +} + +async function ensureNotificationSetup(): Promise { + const Notifications = await getNotificationsModule(); + if (!Notifications) { + return false; + } + + if (!notificationSetupDone) { + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("bug-report-upload", { + name: "Bug Report Upload", + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + + await Notifications.setNotificationCategoryAsync(BUG_REPORT_NOTIFICATION_CATEGORY, [ + { + identifier: BUG_REPORT_OPEN_ACTION, + buttonTitle: "Open GitHub", + options: { + opensAppToForeground: true, + }, + }, + ]); + + notificationSetupDone = true; + } + + const currentPermissions = await Notifications.getPermissionsAsync(); + if (currentPermissions.granted || currentPermissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL) { + return true; + } + + const requestResult = await Notifications.requestPermissionsAsync(); + return ( + requestResult.granted || + requestResult.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +export async function notifyBackgroundBugReportResult( + success: boolean, + issueUrl?: string | null +): Promise { + try { + const ready = await ensureNotificationSetup(); + if (!ready) { + Alert.alert( + success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again." + ); + return; + } + + const Notifications = await getNotificationsModule(); + if (!Notifications) { + Alert.alert( + success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again." + ); + return; + } + + await Notifications.scheduleNotificationAsync({ + content: { + title: "Bug Report Upload", + body: success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again.", + data: issueUrl ? { issueUrl } : undefined, + categoryIdentifier: success && issueUrl ? BUG_REPORT_NOTIFICATION_CATEGORY : undefined, + }, + trigger: null, + }); + } catch (error) { + console.error("[BugReportNotification] Failed to show local notification", error); + } +} + +export async function openBugReportNotificationUrl(issueUrl?: string | null): Promise { + if (!issueUrl) return; + + try { + const canOpen = await import("expo-linking").then((mod) => mod.canOpenURL(issueUrl)); + if (!canOpen) { + Alert.alert("Unable to open GitHub ticket."); + return; + } + + await import("expo-linking").then((mod) => mod.openURL(issueUrl)); + } catch (error) { + console.error("[BugReportNotification] Failed to open GitHub URL", error); + Alert.alert("Unable to open GitHub ticket."); + } +} + +export const BUG_REPORT_NOTIFICATION_OPEN_ACTION = BUG_REPORT_OPEN_ACTION; diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts index dfb9fe8..94544a9 100644 --- a/utils/reportIssue.ts +++ b/utils/reportIssue.ts @@ -23,6 +23,17 @@ export interface SubmitIssueReportInput { includeDraftFolder: boolean; } +export interface PreparedIssueReportPayload { + includeDraftFolder: boolean; + reportId: string; + attachment: ReportIssueAttachment | null; +} + +export interface UploadPreparedIssueReportInput { + summary: string; + description: string; +} + export interface SubmitIssueReportResult { success: boolean; attachedFileName?: string; @@ -107,6 +118,24 @@ function createReportId(): string { return `report-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; } +function validateSubmitInput(input: SubmitIssueReportInput): { + summary: string; + description: string; +} { + const summary = input.summary.trim(); + const description = input.description.trim(); + + if (!summary) { + throw new Error("Summary is required."); + } + + if (!description) { + throw new Error("Description is required."); + } + + return { summary, description }; +} + async function cleanupAttachment(attachment: ReportIssueAttachment | null): Promise { if (!attachment) return; @@ -118,20 +147,20 @@ async function cleanupAttachment(attachment: ReportIssueAttachment | null): Prom } } -export async function submitIssueReport( +export async function discardPreparedIssueReportPayload( + preparedPayload: PreparedIssueReportPayload | null +): Promise { + await cleanupAttachment(preparedPayload?.attachment ?? null); +} + +// Two-phase flow: +// Phase 1 prepares metadata + optional draft attachment zip. +// Phase 2 performs upload/finalization and can run detached from modal lifecycle. +export async function prepareIssueReportPayload( input: SubmitIssueReportInput, onProgress?: SubmitIssueReportProgressCallback -): Promise { - const summary = input.summary.trim(); - const description = input.description.trim(); - - if (!summary) { - throw new Error("Summary is required."); - } - - if (!description) { - throw new Error("Description is required."); - } +): Promise { + validateSubmitInput(input); emitProgress(onProgress, { phase: "preparing-payload", @@ -139,11 +168,7 @@ export async function submitIssueReport( message: "Preparing payload.", }); - const formData = new FormData(); - formData.append("summary", summary); - formData.append("description", description); const reportId = createReportId(); - formData.append("reportId", reportId); let attachment: ReportIssueAttachment | null = null; if (input.includeDraftFolder) { @@ -152,9 +177,7 @@ export async function submitIssueReport( progress: 0.15, message: "Creating draft attachment.", }); - console.log("1 :::", new Date().toLocaleTimeString()); attachment = await buildDraftsAttachment(); - console.log("2 :::", new Date().toLocaleTimeString()); if (!attachment) { emitProgress(onProgress, { phase: "preparing-payload", @@ -171,6 +194,34 @@ export async function submitIssueReport( message: "Payload ready.", }); + return { + includeDraftFolder: input.includeDraftFolder, + reportId, + attachment, + }; +} + +export async function uploadPreparedIssueReport( + preparedPayload: PreparedIssueReportPayload, + input: UploadPreparedIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback +): Promise { + const { summary, description } = validateSubmitInput({ + summary: input.summary, + description: input.description, + includeDraftFolder: preparedPayload.includeDraftFolder, + }); + + const formData = new FormData(); + formData.append("summary", summary); + formData.append("description", description); + formData.append("reportId", preparedPayload.reportId); + + const { reportId, attachment } = preparedPayload; + + // Current backend contract expects attachment upload metadata in this final POST, + // so true "issue-first then attachment" flow requires a server-side API change. + emitProgress(onProgress, { phase: "uploading", progress: UPLOAD_START_PROGRESS, @@ -178,111 +229,126 @@ export async function submitIssueReport( message: "Uploading issue report.", }); - if (attachment) { - console.log("3 :::", new Date().toLocaleTimeString()); - const attachmentType = attachment.type || "application/zip"; - const attachmentName = attachment.name || `issue-report-${Date.now()}.zip`; - - const tusUploadResult = await uploadIssueZipViaTus({ - endpoint: REPORT_ISSUE_TUS_ENDPOINT, - fileUri: attachment.uri, - fileName: attachmentName, - fileType: attachmentType, - reportId, - onProgress: ({ bytesUploaded, bytesTotal }) => { - const uploadProgress = - typeof bytesTotal === "number" && bytesTotal > 0 - ? bytesUploaded / bytesTotal - : 0; - - const globalProgress = - UPLOAD_START_PROGRESS + - (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * - clampProgress(uploadProgress); - - emitProgress(onProgress, { - phase: "uploading", - progress: globalProgress, - loaded: bytesUploaded, - total: bytesTotal, - }); - }, - onNotice: (message) => { - emitProgress(onProgress, { - phase: "uploading", - progress: UPLOAD_START_PROGRESS, - message, - }); - }, - }); + try { + if (attachment) { + const attachmentType = attachment.type || "application/zip"; + const attachmentName = attachment.name || `issue-report-${Date.now()}.zip`; + + const tusUploadResult = await uploadIssueZipViaTus({ + endpoint: REPORT_ISSUE_TUS_ENDPOINT, + fileUri: attachment.uri, + fileName: attachmentName, + fileType: attachmentType, + reportId, + onProgress: ({ bytesUploaded, bytesTotal }) => { + const uploadProgress = + typeof bytesTotal === "number" && bytesTotal > 0 + ? bytesUploaded / bytesTotal + : 0; + + const globalProgress = + UPLOAD_START_PROGRESS + + (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * + clampProgress(uploadProgress); + + emitProgress(onProgress, { + phase: "uploading", + progress: globalProgress, + loaded: bytesUploaded, + total: bytesTotal, + }); + }, + onNotice: (message) => { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_START_PROGRESS, + message, + }); + }, + }); - formData.append("zipUploadMode", "tus"); - formData.append("zipFileName", attachmentName); - formData.append("zipContentType", attachmentType); - formData.append("zipTusUploadId", tusUploadResult.uploadId); - formData.append("zipTusUploadUrl", tusUploadResult.uploadUrl); + formData.append("zipUploadMode", "tus"); + formData.append("zipFileName", attachmentName); + formData.append("zipContentType", attachmentType); + formData.append("zipTusUploadId", tusUploadResult.uploadId); + formData.append("zipTusUploadUrl", tusUploadResult.uploadUrl); - emitProgress(onProgress, { - phase: "uploading", - progress: UPLOAD_DONE_PROGRESS, - loaded: 1, - total: 1, - message: "Upload complete. Submitting report details.", - }); - console.log("4 :::", new Date().toLocaleTimeString()); - } else { - emitProgress(onProgress, { - phase: "uploading", - progress: UPLOAD_DONE_PROGRESS, - loaded: 1, - total: 1, - message: "Submitting report details.", - }); - } + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Upload complete. Submitting report details.", + }); + } else { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Submitting report details.", + }); + } - const { status, responseText } = await postFormData(REPORT_ISSUE_ENDPOINT, formData); + const { status, responseText } = await postFormData(REPORT_ISSUE_ENDPOINT, formData); - emitProgress(onProgress, { - phase: "finalizing", - progress: FINALIZING_START_PROGRESS, - message: "Finalizing issue report.", - }); + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_START_PROGRESS, + message: "Finalizing issue report.", + }); - if (status < 200 || status >= 300) { - const errorText = responseText || ""; - const message = errorText - ? `Failed to report issue (${status}): ${errorText}` - : `Failed to report issue (${status}).`; - throw new Error(message); - } + if (status < 200 || status >= 300) { + const errorText = responseText || ""; + const message = errorText + ? `Failed to report issue (${status}): ${errorText}` + : `Failed to report issue (${status}).`; + throw new Error(message); + } - let responseJson: ReportIssueApiResponse | null = null; - if (responseText) { - try { - responseJson = JSON.parse(responseText) as ReportIssueApiResponse; - } catch { - responseJson = null; + let responseJson: ReportIssueApiResponse | null = null; + if (responseText) { + try { + responseJson = JSON.parse(responseText) as ReportIssueApiResponse; + } catch { + responseJson = null; + } } - } - if (responseJson && responseJson.success === false) { - throw new Error("Issue report was not accepted by the server."); - } + if (responseJson && responseJson.success === false) { + throw new Error("Issue report was not accepted by the server."); + } - emitProgress(onProgress, { - phase: "finalizing", - progress: FINALIZING_DONE_PROGRESS, - message: "Issue report submitted.", - }); + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_DONE_PROGRESS, + message: "Issue report submitted.", + }); - await cleanupAttachment(attachment); + return { + success: responseJson?.success ?? true, + attachedFileName: attachment?.name, + issueNumber: responseJson?.issueNumber, + issueUrl: responseJson?.issueUrl, + uploadId: responseJson?.uploadId ?? null, + downloadUrl: responseJson?.downloadUrl ?? null, + }; + } finally { + await cleanupAttachment(attachment); + } +} - return { - success: responseJson?.success ?? true, - attachedFileName: attachment?.name, - issueNumber: responseJson?.issueNumber, - issueUrl: responseJson?.issueUrl, - uploadId: responseJson?.uploadId ?? null, - downloadUrl: responseJson?.downloadUrl ?? null, - }; +export async function submitIssueReport( + input: SubmitIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback +): Promise { + const preparedPayload = await prepareIssueReportPayload(input, onProgress); + return uploadPreparedIssueReport( + preparedPayload, + { + summary: input.summary, + description: input.description, + }, + onProgress + ); } From b1f082d377401d04e8331c12f5c49727fa3dc079 Mon Sep 17 00:00:00 2001 From: Adithya Seesanabilu Nagaraj <38344535+adithya1012@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:26:48 +0530 Subject: [PATCH 7/8] Bump version from 1.1.2 to 1.1.3 --- app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index 5fb3a93..a7ebbfe 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "pulse", "slug": "pulse", - "version": "1.1.2", + "version": "1.1.3", "orientation": "portrait", "icon": "./assets/images/pulse-logo.png", "scheme": "pulsecam", @@ -76,4 +76,4 @@ }, "owner": "morepriyam" } -} \ No newline at end of file +} From f393023fb19a5d308f594e8df4cc8a088e2ba751 Mon Sep 17 00:00:00 2001 From: Adithya Date: Tue, 14 Apr 2026 23:33:22 -0700 Subject: [PATCH 8/8] feat: set iOS deployment target to 15.5 in workflows and Podfile --- .github/workflows/codeql.yml | 32 ++++++++++++++++++------- .github/workflows/main.yml | 4 ++++ .github/workflows/testflight-deploy.yml | 4 ++++ ios/Podfile.properties.json | 3 ++- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 65515e8..9e36ae9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -112,6 +112,11 @@ jobs: echo "Prebuilding iOS project..." npx expo prebuild --platform ios --clean --no-install + - name: Set iOS deployment target for pods + if: matrix.language == 'swift' + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies if: matrix.language == 'swift' run: | @@ -166,14 +171,25 @@ jobs: run: | echo "Attempting fallback iOS build with minimal configuration..." cd ios - - # Try a minimal build using the same approach as main.yml - xcodebuild -workspace pulse.xcworkspace \ - -scheme pulse \ - -configuration Debug \ - -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \ - build-for-testing + + # Guard against missing workspace when pod install failed earlier. + if [ ! -d "pulse.xcworkspace" ]; then + echo "pulse.xcworkspace is missing; retrying pod install before fallback build..." + pod install --repo-update + fi + + if [ ! -d "pulse.xcworkspace" ]; then + echo "❌ pulse.xcworkspace still missing after pod install. Fallback build cannot continue." + exit 1 + fi + + # Try a minimal build using the same approach as main.yml. + xcodebuild -workspace pulse.xcworkspace \ + -scheme pulse \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \ + build-for-testing - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0560393..482e705 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -180,6 +180,10 @@ jobs: echo "Prebuilding iOS project..." npx expo prebuild --platform ios --clean + - name: Set iOS deployment target for pods + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies run: | cd ios diff --git a/.github/workflows/testflight-deploy.yml b/.github/workflows/testflight-deploy.yml index a030d86..cfc3230 100644 --- a/.github/workflows/testflight-deploy.yml +++ b/.github/workflows/testflight-deploy.yml @@ -49,6 +49,10 @@ jobs: - name: Prebuild iOS project run: npx expo prebuild --clean + - name: Set iOS deployment target for pods + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies run: | cd ios diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 417e2e5..37edce2 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,5 +1,6 @@ { "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" + "newArchEnabled": "true", + "ios.deploymentTarget": "15.5" }