From d7cfd10539b53d914714a74c96ceed780a072afe Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 25 Apr 2026 13:43:28 -0400 Subject: [PATCH] refactor(mobile): replace manual highlight fetch logic with custom hooks for highlight management --- .../ActionSheets/ReadableHighlightSheet.tsx | 118 +++++------------- .../components/Formats/ReadableFormat.tsx | 97 ++++++++------ packages/router/highlights.tsx | 84 +++++++++---- 3 files changed, 154 insertions(+), 145 deletions(-) diff --git a/apps/mobile/components/ActionSheets/ReadableHighlightSheet.tsx b/apps/mobile/components/ActionSheets/ReadableHighlightSheet.tsx index e5464016..707da683 100644 --- a/apps/mobile/components/ActionSheets/ReadableHighlightSheet.tsx +++ b/apps/mobile/components/ActionSheets/ReadableHighlightSheet.tsx @@ -4,15 +4,17 @@ import ActionSheet, { SheetManager, SheetProps, } from "react-native-actions-sheet"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react-native"; import { useColorScheme } from "nativewind"; -import { Highlight } from "@linkwarden/prisma/client"; import { rawTheme, ThemeName } from "@/lib/colors"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import useAuthStore from "@/store/auth"; import { Button } from "@/components/ui/Button"; import Input from "@/components/ui/Input"; +import { + usePostHighlight, + useRemoveHighlight, +} from "@linkwarden/router/highlights"; export const HIGHLIGHT_COLORS = [ { @@ -59,7 +61,6 @@ export default function ReadableHighlightSheet( props: SheetProps<"readable-highlight-sheet"> ) { const { auth } = useAuthStore(); - const queryClient = useQueryClient(); const [draft, setDraft] = useState(null); const { colorScheme } = useColorScheme(); const insets = useSafeAreaInsets(); @@ -73,84 +74,8 @@ export default function ReadableHighlightSheet( void SheetManager.hide("readable-highlight-sheet"); }; - const saveHighlight = useMutation({ - mutationFn: async (highlight: ReadableHighlightDraft) => { - const response = await fetch(`${auth.instance}/api/v1/highlights`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${auth.session}`, - }, - body: JSON.stringify({ - color: highlight.color, - comment: highlight.comment, - startOffset: highlight.startOffset, - endOffset: highlight.endOffset, - text: highlight.text, - linkId: highlight.linkId, - }), - }); - - const data = await response.json(); - - if (!response.ok) throw new Error(data.response); - - return data.response as Highlight; - }, - onSuccess: (highlight) => { - queryClient.setQueryData( - ["highlights", highlight.linkId], - (existing: Highlight[] = []) => { - const index = existing.findIndex((item) => item.id === highlight.id); - - if (index === -1) return [...existing, highlight]; - - const next = [...existing]; - next[index] = highlight; - return next; - } - ); - - closeSheet(); - }, - onError: (error) => { - Alert.alert("Error", "There was an error saving the highlight."); - console.error("Failed to save highlight", error); - }, - }); - - const deleteHighlight = useMutation({ - mutationFn: async (payload: { highlightId: number; linkId: number }) => { - const response = await fetch( - `${auth.instance}/api/v1/highlights/${payload.highlightId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${auth.session}`, - }, - } - ); - - const data = await response.json(); - - if (!response.ok) throw new Error(data.response); - - return payload; - }, - onSuccess: ({ highlightId, linkId }) => { - queryClient.setQueryData( - ["highlights", linkId], - (existing: Highlight[] = []) => - existing.filter((highlight) => highlight.id !== highlightId) - ); - - closeSheet(); - }, - onError: (error) => { - Alert.alert("Error", "There was an error removing the highlight."); - console.error("Failed to delete highlight", error); - }, - }); + const saveHighlight = usePostHighlight(draft?.linkId || 0, auth); + const deleteHighlight = useRemoveHighlight(draft?.linkId || 0, auth); const handleSave = () => { if (!draft) return; @@ -168,15 +93,38 @@ export default function ReadableHighlightSheet( return; } - saveHighlight.mutate(draft); + saveHighlight.mutate( + { + color: draft.color, + comment: draft.comment, + startOffset: draft.startOffset, + endOffset: draft.endOffset, + text: draft.text, + linkId: draft.linkId, + }, + { + onSuccess: () => { + closeSheet(); + }, + onError: (error) => { + Alert.alert("Error", "There was an error saving the highlight."); + console.error("Failed to save highlight", error); + }, + } + ); }; const handleDelete = () => { if (!draft?.highlightId) return; - deleteHighlight.mutate({ - highlightId: draft.highlightId, - linkId: draft.linkId, + deleteHighlight.mutate(draft.highlightId, { + onSuccess: () => { + closeSheet(); + }, + onError: (error) => { + Alert.alert("Error", "There was an error removing the highlight."); + console.error("Failed to delete highlight", error); + }, }); }; diff --git a/apps/mobile/components/Formats/ReadableFormat.tsx b/apps/mobile/components/Formats/ReadableFormat.tsx index 16d22712..ce40afc3 100644 --- a/apps/mobile/components/Formats/ReadableFormat.tsx +++ b/apps/mobile/components/Formats/ReadableFormat.tsx @@ -6,7 +6,6 @@ import React, { useState, } from "react"; import { Alert, Linking, View } from "react-native"; -import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system/legacy"; import * as Clipboard from "expo-clipboard"; import NetInfo from "@react-native-community/netinfo"; @@ -28,6 +27,7 @@ import { MAX_HIGHLIGHT_TEXT_LENGTH, ReadableHighlightDraft, } from "@/components/ActionSheets/ReadableHighlightSheet"; +import { useGetLinkHighlights } from "@linkwarden/router/highlights"; type Props = { link: LinkType; @@ -40,9 +40,22 @@ type SelectionInfo = { startOffset: number; endOffset: number; }; + +type SelectionContext = { + disableHighlightMenu: boolean; +}; + const HIGHLIGHT_MENU_KEY = "highlight"; const COPY_MENU_KEY = "copy"; const SEARCH_WEB_MENU_KEY = "search-web"; +const DEFAULT_MENU_ITEMS = [ + { label: "Highlight", key: HIGHLIGHT_MENU_KEY }, + { label: "Copy", key: COPY_MENU_KEY }, + { label: "Search Web", key: SEARCH_WEB_MENU_KEY }, +]; +const NON_HIGHLIGHT_MENU_ITEMS = DEFAULT_MENU_ITEMS.filter( + (item) => item.key !== HIGHLIGHT_MENU_KEY +); function normalizeHighlightColor(color?: string | null): HighlightColor { if (color === "red" || color === "blue" || color === "green") return color; @@ -80,6 +93,14 @@ function isSelectionInfo(value: unknown): value is SelectionInfo { ); } +function isSelectionContext(value: unknown): value is SelectionContext { + if (!value || typeof value !== "object") return false; + + const candidate = value as SelectionContext; + + return typeof candidate.disableHighlightMenu === "boolean"; +} + function isHighlight(value: unknown): value is Highlight { if (!value || typeof value !== "object") return false; @@ -150,6 +171,7 @@ export default function ReadableFormat({ const { auth } = useAuthStore(); const [content, setContent] = useState(""); + const [disableHighlightMenu, setDisableHighlightMenu] = useState(false); const { colorScheme } = useColorScheme(); const webViewRef = useRef(null); const latestSelectionRef = useRef(null); @@ -212,32 +234,7 @@ export default function ReadableFormat({ day: "numeric", }); - const { data: linkHighlights = [] } = useQuery({ - queryKey: ["highlights", link.id], - queryFn: async () => { - const response = await fetch( - `${auth.instance}/api/v1/links/${link.id}/highlights`, - { - headers: { - Authorization: `Bearer ${auth.session}`, - }, - } - ); - - if (!response.ok) throw new Error("Failed to fetch highlights."); - - const data = await response.json(); - - return data.response as Highlight[]; - }, - enabled: Boolean( - link.id && - auth.instance && - auth.session && - auth.status === "authenticated" - ), - initialData: [] as Highlight[], - }); + const { data: linkHighlights = [] } = useGetLinkHighlights(link.id, auth); const clearWebSelection = useCallback(() => { pendingSelectionTextRef.current = null; @@ -358,6 +355,7 @@ export default function ReadableFormat({ useEffect(() => { latestSelectionRef.current = null; pendingSelectionTextRef.current = null; + setDisableHighlightMenu(false); clearWebSelection(); void SheetManager.hide("readable-highlight-sheet"); }, [clearWebSelection, link.id]); @@ -468,6 +466,12 @@ export default function ReadableFormat({ ); } + function postSelectionContext(disableHighlightMenu) { + postMessage("selection-context", { + disableHighlightMenu: disableHighlightMenu, + }); + } + function getContainer() { return document.getElementById("readable-view"); } @@ -502,7 +506,10 @@ export default function ReadableFormat({ selection.rangeCount === 0 ) { state.currentSelection = null; - if (shouldNotify) postMessage("selection", null); + if (shouldNotify) { + postSelectionContext(false); + postMessage("selection", null); + } return null; } @@ -510,7 +517,11 @@ export default function ReadableFormat({ if (!container.contains(range.commonAncestorContainer)) { state.currentSelection = null; - if (shouldNotify) postMessage("selection", null); + state.lastValidSelection = null; + if (shouldNotify) { + postSelectionContext(true); + postMessage("selection", null); + } return null; } @@ -541,7 +552,11 @@ export default function ReadableFormat({ if (startOffset === -1 || endOffset === -1) { state.currentSelection = null; - if (shouldNotify) postMessage("selection", null); + state.lastValidSelection = null; + if (shouldNotify) { + postSelectionContext(true); + postMessage("selection", null); + } return null; } @@ -554,7 +569,10 @@ export default function ReadableFormat({ if (!info.text.trim()) { state.currentSelection = null; - if (shouldNotify) postMessage("selection", null); + if (shouldNotify) { + postSelectionContext(false); + postMessage("selection", null); + } return null; } @@ -562,6 +580,7 @@ export default function ReadableFormat({ state.lastValidSelection = info; if (shouldNotify) { + postSelectionContext(false); postMessage("selection", info); } @@ -803,6 +822,14 @@ export default function ReadableFormat({ return; } + if ( + message?.type === "selection-context" && + isSelectionContext(message.payload) + ) { + setDisableHighlightMenu(message.payload.disableHighlightMenu); + return; + } + if (message?.type === "selection") { if (!isSelectionInfo(message.payload)) { latestSelectionRef.current = null; @@ -850,11 +877,9 @@ export default function ReadableFormat({ return false; }} javaScriptEnabled - menuItems={[ - { label: "Highlight", key: HIGHLIGHT_MENU_KEY }, - { label: "Copy", key: COPY_MENU_KEY }, - { label: "Search Web", key: SEARCH_WEB_MENU_KEY }, - ]} + menuItems={ + disableHighlightMenu ? NON_HIGHLIGHT_MENU_ITEMS : DEFAULT_MENU_ITEMS + } originWhitelist={["*"]} /> ); diff --git a/packages/router/highlights.tsx b/packages/router/highlights.tsx index f18f9a2d..5793d2b0 100644 --- a/packages/router/highlights.tsx +++ b/packages/router/highlights.tsx @@ -7,47 +7,72 @@ import { import { useSession } from "next-auth/react"; import { Highlight } from "@linkwarden/prisma/client"; import { PostHighlightSchemaType } from "@linkwarden/lib/schemaValidation"; +import { MobileAuth } from "@linkwarden/types/global"; const useGetLinkHighlights = ( - linkId: number + linkId: number, + auth?: MobileAuth ): UseQueryResult => { - const { status } = useSession(); + let status: "loading" | "authenticated" | "unauthenticated"; + + if (!auth) { + const session = useSession(); + status = session.status; + } else { + status = auth.status; + } return useQuery({ queryKey: ["highlights", linkId], queryFn: async () => { - const response = await fetch(`/api/v1/links/${linkId}/highlights`); + const response = await fetch( + (auth?.instance ? auth.instance : "") + + `/api/v1/links/${linkId}/highlights`, + auth?.session + ? { + headers: { + Authorization: `Bearer ${auth.session}`, + }, + } + : undefined + ); if (!response.ok) throw new Error("Failed to fetch highlights."); const data = await response.json(); - return data.response; + return data.response as Highlight[]; }, - enabled: status === "authenticated", + enabled: status === "authenticated" && !!linkId, }); }; -const usePostHighlight = (linkId: number) => { +const usePostHighlight = (linkId: number, auth?: MobileAuth) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (highlight: PostHighlightSchemaType) => { - const response = await fetch("/api/v1/highlights", { - body: JSON.stringify({ ...highlight }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", - }); + const response = await fetch( + (auth?.instance ? auth.instance : "") + "/api/v1/highlights", + { + body: JSON.stringify({ ...highlight }), + headers: { + "Content-Type": "application/json", + ...(auth?.session + ? { Authorization: `Bearer ${auth.session}` } + : {}), + }, + method: "POST", + } + ); const data = await response.json(); if (!response.ok) throw new Error(data.response); - return data.response; + return data.response as Highlight; }, onSuccess: (data: Highlight) => { queryClient.setQueryData( - ["highlights", linkId], - (oldData: Highlight[]) => { + ["highlights", data.linkId || linkId], + (oldData: Highlight[] = []) => { const index = oldData.findIndex((h) => h?.id === data?.id); if (index !== -1) { const newData = [...oldData]; @@ -62,23 +87,34 @@ const usePostHighlight = (linkId: number) => { }); }; -const useRemoveHighlight = (linkId: number) => { +const useRemoveHighlight = (linkId: number, auth?: MobileAuth) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (highlightId: number) => { - const response = await fetch(`/api/v1/highlights/${highlightId}`, { - method: "DELETE", - }); + const response = await fetch( + (auth?.instance ? auth.instance : "") + + `/api/v1/highlights/${highlightId}`, + { + method: "DELETE", + headers: { + ...(auth?.session + ? { Authorization: `Bearer ${auth.session}` } + : {}), + }, + } + ); const data = await response.json(); if (!response.ok) throw new Error(data.response); - return data.response; + return { highlightId: data.response as number, linkId }; }, - onSuccess: (data) => { - queryClient.setQueryData(["highlights", linkId], (oldData: any) => - oldData.filter((highlight: Highlight) => highlight.id !== data) + onSuccess: ({ highlightId, linkId }) => { + queryClient.setQueryData( + ["highlights", linkId], + (oldData: Highlight[] = []) => + oldData.filter((highlight) => highlight.id !== highlightId) ); }, });