aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-03-19 00:33:11 +0000
committerGitHub <noreply@github.com>2024-03-19 00:33:11 +0000
commit785a5b574992296e187a66412dd42f7b4a686353 (patch)
tree64b608927cc63d7494395f639636fd4b36e5a977 /apps/mobile
parent549520919c482e72cdf7adae5ba852d1b6cbe5aa (diff)
downloadkarakeep-785a5b574992296e187a66412dd42f7b4a686353.tar.zst
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
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/app.json12
-rw-r--r--apps/mobile/app/dashboard/(tabs)/index.tsx36
-rw-r--r--apps/mobile/app/sharing.tsx34
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx33
-rw-r--r--apps/mobile/lib/upload.ts72
-rw-r--r--apps/mobile/package.json1
-rw-r--r--apps/mobile/tsconfig.json2
7 files changed, 178 insertions, 12 deletions
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 (
<MenuView
- onPressAction={({ nativeEvent }) => {
+ 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 (
+ <View className="flex gap-2">
+ <Image
+ source={{
+ uri: `${settings.address}/api/assets/${bookmark.content.assetId}`,
+ headers: {
+ Authorization: `Bearer ${settings.apiKey}`,
+ },
+ }}
+ className="h-56 min-h-56 w-full object-cover"
+ />
+ <View className="flex gap-2 p-2">
+ <TagList bookmark={bookmark} />
+ <Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
+ <View className="mt-2 flex flex-row justify-between px-2 pb-2">
+ <View />
+ <ActionBar bookmark={bookmark} />
+ </View>
+ </View>
+ </View>
+ );
+}
+
export default function BookmarkCard({
bookmark: initialData,
}: {
@@ -272,6 +302,9 @@ export default function BookmarkCard({
case "text":
comp = <TextCard bookmark={bookmark} />;
break;
+ case "asset":
+ comp = <AssetCard bookmark={bookmark} />;
+ break;
}
return <View className="border-b border-gray-300 bg-white">{comp}</View>;
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": ".",