mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-06-28 14:55:49 +00:00
refactor(mobile): replace manual highlight fetch logic with custom hooks for highlight management
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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={["*"]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user