diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..07d2538 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +NEVER USE ANY OTHER PACKAGE MANAGER THAN BUN. +run the lint, typecheck and format commands after doing any fix. +DO NOT USE USEEFFECT UNLESS ABSOLUTELY NECESSARY. diff --git a/apps/mobile/.expo/README.md b/apps/mobile/.expo/README.md new file mode 100644 index 0000000..ce8c4b6 --- /dev/null +++ b/apps/mobile/.expo/README.md @@ -0,0 +1,13 @@ +> Why do I have a folder named ".expo" in my project? + +The ".expo" folder is created when an Expo project is started using "expo start" command. + +> What do the files contain? + +- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. +- "settings.json": contains the server configuration that is used to serve the application manifest. + +> Should I commit the ".expo" folder? + +No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. +Upon project creation, the ".expo" folder is already added to your ".gitignore" file. diff --git a/apps/mobile/.expo/devices.json b/apps/mobile/.expo/devices.json new file mode 100644 index 0000000..5efff6c --- /dev/null +++ b/apps/mobile/.expo/devices.json @@ -0,0 +1,3 @@ +{ + "devices": [] +} diff --git a/apps/mobile/.expo/types/router.d.ts b/apps/mobile/.expo/types/router.d.ts new file mode 100644 index 0000000..3e89a11 --- /dev/null +++ b/apps/mobile/.expo/types/router.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +import * as Router from 'expo-router'; + +export * from 'expo-router'; + +declare module 'expo-router' { + export namespace ExpoRouter { + export interface __routes { + hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/sign-in` | `/sign-in`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/bookmarks` | `/bookmarks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}` | `/`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/settings` | `/settings`; params?: Router.UnknownInputParams; } | { pathname: `/article/[sourceId]/[itemId]`, params: Router.UnknownInputParams & { sourceId: string | number;itemId: string | number; } } | { pathname: `/feed/[sourceId]`, params: Router.UnknownInputParams & { sourceId: string | number; } }; + hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/sign-in` | `/sign-in`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/bookmarks` | `/bookmarks`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}` | `/`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/settings` | `/settings`; params?: Router.UnknownOutputParams; } | { pathname: `/article/[sourceId]/[itemId]`, params: Router.UnknownOutputParams & { sourceId: string;itemId: string; } } | { pathname: `/feed/[sourceId]`, params: Router.UnknownOutputParams & { sourceId: string; } }; + href: Router.RelativePathString | Router.ExternalPathString | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/sign-in${`?${string}` | `#${string}` | ''}` | `/sign-in${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/bookmarks${`?${string}` | `#${string}` | ''}` | `/bookmarks${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}${`?${string}` | `#${string}` | ''}` | `/${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/settings${`?${string}` | `#${string}` | ''}` | `/settings${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/sign-in` | `/sign-in`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/bookmarks` | `/bookmarks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}` | `/`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/settings` | `/settings`; params?: Router.UnknownInputParams; } | `/article/${Router.SingleRoutePart}/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/feed/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | { pathname: `/article/[sourceId]/[itemId]`, params: Router.UnknownInputParams & { sourceId: string | number;itemId: string | number; } } | { pathname: `/feed/[sourceId]`, params: Router.UnknownInputParams & { sourceId: string | number; } }; + } + } +} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..5873d9a --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..5ba746e --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "oop", + "slug": "oop-mobile", + "scheme": "oop-mobile", + "version": "1.0.0", + "jsEngine": "hermes", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "plugins": [ + "expo-router", + "expo-secure-store", + [ + "expo-build-properties", + { + "buildReactNativeFromSource": true, + "useHermesV1": true + } + ] + ], + "experiments": { + "typedRoutes": true + }, + "ios": { + "supportsTablet": false, + "bundleIdentifier": "com.t3s.oop.mobile" + }, + "android": { + "package": "com.t3s.oop.mobile" + } + } +} diff --git a/apps/mobile/app/(auth)/sign-in.tsx b/apps/mobile/app/(auth)/sign-in.tsx new file mode 100644 index 0000000..32b88a0 --- /dev/null +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { Redirect } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { makeRedirectUri } from "expo-auth-session"; +import { useAuth, useSSO } from "@clerk/expo"; +import { Button } from "@/components/button"; +import { useColors } from "@/theme"; + +export default function SignInScreen() { + const colors = useColors(); + const { isSignedIn } = useAuth(); + const { startSSOFlow } = useSSO(); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + + if (isSignedIn) { + return ; + } + + const handleSignIn = async () => { + setError(null); + setIsPending(true); + + try { + const { createdSessionId, setActive } = await startSSOFlow({ + strategy: "oauth_google", + redirectUrl: makeRedirectUri({ scheme: "oop-mobile" }), + }); + + if (createdSessionId && setActive) { + await setActive({ session: createdSessionId }); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Google sign-in failed."); + } finally { + setIsPending(false); + } + }; + + return ( + + + {error ? {error} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + }, + button: { + width: "100%", + maxWidth: 320, + }, + buttonContent: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + buttonLabel: { + fontSize: 16, + fontWeight: "600", + }, + error: { + marginTop: 16, + fontSize: 14, + textAlign: "center", + }, +}); diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..9088ed9 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Redirect, Tabs, router } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { useAuth } from "@clerk/expo"; +import { useConvexAuth, useMutation } from "convex/react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { Sheet } from "@/components/sheet"; +import { useColors } from "@/theme"; +import { api } from "@/lib/convex"; +import { normalizeInputUrl } from "@repo/shared/feed/utils"; +import { discoverFeed } from "@repo/shared/feed/service"; + +export default function TabsLayout() { + const { isSignedIn } = useAuth(); + const { isAuthenticated } = useConvexAuth(); + const colors = useColors(); + const insets = useSafeAreaInsets(); + const createSubscription = useMutation(api.feedSubscriptions.mutations.createForCurrentUser); + const queryClient = useQueryClient(); + const [isAddOpen, setIsAddOpen] = useState(false); + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!isSignedIn) { + return ; + } + + const canRunAuthenticatedQueries = isSignedIn && isAuthenticated; + + const handleAddFeed = async () => { + if (!canRunAuthenticatedQueries) { + setError("Still connecting — please wait a moment and try again."); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const discovery = await discoverFeed(normalizeInputUrl(url)); + await createSubscription(discovery.source); + await queryClient.invalidateQueries({ queryKey: ["feed-items"] }); + setUrl(""); + setIsAddOpen(false); + router.push(`/feed/${discovery.source.sourceId}`); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Could not add feed."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + , + }} + /> + , + }} + /> + , + }} + /> + + + setIsAddOpen(true)} + > + + + + + Add feed + + Paste a direct RSS or Atom feed URL. + + + {error ? ( + {error} + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + }, + content: { + padding: 20, + paddingBottom: 132, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + heading: { + fontSize: 24, + fontWeight: "600", + }, + sectionLabel: { + marginTop: 24, + fontSize: 14, + }, + optionRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginTop: 16, + }, + optionBtn: { + flex: 1, + minWidth: 72, + }, + signOut: { + marginTop: 32, + borderWidth: 1, + borderRadius: 18, + justifyContent: "flex-start", + paddingHorizontal: 16, + }, + signOutContent: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + signOutLabel: { + fontSize: 14, + fontWeight: "500", + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000..20ecffb --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,117 @@ +import "react-native-reanimated"; + +import { ClerkLoaded, ClerkProvider } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import { resourceCache } from "@clerk/expo/resource-cache"; +import { ConvexProviderWithClerk } from "convex/react-clerk"; +import { useAuth } from "@clerk/expo"; +import { ConvexReactClient } from "convex/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Stack } from "expo-router"; +import * as WebBrowser from "expo-web-browser"; +import { ActivityIndicator, StatusBar, StyleSheet, Text, View, useColorScheme } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context"; +import { useColors } from "@/theme"; + +WebBrowser.maybeCompleteAuthSession(); + +const convexUrl = process.env.EXPO_PUBLIC_CONVEX_URL; +let convex: ConvexReactClient | undefined; + +if (convexUrl) { + convex = new ConvexReactClient(convexUrl, { unsavedChangesWarning: false }); +} + +const queryClient = new QueryClient(); + +function LoadingScreen() { + const colors = useColors(); + + return ( + + + + ); +} + +function MissingConfigScreen({ message }: { message: string }) { + const colors = useColors(); + + return ( + + {message} + + ); +} + +function StackContent() { + const insets = useSafeAreaInsets(); + const colors = useColors(); + + return ( + + ); +} + +export default function RootLayout() { + const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; + const colorScheme = useColorScheme(); + const colors = useColors(); + + if (!publishableKey) { + return ; + } + + if (!convex) { + return ; + } + + return ( + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/app/article/[sourceId]/[itemId].tsx b/apps/mobile/app/article/[sourceId]/[itemId].tsx new file mode 100644 index 0000000..7249c1e --- /dev/null +++ b/apps/mobile/app/article/[sourceId]/[itemId].tsx @@ -0,0 +1,359 @@ +import { useMemo, useState } from "react"; +import { Linking, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useLocalSearchParams } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { WebView } from "react-native-webview"; +import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { useQuery as useTanstackQuery } from "@tanstack/react-query"; +import { Button } from "@/components/button"; +import { useColors, type ThemeColors } from "@/theme"; +import { api, type Doc } from "@/lib/convex"; +import { refreshDiscoveredFeed } from "@repo/shared/feed/service"; +import { stripHtml } from "@repo/shared/feed/utils"; +import type { FeedSubscription, StoredFeedItem } from "@repo/shared/feed/types"; + +function buildReaderHtml(title: string, html: string, colors: ThemeColors) { + return ` + + + + + + +

${title.replace(/ + ${html} + +`; +} + +export default function ArticleScreen() { + const colors = useColors(); + const { isAuthenticated } = useConvexAuth(); + const canQuery = isAuthenticated; + const params = useLocalSearchParams<{ + sourceId: string; + itemId: string; + url?: string; + title?: string; + excerpt?: string; + sourceLabel?: string; + sourceSiteUrl?: string; + publishedAt?: string; + imageUrl?: string; + }>(); + + const subscriptions = useQuery( + api.feedSubscriptions.queries.listForCurrentUser, + canQuery ? {} : "skip", + ) as FeedSubscription[] | undefined; + const preferences = useQuery( + api.preferences.queries.getForCurrentUser, + canQuery ? {} : "skip", + ); + const bookmarks = (useQuery( + api.bookmarks.queries.listForCurrentUser, + canQuery ? {} : "skip", + ) ?? []) as Doc<"bookmarks">[]; + const toggleBookmark = useMutation(api.bookmarks.mutations.toggleForCurrentUser); + + const source = subscriptions?.find((s) => s.sourceId === params.sourceId); + const [isTogglingBookmark, setIsTogglingBookmark] = useState(false); + const [mode, setMode] = useState<"reader" | "site">(preferences?.defaultView ?? "reader"); + + const { data } = useTanstackQuery({ + queryKey: ["feed-items", params.sourceId], + queryFn: () => + refreshDiscoveredFeed({ + source: { sourceId: source!.sourceId, feedUrl: source!.feedUrl }, + seenItemIds: [], + }), + enabled: !!source, + }); + + const item: StoredFeedItem | undefined = data?.items.find((i) => i.id === params.itemId); + + const fallbackItem = useMemo( + () => + item ?? { + id: params.itemId, + sourceId: params.sourceId, + url: params.url ?? "", + title: params.title ?? "Saved article", + excerpt: params.excerpt ?? undefined, + contentHtml: undefined, + contentText: undefined, + publishedAt: params.publishedAt ?? undefined, + author: undefined, + imageUrl: params.imageUrl ?? undefined, + }, + [item, params], + ); + + const articleUrl = fallbackItem.url || undefined; + const activeMode = mode === "site" && articleUrl ? "site" : "reader"; + const isBookmarked = articleUrl + ? bookmarks.some((bookmark) => bookmark.url === articleUrl) + : false; + const readerText = fallbackItem.contentText ?? stripHtml(fallbackItem.contentHtml); + + const handleToggleBookmark = async () => { + if (isTogglingBookmark || !articleUrl) return; + + setIsTogglingBookmark(true); + + try { + await toggleBookmark({ + sourceId: source?.sourceId, + itemId: fallbackItem.id, + url: articleUrl, + title: fallbackItem.title, + excerpt: fallbackItem.excerpt, + imageUrl: fallbackItem.imageUrl, + sourceLabel: source?.label ?? params.sourceLabel, + sourceSiteUrl: source?.siteUrl ?? params.sourceSiteUrl, + publishedAt: fallbackItem.publishedAt, + }); + } finally { + setIsTogglingBookmark(false); + } + }; + + return ( + + + + {fallbackItem.title} + + {source?.label ? ( + + {source.label} + + ) : null} + + + {activeMode === "site" && articleUrl ? ( + + ) : fallbackItem.contentHtml ? ( + + ) : ( + + {readerText ? ( + {readerText} + ) : ( + + + Fallback reader mode + + + Full article content is not available. You can still open the site view or the + original article. + + {fallbackItem.excerpt ? ( + + {fallbackItem.excerpt} + + ) : null} + + )} + + )} + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + borderBottomWidth: 1, + paddingHorizontal: 20, + paddingVertical: 16, + }, + articleTitle: { + fontSize: 24, + fontWeight: "600", + lineHeight: 30, + }, + sourceLabel: { + marginTop: 8, + fontSize: 14, + }, + webview: { + flex: 1, + }, + readerContent: { + padding: 20, + paddingBottom: 132, + }, + readerText: { + fontSize: 16, + lineHeight: 28, + }, + fallback: { + borderRadius: 24, + borderWidth: 1, + borderStyle: "dashed", + paddingHorizontal: 20, + paddingVertical: 20, + }, + fallbackLabel: { + fontSize: 14, + fontWeight: "500", + }, + fallbackDesc: { + marginTop: 12, + fontSize: 16, + lineHeight: 26, + }, + excerptText: { + marginTop: 16, + fontSize: 18, + lineHeight: 28, + }, + toolbar: { + borderTopWidth: 1, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 24, + }, + toolbarRow: { + flexDirection: "row", + gap: 8, + }, + toolbarBtn: { + flex: 1, + }, + btnContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + btnLabel: { + fontSize: 14, + fontWeight: "500", + }, + openOriginal: { + marginTop: 12, + borderWidth: 1, + borderRadius: 18, + }, +}); diff --git a/apps/mobile/app/feed/[sourceId].tsx b/apps/mobile/app/feed/[sourceId].tsx new file mode 100644 index 0000000..9edbe85 --- /dev/null +++ b/apps/mobile/app/feed/[sourceId].tsx @@ -0,0 +1,152 @@ +import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useLocalSearchParams, router } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { useConvexAuth, useQuery } from "convex/react"; +import { useQuery as useTanstackQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/convex"; +import { useColors } from "@/theme"; +import { refreshDiscoveredFeed } from "@repo/shared/feed/service"; +import type { FeedSubscription } from "@repo/shared/feed/types"; + +export default function FeedScreen() { + const colors = useColors(); + const { sourceId } = useLocalSearchParams<{ sourceId: string }>(); + const { isAuthenticated, isLoading: isConvexAuthLoading } = useConvexAuth(); + const canQuery = isAuthenticated; + const subscriptions = useQuery( + api.feedSubscriptions.queries.listForCurrentUser, + canQuery ? {} : "skip", + ) as FeedSubscription[] | undefined; + const preferences = useQuery( + api.preferences.queries.getForCurrentUser, + canQuery ? {} : "skip", + ); + const source = subscriptions?.find((s) => s.sourceId === sourceId); + const queryClient = useQueryClient(); + const pollingIntervalMs = (preferences?.pollingIntervalMinutes ?? 15) * 60_000; + + const { data, isLoading } = useTanstackQuery({ + queryKey: ["feed-items", sourceId], + queryFn: () => + refreshDiscoveredFeed({ + source: { sourceId: source!.sourceId, feedUrl: source!.feedUrl }, + seenItemIds: [], + }), + enabled: !!source, + refetchInterval: pollingIntervalMs, + refetchIntervalInBackground: false, + }); + + const items = data?.items ?? []; + + if (isConvexAuthLoading) { + return ( + + + + ); + } + + return ( + + void queryClient.invalidateQueries({ queryKey: ["feed-items", sourceId] }) + } + /> + } + > + {source?.label ?? "Feed"} + {source?.siteUrl} + + {isLoading && items.length === 0 ? ( + + + + ) : ( + + {items.map((item) => ( + router.push(`/article/${sourceId}/${item.id}`)} + > + + + + {item.title} + + {item.excerpt ? ( + + {item.excerpt} + + ) : null} + + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + content: { + padding: 20, + paddingBottom: 96, + }, + title: { + fontSize: 28, + fontWeight: "600", + }, + subtitle: { + marginTop: 8, + fontSize: 14, + }, + loadingItems: { + paddingVertical: 48, + alignItems: "center", + }, + list: { + marginTop: 24, + gap: 12, + }, + itemCard: { + borderRadius: 22, + borderWidth: 1, + paddingHorizontal: 16, + paddingVertical: 16, + }, + itemRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: 12, + }, + itemContent: { + flex: 1, + }, + itemTitle: { + fontSize: 16, + fontWeight: "600", + lineHeight: 24, + }, + itemExcerpt: { + marginTop: 8, + fontSize: 14, + lineHeight: 22, + }, +}); diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 0000000..6357201 --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,21 @@ +module.exports = function (api) { + api.cache(true); + + return { + presets: ["babel-preset-expo"], + + plugins: [ + [ + "module-resolver", + { + root: ["./"], + + alias: { + "@": "./src", + "@convex": "./convex", + }, + }, + ], + ], + }; +}; diff --git a/apps/mobile/convex/README.md b/apps/mobile/convex/README.md new file mode 100644 index 0000000..856e610 --- /dev/null +++ b/apps/mobile/convex/README.md @@ -0,0 +1,7 @@ +# Convex linkage + +This app reads generated Convex client types from the root `/convex` workspace. + +- Server functions live in `/convex`. +- `apps/mobile/convex/_generated` is linked to `/convex/_generated`. +- Mobile client imports should go through `src/lib/convex.ts`. diff --git a/apps/mobile/convex/_generated b/apps/mobile/convex/_generated new file mode 120000 index 0000000..0fc6fb9 --- /dev/null +++ b/apps/mobile/convex/_generated @@ -0,0 +1 @@ +../../../convex/_generated \ No newline at end of file diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000..6b84e08 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,17 @@ +const path = require("path"); +const { getDefaultConfig } = require("expo/metro-config"); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, "../.."); +const convexRoot = path.resolve(monorepoRoot, "convex"); + +const config = getDefaultConfig(projectRoot); + +// Convex lives at monorepo root - Metro must watch it for symlink resolution +config.watchFolders = [...(config.watchFolders ?? []), convexRoot]; +config.resolver = { + ...config.resolver, + unstable_enableSymlinks: true, +}; + +module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..0e2b4bc --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,43 @@ +{ + "name": "mobile", + "private": true, + "main": "expo-router/entry", + "scripts": { + "dev": "expo start --clear", + "ios": "expo start --ios", + "android": "expo start --android", + "format": "oxfmt --write . '!convex/_generated'", + "check": "oxfmt --check . '!convex/_generated' && oxlint --ignore-pattern convex/_generated . && bun run typecheck", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "dependencies": { + "@clerk/expo": "^3.1.2", + "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "2.2.0", + "@repo/shared": "workspace:*", + "@tanstack/react-query": "^5.90.21", + "convex": "^1.33.1", + "expo": "^55.0.6", + "expo-auth-session": "^55.0.8", + "expo-build-properties": "^55.0.9", + "expo-linking": "^55.0.7", + "expo-router": "^55.0.5", + "expo-secure-store": "^55.0.8", + "expo-system-ui": "^55.0.9", + "expo-web-browser": "^55.0.9", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-native": "0.83.2", + "react-native-gesture-handler": "^2.30.0", + "react-native-reanimated": "4.2.1", + "react-native-safe-area-context": "^5.6.1", + "react-native-screens": "4.23.0", + "react-native-webview": "13.16.0" + }, + "devDependencies": { + "@types/react": "~19.2.10", + "babel-plugin-module-resolver": "^5.0.0", + "babel-preset-expo": "^55.0.11", + "typescript": "~5.9.2" + } +} diff --git a/apps/mobile/src/components/button.tsx b/apps/mobile/src/components/button.tsx new file mode 100644 index 0000000..2c817ce --- /dev/null +++ b/apps/mobile/src/components/button.tsx @@ -0,0 +1,71 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text, type PressableProps } from "react-native"; +import { useColors, type ThemeColors } from "@/theme"; + +type ButtonVariant = "primary" | "secondary" | "ghost" | "outline"; + +type ButtonProps = PressableProps & { + variant?: ButtonVariant; + label?: string; + loading?: boolean; + children?: React.ReactNode; +}; + +const variantStyles = (c: ThemeColors) => ({ + primary: { bg: c.primary, text: c.primaryForeground, border: c.primary }, + secondary: { bg: c.secondary, text: c.secondaryForeground, border: c.secondary }, + ghost: { bg: "transparent", text: c.foreground, border: "transparent" }, + outline: { bg: "transparent", text: c.foreground, border: c.border }, +}); + +export function Button({ + variant = "primary", + label, + loading, + disabled, + children, + style, + ...rest +}: ButtonProps) { + const colors = useColors(); + const v = variantStyles(colors)[variant]; + + return ( + + {loading ? ( + + ) : children ? ( + children + ) : label ? ( + {label} + ) : null} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + borderRadius: 18, + borderWidth: 1, + paddingHorizontal: 20, + height: 48, + }, + label: { + fontSize: 14, + fontWeight: "600", + }, +}); diff --git a/apps/mobile/src/components/input.tsx b/apps/mobile/src/components/input.tsx new file mode 100644 index 0000000..93591ca --- /dev/null +++ b/apps/mobile/src/components/input.tsx @@ -0,0 +1,32 @@ +import { StyleSheet, TextInput, type TextInputProps } from "react-native"; +import { useColors } from "@/theme"; + +export function Input(props: TextInputProps) { + const colors = useColors(); + + return ( + + ); +} + +const styles = StyleSheet.create({ + input: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 16, + height: 48, + fontSize: 15, + }, +}); diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx new file mode 100644 index 0000000..7d8e42a --- /dev/null +++ b/apps/mobile/src/components/sheet.tsx @@ -0,0 +1,50 @@ +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useColors } from "@/theme"; + +type SheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; +}; + +export function Sheet({ open, onOpenChange, children }: SheetProps) { + const colors = useColors(); + + return ( + onOpenChange(false)}> + onOpenChange(false)} /> + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.4)", + }, + container: { + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderTopWidth: 1, + paddingTop: 12, + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + alignSelf: "center", + opacity: 0.4, + }, + content: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 8, + }, +}); diff --git a/apps/mobile/src/components/source-card.tsx b/apps/mobile/src/components/source-card.tsx new file mode 100644 index 0000000..4a6ec87 --- /dev/null +++ b/apps/mobile/src/components/source-card.tsx @@ -0,0 +1,131 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useColors } from "@/theme"; +import type { FeedSubscription, StoredFeedItem } from "@repo/shared/feed/types"; + +type SourceCardProps = { + source: FeedSubscription; + items: StoredFeedItem[]; + itemCount: number; + onPress: () => void; + onRefresh: () => void; + onRemove: () => void; +}; + +export function SourceCard({ + source, + items, + itemCount, + onPress, + onRefresh, + onRemove, +}: SourceCardProps) { + const colors = useColors(); + + return ( + + + + {source.label} + + {source.siteUrl.replace(/^https?:\/\//, "")} + + + + + + + + + + + + + + {items.slice(0, 4).map((item) => ( + + + {item.title} + + + ))} + {items.length === 0 ? ( + No posts yet. + ) : null} + + + + + {itemCount} {itemCount === 1 ? "article" : "articles"} + + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 24, + borderWidth: 1, + padding: 16, + gap: 16, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + }, + headerText: { + flex: 1, + paddingRight: 12, + }, + title: { + fontSize: 18, + fontWeight: "600", + }, + url: { + fontSize: 12, + marginTop: 4, + }, + actions: { + flexDirection: "row", + gap: 8, + }, + iconBtn: { + width: 40, + height: 40, + alignItems: "center", + justifyContent: "center", + }, + items: { + gap: 8, + }, + itemRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: 8, + paddingBottom: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + itemTitle: { + flex: 1, + fontSize: 14, + lineHeight: 22, + }, + emptyText: { + fontSize: 14, + fontStyle: "italic", + }, + footer: { + flexDirection: "row", + justifyContent: "space-between", + }, + footerText: { + fontSize: 12, + }, +}); diff --git a/apps/mobile/src/lib/convex.ts b/apps/mobile/src/lib/convex.ts new file mode 100644 index 0000000..c61cc19 --- /dev/null +++ b/apps/mobile/src/lib/convex.ts @@ -0,0 +1,2 @@ +export { api } from "@convex/_generated/api"; +export type { Doc } from "@convex/_generated/dataModel"; diff --git a/apps/mobile/src/lib/preferences.ts b/apps/mobile/src/lib/preferences.ts new file mode 100644 index 0000000..729a908 --- /dev/null +++ b/apps/mobile/src/lib/preferences.ts @@ -0,0 +1,6 @@ +import type { ArticleViewMode } from "@repo/shared/feed/types"; + +export const defaultUserPreferences = { + pollingIntervalMinutes: 15, + defaultView: "reader" as ArticleViewMode, +}; diff --git a/apps/mobile/src/providers/feed-provider.tsx b/apps/mobile/src/providers/feed-provider.tsx new file mode 100644 index 0000000..698f39f --- /dev/null +++ b/apps/mobile/src/providers/feed-provider.tsx @@ -0,0 +1,375 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AppState } from "react-native"; +import { useAuth } from "@clerk/expo"; +import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { api, type Doc } from "@/lib/convex"; +import { defaultUserPreferences } from "@/lib/preferences"; +import { + applyLoadMoreSourceItems, + applySourceRefresh, + createEmptyLocalFeedCache, + getSourceItems, + markItemRead, + mergeSourceDiscovery, + reconcileLocalFeedCache, + removeSource, + setSourceError, +} from "@repo/shared/feed/cache"; +import { + discoverFeed, + loadMoreDiscoveredFeedItems, + refreshDiscoveredFeed, +} from "@repo/shared/feed/service"; +import { + localFeedCacheSchema, + type FeedSubscription, + type FeedItem, + type LocalFeedCache, +} from "@repo/shared/feed/types"; + +const createStorageKey = (userId: string) => `papertrail.mobile.feed-cache.${userId}`; + +type FeedContextValue = { + cache: LocalFeedCache; + isCacheReady: boolean; + isConvexAuthLoading: boolean; + subscriptions: FeedSubscription[]; + preferences: typeof defaultUserPreferences; + sourceSummaries: Array<{ + source: FeedSubscription; + items: FeedItem[]; + unreadCount: number; + newCount: number; + }>; + addFeed: (inputUrl: string) => Promise; + removeFeed: (sourceId: string) => Promise; + refreshSource: (sourceId: string) => Promise; + refreshAll: () => Promise; + loadMore: (sourceId: string) => Promise; + markRead: (sourceId: string, itemId: string) => void; + getSource: (sourceId: string) => FeedSubscription | undefined; + getSourceItems: (sourceId: string) => FeedItem[]; + getItem: (sourceId: string, itemId: string) => FeedItem | undefined; + ensureItem: (sourceId: string, itemId: string) => Promise; + updatePreferences: (values: typeof defaultUserPreferences) => Promise; +}; + +const FeedContext = createContext(null); + +export function FeedProvider({ children }: { children: React.ReactNode }) { + const { isSignedIn, userId } = useAuth(); + const { isAuthenticated, isLoading: isConvexAuthLoading } = useConvexAuth(); + const canRunAuthenticatedQueries = isSignedIn && isAuthenticated; + const subscriptions = (useQuery( + api.feedSubscriptions.queries.listForCurrentUser, + canRunAuthenticatedQueries ? {} : "skip", + ) ?? []) as Doc<"feedSubscriptions">[]; + const preferences = + useQuery(api.preferences.queries.getForCurrentUser, canRunAuthenticatedQueries ? {} : "skip") ?? + defaultUserPreferences; + const createSubscription = useMutation(api.feedSubscriptions.mutations.createForCurrentUser); + const removeSubscription = useMutation(api.feedSubscriptions.mutations.removeForCurrentUser); + const upsertPreferences = useMutation(api.preferences.mutations.upsertForCurrentUser); + const [cache, setCache] = useState(createEmptyLocalFeedCache()); + const [isCacheReady, setIsCacheReady] = useState(false); + const cacheRef = useRef(cache); + const subscriptionIds = useMemo( + () => subscriptions.map((subscription) => subscription.sourceId), + [subscriptions], + ); + + cacheRef.current = cache; + + useEffect(() => { + if (!userId) { + setCache(createEmptyLocalFeedCache()); + setIsCacheReady(true); + return; + } + + let cancelled = false; + + setIsCacheReady(false); + + void (async () => { + try { + const rawValue = await AsyncStorage.getItem(createStorageKey(userId)); + + if (cancelled) { + return; + } + + if (!rawValue) { + setCache(createEmptyLocalFeedCache()); + return; + } + + const parsed = localFeedCacheSchema.safeParse(JSON.parse(rawValue)); + + if (cancelled) { + return; + } + + setCache(parsed.success ? parsed.data : createEmptyLocalFeedCache()); + } catch { + if (cancelled) { + return; + } + + setCache(createEmptyLocalFeedCache()); + } finally { + if (!cancelled) { + setIsCacheReady(true); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [userId]); + + useEffect(() => { + if (!userId || !isCacheReady) { + return; + } + + void AsyncStorage.setItem(createStorageKey(userId), JSON.stringify(cache)); + }, [cache, isCacheReady, userId]); + + useEffect(() => { + setCache((current) => reconcileLocalFeedCache(current, subscriptionIds)); + }, [subscriptionIds]); + + useEffect(() => { + if (!isSignedIn || !isCacheReady || subscriptions.length === 0) { + return; + } + + let interval: ReturnType | undefined; + + const start = () => { + interval = setInterval(() => { + void Promise.allSettled( + subscriptions.map((subscription) => refreshSourceById(subscription.sourceId)), + ); + }, preferences.pollingIntervalMinutes * 60_000); + }; + + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active" && !interval) { + start(); + return; + } + + if (state !== "active" && interval) { + clearInterval(interval); + interval = undefined; + } + }); + + start(); + + return () => { + subscription.remove(); + if (interval) { + clearInterval(interval); + } + }; + }, [isCacheReady, isSignedIn, preferences.pollingIntervalMinutes, subscriptions]); + + const refreshSourceById = useCallback( + async (sourceId: string) => { + const source = subscriptions.find((subscription) => subscription.sourceId === sourceId); + + if (!source) { + return; + } + + try { + const result = await refreshDiscoveredFeed({ + source: { + sourceId: source.sourceId, + feedUrl: source.feedUrl, + }, + seenItemIds: cacheRef.current.sources[sourceId]?.seenItemIds ?? [], + }); + + setCache((current) => applySourceRefresh(current, result)); + } catch (error) { + setCache((current) => + setSourceError( + current, + sourceId, + error instanceof Error ? error.message : "This source could not be refreshed.", + ), + ); + } + }, + [subscriptions], + ); + + const addFeed = useCallback( + async (inputUrl: string) => { + if (!canRunAuthenticatedQueries) { + throw new Error("Not authenticated."); + } + + const discovery = await discoverFeed(inputUrl); + + await createSubscription(discovery.source); + setCache((current) => mergeSourceDiscovery(current, discovery)); + + return discovery.source.sourceId; + }, + [canRunAuthenticatedQueries, createSubscription], + ); + + const removeFeedById = useCallback( + async (sourceId: string) => { + if (!canRunAuthenticatedQueries) { + throw new Error("Not authenticated."); + } + + await removeSubscription({ sourceId }); + setCache((current) => removeSource(current, sourceId)); + }, + [canRunAuthenticatedQueries, removeSubscription], + ); + + const refreshAll = useCallback(async () => { + await Promise.all( + subscriptions.map((subscription) => refreshSourceById(subscription.sourceId)), + ); + }, [refreshSourceById, subscriptions]); + + const loadMore = useCallback(async (sourceId: string) => { + const pageUrl = cacheRef.current.sources[sourceId]?.pagination?.nextPageUrl; + + if (!pageUrl) { + return; + } + + const result = await loadMoreDiscoveredFeedItems({ sourceId, pageUrl }); + + setCache((current) => applyLoadMoreSourceItems(current, result)); + }, []); + + const markRead = useCallback((sourceId: string, itemId: string) => { + setCache((current) => markItemRead(current, sourceId, itemId)); + }, []); + + const getSource = useCallback( + (sourceId: string) => subscriptions.find((subscription) => subscription.sourceId === sourceId), + [subscriptions], + ); + + const getSourceItemsById = useCallback( + (sourceId: string) => getSourceItems(cacheRef.current, sourceId), + [], + ); + + const getItem = useCallback( + (sourceId: string, itemId: string) => + getSourceItems(cacheRef.current, sourceId).find((item) => item.id === itemId), + [], + ); + + const ensureItem = useCallback( + async (sourceId: string, itemId: string) => { + const existing = getSourceItems(cacheRef.current, sourceId).find( + (item) => item.id === itemId, + ); + + if (existing) { + return existing; + } + + await refreshSourceById(sourceId); + + return getSourceItems(cacheRef.current, sourceId).find((item) => item.id === itemId); + }, + [refreshSourceById], + ); + + const updatePreferences = useCallback( + async (values: typeof defaultUserPreferences) => { + if (!canRunAuthenticatedQueries) { + throw new Error("Not authenticated."); + } + + await upsertPreferences(values); + }, + [canRunAuthenticatedQueries, upsertPreferences], + ); + + const value = useMemo( + () => ({ + cache, + isCacheReady, + isConvexAuthLoading, + subscriptions, + preferences, + sourceSummaries: subscriptions.map((source) => { + const items = getSourceItems(cache, source.sourceId); + + return { + source, + items, + unreadCount: items.filter((item) => !item.isRead).length, + newCount: items.filter((item) => item.isNew).length, + }; + }), + addFeed, + removeFeed: removeFeedById, + refreshSource: refreshSourceById, + refreshAll, + loadMore, + markRead, + getSource, + getSourceItems: getSourceItemsById, + getItem, + ensureItem, + updatePreferences, + }), + [ + addFeed, + cache, + ensureItem, + getItem, + getSource, + getSourceItemsById, + isCacheReady, + isConvexAuthLoading, + loadMore, + markRead, + preferences, + refreshAll, + refreshSourceById, + removeFeedById, + subscriptions, + updatePreferences, + ], + ); + + return {children}; +} + +export const useFeedData = () => { + const value = useContext(FeedContext); + + if (!value) { + throw new Error("useFeedData must be used within FeedProvider."); + } + + return value; +}; diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts new file mode 100644 index 0000000..01600ae --- /dev/null +++ b/apps/mobile/src/theme.ts @@ -0,0 +1,63 @@ +import { useColorScheme } from "react-native"; + +export const THEME = { + light: { + background: "rgb(255, 255, 255)", + foreground: "rgb(24, 24, 27)", + card: "rgb(255, 255, 255)", + cardForeground: "rgb(24, 24, 27)", + primary: "rgb(91, 70, 255)", + primaryForeground: "rgb(250, 250, 255)", + secondary: "rgb(244, 244, 245)", + secondaryForeground: "rgb(39, 39, 42)", + muted: "rgb(244, 244, 245)", + mutedForeground: "rgb(113, 113, 122)", + accent: "rgb(240, 235, 255)", + accentForeground: "rgb(76, 29, 149)", + destructive: "rgb(220, 38, 38)", + destructiveForeground: "rgb(254, 242, 242)", + border: "rgb(228, 228, 231)", + input: "rgb(228, 228, 231)", + ring: "rgb(167, 139, 250)", + }, + dark: { + background: "rgb(10, 10, 10)", + foreground: "rgb(250, 250, 250)", + card: "rgb(17, 17, 19)", + cardForeground: "rgb(250, 250, 250)", + primary: "rgb(196, 181, 253)", + primaryForeground: "rgb(34, 25, 68)", + secondary: "rgb(39, 39, 42)", + secondaryForeground: "rgb(250, 250, 250)", + muted: "rgb(39, 39, 42)", + mutedForeground: "rgb(161, 161, 170)", + accent: "rgb(49, 46, 129)", + accentForeground: "rgb(238, 242, 255)", + destructive: "rgb(248, 113, 113)", + destructiveForeground: "rgb(69, 10, 10)", + border: "rgb(39, 39, 42)", + input: "rgb(39, 39, 42)", + ring: "rgb(165, 180, 252)", + }, +} as const; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 20, + xl: 24, + xxl: 32, +} as const; + +export const radii = { + sm: 8, + md: 12, + lg: 18, + xl: 24, + xxl: 28, +} as const; + +export type ThemeColors = { [K in keyof (typeof THEME)["light"]]: string }; + +export const useColors = () => THEME[useColorScheme() === "dark" ? "dark" : "light"]; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..374a78f --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "expo/tsconfig.base", + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ], + "compilerOptions": { + "strict": true, + "moduleResolution": "Bundler", + "allowJs": false, + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + "@convex/*": ["./convex/*"] + } + } +} diff --git a/apps/web/.gitignore b/apps/web/.gitignore index bca4afa..4119217 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -13,3 +13,6 @@ count.txt .test-artifacts __unconfig* todos.json + +.env.local +.env.production \ No newline at end of file diff --git a/apps/web/convex/README.md b/apps/web/convex/README.md index 3c93873..b7ab1d2 100644 --- a/apps/web/convex/README.md +++ b/apps/web/convex/README.md @@ -1,88 +1,7 @@ -# Welcome to your Convex functions directory! +# Convex linkage -Write your Convex functions here. -See https://docs.convex.dev/functions for more. +This app reads generated Convex client types from the root `/convex` workspace. -A query function that takes two arguments looks like: - -```ts -// convex/myFunctions.ts -import { query } from "./_generated/server"; -import { v } from "convex/values"; - -export const myQueryFunction = query({ - // Validators for arguments. - args: { - first: v.number(), - second: v.string(), - }, - - // Function implementation. - handler: async (ctx, args) => { - // Read the database as many times as you need here. - // See https://docs.convex.dev/database/reading-data. - const documents = await ctx.db.query("tablename").collect(); - - // Arguments passed from the client are properties of the args object. - console.log(args.first, args.second); - - // Write arbitrary JavaScript here: filter, aggregate, build derived data, - // remove non-public properties, or create new objects. - return documents; - }, -}); -``` - -Using this query function in a React component looks like: - -```ts -const data = useQuery(api.myFunctions.myQueryFunction, { - first: 10, - second: "hello", -}); -``` - -A mutation function looks like: - -```ts -// convex/myFunctions.ts -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const myMutationFunction = mutation({ - // Validators for arguments. - args: { - first: v.string(), - second: v.string(), - }, - - // Function implementation. - handler: async (ctx, args) => { - // Insert or modify documents in the database here. - // Mutations can also read from the database like queries. - // See https://docs.convex.dev/database/writing-data. - const message = { body: args.first, author: args.second }; - const id = await ctx.db.insert("messages", message); - - // Optionally, return a value from your mutation. - return await ctx.db.get("messages", id); - }, -}); -``` - -Using this mutation function in a React component looks like: - -```ts -const mutation = useMutation(api.myFunctions.myMutationFunction); -function handleButtonPress() { - // fire and forget, the most common way to use mutations - mutation({ first: "Hello!", second: "me" }); - // OR - // use the result once the mutation has completed - mutation({ first: "Hello!", second: "me" }).then((result) => console.log(result)); -} -``` - -Use the Convex CLI to push your functions to a deployment. See everything -the Convex CLI can do by running `npx convex -h` in your project root -directory. To learn more, launch the docs with `npx convex docs`. +- Server functions live in `/convex`. +- `apps/web/convex/_generated` is linked to `/convex/_generated`. +- Web client imports should go through `src/lib/convex.ts`. diff --git a/apps/web/convex/_generated b/apps/web/convex/_generated new file mode 120000 index 0000000..0fc6fb9 --- /dev/null +++ b/apps/web/convex/_generated @@ -0,0 +1 @@ +../../../convex/_generated \ No newline at end of file diff --git a/apps/web/convex/feedSubscriptions/mutations.ts b/apps/web/convex/feedSubscriptions/mutations.ts deleted file mode 100644 index 101f8bf..0000000 --- a/apps/web/convex/feedSubscriptions/mutations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { v } from "convex/values"; -import { mutation } from "../_generated/server"; -import { requireCurrentUser } from "../lib/auth"; - -export const addForCurrentUser = mutation({ - args: { - sourceId: v.string(), - label: v.string(), - inputUrl: v.string(), - siteUrl: v.string(), - feedUrl: v.string(), - pollingEnabled: v.boolean(), - pollIntervalMs: v.number(), - }, - handler: async (ctx, args) => { - const identity = await requireCurrentUser(ctx); - - const existing = await ctx.db - .query("feedSubscriptions") - .withIndex("by_userId_sourceId", (q) => - q.eq("userId", identity.subject).eq("sourceId", args.sourceId), - ) - .unique(); - - if (existing) { - const now = Date.now(); - await ctx.db.patch(existing._id, { - label: args.label, - inputUrl: args.inputUrl, - siteUrl: args.siteUrl, - feedUrl: args.feedUrl, - pollingEnabled: args.pollingEnabled, - pollIntervalMs: args.pollIntervalMs, - updatedAt: now, - }); - return; - } - - const now = Date.now(); - await ctx.db.insert("feedSubscriptions", { - userId: identity.subject, - sourceId: args.sourceId, - label: args.label, - inputUrl: args.inputUrl, - siteUrl: args.siteUrl, - feedUrl: args.feedUrl, - pollingEnabled: args.pollingEnabled, - pollIntervalMs: args.pollIntervalMs, - createdAt: now, - updatedAt: now, - }); - }, -}); - -export const removeForCurrentUser = mutation({ - args: { sourceId: v.string() }, - handler: async (ctx, args) => { - const identity = await requireCurrentUser(ctx); - const existing = await ctx.db - .query("feedSubscriptions") - .withIndex("by_userId_sourceId", (q) => - q.eq("userId", identity.subject).eq("sourceId", args.sourceId), - ) - .unique(); - - if (existing) { - await ctx.db.delete(existing._id); - } - }, -}); diff --git a/apps/web/convex/feedSubscriptions/queries.ts b/apps/web/convex/feedSubscriptions/queries.ts deleted file mode 100644 index 0aa0984..0000000 --- a/apps/web/convex/feedSubscriptions/queries.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { query } from "../_generated/server"; -import { requireCurrentUser } from "../lib/auth"; - -export const listForCurrentUser = query({ - args: {}, - handler: async (ctx) => { - const identity = await requireCurrentUser(ctx); - const subscriptions = await ctx.db - .query("feedSubscriptions") - .withIndex("by_userId", (q) => q.eq("userId", identity.subject)) - .collect(); - - return subscriptions.map((sub) => ({ - id: sub.sourceId, - label: sub.label, - inputUrl: sub.inputUrl, - siteUrl: sub.siteUrl, - feedUrl: sub.feedUrl, - pollingEnabled: sub.pollingEnabled, - pollIntervalMs: sub.pollIntervalMs, - })); - }, -}); diff --git a/apps/web/package.json b/apps/web/package.json index 595e95b..2d8a590 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,6 @@ "private": true, "type": "module", "scripts": { - "convex": "bunx convex dev", "dev": "vite dev --port 3000", "build": "vite build", "preview": "vite preview", @@ -19,6 +18,7 @@ "@fontsource-variable/geist": "^5.2.8", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@repo/shared": "workspace:*", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.167.0", diff --git a/apps/web/src/components/feed-reader/feed-card.tsx b/apps/web/src/components/feed-reader/feed-card.tsx index 638a7bd..34ac7e1 100644 --- a/apps/web/src/components/feed-reader/feed-card.tsx +++ b/apps/web/src/components/feed-reader/feed-card.tsx @@ -26,13 +26,13 @@ export function FeedCard({ const latestItems = items.slice(0, 4); return ( -
+
e.key === "Enter" && onSelect()} className={cn( - "h-full cursor-pointer gap-0 py-0 transition-colors duration-150", + "h-full cursor-pointer gap-0 py-0 pr-12 transition-colors duration-150 sm:pr-14 md:pr-0", isSelected ? "ring-primary/40 bg-primary/[0.02]" : "md:hover:bg-muted/30", )} > @@ -66,8 +66,8 @@ export function FeedCard({ {/* Footer — source info */} - -
+ +
{source.label} {source.siteUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")} @@ -88,7 +88,7 @@ export function FeedCard({ {/* Actions: always visible on mobile, hover-only on desktop */} -
+
- +

{displayName}

{email ? ( @@ -243,33 +244,37 @@ export function AppNavbar({ }: AppNavbarProps) { return (
-
-
+
+
oop
-
+
{isSignedIn ? ( - ) : ( )} {onToggleAddFeed ? ( - )} @@ -308,6 +318,16 @@ export function FeedReaderBootScreen() { ); } +function SignInRequiredScreen({ onSignIn }: { onSignIn: () => void }) { + return ( +
+ +
+ ); +} + function EmptyFeedState({ isMobile }: { isMobile: boolean }) { return (
@@ -320,9 +340,9 @@ function EmptyFeedState({ isMobile }: { isMobile: boolean }) { { x: -240, y: 96, rotate: 3 }, { x: 252, y: 88, rotate: -4 }, ] as { x: number; y: number; rotate: number }[] - ).map((tile, index) => ( + ).map((tile) => (
(null); @@ -384,7 +405,9 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) selectedItem, articleViewMode, preferences, + effectivePollingIntervalMs, isPreferencesPending, + isAuthLoading, isSignedIn, isBookmarked, isBookmarkPending, @@ -429,7 +452,7 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) }; const handleBookmarksClick = () => { - if (isSignedIn) { + if (hasClerkSession) { void navigate({ to: "/bookmarks" }); return; } @@ -442,7 +465,16 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) }, []); useEffect(() => { - if (!isClientReady || authIntent !== "sign-in") { + if (!isClientReady || !isClerkLoaded || authIntent !== "sign-in") { + return; + } + + if (hasClerkSession) { + void navigate({ + to: "/", + search: {}, + replace: true, + }); return; } @@ -462,7 +494,7 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) search: {}, replace: true, }); - }, [authIntent, authRedirect, clerk, isClientReady, navigate]); + }, [authIntent, authRedirect, clerk, hasClerkSession, isClerkLoaded, isClientReady, navigate]); useEffect(() => { if (isMobile) { @@ -489,7 +521,11 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) wasDetailPanelOpenRef.current = detailPanelOpen; }, [detailPanel, detailPanelOpen, detailPanelSize, isMobile, setDetailPanelSize]); - if (shouldShowFeedReaderBootScreen(isClientReady)) { + if ( + shouldShowFeedReaderBootScreen(isClientReady) || + !isClerkLoaded || + (hasClerkSession && isAuthLoading) + ) { return ; } @@ -550,24 +586,30 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) ); + if (!hasClerkSession) { + return void openSignInModal("/")} />; + } + return ( <> - {state.sources.map((source) => ( + {sourceSummaries.map(({ source }) => ( handleSourceRefresh(result, source.id)} - onError={(message) => handleSourceError(source.id, message)} + pollingIntervalMs={effectivePollingIntervalMs} + lastCheckedAt={state.sources[source.sourceId]?.lastCheckedAt} + onRefresh={(result) => handleSourceRefresh(result, source.sourceId)} + onError={(message) => handleSourceError(source.sourceId, message)} /> ))} -
+
diff --git a/apps/web/src/components/feed-reader/item-list.tsx b/apps/web/src/components/feed-reader/item-list.tsx index f5907e5..3a91c66 100644 --- a/apps/web/src/components/feed-reader/item-list.tsx +++ b/apps/web/src/components/feed-reader/item-list.tsx @@ -51,19 +51,37 @@ export function ItemList({ isSignedIn = false, isBookmarkPending = false, }: ItemListProps) { + const sourceHost = source?.siteUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); const unreadCount = items.filter((item) => !item.isRead).length; const readCount = items.length - unreadCount; const hasContextMenuActions = (onMarkUnread || onBookmarkItem) && source; return (
-
-
- {unreadCount} unread - {readCount} read +
+
+ {source ? ( +
+

{source.label}

+ {sourceHost ? ( +

{sourceHost}

+ ) : null} +
+ ) : null} +
+ {unreadCount} unread + {readCount} read +
{onClose && ( - )} diff --git a/apps/web/src/components/feed-reader/reader-pane.tsx b/apps/web/src/components/feed-reader/reader-pane.tsx index a84aad8..bff93b8 100644 --- a/apps/web/src/components/feed-reader/reader-pane.tsx +++ b/apps/web/src/components/feed-reader/reader-pane.tsx @@ -111,15 +111,27 @@ export function ReaderPane({ }; const modeToggle = ( onArticleViewModeChange(value as ArticleViewMode)} > - - + + - + @@ -127,30 +139,32 @@ export function ReaderPane({ ); const topNav = ( -
-
-
- {onBack ? ( - - ) : ( -
- )} -
-
+
+ ); @@ -308,6 +328,7 @@ export function ReaderPane({
diff --git a/apps/web/src/components/feed-reader/source-form.tsx b/apps/web/src/components/feed-reader/source-form.tsx index c1f12ff..562c67f 100644 --- a/apps/web/src/components/feed-reader/source-form.tsx +++ b/apps/web/src/components/feed-reader/source-form.tsx @@ -29,47 +29,51 @@ export function SourceForm({ }; return ( -
+ onChange(e.target.value)} placeholder="https://example.com/feed.xml or https://example.com/atom.xml" - className="focus:border-primary/50" + className="h-10 text-sm focus:border-primary/50 md:h-9" // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus /> {error ?

{error}

: null} -
- - {onCancel && ( +
+
- )} + {onCancel && ( + + )} +
diff --git a/apps/web/src/components/feed-reader/source-grid.tsx b/apps/web/src/components/feed-reader/source-grid.tsx index d4c42c0..a62802d 100644 --- a/apps/web/src/components/feed-reader/source-grid.tsx +++ b/apps/web/src/components/feed-reader/source-grid.tsx @@ -109,14 +109,14 @@ export function SourceGrid({
{sourceSummaries.map(({ source, items, unreadCount, newCount }) => ( onOpenFeed(source.id)} - onRemove={() => onRemoveSource(source.id)} + isSelected={selectedSourceId === source.sourceId} + onSelect={() => onOpenFeed(source.sourceId)} + onRemove={() => onRemoveSource(source.sourceId)} /> ))}
@@ -150,14 +150,14 @@ export function SourceGrid({ > {rows[virtualRow.index]?.map(({ source, items, unreadCount, newCount }) => ( onOpenFeed(source.id)} - onRemove={() => onRemoveSource(source.id)} + isSelected={selectedSourceId === source.sourceId} + onSelect={() => onOpenFeed(source.sourceId)} + onRemove={() => onRemoveSource(source.sourceId)} /> ))}
diff --git a/apps/web/src/components/feed-reader/source-list.tsx b/apps/web/src/components/feed-reader/source-list.tsx index f6f2075..8c2b16d 100644 --- a/apps/web/src/components/feed-reader/source-list.tsx +++ b/apps/web/src/components/feed-reader/source-list.tsx @@ -15,7 +15,6 @@ type SourceListProps = { selectedSourceId: string | null; refreshingSourceIds: string[]; onSelect: (sourceId: string) => void; - onTogglePolling: (sourceId: string, enabled: boolean) => void; onRefresh: (sourceId: string) => void; onRemove: (sourceId: string) => void; }; @@ -25,7 +24,6 @@ export function SourceList({ selectedSourceId, refreshingSourceIds, onSelect, - onTogglePolling, onRefresh, onRemove, }: SourceListProps) { @@ -42,14 +40,17 @@ export function SourceList({ return (