From 78072df64514c20309c19902b5c348f0b5aac24f Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:14:07 +0530 Subject: [PATCH 1/8] Add Expo mobile feed reader with shared Convex feed services - scaffold `apps/mobile` with Expo Router, Clerk auth, tabs, feeds, bookmarks, and article reader - extract feed parsing/content/cache logic into new `packages/shared` - move Convex server code to workspace root and update web app to use shared feed + subscription APIs --- apps/mobile/.expo/README.md | 13 + apps/mobile/.expo/types/router.d.ts | 73 + apps/mobile/.gitignore | 6 + apps/mobile/app.json | 21 + apps/mobile/app/(auth)/sign-in.tsx | 62 + apps/mobile/app/(tabs)/_layout.tsx | 172 ++ apps/mobile/app/(tabs)/bookmarks.tsx | 74 + apps/mobile/app/(tabs)/index.tsx | 41 + apps/mobile/app/_layout.tsx | 58 + .../app/article/[sourceId]/[itemId].tsx | 179 ++ apps/mobile/app/feed/[sourceId].tsx | 68 + apps/mobile/babel.config.js | 8 + apps/mobile/convex/README.md | 7 + apps/mobile/convex/_generated | 1 + apps/mobile/global.css | 16 + apps/mobile/global.d.ts | 2 + apps/mobile/metro.config.js | 6 + apps/mobile/nativewind-env.d.ts | 1 + apps/mobile/package.json | 47 + apps/mobile/postcss.config.mjs | 5 + apps/mobile/src/components/source-card.tsx | 79 + apps/mobile/src/components/ui/button.tsx | 90 + apps/mobile/src/components/ui/card.tsx | 11 + apps/mobile/src/components/ui/input.tsx | 15 + apps/mobile/src/components/ui/sheet.tsx | 37 + apps/mobile/src/components/ui/text.tsx | 6 + apps/mobile/src/lib/auth.ts | 11 + apps/mobile/src/lib/convex.ts | 2 + apps/mobile/src/lib/preferences.ts | 6 + apps/mobile/src/lib/utils.ts | 4 + apps/mobile/src/providers/feed-provider.tsx | 280 +++ apps/mobile/tsconfig.json | 20 + apps/web/.gitignore | 3 + apps/web/convex/README.md | 91 +- apps/web/convex/_generated | 1 + apps/web/package.json | 2 +- .../src/components/feed-reader/feed-card.tsx | 10 +- .../feed-reader/feed-reader-app.tsx | 88 +- .../src/components/feed-reader/item-list.tsx | 29 +- .../components/feed-reader/reader-pane.tsx | 185 +- .../components/feed-reader/source-form.tsx | 45 +- .../components/feed-reader/source-grid.tsx | 16 +- .../components/feed-reader/source-list.tsx | 42 +- .../feed-reader/source-sync-controller.tsx | 26 +- apps/web/src/components/feed-reader/store.ts | 134 +- .../components/feed-reader/use-feed-reader.ts | 166 +- apps/web/src/lib/convex.ts | 2 +- apps/web/src/lib/feed-reader-state.ts | 312 +-- apps/web/src/lib/feed/content.ts | 183 +- apps/web/src/lib/feed/parser.ts | 356 +--- apps/web/src/lib/feed/utils.ts | 149 +- apps/web/src/lib/server/feed.ts | 163 +- apps/web/src/lib/types.ts | 198 +- .../src/routes/_authenticated/bookmarks.tsx | 11 +- bun.lock | 1676 ++++++++++++++--- .../web/convex => convex}/_generated/api.d.ts | 4 + {apps/web/convex => convex}/_generated/api.js | 0 .../_generated/dataModel.d.ts | 0 .../convex => convex}/_generated/server.d.ts | 0 .../convex => convex}/_generated/server.js | 0 {apps/web/convex => convex}/auth.config.ts | 0 .../convex => convex}/bookmarks/mutations.ts | 4 + .../convex => convex}/bookmarks/queries.ts | 0 convex/feedSubscriptions/mutations.ts | 108 ++ convex/feedSubscriptions/queries.ts | 17 + {apps/web/convex => convex}/lib/auth.ts | 0 .../preferences/mutations.ts | 4 - .../convex => convex}/preferences/queries.ts | 0 {apps/web/convex => convex}/schema.ts | 14 + {apps/web/convex => convex}/tsconfig.json | 0 package.json | 15 +- packages/shared/package.json | 28 + packages/shared/src/feed/cache.ts | 343 ++++ packages/shared/src/feed/content.ts | 182 ++ packages/shared/src/feed/parser.ts | 355 ++++ packages/shared/src/feed/service.ts | 137 ++ packages/shared/src/feed/types.ts | 191 ++ packages/shared/src/feed/utils.ts | 135 ++ packages/shared/tsconfig.json | 13 + turbo.json | 2 +- 80 files changed, 4991 insertions(+), 1870 deletions(-) create mode 100644 apps/mobile/.expo/README.md create mode 100644 apps/mobile/.expo/types/router.d.ts create mode 100644 apps/mobile/.gitignore create mode 100644 apps/mobile/app.json create mode 100644 apps/mobile/app/(auth)/sign-in.tsx create mode 100644 apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/bookmarks.tsx create mode 100644 apps/mobile/app/(tabs)/index.tsx create mode 100644 apps/mobile/app/_layout.tsx create mode 100644 apps/mobile/app/article/[sourceId]/[itemId].tsx create mode 100644 apps/mobile/app/feed/[sourceId].tsx create mode 100644 apps/mobile/babel.config.js create mode 100644 apps/mobile/convex/README.md create mode 120000 apps/mobile/convex/_generated create mode 100644 apps/mobile/global.css create mode 100644 apps/mobile/global.d.ts create mode 100644 apps/mobile/metro.config.js create mode 100644 apps/mobile/nativewind-env.d.ts create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/postcss.config.mjs create mode 100644 apps/mobile/src/components/source-card.tsx create mode 100644 apps/mobile/src/components/ui/button.tsx create mode 100644 apps/mobile/src/components/ui/card.tsx create mode 100644 apps/mobile/src/components/ui/input.tsx create mode 100644 apps/mobile/src/components/ui/sheet.tsx create mode 100644 apps/mobile/src/components/ui/text.tsx create mode 100644 apps/mobile/src/lib/auth.ts create mode 100644 apps/mobile/src/lib/convex.ts create mode 100644 apps/mobile/src/lib/preferences.ts create mode 100644 apps/mobile/src/lib/utils.ts create mode 100644 apps/mobile/src/providers/feed-provider.tsx create mode 100644 apps/mobile/tsconfig.json create mode 120000 apps/web/convex/_generated rename {apps/web/convex => convex}/_generated/api.d.ts (82%) rename {apps/web/convex => convex}/_generated/api.js (100%) rename {apps/web/convex => convex}/_generated/dataModel.d.ts (100%) rename {apps/web/convex => convex}/_generated/server.d.ts (100%) rename {apps/web/convex => convex}/_generated/server.js (100%) rename {apps/web/convex => convex}/auth.config.ts (100%) rename {apps/web/convex => convex}/bookmarks/mutations.ts (90%) rename {apps/web/convex => convex}/bookmarks/queries.ts (100%) create mode 100644 convex/feedSubscriptions/mutations.ts create mode 100644 convex/feedSubscriptions/queries.ts rename {apps/web/convex => convex}/lib/auth.ts (100%) rename {apps/web/convex => convex}/preferences/mutations.ts (92%) rename {apps/web/convex => convex}/preferences/queries.ts (100%) rename {apps/web/convex => convex}/schema.ts (66%) rename {apps/web/convex => convex}/tsconfig.json (100%) create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/feed/cache.ts create mode 100644 packages/shared/src/feed/content.ts create mode 100644 packages/shared/src/feed/parser.ts create mode 100644 packages/shared/src/feed/service.ts create mode 100644 packages/shared/src/feed/types.ts create mode 100644 packages/shared/src/feed/utils.ts create mode 100644 packages/shared/tsconfig.json 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/types/router.d.ts b/apps/mobile/.expo/types/router.d.ts new file mode 100644 index 0000000..eea4539 --- /dev/null +++ b/apps/mobile/.expo/types/router.d.ts @@ -0,0 +1,73 @@ +/* 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: `/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: `/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}` | ""}` + | { 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 } + | `/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..2c85508 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,21 @@ +{ + "expo": { + "name": "oop", + "slug": "oop-mobile", + "scheme": "oop-mobile", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "plugins": ["expo-router", "expo-secure-store"], + "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..a79efd8 --- /dev/null +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { View } from "react-native"; +import { Redirect } from "expo-router"; +import { GoogleLogo } from "phosphor-react-native"; +import { makeRedirectUri } from "expo-auth-session"; +import { useAuth, useOAuth } from "@clerk/expo"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; + +export default function SignInScreen() { + const { isSignedIn } = useAuth(); + const { startOAuthFlow } = useOAuth({ strategy: "oauth_google" }); + 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 startOAuthFlow({ + 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 ( + + + + Mobile Reader + + + Sign in with Google to load your feeds. + + + Subscriptions, bookmarks, and preferences sync through Convex. Articles and read state + stay local on this device. + + + {error ? {error} : null} + + + ); +} diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..8284485 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { View } from "react-native"; +import { Redirect, Tabs, router } from "expo-router"; +import { BookmarkSimple, House, Plus, SlidersHorizontal, SignOut } from "phosphor-react-native"; +import { useAuth, useClerk } from "@clerk/expo"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Sheet } from "@/components/ui/sheet"; +import { Text } from "@/components/ui/text"; +import { useFeedData } from "@/providers/feed-provider"; +import { normalizeInputUrl } from "@repo/shared/feed/utils"; + +export default function TabsLayout() { + const { isSignedIn } = useAuth(); + const clerk = useClerk(); + const insets = useSafeAreaInsets(); + const { addFeed, preferences, updatePreferences } = useFeedData(); + const [isAddOpen, setIsAddOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!isSignedIn) { + return ; + } + + const handleAddFeed = async () => { + setError(null); + setIsSubmitting(true); + + try { + const sourceId = await addFeed(normalizeInputUrl(url)); + setUrl(""); + setIsAddOpen(false); + router.push(`/feed/${sourceId}`); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Could not add feed."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + , + headerRight: () => ( + + ), + }} + /> + ( + + ), + }} + /> + + + + + + + + Add feed + + Paste a direct RSS or Atom feed URL. + + + {error ? {error} : null} + + + + ); +} diff --git a/apps/mobile/app/(tabs)/bookmarks.tsx b/apps/mobile/app/(tabs)/bookmarks.tsx new file mode 100644 index 0000000..2062d5f --- /dev/null +++ b/apps/mobile/app/(tabs)/bookmarks.tsx @@ -0,0 +1,74 @@ +import { Image, Pressable, ScrollView, View } from "react-native"; +import { router } from "expo-router"; +import { useQuery } from "convex/react"; +import { BookmarkSimple } from "phosphor-react-native"; +import { Text } from "@/components/ui/text"; +import { api, type Doc } from "@/lib/convex"; +import { createFeedItemId, createSourceId } from "@repo/shared/feed/utils"; + +export default function BookmarksScreen() { + const bookmarks = (useQuery(api.bookmarks.queries.listForCurrentUser, {}) ?? + []) as Doc<"bookmarks">[]; + + return ( + + {bookmarks.length === 0 ? ( + + + No bookmarks yet. + + Save articles from the reader and they will appear here on every device. + + + ) : ( + + {bookmarks.map((bookmark) => { + const sourceId = + bookmark.sourceId ?? createSourceId(bookmark.sourceSiteUrl ?? bookmark.url); + const itemId = + bookmark.itemId ?? + createFeedItemId(sourceId, [bookmark.url, bookmark.title, bookmark.publishedAt]); + + return ( + + router.push({ + pathname: "/article/[sourceId]/[itemId]", + params: { + sourceId, + itemId, + url: bookmark.url, + title: bookmark.title, + excerpt: bookmark.excerpt ?? "", + imageUrl: bookmark.imageUrl ?? "", + sourceLabel: bookmark.sourceLabel ?? "", + sourceSiteUrl: bookmark.sourceSiteUrl ?? "", + publishedAt: bookmark.publishedAt ?? "", + }, + }) + } + > + {bookmark.imageUrl ? ( + + ) : null} + {bookmark.title} + {bookmark.sourceLabel ? ( + {bookmark.sourceLabel} + ) : null} + + ); + })} + + )} + + ); +} diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..1e43281 --- /dev/null +++ b/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,41 @@ +import { RefreshControl, ScrollView, View } from "react-native"; +import { router } from "expo-router"; +import { useFeedData } from "@/providers/feed-provider"; +import { SourceCard } from "@/components/source-card"; +import { Text } from "@/components/ui/text"; + +export default function HomeScreen() { + const { sourceSummaries, refreshAll, refreshSource, removeFeed } = useFeedData(); + + return ( + void refreshAll()} />} + > + {sourceSummaries.length === 0 ? ( + + No subscriptions yet. + + Add a feed from the floating plus button. Subscriptions sync across web and mobile. + + + ) : ( + + {sourceSummaries.map((summary) => ( + router.push(`/feed/${summary.source.sourceId}`)} + onRefresh={() => void refreshSource(summary.source.sourceId)} + onRemove={() => void removeFeed(summary.source.sourceId)} + /> + ))} + + )} + + ); +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000..9c5ac69 --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,58 @@ +import "react-native-url-polyfill/auto"; +import "react-native-reanimated"; +import "../global.css"; + +import { Stack } from "expo-router"; +import * as WebBrowser from "expo-web-browser"; +import { ClerkLoaded, ClerkProvider, useAuth } from "@clerk/expo"; +import { ConvexProviderWithClerk } from "convex/react-clerk"; +import { ConvexReactClient } from "convex/react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { ActivityIndicator, View } from "react-native"; +import { FeedProvider } from "@/providers/feed-provider"; +import { secureTokenCache } from "@/lib/auth"; + +WebBrowser.maybeCompleteAuthSession(); + +const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL ?? ""); + +function LoadingScreen() { + return ( + + + + ); +} + +export default function RootLayout() { + const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; + + if (!publishableKey) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + ); +} diff --git a/apps/mobile/app/article/[sourceId]/[itemId].tsx b/apps/mobile/app/article/[sourceId]/[itemId].tsx new file mode 100644 index 0000000..700a416 --- /dev/null +++ b/apps/mobile/app/article/[sourceId]/[itemId].tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { Dimensions, Linking, ScrollView, View } from "react-native"; +import { useLocalSearchParams } from "expo-router"; +import RenderHtml from "react-native-render-html"; +import { WebView } from "react-native-webview"; +import { useMutation, useQuery } from "convex/react"; +import { + ArrowSquareOut, + BookOpenText, + BookmarkSimple, + GlobeHemisphereWest, +} from "phosphor-react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { api, type Doc } from "@/lib/convex"; +import { useFeedData } from "@/providers/feed-provider"; +import { stripHtml } from "@repo/shared/feed/utils"; + +export default function ArticleScreen() { + const params = useLocalSearchParams<{ + sourceId: string; + itemId: string; + url?: string; + title?: string; + excerpt?: string; + sourceLabel?: string; + sourceSiteUrl?: string; + publishedAt?: string; + imageUrl?: string; + }>(); + const { width } = Dimensions.get("window"); + const { preferences, ensureItem, getSource, markRead } = useFeedData(); + const bookmarks = (useQuery(api.bookmarks.queries.listForCurrentUser, {}) ?? + []) as Doc<"bookmarks">[]; + const toggleBookmark = useMutation(api.bookmarks.mutations.toggleForCurrentUser); + const [item, setItem] = useState>>(); + const [mode, setMode] = useState<"reader" | "site">(preferences.defaultView); + const source = getSource(params.sourceId); + + useEffect(() => { + void (async () => { + const resolved = await ensureItem(params.sourceId, params.itemId); + setItem(resolved); + markRead(params.sourceId, params.itemId); + })(); + }, [ensureItem, markRead, params.itemId, params.sourceId]); + + const fallbackItem = useMemo( + () => + item ?? { + id: params.itemId, + sourceId: params.sourceId, + url: params.url ?? source?.siteUrl ?? "", + title: params.title ?? "Saved article", + excerpt: params.excerpt ?? undefined, + contentHtml: undefined, + contentText: undefined, + publishedAt: params.publishedAt ?? undefined, + author: undefined, + imageUrl: params.imageUrl ?? undefined, + isNew: false, + isRead: true, + }, + [ + item, + params.excerpt, + params.imageUrl, + params.itemId, + params.publishedAt, + params.sourceId, + params.title, + params.url, + source?.siteUrl, + ], + ); + const isBookmarked = bookmarks.some((bookmark) => bookmark.url === fallbackItem.url); + const readerText = fallbackItem.contentText ?? stripHtml(fallbackItem.contentHtml); + + return ( + + + {fallbackItem.title} + {source?.label ? {source.label} : null} + + + {mode === "site" ? ( + + ) : ( + + {fallbackItem.contentHtml ? ( + + ) : readerText ? ( + {readerText} + ) : ( + + Fallback reader mode + + Full article content is not available in the cached feed item. You can still open + the site view or the original article. + + {fallbackItem.excerpt ? ( + {fallbackItem.excerpt} + ) : null} + + )} + + )} + + + + + + + + + + + ); +} diff --git a/apps/mobile/app/feed/[sourceId].tsx b/apps/mobile/app/feed/[sourceId].tsx new file mode 100644 index 0000000..eb63de6 --- /dev/null +++ b/apps/mobile/app/feed/[sourceId].tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useLocalSearchParams, router } from "expo-router"; +import { CaretRight } from "phosphor-react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { useFeedData } from "@/providers/feed-provider"; + +export default function FeedScreen() { + const { sourceId } = useLocalSearchParams<{ sourceId: string }>(); + const { getSource, getSourceItems, loadMore, markRead, refreshSource } = useFeedData(); + const source = getSource(sourceId); + const items = getSourceItems(sourceId); + + useEffect(() => { + if (source && items.length === 0) { + void refreshSource(source.sourceId); + } + }, [items.length, refreshSource, source]); + + return ( + void refreshSource(sourceId)} /> + } + > + {source?.label ?? "Feed"} + {source?.siteUrl} + + + {items.map((item) => ( + { + markRead(sourceId, item.id); + router.push(`/article/${sourceId}/${item.id}`); + }} + > + + + + {item.title} + {item.excerpt ? ( + {item.excerpt} + ) : null} + + + + + ))} + + + {source && (items.length > 0 || source.feedUrl) ? ( + + + + + + + {items.slice(0, 4).map((item) => ( + + + + {item.title} + + + ))} + {items.length === 0 ? ( + No posts yet. + ) : null} + + + + {unreadCount} unread + {newCount > 0 ? ( + {newCount} new + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/ui/button.tsx b/apps/mobile/src/components/ui/button.tsx new file mode 100644 index 0000000..5561d8b --- /dev/null +++ b/apps/mobile/src/components/ui/button.tsx @@ -0,0 +1,90 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Pressable, type PressableProps, ActivityIndicator } from "react-native"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +const buttonStyles = cva( + "flex-row items-center justify-center rounded-full px-4 py-3 active:opacity-90", + { + variants: { + variant: { + primary: "bg-accent", + secondary: "bg-accentSoft", + ghost: "bg-transparent", + outline: "border border-line bg-card", + }, + size: { + md: "min-h-12", + sm: "min-h-10 px-3 py-2", + icon: "size-12 px-0 py-0", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + }, +); + +const textStyles = cva("text-center font-medium", { + variants: { + variant: { + primary: "text-white", + secondary: "text-accent", + ghost: "text-ink", + outline: "text-ink", + }, + size: { + md: "text-sm", + sm: "text-sm", + icon: "text-sm", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, +}); + +type ButtonProps = Omit & + VariantProps & { + children?: React.ReactNode; + className?: string; + textClassName?: string; + label?: string; + loading?: boolean; + }; + +export function Button({ + children, + className, + disabled, + label, + loading, + size, + textClassName, + variant, + ...props +}: ButtonProps) { + return ( + + {loading ? ( + + ) : typeof children === "string" || label ? ( + + {children ?? label} + + ) : ( + children + )} + + ); +} diff --git a/apps/mobile/src/components/ui/card.tsx b/apps/mobile/src/components/ui/card.tsx new file mode 100644 index 0000000..783bae1 --- /dev/null +++ b/apps/mobile/src/components/ui/card.tsx @@ -0,0 +1,11 @@ +import { View, type ViewProps } from "react-native"; +import { cn } from "@/lib/utils"; + +export function Card({ className, ...props }: ViewProps & { className?: string }) { + return ( + + ); +} diff --git a/apps/mobile/src/components/ui/input.tsx b/apps/mobile/src/components/ui/input.tsx new file mode 100644 index 0000000..5fabe41 --- /dev/null +++ b/apps/mobile/src/components/ui/input.tsx @@ -0,0 +1,15 @@ +import { TextInput, type TextInputProps } from "react-native"; +import { cn } from "@/lib/utils"; + +export function Input({ className, ...props }: TextInputProps & { className?: string }) { + return ( + + ); +} diff --git a/apps/mobile/src/components/ui/sheet.tsx b/apps/mobile/src/components/ui/sheet.tsx new file mode 100644 index 0000000..5aa4a8a --- /dev/null +++ b/apps/mobile/src/components/ui/sheet.tsx @@ -0,0 +1,37 @@ +import { Modal, Pressable, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { cn } from "@/lib/utils"; + +type SheetProps = { + children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; +}; + +export function Sheet({ children, open, onOpenChange, className }: SheetProps) { + return ( + onOpenChange(false)} + > + onOpenChange(false)}> + + event.stopPropagation()}> + + + {children} + + + + + + ); +} diff --git a/apps/mobile/src/components/ui/text.tsx b/apps/mobile/src/components/ui/text.tsx new file mode 100644 index 0000000..1c244b3 --- /dev/null +++ b/apps/mobile/src/components/ui/text.tsx @@ -0,0 +1,6 @@ +import { Text as RNText, type TextProps } from "react-native"; +import { cn } from "@/lib/utils"; + +export function Text({ className, ...props }: TextProps & { className?: string }) { + return ; +} diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts new file mode 100644 index 0000000..c327272 --- /dev/null +++ b/apps/mobile/src/lib/auth.ts @@ -0,0 +1,11 @@ +import * as SecureStore from "expo-secure-store"; +import { tokenCache } from "@clerk/expo/token-cache"; + +export const secureTokenCache = { + ...tokenCache, + saveToken: (key: string, value: string) => + SecureStore.setItemAsync(key, value, { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, + }), + getToken: (key: string) => SecureStore.getItemAsync(key), +}; diff --git a/apps/mobile/src/lib/convex.ts b/apps/mobile/src/lib/convex.ts new file mode 100644 index 0000000..312a57c --- /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/lib/utils.ts b/apps/mobile/src/lib/utils.ts new file mode 100644 index 0000000..a500a73 --- /dev/null +++ b/apps/mobile/src/lib/utils.ts @@ -0,0 +1,4 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/apps/mobile/src/providers/feed-provider.tsx b/apps/mobile/src/providers/feed-provider.tsx new file mode 100644 index 0000000..862cb4c --- /dev/null +++ b/apps/mobile/src/providers/feed-provider.tsx @@ -0,0 +1,280 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { AppState } from "react-native"; +import { useAuth } from "@clerk/expo"; +import { 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; + 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 subscriptions = (useQuery( + api.feedSubscriptions.queries.listForCurrentUser, + isSignedIn ? {} : "skip", + ) ?? []) as Doc<"feedSubscriptions">[]; + const preferences = + useQuery(api.preferences.queries.getForCurrentUser, isSignedIn ? {} : "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); + + cacheRef.current = cache; + + useEffect(() => { + if (!userId) { + setCache(createEmptyLocalFeedCache()); + setIsCacheReady(true); + return; + } + + setIsCacheReady(false); + + void (async () => { + const rawValue = await AsyncStorage.getItem(createStorageKey(userId)); + + if (!rawValue) { + setCache(createEmptyLocalFeedCache()); + setIsCacheReady(true); + return; + } + + try { + const parsed = localFeedCacheSchema.safeParse(JSON.parse(rawValue)); + setCache(parsed.success ? parsed.data : createEmptyLocalFeedCache()); + } catch { + setCache(createEmptyLocalFeedCache()); + } finally { + setIsCacheReady(true); + } + })(); + }, [userId]); + + useEffect(() => { + if (!userId || !isCacheReady) { + return; + } + + void AsyncStorage.setItem(createStorageKey(userId), JSON.stringify(cache)); + }, [cache, isCacheReady, userId]); + + useEffect(() => { + setCache((current) => + reconcileLocalFeedCache( + current, + subscriptions.map((subscription) => subscription.sourceId), + ), + ); + }, [subscriptions]); + + 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 = 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.", + ), + ); + } + }; + + const value = useMemo( + () => ({ + cache, + isCacheReady, + 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: async (inputUrl) => { + const discovery = await discoverFeed(inputUrl); + + await createSubscription(discovery.source); + setCache((current) => mergeSourceDiscovery(current, discovery)); + + return discovery.source.sourceId; + }, + removeFeed: async (sourceId) => { + await removeSubscription({ sourceId }); + setCache((current) => removeSource(current, sourceId)); + }, + refreshSource: (sourceId) => refreshSourceById(sourceId), + refreshAll: async () => { + await Promise.all( + subscriptions.map((subscription) => refreshSourceById(subscription.sourceId)), + ); + }, + loadMore: async (sourceId) => { + const pageUrl = cacheRef.current.sources[sourceId]?.pagination?.nextPageUrl; + + if (!pageUrl) { + return; + } + + const result = await loadMoreDiscoveredFeedItems({ sourceId, pageUrl }); + + setCache((current) => applyLoadMoreSourceItems(current, result)); + }, + markRead: (sourceId, itemId) => { + setCache((current) => markItemRead(current, sourceId, itemId)); + }, + getSource: (sourceId) => + subscriptions.find((subscription) => subscription.sourceId === sourceId), + getSourceItems: (sourceId) => getSourceItems(cacheRef.current, sourceId), + getItem: (sourceId, itemId) => + getSourceItems(cacheRef.current, sourceId).find((item) => item.id === itemId), + ensureItem: async (sourceId, itemId) => { + 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); + }, + updatePreferences: async (values) => { + await upsertPreferences(values); + }, + }), + [ + cache, + createSubscription, + isCacheReady, + preferences, + removeSubscription, + subscriptions, + upsertPreferences, + ], + ); + + 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/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..bfb99eb --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "expo/tsconfig.base", + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "nativewind-env.d.ts", + "global.d.ts", + "expo-env.d.ts" + ], + "compilerOptions": { + "strict": true, + "moduleResolution": "Bundler", + "allowJs": false, + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"] + } + } +} 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/package.json b/apps/web/package.json index 082793f..206069f 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", @@ -24,6 +23,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 e82a57c..e7a89b9 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 pr-14 transition-colors duration-150 md:pr-0", + "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]" : "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({ {/* Hover actions */} -
+
- +

{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 (
@@ -368,6 +388,7 @@ type FeedReaderAppProps = { }; export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) { + const { isLoaded: isClerkLoaded, isSignedIn: hasClerkSession } = useAuth(); const clerk = useClerk(); const navigate = useNavigate({ from: "/" }); const autoOpenAuthKeyRef = useRef(null); @@ -384,7 +405,9 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) selectedItem, articleViewMode, preferences, + effectivePollingIntervalMs, isPreferencesPending, + isAuthLoading, isSignedIn, isBookmarked, isBookmarkPending, @@ -426,7 +449,7 @@ export function FeedReaderApp({ authIntent, authRedirect }: FeedReaderAppProps) }; const handleBookmarksClick = () => { - if (isSignedIn) { + if (hasClerkSession) { void navigate({ to: "/bookmarks" }); return; } @@ -439,7 +462,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; } @@ -459,7 +491,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) { @@ -486,7 +518,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 ; } @@ -536,24 +572,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 8645505..34fd8eb 100644 --- a/apps/web/src/components/feed-reader/item-list.tsx +++ b/apps/web/src/components/feed-reader/item-list.tsx @@ -24,6 +24,7 @@ const formatDate = (value: string | undefined) => : null; export function ItemList({ + source, items, selectedItemId, hasMore = false, @@ -32,18 +33,36 @@ export function ItemList({ onLoadMore, onClose, }: ItemListProps) { + const sourceHost = source?.siteUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); const unreadCount = items.filter((item) => !item.isRead).length; const readCount = items.length - unreadCount; 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 e5a8e9f..8946492 100644 --- a/apps/web/src/components/feed-reader/reader-pane.tsx +++ b/apps/web/src/components/feed-reader/reader-pane.tsx @@ -102,15 +102,27 @@ export function ReaderPane({ }; const modeToggle = ( onArticleViewModeChange(value as ArticleViewMode)} > - - + + - + @@ -118,13 +130,14 @@ export function ReaderPane({ ); const topNav = ( -
-
+
+
{onBack ? ( - - - {modeToggle} - {onToggleFullScreen && !isMobile && ( +
+
- )} - {onClose && ( + - )} + {onToggleFullScreen && !isMobile && ( + + )} + {onClose && ( + + )} +
+
{modeToggle}
); diff --git a/apps/web/src/components/feed-reader/source-form.tsx b/apps/web/src/components/feed-reader/source-form.tsx index c1f12ff..d88403f 100644 --- a/apps/web/src/components/feed-reader/source-form.tsx +++ b/apps/web/src/components/feed-reader/source-form.tsx @@ -29,47 +29,50 @@ 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 (