refactor(mobile): replace manual highlight fetch logic with custom hooks for highlight management

This commit is contained in:
daniel31x13
2026-04-25 13:43:28 -04:00
parent 8e8e6f92aa
commit d7cfd10539
3 changed files with 154 additions and 145 deletions

View File

@@ -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<ReadableHighlightDraft | null>(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);
},
});
};

View File

@@ -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<string>("");
const [disableHighlightMenu, setDisableHighlightMenu] = useState(false);
const { colorScheme } = useColorScheme();
const webViewRef = useRef<any>(null);
const latestSelectionRef = useRef<SelectionInfo | null>(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={["*"]}
/>
);

View File

@@ -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<Highlight[], Error> => {
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)
);
},
});