Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions src/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
9 changes: 9 additions & 0 deletions src/app/changelog/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
38 changes: 16 additions & 22 deletions src/components/NotificationsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -165,27 +165,21 @@ export default function NotificationsFeed() {
<SettingsButton />
}
/>
<div className='gap-y-2 px-4 sm:px-6 lg:px-8 border-b dark:border-gray-800'>
{/* two tabs, "all" and "mentions" */}
<div className='flex flex-row'>
<button
className={`w-1/2 flex flex-row justify-center py-2 lg:hover:bg-gray-50 lg:dark:hover:bg-gray-800 dark:border-gray-700 ${selectedMode === 'all' && 'border-b-4'}`}
onClick={() => {
setSelectedMode('all')
queryClient.resetQueries(['notifications']);
}}
>
<span className='text-sm font-semibold text-gray-700 dark:text-gray-200'>All</span>
</button>
<button
className={`w-1/2 flex flex-row justify-center py-2 lg:hover:bg-gray-50 lg:dark:hover:bg-gray-800 dark:border-gray-700 ${selectedMode === 'mentions' && 'border-b-4'}`}
onClick={() => {
setSelectedMode('mentions')
queryClient.resetQueries(['notifications']);
}}
>
<span className='text-sm font-semibold text-gray-700 dark:text-gray-200'>Mentions</span>
</button>
<div className='border-b dark:border-gray-800'>
{/* tabs: All / Replies / Mentions / Likes / Recasts / Follows */}
<div className='flex flex-row overflow-x-auto'>
{NOTIFICATION_TABS.map((tab) => (
<button
key={tab.key}
className={`flex-1 min-w-[84px] flex flex-row justify-center py-2 px-3 whitespace-nowrap border-b-4 border-gray-900 dark:border-gray-100 lg:hover:bg-gray-50 lg:dark:hover:bg-gray-800 ${selectedMode === tab.key ? '' : 'border-transparent'}`}
onClick={() => {
setSelectedMode(tab.key)
queryClient.resetQueries(['notifications']);
}}
>
<span className={`text-sm ${selectedMode === tab.key ? 'font-semibold text-gray-900 dark:text-gray-100' : 'font-medium text-gray-500 dark:text-gray-400'}`}>{tab.label}</span>
</button>
))}
</div>
</div>
<div>
Expand Down
72 changes: 72 additions & 0 deletions src/components/casts/ParentCastPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mb-2 rounded-lg border dark:border-gray-800 p-2">
<Skeleton className="h-3 w-3/4" />
</div>
)
}

// Couldn't load (deleted / not found) — show nothing rather than a broken box.
if (isError || !cast?.author) return null

return (
<div
onClick={(e) => { 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"
>
<div className="flex flex-row items-center gap-x-1 mb-0.5 min-w-0">
<Avatar className="h-4 w-4 shrink-0">
<AvatarImage src={cast.author.pfp_url} alt="" />
<AvatarFallback>
<Skeleton className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{cast.author.display_name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500 truncate">@{cast.author.username}</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 break-words">
<CastText text={cast.text} />
</div>
</div>
)
}
6 changes: 6 additions & 0 deletions src/components/notifications/MentionNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -96,6 +97,11 @@ export default function MentionNotification({ notification, isSelected = false }
<Link href={`/${notification.cast.author.username}`} className='text-gray-500 hover:underline'>@{notification.cast.author.username}</Link>
</ProfileHoverCard>
</div>
{notification.cast.parent_hash && (
<div className="mt-1">
<ParentCastPreview hash={notification.cast.parent_hash} />
</div>
)}
<div className="max-w-full mb-2">
{/* display the content of the cast in gray color and small font */}
<div className="text-gray-900 dark:text-gray-100 text-sm mb-2">
Expand Down
6 changes: 6 additions & 0 deletions src/components/notifications/ReplyNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -102,6 +103,11 @@ export default function ReplyNotification({ notification, isSelected = false }:
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs">Replying to <span className="text-blue-500">you</span></span>
</div>
{notification.cast.parent_hash && (
<div className="mt-1">
<ParentCastPreview hash={notification.cast.parent_hash} />
</div>
)}
<div className="max-w-full mb-2">
{/* display the content of the cast in gray color and small font */}
<div className="text-gray-900 dark:text-gray-200 text-sm mb-2">
Expand Down
32 changes: 29 additions & 3 deletions src/providers/NotificationsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationTab, string> = {
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<NotificationTab>(() => {
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';
});
Expand Down Expand Up @@ -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,
Expand Down
Loading