From 785a5b574992296e187a66412dd42f7b4a686353 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 19 Mar 2024 00:33:11 +0000 Subject: Feature: Add support for uploading images and automatically inferring their tags (#2) * feature: Experimental support for asset uploads * feature(web): Add new bookmark type asset * feature: Add support for automatically tagging images * fix: Add support for image assets in preview page * use next Image for fetching the images * Fix auth and error codes in the route handlers * Add support for image uploads on mobile * Fix typing of upload requests * Remove the ugly dragging box * Bump mobile version to 1.3 * Change the editor card placeholder to mention uploading images * Fix a typo * Change ios icon for photo library * Silence typescript error --- apps/mobile/app.json | 12 +++- apps/mobile/app/dashboard/(tabs)/index.tsx | 36 +++++++++++- apps/mobile/app/sharing.tsx | 34 +++++++++-- apps/mobile/components/bookmarks/BookmarkCard.tsx | 33 +++++++++++ apps/mobile/lib/upload.ts | 72 +++++++++++++++++++++++ apps/mobile/package.json | 1 + apps/mobile/tsconfig.json | 2 +- 7 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/lib/upload.ts (limited to 'apps/mobile') diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e674f8b5..9df4c895 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "Hoarder App", "slug": "hoarder", "scheme": "hoarder", - "version": "1.2.4", + "version": "1.3.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -35,13 +35,19 @@ "iosActivationRules": { "NSExtensionActivationSupportsWebURLWithMaxCount": 1, "NSExtensionActivationSupportsWebPageWithMaxCount": 1, - "NSExtensionActivationSupportsImageWithMaxCount": 0, + "NSExtensionActivationSupportsImageWithMaxCount": 1, "NSExtensionActivationSupportsMovieWithMaxCount": 0, "NSExtensionActivationSupportsText": true } } ], - "expo-secure-store" + "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "The app access your photo gallary on your request to hoard them." + } + ] ], "extra": { "router": { diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx index 7f70af6b..a840ca93 100644 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/index.tsx @@ -1,21 +1,45 @@ import { Platform, SafeAreaView, View } from "react-native"; import * as Haptics from "expo-haptics"; +import * as ImagePicker from "expo-image-picker"; import { useRouter } from "expo-router"; import BookmarkList from "@/components/bookmarks/BookmarkList"; import PageTitle from "@/components/ui/PageTitle"; +import useAppSettings from "@/lib/settings"; +import { useUploadAsset } from "@/lib/upload"; import { MenuView } from "@react-native-menu/menu"; import { SquarePen } from "lucide-react-native"; +import { useToast } from "@/components/ui/Toast"; function HeaderRight() { + const {toast} = useToast(); const router = useRouter(); + const { settings } = useAppSettings(); + const { uploadAsset } = useUploadAsset(settings, { + onError: (e) => { + toast({message: e, variant: "destructive"}); + }, + }); return ( { + onPressAction={async ({ nativeEvent }) => { Haptics.selectionAsync(); if (nativeEvent.event === "note") { router.navigate("dashboard/add-note"); } else if (nativeEvent.event === "link") { router.navigate("dashboard/add-link"); + } else if (nativeEvent.event === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0, + allowsMultipleSelection: false, + }); + if (!result.canceled) { + uploadAsset({ + type: result.assets[0].mimeType ?? "", + name: result.assets[0].fileName ?? "", + uri: result.assets[0].uri, + }); + } } }} actions={[ @@ -31,10 +55,18 @@ function HeaderRight() { id: "note", title: "New Note", image: Platform.select({ - ios: "note", + ios: "note.text", android: "ic_menu_note", }), }, + { + id: "library", + title: "Photo Library", + image: Platform.select({ + ios: "photo", + android: "ic_menu_photo", + }), + }, ]} shouldOpenOnLongPress={false} > diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx index e8b0ad09..2f9dbb27 100644 --- a/apps/mobile/app/sharing.tsx +++ b/apps/mobile/app/sharing.tsx @@ -2,8 +2,11 @@ import { useEffect, useState } from "react"; import { Text, View } from "react-native"; import { Link, useRouter } from "expo-router"; import { useShareIntentContext } from "expo-share-intent"; +import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { useUploadAsset } from "@/lib/upload"; import { z } from "zod"; +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; type Mode = | { type: "idle" } @@ -11,12 +14,28 @@ type Mode = | { type: "error" }; function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { - const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext(); + const onSaved = (d: ZBookmark) => { + invalidateAllBookmarks(); + setMode({ type: "success", bookmarkId: d.id }); + }; + + const { hasShareIntent, shareIntent, resetShareIntent } = + useShareIntentContext(); + const { settings, isLoading } = useAppSettings(); + const { uploadAsset } = useUploadAsset(settings, { + onSuccess: onSaved, + onError: () => { + setMode({ type: "error" }); + }, + }); const invalidateAllBookmarks = api.useUtils().bookmarks.getBookmarks.invalidate; useEffect(() => { + if (isLoading) { + return; + } if (!isPending && shareIntent?.text) { const val = z.string().url(); if (val.safeParse(shareIntent.text).success) { @@ -25,17 +44,20 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { } else { mutate({ type: "text", text: shareIntent.text }); } + } else if (!isPending && shareIntent?.files) { + uploadAsset({ + type: shareIntent.files[0].type, + name: shareIntent.files[0].fileName ?? "", + uri: shareIntent.files[0].path, + }); } if (hasShareIntent) { resetShareIntent(); } - }, []); + }, [isLoading]); const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({ - onSuccess: (d) => { - invalidateAllBookmarks(); - setMode({ type: "success", bookmarkId: d.id }); - }, + onSuccess: onSaved, onError: () => { setMode({ type: "error" }); }, diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index a969bc8b..ac6eaea4 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -11,6 +11,7 @@ import Markdown from "react-native-markdown-display"; import * as Haptics from "expo-haptics"; import { Link } from "expo-router"; import * as WebBrowser from "expo-web-browser"; +import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; import { MenuView } from "@react-native-menu/menu"; import { Ellipsis, Star } from "lucide-react-native"; @@ -239,6 +240,35 @@ function TextCard({ bookmark }: { bookmark: ZBookmark }) { ); } +function AssetCard({ bookmark }: { bookmark: ZBookmark }) { + const { settings } = useAppSettings(); + if (bookmark.content.type !== "asset") { + throw new Error("Wrong content type rendered"); + } + + return ( + + + + + + + + + + + + ); +} + export default function BookmarkCard({ bookmark: initialData, }: { @@ -272,6 +302,9 @@ export default function BookmarkCard({ case "text": comp = ; break; + case "asset": + comp = ; + break; } return {comp}; diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts new file mode 100644 index 00000000..d511becc --- /dev/null +++ b/apps/mobile/lib/upload.ts @@ -0,0 +1,72 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { Settings } from "./settings"; +import { api } from "./trpc"; +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { zUploadResponseSchema, zUploadErrorSchema } from "@hoarder/trpc/types/uploads"; + +export function useUploadAsset( + settings: Settings, + options: { onSuccess?: (bookmark: ZBookmark) => void; onError?: (e: string) => void }, +) { + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { + mutate: createBookmark, + isPending: isCreatingBookmark, + } = api.bookmarks.createBookmark.useMutation({ + onSuccess: (d) => { + invalidateAllBookmarks(); + if (options.onSuccess) { + options.onSuccess(d); + } + }, + onError: (e) => { + if (options.onError) { + options.onError(e.message); + } + }, + }); + + const { + mutate: uploadAsset, + isPending: isUploading, + } = useMutation({ + mutationFn: async (file: { type: string; name: string; uri: string }) => { + const formData = new FormData(); + // @ts-expect-error This is a valid api in react native + formData.append("image", { + uri: file.uri, + name: file.name, + type: file.type, + }); + const resp = await fetch(`${settings.address}/api/assets`, { + method: "POST", + body: formData, + headers: { + Authorization: `Bearer ${settings.apiKey}`, + }, + }); + if (!resp.ok) { + throw new Error(await resp.text()); + } + return zUploadResponseSchema.parse(await resp.json()); + }, + onSuccess: (resp) => { + const assetId = resp.assetId; + createBookmark({ type: "asset", assetId, assetType: "image" }); + }, + onError: (e) => { + if (options.onError) { + const err = zUploadErrorSchema.parse(JSON.parse(e.message)); + options.onError(err.error); + } + }, + }); + + return { + uploadAsset, + isPending: isUploading || isCreatingBookmark, + }; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b35c420c..cf097c09 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,7 @@ "expo-dev-client": "^3.3.9", "expo-haptics": "^12.8.1", "expo-image": "^1.10.6", + "expo-image-picker": "^14.7.1", "expo-linking": "~6.2.2", "expo-router": "~3.4.8", "expo-secure-store": "^12.8.1", diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 3bcf5741..77379cd1 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,7 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "types": ["nativewind/types"], + "types": ["nativewind/types", "react-native"], "incremental": true, "strict": true, "baseUrl": ".", -- cgit v1.2.3-70-g09d2