aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-13 21:43:44 +0000
committerMohamed Bassem <me@mbassem.com>2024-03-14 16:40:45 +0000
commit04572a8e5081b1e4871e273cde9dbaaa44c52fe0 (patch)
tree8e993acb732a50d1306d4d6953df96c165c57f57 /apps/mobile
parent2df08ed08c065e8b91bc8df0266bd4bcbb062be4 (diff)
downloadkarakeep-04572a8e5081b1e4871e273cde9dbaaa44c52fe0.tar.zst
structure: Create apps dir and copy tooling dir from t3-turbo repo
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/.eslintrc.js4
-rw-r--r--apps/mobile/.gitignore39
-rw-r--r--apps/mobile/.npmrc1
-rw-r--r--apps/mobile/app.json57
-rw-r--r--apps/mobile/app/+not-found.tsx6
-rw-r--r--apps/mobile/app/_layout.tsx53
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx38
-rw-r--r--apps/mobile/app/dashboard/(tabs)/index.tsx31
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx67
-rw-r--r--apps/mobile/app/dashboard/(tabs)/search.tsx35
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx41
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx38
-rw-r--r--apps/mobile/app/dashboard/add-link.tsx57
-rw-r--r--apps/mobile/app/dashboard/add-note.tsx53
-rw-r--r--apps/mobile/app/dashboard/archive.tsx11
-rw-r--r--apps/mobile/app/dashboard/favourites.tsx11
-rw-r--r--apps/mobile/app/dashboard/lists/[slug].tsx31
-rw-r--r--apps/mobile/app/error.tsx9
-rw-r--r--apps/mobile/app/index.tsx20
-rw-r--r--apps/mobile/app/sharing.tsx99
-rw-r--r--apps/mobile/app/signin.tsx101
-rw-r--r--apps/mobile/assets/blur.jpegbin0 -> 178818 bytes
-rw-r--r--apps/mobile/assets/icon.pngbin0 -> 2362 bytes
-rw-r--r--apps/mobile/assets/splash.pngbin0 -> 117993 bytes
-rw-r--r--apps/mobile/babel.config.js9
-rw-r--r--apps/mobile/components/Logo.tsx11
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx243
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx61
-rw-r--r--apps/mobile/components/ui/ActionButton.tsx21
-rw-r--r--apps/mobile/components/ui/Button.tsx81
-rw-r--r--apps/mobile/components/ui/Divider.tsx28
-rw-r--r--apps/mobile/components/ui/FullPageSpinner.tsx9
-rw-r--r--apps/mobile/components/ui/Input.tsx28
-rw-r--r--apps/mobile/components/ui/Skeleton.tsx38
-rw-r--r--apps/mobile/components/ui/Toast.tsx183
-rw-r--r--apps/mobile/eas.json19
-rw-r--r--apps/mobile/globals.css80
-rw-r--r--apps/mobile/lib/last-shared-intent.ts15
-rw-r--r--apps/mobile/lib/providers.tsx54
-rw-r--r--apps/mobile/lib/session.ts20
-rw-r--r--apps/mobile/lib/settings.ts29
-rw-r--r--apps/mobile/lib/storage-state.ts51
-rw-r--r--apps/mobile/lib/trpc.ts4
-rw-r--r--apps/mobile/lib/utils.ts6
-rw-r--r--apps/mobile/metro.config.js58
-rw-r--r--apps/mobile/nativewind-env.d.ts1
-rw-r--r--apps/mobile/package.json71
-rw-r--r--apps/mobile/tailwind.config.ts73
-rw-r--r--apps/mobile/tsconfig.json14
49 files changed, 2009 insertions, 0 deletions
diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js
new file mode 100644
index 00000000..53beac49
--- /dev/null
+++ b/apps/mobile/.eslintrc.js
@@ -0,0 +1,4 @@
+module.exports = {
+ root: true,
+ extends: ["universe/native"],
+};
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
new file mode 100644
index 00000000..2920e5a8
--- /dev/null
+++ b/apps/mobile/.gitignore
@@ -0,0 +1,39 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+
+# Native
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+#build files
+ios/
+android/
diff --git a/apps/mobile/.npmrc b/apps/mobile/.npmrc
new file mode 100644
index 00000000..d67f3748
--- /dev/null
+++ b/apps/mobile/.npmrc
@@ -0,0 +1 @@
+node-linker=hoisted
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
new file mode 100644
index 00000000..e16baa37
--- /dev/null
+++ b/apps/mobile/app.json
@@ -0,0 +1,57 @@
+{
+ "expo": {
+ "name": "Hoarder App",
+ "slug": "hoarder",
+ "scheme": "hoarder",
+ "version": "1.2.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "assetBundlePatterns": [
+ "**/*"
+ ],
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "app.hoarder.hoardermobile",
+ "config": {
+ "usesNonExemptEncryption": false
+ }
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "app.hoarder.hoardermobile"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-share-intent",
+ {
+ "iosActivationRules": {
+ "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
+ "NSExtensionActivationSupportsWebPageWithMaxCount": 0,
+ "NSExtensionActivationSupportsImageWithMaxCount": 0,
+ "NSExtensionActivationSupportsMovieWithMaxCount": 0,
+ "NSExtensionActivationSupportsText": true
+ }
+ }
+ ],
+ "expo-secure-store"
+ ],
+ "extra": {
+ "router": {
+ "origin": false
+ },
+ "eas": {
+ "projectId": "d6d14643-ad43-4cd3-902a-92c5944d5e45"
+ }
+ }
+ }
+}
diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx
new file mode 100644
index 00000000..466505b6
--- /dev/null
+++ b/apps/mobile/app/+not-found.tsx
@@ -0,0 +1,6 @@
+import { View } from "react-native";
+
+// This is kinda important given that the sharing modal always resolve to an unknown route
+export default function NotFound() {
+ return <View />;
+}
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
new file mode 100644
index 00000000..6304ced5
--- /dev/null
+++ b/apps/mobile/app/_layout.tsx
@@ -0,0 +1,53 @@
+import "@/globals.css";
+import "expo-dev-client";
+
+import { useRouter } from "expo-router";
+import { Stack } from "expo-router/stack";
+import { useShareIntent } from "expo-share-intent";
+import { StatusBar } from "expo-status-bar";
+import { useEffect } from "react";
+import { View } from "react-native";
+
+import { useLastSharedIntent } from "@/lib/last-shared-intent";
+import { Providers } from "@/lib/providers";
+
+export default function RootLayout() {
+ const router = useRouter();
+ const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
+
+ const lastSharedIntent = useLastSharedIntent();
+
+ useEffect(() => {
+ const intentJson = JSON.stringify(shareIntent);
+ if (hasShareIntent && !lastSharedIntent.isPreviouslyShared(intentJson)) {
+ // TODO: Remove once https://github.com/achorein/expo-share-intent/issues/14 is fixed
+ lastSharedIntent.setIntent(intentJson);
+ router.replace({
+ pathname: "sharing",
+ params: { shareIntent: intentJson },
+ });
+ resetShareIntent();
+ }
+ }, [hasShareIntent]);
+
+ return (
+ <Providers>
+ <View className="h-full w-full bg-white">
+ <Stack
+ screenOptions={{
+ headerShown: false,
+ }}
+ >
+ <Stack.Screen name="index" />
+ <Stack.Screen
+ name="sharing"
+ options={{
+ presentation: "modal",
+ }}
+ />
+ </Stack>
+ <StatusBar style="auto" />
+ </View>
+ </Providers>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
new file mode 100644
index 00000000..5b2d810a
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -0,0 +1,38 @@
+import { Tabs } from "expo-router";
+import { ClipboardList, Home, Search, Settings } from "lucide-react-native";
+import React from "react";
+
+export default function TabLayout() {
+ return (
+ <Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
+ <Tabs.Screen
+ name="index"
+ options={{
+ title: "Home",
+ tabBarIcon: ({ color }) => <Home color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="search"
+ options={{
+ title: "Search",
+ tabBarIcon: ({ color }) => <Search color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="lists"
+ options={{
+ title: "Lists",
+ tabBarIcon: ({ color }) => <ClipboardList color={color} />,
+ }}
+ />
+ <Tabs.Screen
+ name="settings"
+ options={{
+ title: "Settings",
+ tabBarIcon: ({ color }) => <Settings color={color} />,
+ }}
+ />
+ </Tabs>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx
new file mode 100644
index 00000000..b2349525
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/index.tsx
@@ -0,0 +1,31 @@
+import { Link, Stack } from "expo-router";
+import { SquarePen, Link as LinkIcon } from "lucide-react-native";
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+function HeaderRight() {
+ return (
+ <View className="flex flex-row">
+ <Link href="dashboard/add-link" className="mt-2 px-2">
+ <LinkIcon />
+ </Link>
+ <Link href="dashboard/add-note" className="mt-2 px-2">
+ <SquarePen />
+ </Link>
+ </View>
+ );
+}
+
+export default function Home() {
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => <HeaderRight />,
+ }}
+ />
+ <BookmarkList archived={false} />
+ </>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx
new file mode 100644
index 00000000..b534ddda
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/lists.tsx
@@ -0,0 +1,67 @@
+import { Link } from "expo-router";
+import { useEffect, useState } from "react";
+import { FlatList, View } from "react-native";
+
+import { api } from "@/lib/trpc";
+
+export default function Lists() {
+ const [refreshing, setRefreshing] = useState(false);
+ const { data: lists, isPending } = api.lists.list.useQuery();
+ const apiUtils = api.useUtils();
+
+ useEffect(() => {
+ setRefreshing(isPending);
+ }, [isPending]);
+
+ if (!lists) {
+ // Add spinner
+ return <View />;
+ }
+
+ const onRefresh = () => {
+ apiUtils.lists.list.invalidate();
+ };
+
+ const links = [
+ {
+ id: "fav",
+ logo: "⭐️",
+ name: "Favourites",
+ href: "/dashboard/favourites",
+ },
+ {
+ id: "arch",
+ logo: "🗄️",
+ name: "Archive",
+ href: "/dashboard/archive",
+ },
+ ];
+
+ links.push(
+ ...lists.lists.map((l) => ({
+ id: l.id,
+ logo: l.icon,
+ name: l.name,
+ href: `/dashboard/lists/${l.id}`,
+ })),
+ );
+
+ return (
+ <FlatList
+ contentContainerStyle={{
+ gap: 10,
+ marginTop: 10,
+ }}
+ renderItem={(l) => (
+ <View className="mx-2 block rounded-xl border border-gray-100 bg-white px-4 py-2">
+ <Link key={l.item.id} href={l.item.href} className="text-lg">
+ {l.item.logo} {l.item.name}
+ </Link>
+ </View>
+ )}
+ data={links}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ />
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/search.tsx b/apps/mobile/app/dashboard/(tabs)/search.tsx
new file mode 100644
index 00000000..980cab36
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/search.tsx
@@ -0,0 +1,35 @@
+import { keepPreviousData } from "@tanstack/react-query";
+import { useState } from "react";
+import { View } from "react-native";
+import { useDebounce } from "use-debounce";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+import { Divider } from "@/components/ui/Divider";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function Search() {
+ const [search, setSearch] = useState("");
+
+ const [query] = useDebounce(search, 200);
+
+ const { data } = api.bookmarks.searchBookmarks.useQuery(
+ { text: query },
+ { placeholderData: keepPreviousData },
+ );
+
+ return (
+ <View>
+ <Input
+ placeholder="Search"
+ className="mx-4 mt-4 bg-white"
+ value={search}
+ onChangeText={setSearch}
+ autoFocus
+ autoCapitalize="none"
+ />
+ <Divider orientation="horizontal" className="mb-1 mt-4 w-full" />
+ {data && <BookmarkList ids={data.bookmarks.map((b) => b.id)} />}
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx
new file mode 100644
index 00000000..9f86d5ec
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx
@@ -0,0 +1,41 @@
+import { useRouter } from "expo-router";
+import { useEffect } from "react";
+import { Text, View } from "react-native";
+
+import Logo from "@/components/Logo";
+import { Button } from "@/components/ui/Button";
+import { useSession } from "@/lib/session";
+import { api } from "@/lib/trpc";
+
+export default function Dashboard() {
+ const router = useRouter();
+
+ const { isLoggedIn, logout } = useSession();
+
+ useEffect(() => {
+ if (isLoggedIn !== undefined && !isLoggedIn) {
+ router.replace("signin");
+ }
+ }, [isLoggedIn]);
+
+ const { data, error, isLoading } = api.users.whoami.useQuery();
+
+ useEffect(() => {
+ if (error?.data?.code === "UNAUTHORIZED") {
+ logout();
+ }
+ }, [error]);
+
+ return (
+ <View className="flex h-full w-full items-center gap-4 p-4">
+ <Logo />
+ <View className="w-full rounded-lg bg-white px-4 py-2">
+ <Text className="text-lg">
+ {isLoading ? "Loading ..." : data?.email}
+ </Text>
+ </View>
+
+ <Button className="w-full" label="Log Out" onPress={logout} />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx
new file mode 100644
index 00000000..ff2384d2
--- /dev/null
+++ b/apps/mobile/app/dashboard/_layout.tsx
@@ -0,0 +1,38 @@
+import { Stack } from "expo-router/stack";
+
+export default function Dashboard() {
+ return (
+ <Stack>
+ <Stack.Screen
+ name="(tabs)"
+ options={{ headerShown: false, title: "Home" }}
+ />
+ <Stack.Screen
+ name="favourites"
+ options={{
+ title: "⭐️ Favourites",
+ }}
+ />
+ <Stack.Screen
+ name="archive"
+ options={{
+ title: "🗄️ Archive",
+ }}
+ />
+ <Stack.Screen
+ name="add-link"
+ options={{
+ title: "New link",
+ presentation: "modal",
+ }}
+ />
+ <Stack.Screen
+ name="add-note"
+ options={{
+ title: "New Note",
+ presentation: "modal",
+ }}
+ />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/add-link.tsx b/apps/mobile/app/dashboard/add-link.tsx
new file mode 100644
index 00000000..69a9c7a2
--- /dev/null
+++ b/apps/mobile/app/dashboard/add-link.tsx
@@ -0,0 +1,57 @@
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { View, Text } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function AddNote() {
+ const [text, setText] = useState("");
+ const [error, setError] = useState<string | undefined>();
+ const router = useRouter();
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const { mutate } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateAllBookmarks();
+ if (router.canGoBack()) {
+ router.replace("../");
+ } else {
+ router.replace("dashboard");
+ }
+ },
+ onError: (e) => {
+ let message;
+ if (e.data?.code === "BAD_REQUEST") {
+ const error = JSON.parse(e.message)[0];
+ message = error.message;
+ } else {
+ message = `Something went wrong: ${e.message}`;
+ }
+ setError(message);
+ },
+ });
+
+ return (
+ <View className="flex gap-2 p-4">
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <Input
+ className="bg-white"
+ value={text}
+ onChangeText={setText}
+ placeholder="Link"
+ autoCapitalize="none"
+ inputMode="url"
+ autoFocus
+ />
+ <Button
+ onPress={() => mutate({ type: "link", url: text })}
+ label="Add Link"
+ />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/add-note.tsx b/apps/mobile/app/dashboard/add-note.tsx
new file mode 100644
index 00000000..cf775a15
--- /dev/null
+++ b/apps/mobile/app/dashboard/add-note.tsx
@@ -0,0 +1,53 @@
+import { useRouter } from "expo-router";
+import { useState } from "react";
+import { View, Text } from "react-native";
+
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { api } from "@/lib/trpc";
+
+export default function AddNote() {
+ const [text, setText] = useState("");
+ const [error, setError] = useState<string | undefined>();
+ const router = useRouter();
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ const { mutate } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: () => {
+ invalidateAllBookmarks();
+ if (router.canGoBack()) {
+ router.replace("../");
+ } else {
+ router.replace("dashboard");
+ }
+ },
+ onError: (e) => {
+ let message;
+ if (e.data?.code === "BAD_REQUEST") {
+ const error = JSON.parse(e.message)[0];
+ message = error.message;
+ } else {
+ message = `Something went wrong: ${e.message}`;
+ }
+ setError(message);
+ },
+ });
+
+ return (
+ <View className="flex gap-2 p-4">
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <Input
+ className="bg-white"
+ value={text}
+ onChangeText={setText}
+ multiline
+ placeholder="What's on your mind?"
+ autoFocus
+ />
+ <Button onPress={() => mutate({ type: "text", text })} label="Add Note" />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/archive.tsx b/apps/mobile/app/dashboard/archive.tsx
new file mode 100644
index 00000000..d75cfe22
--- /dev/null
+++ b/apps/mobile/app/dashboard/archive.tsx
@@ -0,0 +1,11 @@
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+export default function Archive() {
+ return (
+ <View>
+ <BookmarkList archived />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/favourites.tsx b/apps/mobile/app/dashboard/favourites.tsx
new file mode 100644
index 00000000..90374f18
--- /dev/null
+++ b/apps/mobile/app/dashboard/favourites.tsx
@@ -0,0 +1,11 @@
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+
+export default function Favourites() {
+ return (
+ <View>
+ <BookmarkList archived={false} favourited />
+ </View>
+ );
+}
diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug].tsx
new file mode 100644
index 00000000..54744874
--- /dev/null
+++ b/apps/mobile/app/dashboard/lists/[slug].tsx
@@ -0,0 +1,31 @@
+import { useLocalSearchParams, Stack } from "expo-router";
+import { View } from "react-native";
+
+import BookmarkList from "@/components/bookmarks/BookmarkList";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { api } from "@/lib/trpc";
+
+export default function ListView() {
+ const { slug } = useLocalSearchParams();
+ if (typeof slug !== "string") {
+ throw new Error("Unexpected param type");
+ }
+ const { data: list } = api.lists.get.useQuery({ listId: slug });
+
+ if (!list) {
+ return <FullPageSpinner />;
+ }
+
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerTitle: `${list.icon} ${list.name}`,
+ }}
+ />
+ <View>
+ <BookmarkList archived={false} ids={list.bookmarks} />
+ </View>
+ </>
+ );
+}
diff --git a/apps/mobile/app/error.tsx b/apps/mobile/app/error.tsx
new file mode 100644
index 00000000..2ca227a4
--- /dev/null
+++ b/apps/mobile/app/error.tsx
@@ -0,0 +1,9 @@
+import { View, Text } from "react-native";
+
+export default function ErrorPage() {
+ return (
+ <View className="flex-1 items-center justify-center gap-4">
+ <Text className="text-4xl">Error!</Text>
+ </View>
+ );
+}
diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx
new file mode 100644
index 00000000..5ce20cda
--- /dev/null
+++ b/apps/mobile/app/index.tsx
@@ -0,0 +1,20 @@
+import { useRouter } from "expo-router";
+import { useEffect } from "react";
+import { View } from "react-native";
+
+import { useSession } from "@/lib/session";
+
+export default function App() {
+ const router = useRouter();
+ const { isLoggedIn } = useSession();
+ useEffect(() => {
+ if (isLoggedIn === undefined) {
+ // Wait until it's loaded
+ } else if (isLoggedIn) {
+ router.replace("dashboard");
+ } else {
+ router.replace("signin");
+ }
+ }, [isLoggedIn]);
+ return <View />;
+}
diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx
new file mode 100644
index 00000000..64bbd933
--- /dev/null
+++ b/apps/mobile/app/sharing.tsx
@@ -0,0 +1,99 @@
+import { Link, useLocalSearchParams, useRouter } from "expo-router";
+import { ShareIntent, useShareIntent } from "expo-share-intent";
+import { useEffect, useMemo, useState } from "react";
+import { View, Text } from "react-native";
+import { z } from "zod";
+
+import { api } from "@/lib/trpc";
+
+type Mode =
+ | { type: "idle" }
+ | { type: "success"; bookmarkId: string }
+ | { type: "error" };
+
+function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
+ // Desperate attempt to fix sharing duplication
+ const { hasShareIntent, resetShareIntent } = useShareIntent();
+
+ const params = useLocalSearchParams();
+
+ const shareIntent = useMemo(() => {
+ if (params && params.shareIntent) {
+ if (typeof params.shareIntent === "string") {
+ return JSON.parse(params.shareIntent) as ShareIntent;
+ }
+ }
+ return null;
+ }, [params]);
+
+ const invalidateAllBookmarks =
+ api.useUtils().bookmarks.getBookmarks.invalidate;
+
+ useEffect(() => {
+ if (!isPending && shareIntent?.text) {
+ const val = z.string().url();
+ if (val.safeParse(shareIntent.text).success) {
+ // This is a URL, else treated as text
+ mutate({ type: "link", url: shareIntent.text });
+ } else {
+ mutate({ type: "text", text: shareIntent.text });
+ }
+ }
+ if (hasShareIntent) {
+ resetShareIntent();
+ }
+ }, []);
+
+ const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({
+ onSuccess: (d) => {
+ invalidateAllBookmarks();
+ setMode({ type: "success", bookmarkId: d.id });
+ },
+ onError: () => {
+ setMode({ type: "error" });
+ },
+ });
+
+ return <Text className="text-4xl">Hoarding ...</Text>;
+}
+
+export default function Sharing() {
+ const router = useRouter();
+ const [mode, setMode] = useState<Mode>({ type: "idle" });
+
+ let comp;
+ switch (mode.type) {
+ case "idle": {
+ comp = <SaveBookmark setMode={setMode} />;
+ break;
+ }
+ case "success": {
+ comp = <Text className="text-4xl">Hoarded!</Text>;
+ break;
+ }
+ case "error": {
+ comp = <Text className="text-4xl">Error!</Text>;
+ break;
+ }
+ }
+
+ // Auto dismiss the modal after saving.
+ useEffect(() => {
+ if (mode.type === "idle") {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ router.replace("dashboard");
+ }, 2000);
+
+ return () => clearTimeout(timeoutId);
+ }, [mode.type]);
+
+ return (
+ <View className="flex-1 items-center justify-center gap-4">
+ {comp}
+ <Link href="dashboard">Dismiss</Link>
+ </View>
+ );
+}
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx
new file mode 100644
index 00000000..a89b0087
--- /dev/null
+++ b/apps/mobile/app/signin.tsx
@@ -0,0 +1,101 @@
+import { useRouter } from "expo-router";
+import { useEffect, useState } from "react";
+import { View, Text } from "react-native";
+
+import Logo from "@/components/Logo";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import useAppSettings from "@/lib/settings";
+import { api } from "@/lib/trpc";
+
+export default function Signin() {
+ const router = useRouter();
+
+ const { settings, setSettings } = useAppSettings();
+
+ const [error, setError] = useState<string | undefined>();
+
+ const { mutate: login, isPending } = api.apiKeys.exchange.useMutation({
+ onSuccess: (resp) => {
+ setSettings({ ...settings, apiKey: resp.key });
+ router.replace("dashboard");
+ },
+ onError: (e) => {
+ if (e.data?.code === "UNAUTHORIZED") {
+ setError("Wrong username or password");
+ } else {
+ setError(`${e.message}`);
+ }
+ },
+ });
+
+ const [formData, setFormData] = useState<{
+ email: string;
+ password: string;
+ }>({
+ email: "",
+ password: "",
+ });
+
+ useEffect(() => {
+ if (settings.apiKey) {
+ router.navigate("dashboard");
+ }
+ }, [settings]);
+
+ const onSignin = () => {
+ const randStr = (Math.random() + 1).toString(36).substring(5);
+ login({ ...formData, keyName: `Mobile App: (${randStr})` });
+ };
+
+ return (
+ <View className="flex h-full flex-col justify-center gap-2 px-4">
+ <View className="items-center">
+ <Logo />
+ </View>
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <View className="gap-2">
+ <Text className="font-bold">Server Address</Text>
+ <Input
+ className="w-full"
+ placeholder="Server Address"
+ value={settings.address}
+ autoCapitalize="none"
+ keyboardType="url"
+ onEndEditing={(e) =>
+ setSettings({ ...settings, address: e.nativeEvent.text })
+ }
+ />
+ </View>
+ <View className="gap-2">
+ <Text className="font-bold">Email</Text>
+ <Input
+ className="w-full"
+ placeholder="Email"
+ keyboardType="email-address"
+ autoCapitalize="none"
+ value={formData.email}
+ onChangeText={(e) => setFormData((s) => ({ ...s, email: e }))}
+ />
+ </View>
+ <View className="gap-2">
+ <Text className="font-bold">Password</Text>
+ <Input
+ className="w-full"
+ placeholder="Password"
+ secureTextEntry
+ value={formData.password}
+ onChangeText={(e) => setFormData((s) => ({ ...s, password: e }))}
+ />
+ </View>
+ <Button
+ className="w-full"
+ label="Sign In"
+ onPress={onSignin}
+ disabled={isPending}
+ />
+ </View>
+ );
+}
diff --git a/apps/mobile/assets/blur.jpeg b/apps/mobile/assets/blur.jpeg
new file mode 100644
index 00000000..387ce697
--- /dev/null
+++ b/apps/mobile/assets/blur.jpeg
Binary files differ
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
new file mode 100644
index 00000000..71ead90c
--- /dev/null
+++ b/apps/mobile/assets/icon.png
Binary files differ
diff --git a/apps/mobile/assets/splash.png b/apps/mobile/assets/splash.png
new file mode 100644
index 00000000..3759c518
--- /dev/null
+++ b/apps/mobile/assets/splash.png
Binary files differ
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
new file mode 100644
index 00000000..f3c649bb
--- /dev/null
+++ b/apps/mobile/babel.config.js
@@ -0,0 +1,9 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [
+ ["babel-preset-expo", { jsxImportSource: "nativewind" }],
+ "nativewind/babel",
+ ],
+ };
+};
diff --git a/apps/mobile/components/Logo.tsx b/apps/mobile/components/Logo.tsx
new file mode 100644
index 00000000..57f7a5c3
--- /dev/null
+++ b/apps/mobile/components/Logo.tsx
@@ -0,0 +1,11 @@
+import { PackageOpen } from "lucide-react-native";
+import { View, Text } from "react-native";
+
+export default function Logo() {
+ return (
+ <View className="flex flex-row items-center justify-center gap-2 ">
+ <PackageOpen color="black" size={70} />
+ <Text className="text-5xl">Hoarder</Text>
+ </View>
+ );
+}
diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
new file mode 100644
index 00000000..25947790
--- /dev/null
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -0,0 +1,243 @@
+import { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+import * as WebBrowser from "expo-web-browser";
+import { Star, Archive, Trash, ArchiveRestore } from "lucide-react-native";
+import { View, Text, Image, ScrollView, Pressable } from "react-native";
+import Markdown from "react-native-markdown-display";
+
+import { ActionButton } from "../ui/ActionButton";
+import { Divider } from "../ui/Divider";
+import { Skeleton } from "../ui/Skeleton";
+import { useToast } from "../ui/Toast";
+
+import { api } from "@/lib/trpc";
+
+const MAX_LOADING_MSEC = 30 * 1000;
+
+export function isBookmarkStillCrawling(bookmark: ZBookmark) {
+ return (
+ bookmark.content.type === "link" &&
+ !bookmark.content.crawledAt &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillTagging(bookmark: ZBookmark) {
+ return (
+ bookmark.taggingStatus === "pending" &&
+ Date.now().valueOf() - bookmark.createdAt.valueOf() < MAX_LOADING_MSEC
+ );
+}
+
+export function isBookmarkStillLoading(bookmark: ZBookmark) {
+ return isBookmarkStillTagging(bookmark) || isBookmarkStillCrawling(bookmark);
+}
+
+function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
+ const { toast } = useToast();
+ const apiUtils = api.useUtils();
+
+ const { mutate: deleteBookmark, isPending: isDeletionPending } =
+ api.bookmarks.deleteBookmark.useMutation({
+ onSuccess: () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ },
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ },
+ });
+ const {
+ mutate: updateBookmark,
+ variables,
+ isPending: isUpdatePending,
+ } = api.bookmarks.updateBookmark.useMutation({
+ onSuccess: () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id });
+ },
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ },
+ });
+
+ return (
+ <View className="flex flex-row gap-4">
+ <Pressable
+ onPress={() =>
+ updateBookmark({
+ bookmarkId: bookmark.id,
+ favourited: !bookmark.favourited,
+ })
+ }
+ >
+ {(variables ? variables.favourited : bookmark.favourited) ? (
+ <Star fill="#ebb434" color="#ebb434" />
+ ) : (
+ <Star color="gray" />
+ )}
+ </Pressable>
+ <ActionButton
+ loading={isUpdatePending}
+ onPress={() =>
+ updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: !bookmark.archived,
+ })
+ }
+ >
+ {bookmark.archived ? (
+ <ArchiveRestore color="gray" />
+ ) : (
+ <Archive color="gray" />
+ )}
+ </ActionButton>
+ <ActionButton
+ loading={isDeletionPending}
+ onPress={() =>
+ deleteBookmark({
+ bookmarkId: bookmark.id,
+ })
+ }
+ >
+ <Trash color="gray" />
+ </ActionButton>
+ </View>
+ );
+}
+
+function TagList({ bookmark }: { bookmark: ZBookmark }) {
+ const tags = bookmark.tags;
+
+ if (isBookmarkStillTagging(bookmark)) {
+ return (
+ <>
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ </>
+ );
+ }
+
+ return (
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
+ <View className="flex flex-row gap-2">
+ {tags.map((t) => (
+ <View
+ key={t.id}
+ className="rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold"
+ >
+ <Text>{t.name}</Text>
+ </View>
+ ))}
+ </View>
+ </ScrollView>
+ );
+}
+
+function LinkCard({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== "link") {
+ throw new Error("Wrong content type rendered");
+ }
+
+ const url = bookmark.content.url;
+ const parsedUrl = new URL(url);
+
+ const imageComp = bookmark.content.imageUrl ? (
+ <Image
+ source={{ uri: bookmark.content.imageUrl }}
+ className="h-56 min-h-56 w-full rounded-t-lg object-cover"
+ />
+ ) : (
+ <Image
+ source={require("@/assets/blur.jpeg")}
+ className="h-56 w-full rounded-t-lg"
+ />
+ );
+
+ return (
+ <View className="flex gap-2">
+ {imageComp}
+ <View className="flex gap-2 p-2">
+ <Text
+ className="line-clamp-2 text-xl font-bold"
+ onPress={() => WebBrowser.openBrowserAsync(url)}
+ >
+ {bookmark.content.title || parsedUrl.host}
+ </Text>
+ <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">
+ <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text>
+ <ActionBar bookmark={bookmark} />
+ </View>
+ </View>
+ </View>
+ );
+}
+
+function TextCard({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== "text") {
+ throw new Error("Wrong content type rendered");
+ }
+ return (
+ <View className="flex max-h-96 gap-2 p-2">
+ <View className="max-h-56 overflow-hidden p-2">
+ <Markdown>{bookmark.content.text}</Markdown>
+ </View>
+ <TagList bookmark={bookmark} />
+ <Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
+ <View className="flex flex-row justify-between p-2">
+ <View />
+ <ActionBar bookmark={bookmark} />
+ </View>
+ </View>
+ );
+}
+
+export default function BookmarkCard({
+ bookmark: initialData,
+}: {
+ bookmark: ZBookmark;
+}) {
+ const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
+ {
+ bookmarkId: initialData.id,
+ },
+ {
+ initialData,
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ if (!data) {
+ return false;
+ }
+ // If the link is not crawled or not tagged
+ if (isBookmarkStillLoading(data)) {
+ return 1000;
+ }
+ return false;
+ },
+ },
+ );
+
+ let comp;
+ switch (bookmark.content.type) {
+ case "link":
+ comp = <LinkCard bookmark={bookmark} />;
+ break;
+ case "text":
+ comp = <TextCard bookmark={bookmark} />;
+ break;
+ }
+
+ return (
+ <View className="w-96 rounded-lg border border-gray-300 bg-white shadow-sm">
+ {comp}
+ </View>
+ );
+}
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx
new file mode 100644
index 00000000..8e408709
--- /dev/null
+++ b/apps/mobile/components/bookmarks/BookmarkList.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { Text, View } from "react-native";
+import Animated, { LinearTransition } from "react-native-reanimated";
+
+import BookmarkCard from "./BookmarkCard";
+import FullPageSpinner from "../ui/FullPageSpinner";
+
+import { api } from "@/lib/trpc";
+
+export default function BookmarkList({
+ favourited,
+ archived,
+ ids,
+}: {
+ favourited?: boolean;
+ archived?: boolean;
+ ids?: string[];
+}) {
+ const apiUtils = api.useUtils();
+ const [refreshing, setRefreshing] = useState(false);
+ const { data, isPending, isPlaceholderData } =
+ api.bookmarks.getBookmarks.useQuery({
+ favourited,
+ archived,
+ ids,
+ });
+
+ useEffect(() => {
+ setRefreshing(isPending || isPlaceholderData);
+ }, [isPending, isPlaceholderData]);
+
+ if (isPending || !data) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate();
+ };
+
+ return (
+ <Animated.FlatList
+ itemLayoutAnimation={LinearTransition}
+ contentContainerStyle={{
+ gap: 15,
+ marginVertical: 15,
+ alignItems: "center",
+ }}
+ renderItem={(b) => <BookmarkCard bookmark={b.item} />}
+ ListEmptyComponent={
+ <View className="h-full items-center justify-center">
+ <Text className="text-xl">No Bookmarks</Text>
+ </View>
+ }
+ data={data.bookmarks}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ keyExtractor={(b) => b.id}
+ />
+ );
+}
diff --git a/apps/mobile/components/ui/ActionButton.tsx b/apps/mobile/components/ui/ActionButton.tsx
new file mode 100644
index 00000000..c51eb332
--- /dev/null
+++ b/apps/mobile/components/ui/ActionButton.tsx
@@ -0,0 +1,21 @@
+import { ActivityIndicator, Pressable, PressableProps } from "react-native";
+
+export function ActionButton({
+ children,
+ loading,
+ disabled,
+ ...props
+}: PressableProps & {
+ loading: boolean;
+}) {
+ if (disabled !== undefined) {
+ disabled ||= loading;
+ } else if (loading) {
+ disabled = true;
+ }
+ return (
+ <Pressable {...props} disabled={disabled}>
+ {loading ? <ActivityIndicator /> : children}
+ </Pressable>
+ );
+}
diff --git a/apps/mobile/components/ui/Button.tsx b/apps/mobile/components/ui/Button.tsx
new file mode 100644
index 00000000..4c3cbc69
--- /dev/null
+++ b/apps/mobile/components/ui/Button.tsx
@@ -0,0 +1,81 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import { Text, TouchableOpacity } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "flex flex-row items-center justify-center rounded-md",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary",
+ secondary: "bg-secondary",
+ destructive: "bg-destructive",
+ ghost: "bg-slate-700",
+ link: "text-primary underline-offset-4",
+ },
+ size: {
+ default: "h-10 px-4",
+ sm: "h-8 px-2",
+ lg: "h-12 px-8",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const buttonTextVariants = cva("text-center font-medium", {
+ variants: {
+ variant: {
+ default: "text-primary-foreground",
+ secondary: "text-secondary-foreground",
+ destructive: "text-destructive-foreground",
+ ghost: "text-primary-foreground",
+ link: "text-primary-foreground underline",
+ },
+ size: {
+ default: "text-base",
+ sm: "text-sm",
+ lg: "text-xl",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+});
+
+interface ButtonProps
+ extends React.ComponentPropsWithoutRef<typeof TouchableOpacity>,
+ VariantProps<typeof buttonVariants> {
+ label: string;
+ labelClasses?: string;
+}
+function Button({
+ label,
+ labelClasses,
+ className,
+ variant,
+ size,
+ ...props
+}: ButtonProps) {
+ return (
+ <TouchableOpacity
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ >
+ <Text
+ className={cn(
+ buttonTextVariants({ variant, size, className: labelClasses }),
+ )}
+ >
+ {label}
+ </Text>
+ </TouchableOpacity>
+ );
+}
+
+export { Button, buttonVariants, buttonTextVariants };
diff --git a/apps/mobile/components/ui/Divider.tsx b/apps/mobile/components/ui/Divider.tsx
new file mode 100644
index 00000000..1da0a71e
--- /dev/null
+++ b/apps/mobile/components/ui/Divider.tsx
@@ -0,0 +1,28 @@
+import { View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+function Divider({
+ color = "#DFE4EA",
+ className,
+ orientation,
+ ...props
+}: {
+ color?: string;
+ orientation: "horizontal" | "vertical";
+} & React.ComponentPropsWithoutRef<typeof View>) {
+ const dividerStyles = [{ backgroundColor: color }];
+
+ return (
+ <View
+ className={cn(
+ orientation === "horizontal" ? "h-0.5" : "w-0.5",
+ className,
+ )}
+ style={dividerStyles}
+ {...props}
+ />
+ );
+}
+
+export { Divider };
diff --git a/apps/mobile/components/ui/FullPageSpinner.tsx b/apps/mobile/components/ui/FullPageSpinner.tsx
new file mode 100644
index 00000000..01187f11
--- /dev/null
+++ b/apps/mobile/components/ui/FullPageSpinner.tsx
@@ -0,0 +1,9 @@
+import { View, ActivityIndicator } from "react-native";
+
+export default function FullPageSpinner() {
+ return (
+ <View className="h-full w-full items-center justify-center">
+ <ActivityIndicator />
+ </View>
+ );
+}
diff --git a/apps/mobile/components/ui/Input.tsx b/apps/mobile/components/ui/Input.tsx
new file mode 100644
index 00000000..2fcb2764
--- /dev/null
+++ b/apps/mobile/components/ui/Input.tsx
@@ -0,0 +1,28 @@
+import { forwardRef } from "react";
+import { Text, TextInput, View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.ComponentPropsWithoutRef<typeof TextInput> {
+ label?: string;
+ labelClasses?: string;
+ inputClasses?: string;
+}
+
+const Input = forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
+ ({ className, label, labelClasses, inputClasses, ...props }, ref) => (
+ <View className={cn("flex flex-col gap-1.5", className)}>
+ {label && <Text className={cn("text-base", labelClasses)}>{label}</Text>}
+ <TextInput
+ className={cn(
+ inputClasses,
+ "border-input rounded-lg border px-4 py-2.5",
+ )}
+ {...props}
+ />
+ </View>
+ ),
+);
+
+export { Input };
diff --git a/apps/mobile/components/ui/Skeleton.tsx b/apps/mobile/components/ui/Skeleton.tsx
new file mode 100644
index 00000000..68b22e1e
--- /dev/null
+++ b/apps/mobile/components/ui/Skeleton.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useRef } from "react";
+import { Animated, type View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: { className?: string } & React.ComponentPropsWithoutRef<typeof View>) {
+ const fadeAnim = useRef(new Animated.Value(0.5)).current;
+
+ useEffect(() => {
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 0.5,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ ]),
+ ).start();
+ }, [fadeAnim]);
+
+ return (
+ <Animated.View
+ className={cn("bg-muted rounded-md", className)}
+ style={[{ opacity: fadeAnim }]}
+ {...props}
+ />
+ );
+}
+
+export { Skeleton };
diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx
new file mode 100644
index 00000000..fb319f84
--- /dev/null
+++ b/apps/mobile/components/ui/Toast.tsx
@@ -0,0 +1,183 @@
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { Animated, Text, View } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+const toastVariants = {
+ default: "bg-foreground",
+ destructive: "bg-destructive",
+ success: "bg-green-500",
+ info: "bg-blue-500",
+};
+
+interface ToastProps {
+ id: number;
+ message: string;
+ onHide: (id: number) => void;
+ variant?: keyof typeof toastVariants;
+ duration?: number;
+ showProgress?: boolean;
+}
+function Toast({
+ id,
+ message,
+ onHide,
+ variant = "default",
+ duration = 3000,
+ showProgress = true,
+}: ToastProps) {
+ const opacity = useRef(new Animated.Value(0)).current;
+ const progress = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.sequence([
+ Animated.timing(opacity, {
+ toValue: 1,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ Animated.timing(progress, {
+ toValue: 1,
+ duration: duration - 1000,
+ useNativeDriver: false,
+ }),
+ Animated.timing(opacity, {
+ toValue: 0,
+ duration: 500,
+ useNativeDriver: true,
+ }),
+ ]).start(() => onHide(id));
+ }, [duration]);
+
+ return (
+ <Animated.View
+ className={`
+ ${toastVariants[variant]}
+ m-2 mb-1 transform rounded-lg p-4 shadow-md transition-all
+ `}
+ style={{
+ opacity,
+ transform: [
+ {
+ translateY: opacity.interpolate({
+ inputRange: [0, 1],
+ outputRange: [-20, 0],
+ }),
+ },
+ ],
+ }}
+ >
+ <Text className="text-background text-left font-semibold">{message}</Text>
+ {showProgress && (
+ <View className="mt-2 rounded">
+ <Animated.View
+ className="h-2 rounded bg-white opacity-30 dark:bg-black"
+ style={{
+ width: progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: ["0%", "100%"],
+ }),
+ }}
+ />
+ </View>
+ )}
+ </Animated.View>
+ );
+}
+
+type ToastVariant = keyof typeof toastVariants;
+
+interface ToastMessage {
+ id: number;
+ text: string;
+ variant: ToastVariant;
+ duration?: number;
+ position?: string;
+ showProgress?: boolean;
+}
+interface ToastContextProps {
+ toast: (t: {
+ message: string;
+ variant?: keyof typeof toastVariants;
+ duration?: number;
+ position?: "top" | "bottom";
+ showProgress?: boolean;
+ }) => void;
+ removeToast: (id: number) => void;
+}
+const ToastContext = createContext<ToastContextProps | undefined>(undefined);
+
+// TODO: refactor to pass position to Toast instead of ToastProvider
+function ToastProvider({
+ children,
+ position = "top",
+}: {
+ children: React.ReactNode;
+ position?: "top" | "bottom";
+}) {
+ const [messages, setMessages] = useState<ToastMessage[]>([]);
+
+ const toast: ToastContextProps["toast"] = ({
+ message,
+ variant = "default",
+ duration = 3000,
+ position = "top",
+ showProgress = true,
+ }: {
+ message: string;
+ variant?: ToastVariant;
+ duration?: number;
+ position?: "top" | "bottom";
+ showProgress?: boolean;
+ }) => {
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: Date.now(),
+ text: message,
+ variant,
+ duration,
+ position,
+ showProgress,
+ },
+ ]);
+ };
+
+ const removeToast = (id: number) => {
+ setMessages((prev) => prev.filter((message) => message.id !== id));
+ };
+
+ return (
+ <ToastContext.Provider value={{ toast, removeToast }}>
+ {children}
+ <View
+ className={cn("absolute left-0 right-0", {
+ "top-[45px]": position === "top",
+ "bottom-0": position === "bottom",
+ })}
+ >
+ {messages.map((message) => (
+ <Toast
+ key={message.id}
+ id={message.id}
+ message={message.text}
+ variant={message.variant}
+ duration={message.duration}
+ showProgress={message.showProgress}
+ onHide={removeToast}
+ />
+ ))}
+ </View>
+ </ToastContext.Provider>
+ );
+}
+
+function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error("useToast must be used within ToastProvider");
+ }
+ return context;
+}
+
+export { ToastProvider, ToastVariant, Toast, toastVariants, useToast };
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
new file mode 100644
index 00000000..0897755d
--- /dev/null
+++ b/apps/mobile/eas.json
@@ -0,0 +1,19 @@
+{
+ "cli": {
+ "version": ">= 7.5.0",
+ "promptToConfigurePushNotifications": false
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "distribution": "internal"
+ },
+ "production": {}
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css
new file mode 100644
index 00000000..de1cf559
--- /dev/null
+++ b/apps/mobile/globals.css
@@ -0,0 +1,80 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 47.4% 11.2%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 47.4% 11.2%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 100% 50%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 215 20.2% 65.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark:root {
+ --background: 224 71% 4%;
+ --foreground: 213 31% 91%;
+
+ --muted: 223 47% 11%;
+ --muted-foreground: 215.4 16.3% 56.9%;
+
+ --accent: 216 34% 17%;
+ --accent-foreground: 210 40% 98%;
+
+ --popover: 224 71% 4%;
+ --popover-foreground: 215 20.2% 65.1%;
+
+ --border: 216 34% 17%;
+ --input: 216 34% 17%;
+
+ --card: 224 71% 4%;
+ --card-foreground: 213 31% 91%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 1.2%;
+
+ --secondary: 222.2 47.4% 11.2%;
+ --secondary-foreground: 210 40% 98%;
+
+ --destructive: 0 63% 31%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 216 34% 17%;
+
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/mobile/lib/last-shared-intent.ts b/apps/mobile/lib/last-shared-intent.ts
new file mode 100644
index 00000000..951bcf74
--- /dev/null
+++ b/apps/mobile/lib/last-shared-intent.ts
@@ -0,0 +1,15 @@
+import { create } from "zustand";
+
+interface LastSharedIntent {
+ lastIntent: string;
+ setIntent: (intent: string) => void;
+ isPreviouslyShared: (intent: string) => boolean;
+}
+
+export const useLastSharedIntent = create<LastSharedIntent>((set, get) => ({
+ lastIntent: "",
+ setIntent: (intent: string) => set({ lastIntent: intent }),
+ isPreviouslyShared: (intent: string) => {
+ return get().lastIntent === intent;
+ },
+}));
diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx
new file mode 100644
index 00000000..1717afb2
--- /dev/null
+++ b/apps/mobile/lib/providers.tsx
@@ -0,0 +1,54 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { httpBatchLink } from "@trpc/client";
+import { useEffect, useState } from "react";
+import superjson from "superjson";
+
+import useAppSettings, { getAppSettings } from "./settings";
+import { api } from "./trpc";
+
+import { ToastProvider } from "@/components/ui/Toast";
+
+function getTRPCClient(address: string) {
+ return api.createClient({
+ links: [
+ httpBatchLink({
+ url: `${address}/api/trpc`,
+ async headers() {
+ const settings = await getAppSettings();
+ return {
+ Authorization:
+ settings && settings.apiKey
+ ? `Bearer ${settings.apiKey}`
+ : undefined,
+ };
+ },
+ transformer: superjson,
+ }),
+ ],
+ });
+}
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const { settings } = useAppSettings();
+ const [queryClient] = useState(() => new QueryClient());
+
+ const [trpcClient, setTrpcClient] = useState<
+ ReturnType<typeof getTRPCClient>
+ >(getTRPCClient(settings.address));
+
+ useEffect(() => {
+ setTrpcClient(getTRPCClient(settings.address));
+ }, [settings.address]);
+
+ return (
+ <api.Provider
+ key={settings.address}
+ client={trpcClient}
+ queryClient={queryClient}
+ >
+ <QueryClientProvider client={queryClient}>
+ <ToastProvider>{children}</ToastProvider>
+ </QueryClientProvider>
+ </api.Provider>
+ );
+}
diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts
new file mode 100644
index 00000000..e2ab245b
--- /dev/null
+++ b/apps/mobile/lib/session.ts
@@ -0,0 +1,20 @@
+import { useCallback, useMemo } from "react";
+
+import useAppSettings from "./settings";
+
+export function useSession() {
+ const { settings, isLoading, setSettings } = useAppSettings();
+ const isLoggedIn = useMemo(() => {
+ return isLoading ? undefined : !!settings.apiKey;
+ }, [isLoading, settings]);
+
+ const logout = useCallback(() => {
+ setSettings({ ...settings, apiKey: undefined });
+ }, [settings]);
+
+ return {
+ isLoggedIn,
+ isLoading,
+ logout,
+ };
+}
diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts
new file mode 100644
index 00000000..21f40528
--- /dev/null
+++ b/apps/mobile/lib/settings.ts
@@ -0,0 +1,29 @@
+import * as SecureStore from "expo-secure-store";
+
+import { useStorageState } from "./storage-state";
+
+const SETTING_NAME = "settings";
+
+export type Settings = {
+ apiKey?: string;
+ address: string;
+};
+
+export default function useAppSettings() {
+ let [[isLoading, settings], setSettings] =
+ useStorageState<Settings>(SETTING_NAME);
+
+ settings ||= {
+ address: "https://demo.hoarder.app",
+ };
+
+ return { settings, setSettings, isLoading };
+}
+
+export async function getAppSettings() {
+ const val = await SecureStore.getItemAsync(SETTING_NAME);
+ if (!val) {
+ return null;
+ }
+ return JSON.parse(val) as Settings;
+}
diff --git a/apps/mobile/lib/storage-state.ts b/apps/mobile/lib/storage-state.ts
new file mode 100644
index 00000000..4988f0e0
--- /dev/null
+++ b/apps/mobile/lib/storage-state.ts
@@ -0,0 +1,51 @@
+import * as SecureStore from "expo-secure-store";
+import * as React from "react";
+
+type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
+
+function useAsyncState<T>(
+ initialValue: [boolean, T | null] = [true, null],
+): UseStateHook<T> {
+ return React.useReducer(
+ (
+ state: [boolean, T | null],
+ action: T | null = null,
+ ): [boolean, T | null] => [false, action],
+ initialValue,
+ ) as UseStateHook<T>;
+}
+
+export async function setStorageItemAsync(key: string, value: string | null) {
+ if (value == null) {
+ await SecureStore.deleteItemAsync(key);
+ } else {
+ await SecureStore.setItemAsync(key, value);
+ }
+}
+
+export function useStorageState<T>(key: string): UseStateHook<T> {
+ // Public
+ const [state, setState] = useAsyncState<T>();
+
+ // Get
+ React.useEffect(() => {
+ SecureStore.getItemAsync(key).then((value) => {
+ if (!value) {
+ setState(null);
+ return null;
+ }
+ setState(JSON.parse(value));
+ });
+ }, [key]);
+
+ // Set
+ const setValue = React.useCallback(
+ (value: T | null) => {
+ setState(value);
+ setStorageItemAsync(key, JSON.stringify(value));
+ },
+ [key],
+ );
+
+ return [state, setValue];
+}
diff --git a/apps/mobile/lib/trpc.ts b/apps/mobile/lib/trpc.ts
new file mode 100644
index 00000000..6b428bd9
--- /dev/null
+++ b/apps/mobile/lib/trpc.ts
@@ -0,0 +1,4 @@
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
+import { createTRPCReact } from "@trpc/react-query";
+
+export const api = createTRPCReact<AppRouter>();
diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/apps/mobile/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
new file mode 100644
index 00000000..c5630a83
--- /dev/null
+++ b/apps/mobile/metro.config.js
@@ -0,0 +1,58 @@
+// Learn more: https://docs.expo.dev/guides/monorepos/
+const { getDefaultConfig } = require("expo/metro-config");
+const { FileStore } = require("metro-cache");
+const { withNativeWind } = require("nativewind/metro");
+
+const path = require("path");
+
+module.exports = withTurborepoManagedCache(
+ withMonorepoPaths(
+ withNativeWind(getDefaultConfig(__dirname), {
+ input: "./globals.css",
+ configPath: "./tailwind.config.ts",
+ }),
+ ),
+);
+
+/**
+ * Add the monorepo paths to the Metro config.
+ * This allows Metro to resolve modules from the monorepo.
+ *
+ * @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
+ * @param {import('expo/metro-config').MetroConfig} config
+ * @returns {import('expo/metro-config').MetroConfig}
+ */
+function withMonorepoPaths(config) {
+ const projectRoot = __dirname;
+ const workspaceRoot = path.resolve(projectRoot, "../..");
+
+ // #1 - Watch all files in the monorepo
+ config.watchFolders = [workspaceRoot];
+
+ // #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
+ config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, "node_modules"),
+ path.resolve(workspaceRoot, "node_modules"),
+ ];
+
+ return config;
+}
+
+/**
+ * Move the Metro cache to the `node_modules/.cache/metro` folder.
+ * This repository configured Turborepo to use this cache location as well.
+ * If you have any environment variables, you can configure Turborepo to invalidate it when needed.
+ *
+ * @see https://turbo.build/repo/docs/reference/configuration#env
+ * @param {import('expo/metro-config').MetroConfig} config
+ * @returns {import('expo/metro-config').MetroConfig}
+ */
+function withTurborepoManagedCache(config) {
+ config.cacheStores = [
+ new FileStore({ root: path.join(__dirname, "node_modules/.cache/metro") }),
+ ];
+ return config;
+}
+
+
+
diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts
new file mode 100644
index 00000000..a13e3136
--- /dev/null
+++ b/apps/mobile/nativewind-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="nativewind/types" />
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..80036724
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@hoarder/mobile",
+ "version": "1.0.0",
+ "main": "expo-router/entry",
+ "scripts": {
+ "clean": "git clean -xdf .expo .turbo node_modules",
+ "start": "expo start",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "web": "expo start --web",
+ "lint": "eslint .",
+ "format": "prettier --check . --ignore-path .gitignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hoarder/trpc": "0.1.0",
+ "@tanstack/react-query": "^5.24.8",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "expo": "~50.0.11",
+ "expo-config-plugin-ios-share-extension": "^0.0.4",
+ "expo-constants": "~15.4.5",
+ "expo-dev-client": "^3.3.9",
+ "expo-image": "^1.10.6",
+ "expo-linking": "~6.2.2",
+ "expo-router": "~3.4.8",
+ "expo-secure-store": "^12.8.1",
+ "expo-share-intent": "^1.0.1",
+ "expo-status-bar": "~1.11.1",
+ "expo-web-browser": "^12.8.2",
+ "lucide-react-native": "^0.354.0",
+ "nativewind": "^4.0.1",
+ "react": "^18.2.0",
+ "react-native": "0.73.4",
+ "react-native-markdown-display": "^7.0.2",
+ "react-native-reanimated": "^3.8.0",
+ "react-native-safe-area-context": "4.8.2",
+ "react-native-screens": "~3.29.0",
+ "react-native-svg": "^15.1.0",
+ "tailwind-merge": "^2.2.1",
+ "use-debounce": "^10.0.0",
+ "zod": "^3.22.4",
+ "zustand": "^4.5.1"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0",
+ "@hoarder/tailwind-config": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@babel/core": "^7.20.0",
+ "@types/react": "^18.2.55",
+ "ajv": "latest",
+ "eslint": "^8.57.0",
+ "eslint-config-universe": "^12.0.0",
+ "prettier": "^3.2.5",
+ "tailwindcss": "^3.4.1",
+ "typescript": "^5.3.3"
+ },
+ "private": true,
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base",
+ "@hoarder/eslint-config/react"
+ ],
+ "ignorePatterns": [
+ "expo-plugins/**"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts
new file mode 100644
index 00000000..ce0059d4
--- /dev/null
+++ b/apps/mobile/tailwind.config.ts
@@ -0,0 +1,73 @@
+import type { Config } from "tailwindcss";
+import { hairlineWidth } from "nativewind/theme";
+
+const config = {
+ content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
+ plugins: [],
+ presets: [require("nativewind/preset")],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderWidth: {
+ hairline: hairlineWidth(),
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+} satisfies Config;
+
+export default config;
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
new file mode 100644
index 00000000..3bcf5741
--- /dev/null
+++ b/apps/mobile/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
+ "types": ["nativewind/types"],
+ "incremental": true,
+ "strict": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "exclude": ["node_modules"]
+}