diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d898edc..69918ae 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -114,7 +114,7 @@ async function apiRequest( return response; } -interface GetFeedParams { +export interface GetFeedParams { source?: string; type?: string; limit?: number; diff --git a/web/src/components/feed/FeedItems.tsx b/web/src/components/feed/FeedItems.tsx new file mode 100644 index 0000000..b7e6c13 --- /dev/null +++ b/web/src/components/feed/FeedItems.tsx @@ -0,0 +1,158 @@ +import { Clock, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { type GetFeedParams, getFeed } from "../../api/client"; +import Card from "../../components/common/Card"; +import { EmptyState } from "../../components/ui"; +import type { AnnotationItem } from "../../types"; + +const LIMIT = 50; + +export interface FeedItemsProps extends Omit< + GetFeedParams, + "limit" | "offset" +> { + layout: "list" | "mosaic"; + emptyMessage: string; +} + +export default function FeedItems({ + creator, + source, + tag, + type, + motivation, + emptyMessage, + layout, +}: FeedItemsProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + + useEffect(() => { + let cancelled = false; + + getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) + .then((data) => { + if (cancelled) return; + const fetched = data.items; + setItems(fetched); + setHasMore(data.hasMore); + setOffset(data.fetchedCount); + setLoading(false); + }) + .catch((e) => { + if (cancelled) return; + console.error(e); + setItems([]); + setHasMore(false); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [type, motivation, tag, creator, source]); + + const loadMore = useCallback(async () => { + setLoadingMore(true); + try { + const data = await getFeed({ + type, + motivation, + tag, + creator, + source, + limit: LIMIT, + offset, + }); + const fetched = data?.items || []; + setItems((prev) => [...prev, ...fetched]); + setHasMore(data.hasMore); + setOffset((prev) => prev + data.fetchedCount); + } catch (e) { + console.error(e); + } finally { + setLoadingMore(false); + } + }, [type, motivation, tag, creator, source, offset]); + + const handleDelete = (uri: string) => { + setItems((prev) => prev.filter((i) => i.uri !== uri)); + }; + + if (loading) { + return ( +
+ +

+ Loading... +

+
+ ); + } + + if (items.length === 0) { + return ( + } + title="Nothing here yet" + message={emptyMessage} + /> + ); + } + + const loadMoreButton = hasMore && ( +
+ +
+ ); + + if (layout === "mosaic") { + return ( + <> +
+ {items.map((item) => ( +
+ +
+ ))} +
+ {loadMoreButton} + + ); + } + + return ( + <> +
+ {items.map((item) => ( + + ))} +
+ {loadMoreButton} + + ); +} diff --git a/web/src/components/feed/MasonryFeed.tsx b/web/src/components/feed/MasonryFeed.tsx index 18ea0a1..2640f17 100644 --- a/web/src/components/feed/MasonryFeed.tsx +++ b/web/src/components/feed/MasonryFeed.tsx @@ -1,13 +1,10 @@ import { useStore as useNanoStore, useStore } from "@nanostores/react"; -import { Loader2 } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { getFeed } from "../../api/client"; +import { useState } from "react"; import { $user } from "../../store/auth"; import { $feedLayout } from "../../store/feedLayout"; -import type { AnnotationItem } from "../../types"; -import Card from "../common/Card"; -import { EmptyState, Tabs } from "../ui"; +import { Tabs } from "../ui"; import LayoutToggle from "../ui/LayoutToggle"; +import FeedItems from "./FeedItems"; interface MasonryFeedProps { motivation?: string; @@ -16,106 +13,6 @@ interface MasonryFeedProps { title?: string; } -function MasonryContent({ - tab, - motivation, - emptyMessage, - userDid, - layout, -}: { - tab: string; - motivation?: string; - emptyMessage: string; - userDid?: string; - layout: "list" | "mosaic"; -}) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - const params: { type?: string; motivation?: string; creator?: string } = { - motivation, - }; - - if (tab === "my" && userDid) { - params.creator = userDid; - params.type = "my-feed"; - } else { - params.type = "all"; - } - - getFeed(params) - .then((data) => { - if (cancelled) return; - setItems(data.items); - setLoading(false); - }) - .catch((e) => { - if (cancelled) return; - console.error(e); - setItems([]); - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [tab, motivation, userDid]); - - const handleDelete = (uri: string) => { - setItems((prev) => prev.filter((i) => i.uri !== uri)); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (items.length === 0) { - return ( - - ); - } - - if (layout === "list") { - return ( -
- {items.map((item) => ( - - ))} -
- ); - } - - return ( -
- {items.map((item) => ( -
- -
- ))} -
- ); -} - export default function MasonryFeed({ motivation, emptyMessage = "No items found.", @@ -139,6 +36,9 @@ export default function MasonryFeed({ ] : [{ id: "global", label: "Global" }]; + const creator = activeTab === "my" ? user?.did : undefined; + const type = activeTab === "my" ? "my-feed" : "all"; + return (
{title && ( @@ -168,12 +68,16 @@ export default function MasonryFeed({
)} - diff --git a/web/src/views/core/Feed.tsx b/web/src/views/core/Feed.tsx index 8e51a04..87bc2ce 100644 --- a/web/src/views/core/Feed.tsx +++ b/web/src/views/core/Feed.tsx @@ -1,21 +1,13 @@ import { useStore } from "@nanostores/react"; import { clsx } from "clsx"; -import { - Bookmark, - Clock, - Highlighter, - Loader2, - MessageSquareText, -} from "lucide-react"; -import React, { useCallback, useEffect, useState } from "react"; +import { Bookmark, Highlighter, MessageSquareText } from "lucide-react"; +import { useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { getFeed } from "../../api/client"; -import Card from "../../components/common/Card"; -import { Button, EmptyState, Tabs } from "../../components/ui"; +import FeedItems from "../../components/feed/FeedItems"; +import { Button, Tabs } from "../../components/ui"; import LayoutToggle from "../../components/ui/LayoutToggle"; import { $user } from "../../store/auth"; import { $feedLayout } from "../../store/feedLayout"; -import type { AnnotationItem } from "../../types"; interface FeedProps { initialType?: string; @@ -24,151 +16,6 @@ interface FeedProps { emptyMessage?: string; } -function FeedContent({ - type, - motivation, - emptyMessage, - layout, - tag, -}: { - type: string; - motivation?: string; - emptyMessage: string; - layout: "list" | "mosaic"; - tag?: string; -}) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(false); - const [offset, setOffset] = useState(0); - - const LIMIT = 50; - - useEffect(() => { - let cancelled = false; - - getFeed({ type, motivation, tag, limit: LIMIT, offset: 0 }) - .then((data) => { - if (cancelled) return; - const fetched = data.items; - setItems(fetched); - setHasMore(data.hasMore); - setOffset(data.fetchedCount); - setLoading(false); - }) - .catch((e) => { - if (cancelled) return; - console.error(e); - setItems([]); - setHasMore(false); - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [type, motivation, tag]); - - const loadMore = useCallback(async () => { - setLoadingMore(true); - try { - const data = await getFeed({ - type, - motivation, - tag, - limit: LIMIT, - offset, - }); - const fetched = data?.items || []; - setItems((prev) => [...prev, ...fetched]); - setHasMore(data.hasMore); - setOffset((prev) => prev + data.fetchedCount); - } catch (e) { - console.error(e); - } finally { - setLoadingMore(false); - } - }, [type, motivation, tag, offset]); - - const handleDelete = (uri: string) => { - setItems((prev) => prev.filter((i) => i.uri !== uri)); - }; - - if (loading) { - return ( -
- -

- Loading feed... -

-
- ); - } - - if (items.length === 0) { - return ( - } - title="Nothing here yet" - message={emptyMessage} - /> - ); - } - - const loadMoreButton = hasMore && ( -
- -
- ); - - if (layout === "mosaic") { - return ( - <> -
- {items.map((item) => ( -
- -
- ))} -
- {loadMoreButton} - - ); - } - - return ( - <> -
- {items.map((item) => ( - - ))} -
- {loadMoreButton} - - ); -} - export default function Feed({ initialType = "all", motivation, @@ -306,13 +153,13 @@ export default function Feed({ )} - ); diff --git a/web/src/views/profile/Profile.tsx b/web/src/views/profile/Profile.tsx index c82e019..2a924a6 100644 --- a/web/src/views/profile/Profile.tsx +++ b/web/src/views/profile/Profile.tsx @@ -1,7 +1,6 @@ import { useStore } from "@nanostores/react"; import { clsx } from "clsx"; import { - Bookmark, Edit2, Eye, EyeOff, @@ -11,32 +10,28 @@ import { Link2, Linkedin, Loader2, - MessageSquare, - PenTool, ShieldBan, ShieldOff, Volume2, VolumeX, } from "lucide-react"; -import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { blockUser, getCollections, - getFeed, getModerationRelationship, getProfile, muteUser, unblockUser, unmuteUser, } from "../../api/client"; -import Card from "../../components/common/Card"; import CollectionIcon from "../../components/common/CollectionIcon"; import { BlueskyIcon, TangledIcon } from "../../components/common/Icons"; import type { MoreMenuItem } from "../../components/common/MoreMenu"; import MoreMenu from "../../components/common/MoreMenu"; import RichText from "../../components/common/RichText"; +import FeedItems from "../../components/feed/FeedItems"; import EditProfileModal from "../../components/modals/EditProfileModal"; import ExternalLinkModal from "../../components/modals/ExternalLinkModal"; import ReportModal from "../../components/modals/ReportModal"; @@ -50,7 +45,6 @@ import { import { $user } from "../../store/auth"; import { $preferences, loadPreferences } from "../../store/preferences"; import type { - AnnotationItem, Collection, ContentLabel, ModerationRelationship, @@ -71,8 +65,6 @@ const motivationMap: Record = { collections: undefined, }; -const LIMIT = 50; - export default function Profile({ did }: ProfileProps) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -81,33 +73,11 @@ export default function Profile({ did }: ProfileProps) { const [collections, setCollections] = useState([]); const [dataLoading, setDataLoading] = useState(false); - const [items, setItems] = useState<{ - all: AnnotationItem[]; - annotations: AnnotationItem[]; - highlights: AnnotationItem[]; - bookmarks: AnnotationItem[]; - }>({ - all: [], - annotations: [], - highlights: [], - bookmarks: [], - }); - const user = useStore($user); const isOwner = user?.did === did; const [showEdit, setShowEdit] = useState(false); const [externalLink, setExternalLink] = useState(null); const [showReportModal, setShowReportModal] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [loadMoreError, setLoadMoreError] = useState(null); - const [pagination, setPagination] = useState< - Record - >({ - all: { hasMore: false, offset: 0 }, - annotations: { hasMore: false, offset: 0 }, - highlights: { hasMore: false, offset: 0 }, - bookmarks: { hasMore: false, offset: 0 }, - }); const loadMoreTimerRef = useRef | null>(null); const [modRelation, setModRelation] = useState({ blocking: false, @@ -146,12 +116,6 @@ export default function Profile({ did }: ProfileProps) { useEffect(() => { setProfile(null); - setItems({ - all: [], - annotations: [], - highlights: [], - bookmarks: [], - }); setCollections([]); setActiveTab("all"); setLoading(true); @@ -218,6 +182,9 @@ export default function Profile({ did }: ProfileProps) { }; }, []); + const isHandle = !did.startsWith("did:"); + const resolvedDid = isHandle ? profile?.did : did; + useEffect(() => { const loadTabContent = async () => { const isHandle = !did.startsWith("did:"); @@ -226,31 +193,8 @@ export default function Profile({ did }: ProfileProps) { if (!resolvedDid) return; setDataLoading(true); - setPagination((prev) => ({ - ...prev, - [activeTab]: { hasMore: false, offset: 0 }, - })); try { - if ( - ["all", "annotations", "highlights", "bookmarks"].includes(activeTab) - ) { - const res = await getFeed({ - creator: resolvedDid, - limit: LIMIT, - motivation: motivationMap[activeTab], - }); - setItems((prev) => ({ - ...prev, - [activeTab]: res.items, - })); - setPagination((prev) => ({ - ...prev, - [activeTab]: { - hasMore: res.hasMore, - offset: res.fetchedCount, - }, - })); - } else if (activeTab === "collections") { + if (activeTab === "collections") { const res = await getCollections(resolvedDid); setCollections(res); } @@ -263,49 +207,6 @@ export default function Profile({ did }: ProfileProps) { loadTabContent(); }, [profile?.did, did, activeTab]); - const loadMore = useCallback(async () => { - if (activeTab === "collections") return; - - const isHandle = !did.startsWith("did:"); - const resolvedDid = isHandle ? profile?.did : did; - if (!resolvedDid) return; - - const tabPagination = pagination[activeTab]; - if (!tabPagination) return; - - const capturedTab = activeTab; - setLoadingMore(true); - setLoadMoreError(null); - - try { - const res = await getFeed({ - creator: resolvedDid, - motivation: motivationMap[capturedTab], - limit: LIMIT, - offset: tabPagination.offset, - }); - setItems((prev) => ({ - ...prev, - [capturedTab]: [...prev[capturedTab], ...res.items], - })); - setPagination((prev) => ({ - ...prev, - [capturedTab]: { - hasMore: res.hasMore, - offset: prev[capturedTab].offset + res.fetchedCount, - }, - })); - } catch (e) { - console.error(e); - const msg = e instanceof Error ? e.message : "Something went wrong"; - setLoadMoreError(msg); - if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); - loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); - } finally { - setLoadingMore(false); - } - }, [did, profile?.did, activeTab, pagination]); - if (loading) { return (
@@ -345,8 +246,6 @@ export default function Profile({ did }: ProfileProps) { { id: "collections", label: "Collections" }, ]; - const currentItems = activeTab !== "collections" ? items[activeTab] : []; - const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", nudity: "Nudity", @@ -728,50 +627,20 @@ export default function Profile({ did }: ProfileProps) { ))}
) - ) : currentItems.length > 0 ? ( -
- {currentItems.map((item) => ( - - ))} -
) : ( - - ) : activeTab === "highlights" ? ( - - ) : ( - - ) - } - message={ + )} - - {activeTab !== "collections" && pagination[activeTab]?.hasMore && ( -
- {loadMoreError && ( -

- Failed to load more: {loadMoreError} -

- )} - -
- )} {showEdit && profile && (