diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9619c..d1cc10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ the public changelog at [castora.social/changelog](https://castora.social/change > `src/app/changelog/entries.ts` (the website source of truth, newest first) and > mirror it here. Keep wording user-friendly. See `AGENTS.md`. +## 2026-06-22 — Tabbed notifications +- Notifications now have tabs — All, Replies, Mentions, Likes, Recasts, and Follows — so you can jump straight to the type you care about. +- Replies and mentions now show the cast they're responding to, inline, so you get the full context without opening the thread. + ## 2026-06-22 — Multiple accounts are back - Connect more than one Farcaster account and switch between them from the profile menu — browse, post, and get notifications as any of your accounts. - The "Add account" button works again (sign in with Neynar): a new account is attached alongside your existing ones instead of replacing them. diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 62146e4..c690601 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -27,18 +27,27 @@ export const GET = withAuthInfra(async (req: Request) => { const url = new URL(req.url) const cursor = url.searchParams.get("cursor") - const mode = url.searchParams.get("mode") const priority = url.searchParams.get("priority") + // New tabbed inbox: filter by a Neynar notification type (comma-separated ok). + const type = url.searchParams.get("type") + // Legacy two-tab UI: mode=all | mentions. + const mode = url.searchParams.get("mode") - let response; + const NEYNAR_NOTIFICATION_TYPES = new Set(["follows", "recasts", "likes", "mentions", "replies", "quotes"]) - if (mode === "all") { - response = await neynar.get(`/v2/farcaster/notifications/?fid=${targetFid}&priority_mode=${priority}&cursor=${cursor}`) + let typeFilter = "" + if (type) { + if (!type.split(",").every((t) => NEYNAR_NOTIFICATION_TYPES.has(t))) { + return Response.json({ "error": "Invalid notification type" }, { status: 400 }) + } + typeFilter = `&type=${type}` } else if (mode === "mentions") { - response = await neynar.get(`/v2/farcaster/notifications/?fid=${targetFid}&type=mentions,replies&priority_mode=${priority}&cursor=${cursor}`) - } else { + typeFilter = "&type=mentions,replies" + } else if (mode && mode !== "all") { return Response.json({ "error": "Invalid mode (expected 'all' or 'mentions')" }, { status: 400 }) } + const response = await neynar.get(`/v2/farcaster/notifications/?fid=${targetFid}${typeFilter}&priority_mode=${priority}&cursor=${cursor}`) + return Response.json({ "unread": response.data.unseen_notifications_count, "notifications": response.data.notifications, "cursor": response.data.next.cursor }) }) \ No newline at end of file diff --git a/src/app/changelog/entries.ts b/src/app/changelog/entries.ts index 8331a8c..70e3287 100644 --- a/src/app/changelog/entries.ts +++ b/src/app/changelog/entries.ts @@ -18,6 +18,15 @@ export type ChangelogEntry = { } export const changelog: ChangelogEntry[] = [ + { + date: '2026-06-22', + title: 'Tabbed notifications', + tag: 'feature', + items: [ + 'Notifications now have tabs — All, Replies, Mentions, Likes, Recasts, and Follows — so you can jump straight to the type you care about.', + 'Replies and mentions now show the cast they\'re responding to, inline, so you get the full context without opening the thread.', + ], + }, { date: '2026-06-22', title: 'Multiple accounts are back', diff --git a/src/components/NotificationsFeed.tsx b/src/components/NotificationsFeed.tsx index 52136eb..4182dca 100644 --- a/src/components/NotificationsFeed.tsx +++ b/src/components/NotificationsFeed.tsx @@ -7,7 +7,7 @@ import PullToRefresh from 'react-simple-pull-to-refresh'; import { isMobile } from 'react-device-detect' import { useInView } from 'react-intersection-observer'; import FeedHeader from './FeedHeader'; -import { useNotifications } from '@/providers/NotificationsProvider' +import { useNotifications, NOTIFICATION_TABS } from '@/providers/NotificationsProvider' import { useNotificationsRefresh } from '@/providers/FeedRefreshProvider' import CastInFeedSkeleton from './casts/CastInFeedSkeleton' import { Cog6ToothIcon } from '@heroicons/react/24/outline' @@ -165,27 +165,21 @@ export default function NotificationsFeed() { } /> -
- {/* two tabs, "all" and "mentions" */} -
- - +
+ {/* tabs: All / Replies / Mentions / Likes / Recasts / Follows */} +
+ {NOTIFICATION_TABS.map((tab) => ( + + ))}
diff --git a/src/components/casts/ParentCastPreview.tsx b/src/components/casts/ParentCastPreview.tsx new file mode 100644 index 0000000..fdf3140 --- /dev/null +++ b/src/components/casts/ParentCastPreview.tsx @@ -0,0 +1,72 @@ +'use client' + +import axios from 'axios' +import { useQuery } from 'react-query' +import { usePrivy } from '@privy-io/react-auth' +import { HOST_URL } from '@/utils/hostURL' +import { useSupercastUserState } from '@/providers/SupercastUserStateProvider' +import { useSelectedCast } from '@/providers/SelectedCastProvider' +import CastText from './CastText' +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar' +import { Skeleton } from '../ui/skeleton' + +// A small, muted preview of the cast a reply/mention is on, shown inline in the +// notification so you have context without opening the thread. Fetches the parent +// by hash (cached by react-query) and quietly renders nothing if it can't load. +export default function ParentCastPreview({ hash }: { hash: string }) { + const { getAccessToken } = usePrivy() + const { supercastUserState } = useSupercastUserState() + const { navigateToCast } = useSelectedCast() + + const { data: cast, isLoading, isError } = useQuery( + ['parentCast', hash], + async () => { + const accessToken = await getAccessToken() + const response = await axios.get(`${HOST_URL}/api/cast/single?hash=${hash}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + asFid: supercastUserState?.currentFid, + }, + }) + return response.data.currentCast + }, + { + enabled: !!hash, + staleTime: 30 * 60 * 1000, // 30 min — parents rarely change + cacheTime: 60 * 60 * 1000, + retry: 1, + }, + ) + + if (isLoading) { + return ( +
+ +
+ ) + } + + // Couldn't load (deleted / not found) — show nothing rather than a broken box. + if (isError || !cast?.author) return null + + return ( +
{ e.stopPropagation(); navigateToCast(e, cast.hash) }} + className="mb-2 rounded-lg border dark:border-gray-800 p-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 hover:cursor-pointer" + > +
+ + + + + + + {cast.author.display_name} + @{cast.author.username} +
+
+ +
+
+ ) +} diff --git a/src/components/notifications/MentionNotification.tsx b/src/components/notifications/MentionNotification.tsx index ef48f52..f2d58e9 100644 --- a/src/components/notifications/MentionNotification.tsx +++ b/src/components/notifications/MentionNotification.tsx @@ -15,6 +15,7 @@ import { useSupercastUserState } from "@/providers/SupercastUserStateProvider"; import CastoraBadge from '../CastoraBadge'; import { useSupercastMember } from '@/providers/SupercastMemberProvider' import CastEmbeds from "../casts/CastEmbeds"; +import ParentCastPreview from "../casts/ParentCastPreview"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Skeleton } from "../ui/skeleton"; @@ -96,6 +97,11 @@ export default function MentionNotification({ notification, isSelected = false } @{notification.cast.author.username}
+ {notification.cast.parent_hash && ( +
+ +
+ )}
{/* display the content of the cast in gray color and small font */}
diff --git a/src/components/notifications/ReplyNotification.tsx b/src/components/notifications/ReplyNotification.tsx index c7470d0..92464a9 100644 --- a/src/components/notifications/ReplyNotification.tsx +++ b/src/components/notifications/ReplyNotification.tsx @@ -15,6 +15,7 @@ import { useSupercastUserState } from "@/providers/SupercastUserStateProvider"; import CastoraBadge from '../CastoraBadge'; import { useSupercastMember } from '@/providers/SupercastMemberProvider' import CastEmbeds from "../casts/CastEmbeds"; +import ParentCastPreview from "../casts/ParentCastPreview"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Skeleton } from "../ui/skeleton"; @@ -102,6 +103,11 @@ export default function ReplyNotification({ notification, isSelected = false }:
Replying to you
+ {notification.cast.parent_hash && ( +
+ +
+ )}
{/* display the content of the cast in gray color and small font */}
diff --git a/src/providers/NotificationsProvider.tsx b/src/providers/NotificationsProvider.tsx index ce56059..c1d915c 100644 --- a/src/providers/NotificationsProvider.tsx +++ b/src/providers/NotificationsProvider.tsx @@ -7,16 +7,39 @@ import { usePrivy } from '@privy-io/react-auth'; import { useSupercastUserState } from '@/providers/SupercastUserStateProvider'; import { HOST_URL } from '@/utils/hostURL'; +export type NotificationTab = 'all' | 'replies' | 'mentions' | 'likes' | 'recasts' | 'follows'; + +export const NOTIFICATION_TABS: { key: NotificationTab; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'replies', label: 'Replies' }, + { key: 'mentions', label: 'Mentions' }, + { key: 'likes', label: 'Likes' }, + { key: 'recasts', label: 'Recasts' }, + { key: 'follows', label: 'Follows' }, +]; + +// Maps a tab to the Neynar notification `type` filter ('' = no filter / all). +const TAB_TO_NEYNAR_TYPE: Record = { + all: '', + replies: 'replies', + mentions: 'mentions', + likes: 'likes', + recasts: 'recasts', + follows: 'follows', +}; + const NotificationsContext = createContext(null); export const NotificationsProvider = ({ children }) => { const { supercastUserState, isRegularUser } = useSupercastUserState(); const { ready: privyReady, authenticated, getAccessToken } = usePrivy(); const queryClient = useQueryClient(); - const [selectedMode, setSelectedMode] = useState<'all' | 'mentions'>(() => { + const [selectedMode, setSelectedMode] = useState(() => { if (typeof window !== 'undefined') { const storedMode = localStorage.getItem('selectedMode'); - return (storedMode === 'all' || storedMode === 'mentions') ? storedMode : 'all'; + if (storedMode && NOTIFICATION_TABS.some((t) => t.key === storedMode)) { + return storedMode as NotificationTab; + } } return 'all'; }); @@ -55,7 +78,10 @@ export const NotificationsProvider = ({ children }) => { const fetchNotifications = async ({ pageParam = '' }) => { const accessToken = await getAccessToken(); - return axios.get(`${HOST_URL}/api/notifications?mode=${selectedMode}&priority=${priorityMode}&cursor=${pageParam}`, { + const params = new URLSearchParams({ priority: String(priorityMode), cursor: pageParam }); + const neynarType = TAB_TO_NEYNAR_TYPE[selectedMode]; + if (neynarType) params.set('type', neynarType); + return axios.get(`${HOST_URL}/api/notifications?${params.toString()}`, { headers: { Authorization: `Bearer ${accessToken}`, 'asFid': supercastUserState.currentFid,