feat(mobile): add share, edit, pin, delete and open in web browser support inside opened link pages

This commit is contained in:
daniel31x13
2025-11-15 15:58:35 -05:00
parent 26da55dfb9
commit 8f1612d10b
8 changed files with 340 additions and 94 deletions

View File

@@ -10,7 +10,7 @@ import {
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
import Spinner from "@/components/ui/Spinner";

View File

@@ -83,14 +83,18 @@ export default function DashboardScreen() {
className="bg-base-100"
contentInsetAdjustmentBehavior="automatic"
>
{orderedSections.map((sectionData) => {
{orderedSections.map((sectionData, i) => {
if (!collections || !collections[0]) return <></>;
const collection = collections.find(
(c) => c.id === sectionData.collectionId
);
return (
<DashboardSection
key={sectionData.id}
key={i}
sectionData={sectionData}
collection={collections.find(
(c) => c.id === sectionData.collectionId
)}
collection={collection}
collectionLinks={
sectionData.collectionId
? collectionLinks[sectionData.collectionId]

View File

@@ -1,4 +1,5 @@
import {
router,
Stack,
usePathname,
useRootNavigationState,
@@ -8,11 +9,18 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
import { mmkvPersister } from "@/lib/queryPersister";
import { useState, useEffect } from "react";
import "../styles/global.css";
import { SheetProvider } from "react-native-actions-sheet";
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
import "@/components/ActionSheets/Sheets";
import { useColorScheme } from "nativewind";
import { lightTheme, darkTheme } from "../lib/theme";
import { Platform, View } from "react-native";
import {
Alert,
Linking,
Platform,
Share,
TouchableOpacity,
View,
} from "react-native";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useShareIntent } from "expo-share-intent";
import useDataStore from "@/store/data";
@@ -20,6 +28,15 @@ import useAuthStore from "@/store/auth";
import { QueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { KeyboardProvider } from "react-native-keyboard-controller";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Compass, Ellipsis } from "lucide-react-native";
import { Chromium } from "@/components/ui/Icons";
import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
const queryClient = new QueryClient({
defaultOptions: {
@@ -34,7 +51,6 @@ const queryClient = new QueryClient({
export default function RootLayout() {
const [isLoading, setIsLoading] = useState(true);
const { colorScheme } = useColorScheme();
const { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntent();
const { updateData, setData, data } = useDataStore();
@@ -114,91 +130,239 @@ export default function RootLayout() {
queryClient.invalidateQueries();
}}
>
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<KeyboardProvider>
<SheetProvider>
{!isLoading && (
<Stack
screenOptions={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-200"],
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
...Platform.select({
android: {
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}),
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
<Stack.Screen
name="links/[id]"
options={{
headerShown: true,
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
headerStyle: {
backgroundColor:
colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
<Stack.Screen
name="login"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="index"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="incoming"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</KeyboardProvider>
</View>
<RootComponent isLoading={isLoading} auth={auth} />
</PersistQueryClientProvider>
);
}
const RootComponent = ({
isLoading,
auth,
}: {
isLoading: boolean;
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const { tmp } = useTmpStore();
return (
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<KeyboardProvider>
<SheetProvider>
{!isLoading && (
<Stack
screenOptions={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-200"],
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
...Platform.select({
android: {
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}),
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
<Stack.Screen
name="links/[id]"
options={{
headerShown: true,
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
headerStyle: {
backgroundColor:
colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
headerRight: () => (
<View className="flex-row gap-5">
{tmp.link?.url && (
<TouchableOpacity
onPress={() =>
Linking.openURL(tmp.link?.url as string)
}
>
{Platform.OS === "ios" ? (
<Compass
size={21}
color={
rawTheme[colorScheme as ThemeName][
"base-content"
]
}
/>
) : (
<Chromium />
)}
</TouchableOpacity>
)}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Ellipsis
size={21}
color={
rawTheme[colorScheme as ThemeName][
"base-content"
]
}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{tmp.link?.url && (
<DropdownMenu.Item
key="share"
onSelect={async () => {
// share url
await Share.share({
url: tmp.link?.url as string,
});
}}
>
<DropdownMenu.ItemTitle>
Share
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={async () => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
await updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: tmp.user?.id }]) as any,
});
}}
>
<DropdownMenu.ItemTitle>
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
? "Unpin Link"
: "Pin Link"}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: {
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
},
});
}}
>
<DropdownMenu.ItemTitle>
Edit Link
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="delete-link"
onSelect={() => {
return Alert.alert(
"Delete Link",
"Are you sure you want to delete this link? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(
tmp.link?.id as number
);
// go back
router.back();
},
},
]
);
}}
>
<DropdownMenu.ItemTitle>
Delete
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
),
}}
/>
<Stack.Screen
name="login"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="index"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="incoming"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</KeyboardProvider>
</View>
);
};

View File

@@ -20,6 +20,7 @@ import { useGetLink } from "@linkwarden/router/links";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
@@ -44,6 +45,23 @@ export default function LinkScreen() {
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
const { updateTmp } = useTmpStore();
useEffect(() => {
if (link?.id && user?.id)
updateTmp({
link,
user: {
id: user.id,
},
});
return () =>
updateTmp({
link: null,
});
}, [link, user]);
useEffect(() => {
async function loadCacheOrFetch() {
await ensureCacheDir();

View File

@@ -20,6 +20,7 @@ import { useCollections } from "@linkwarden/router/collections";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { Folder, ChevronRight, Check } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
const Main = (props: SheetProps<"edit-link-sheet">) => {
const { auth } = useAuthStore();
@@ -39,6 +40,8 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
}
}, [params?.link]);
const { tmp, updateTmp } = useTmpStore();
return (
<View className="px-8 py-5">
<Input
@@ -111,6 +114,11 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {

View File

@@ -0,0 +1,22 @@
import React from "react";
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
export const Chromium = (props: SvgProps) => (
<Svg
width={21}
height={21}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<Path d="M10.88 21.94 15.46 14" />
<Path d="M21.17 8H12" />
<Path d="M3.95 6.06 8.54 14" />
<Circle cx={12} cy={12} r={10} />
<Circle cx={12} cy={12} r={4} />
</Svg>
);

View File

@@ -3,6 +3,9 @@ import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
import * as FileSystem from "expo-file-system";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
type AuthStore = {
auth: MobileAuth;
@@ -102,6 +105,7 @@ const useAuthStore = create<AuthStore>((set) => ({
signOut: async () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
set({
auth: {

26
apps/mobile/store/tmp.ts Normal file
View File

@@ -0,0 +1,26 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { User } from "@linkwarden/prisma/client";
type Tmp = {
link: LinkIncludingShortenedCollectionAndTags | null;
user: Pick<User, "id"> | null;
};
type TmpStore = {
tmp: Tmp;
updateTmp: (newData: Partial<Tmp>) => void;
};
const useTmpStore = create<TmpStore>((set, get) => ({
tmp: {
link: null,
user: null,
},
updateTmp: async (patch) => {
const merged = { ...get().tmp, ...patch };
set({ tmp: merged });
},
}));
export default useTmpStore;