import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHeart, faRetweet, faUserPlus, faAt, faReply, faQuoteLeft, faBell, faArrowDown, faRss, } from '@fortawesome/free-solid-svg-icons'; import { atprotoClient } from '../api/client'; import type { Notification, NotificationReason, PostView } from '../api/types'; import { usePostViewMode } from '../settings/usePostViewMode'; import { usePostLabels } from '../settings/usePostLabels'; import { usePostViewer } from './usePostViewer'; import HandleHoverCard from './HandleHoverCard'; import PdsFavicon from './PdsFavicon'; import { buildPostPath, cacheHandleDid } from '../utils/postRoutes'; import { useMuteBlock } from './muteBlockContext'; import './NotificationsPage.css'; const REASON_CONFIG: Record = { like: { label: 'liked', icon: faHeart, color: '#e0245e' }, repost: { label: 'reposted', icon: faRetweet, color: '#17bf63' }, follow: { label: 'followed you', icon: faUserPlus, color: 'var(--accent)' }, mention: { label: 'mentioned you', icon: faAt, color: 'var(--accent)' }, reply: { label: '__REPLY_ACTION__', icon: faReply, color: 'var(--accent)' }, quote: { label: 'quoted', icon: faQuoteLeft, color: 'var(--accent)' }, 'starterpack-joined': { label: 'joined your starter pack', icon: faUserPlus, color: 'var(--accent)' }, verified: { label: 'verified', icon: faBell, color: 'var(--accent)' }, unverified: { label: 'unverified', icon: faBell, color: 'var(--text)' }, 'like-via-repost': { label: 'liked your repost', icon: faHeart, color: '#e0245e' }, 'repost-via-repost': { label: 'reposted your repost', icon: faRetweet, color: '#17bf63' }, 'subscribed-post': { label: 'posted', icon: faRss, color: 'var(--accent)' }, 'contact-match': { label: 'is on Bluesky', icon: faUserPlus, color: 'var(--accent)' }, }; function formatTimeAgo(dateStr: string): string { const now = Date.now(); const then = new Date(dateStr).getTime(); const diff = now - then; const seconds = Math.floor(diff / 1000); if (seconds < 60) return 'just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo`; return `${Math.floor(days / 365)}y`; } function truncate(text: string, max = 120): string { if (text.length <= max) return text; return text.slice(0, max).trimEnd() + '…'; } export interface GroupedNotification { key: string; reason: NotificationReason; reasonSubject?: string; notifUri?: string; actors: Array<{ did: string; handle: string; displayName?: string; avatar?: string; }>; indexedAt: string; isUnread: boolean; replyTexts?: Array<{ authorDid: string; text: string }>; } function groupNotifications(notifications: Notification[]): GroupedNotification[] { const groups: GroupedNotification[] = []; const groupMap = new Map(); for (const notif of notifications) { const { reason, reasonSubject } = notif; if (reason === 'follow') { const actor = { did: notif.author.did, handle: notif.author.handle, displayName: notif.author.displayName, avatar: notif.author.avatar, }; const lastGroup = groups[groups.length - 1]; if (lastGroup && lastGroup.reason === 'follow') { lastGroup.actors.push(actor); if (notif.indexedAt > lastGroup.indexedAt) { lastGroup.indexedAt = notif.indexedAt; } if (!notif.isRead) lastGroup.isUnread = true; continue; } const group: GroupedNotification = { key: `follow-${notif.uri}`, reason: 'follow', actors: [actor], indexedAt: notif.indexedAt, isUnread: !notif.isRead, }; groups.push(group); continue; } if (reason === 'reply' || reason === 'mention' || reason === 'quote' || reason === 'subscribed-post') { const actor = { did: notif.author.did, handle: notif.author.handle, displayName: notif.author.displayName, avatar: notif.author.avatar, }; const record = notif.record as { text?: string } | undefined; const replyText = record?.text ?? ''; const group: GroupedNotification = { key: notif.uri, reason, // For subscribed-post the notification URI IS the post; // for reply/mention/quote the reasonSubject is the parent post // but we still want a fallback to the notification URI reasonSubject: reasonSubject || notif.uri, notifUri: notif.uri, actors: [actor], indexedAt: notif.indexedAt, isUnread: !notif.isRead, replyTexts: replyText ? [{ authorDid: notif.author.did, text: replyText }] : undefined, }; groups.push(group); continue; } const groupKey = `${reason}:${reasonSubject ?? ''}`; const existing = groupMap.get(groupKey); if (existing) { if (!existing.actors.some((a) => a.did === notif.author.did)) { existing.actors.push({ did: notif.author.did, handle: notif.author.handle, displayName: notif.author.displayName, avatar: notif.author.avatar, }); } if (notif.indexedAt > existing.indexedAt) { existing.indexedAt = notif.indexedAt; } if (!notif.isRead) existing.isUnread = true; } else { const group: GroupedNotification = { key: groupKey, reason, reasonSubject, actors: [{ did: notif.author.did, handle: notif.author.handle, displayName: notif.author.displayName, avatar: notif.author.avatar, }], indexedAt: notif.indexedAt, isUnread: !notif.isRead, }; groups.push(group); groupMap.set(groupKey, group); } } return groups; } function AvatarStack({ actors, max = 3 }: { actors: GroupedNotification['actors']; max?: number }) { const shown = actors.slice(0, max); const remaining = actors.length - max; return (
{shown.map((actor) => (
{actor.avatar ? ( ) : (
{(actor.displayName || actor.handle)[0].toUpperCase()}
)}
))} {remaining > 0 && (
+{remaining}
)}
); } function ActorNames({ actors, max = 2 }: { actors: GroupedNotification['actors']; max?: number }) { const { isBlocked, isMuted } = useMuteBlock(); if (actors.length === 0) return null; const shown = actors.slice(0, max); const remaining = actors.length - max; return ( {shown.map((actor, i) => ( {i > 0 && i < shown.length - 1 && ', '} {i > 0 && i === shown.length - 1 && remaining === 0 && ' and '} {i > 0 && i === shown.length - 1 && remaining > 0 && ', '} {actor.displayName || actor.handle} {isBlocked(actor.did) && Blocked} {isMuted(actor.did) && Muted} ))} {remaining > 0 && ( and {remaining} other{remaining > 1 ? 's' : ''} )} ); } function PostPreview({ post }: { post: PostView | null }) { const { isBlocked, isMuted } = useMuteBlock(); if (!post) { return (
Loading post…
); } return (
{post.author.avatar ? ( ) : (
{(post.author.displayName || post.author.handle)[0].toUpperCase()}
)} {post.author.displayName || post.author.handle} {isBlocked(post.author.did) && Blocked} {isMuted(post.author.did) && Muted}
{truncate(post.record.text)}
); } function ReplyPreview({ text }: { text: string }) { return (
{truncate(text, 200)}
); } function NotificationGroupItem({ group, post, onClick, }: { group: GroupedNotification; post: PostView | null; onClick: () => void; }) { const { t: label } = usePostLabels(); const config = REASON_CONFIG[group.reason] ?? { label: group.reason, icon: faBell, color: 'var(--text)', }; const showPostPreview = post && ( group.reason === 'like' || group.reason === 'repost' || group.reason === 'like-via-repost' || group.reason === 'repost-via-repost' ); const actionLabel = (() => { switch (group.reason) { case 'like': return `${label('like')}d`; case 'repost': return `${label('repost')}ed`; case 'like-via-repost': return `${label('like')}d your ${label('repost')}`; case 'repost-via-repost': return `${label('repost')}ed your ${label('repost')}`; case 'subscribed-post': return `${label('post')}ed`; case 'reply': return `${label('reply')}ed to`; default: return config.label === '__REPLY_ACTION__' ? `${label('reply')}ed to` : config.label; } })(); return ( ); } export default function NotificationsPage() { const navigate = useNavigate(); const { mode: viewMode } = usePostViewMode(); const { open: openPost } = usePostViewer(); const { isMuted, isBlocked, ignoreBlocks } = useMuteBlock(); const [notifications, setNotifications] = useState([]); const [grouped, setGrouped] = useState([]); const [posts, setPosts] = useState>(new Map()); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [cursor, setCursor] = useState(); const [error, setError] = useState(null); const sentinelRef = useRef(null); const hasFetchedRef = useRef(false); const [resolvedUris, setResolvedUris] = useState>(new Map()); const fetchSubjectPosts = useCallback(async (groups: GroupedNotification[]) => { const uris = new Set(); const repostUris: string[] = []; for (const g of groups) { if (!g.reasonSubject || g.reason === 'follow') continue; if ((g.reason === 'like-via-repost' || g.reason === 'repost-via-repost') && !resolvedUris.has(g.reasonSubject)) { repostUris.push(g.reasonSubject); } else { uris.add(g.reasonSubject); } } if (repostUris.length > 0) { const newMappings: Array<[string, string]> = []; const resolvePromises = repostUris.map(async (repostUri) => { try { const match = repostUri.match(/^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.repost\/(.+)$/); if (!match) { newMappings.push([repostUri, repostUri]); return; } const [, repo, rkey] = match; const resp = await atprotoClient.api!.com.atproto.repo.getRecord({ repo, collection: 'app.bsky.feed.repost', rkey, }); const subject = (resp.data as { value?: { subject?: { uri?: string } } }).value?.subject?.uri; newMappings.push([repostUri, subject ?? repostUri]); } catch { newMappings.push([repostUri, repostUri]); } }); await Promise.all(resolvePromises); setResolvedUris((prev) => { const next = new Map(prev); for (const [key, val] of newMappings) { next.set(key, val); } return next; }); } for (const g of groups) { if (!g.reasonSubject || g.reason === 'follow') continue; const effectiveUri = resolvedUris.get(g.reasonSubject) ?? g.reasonSubject; uris.add(effectiveUri); } const toFetch = [...uris].filter((uri) => !posts.has(uri)); if (toFetch.length === 0) return; try { const fetched = await atprotoClient.getPosts(toFetch); setPosts((prev) => { const next = new Map(prev); for (const p of fetched) { next.set(p.uri, p); // Cache handle/did so buildPostPath can generate clean URLs cacheHandleDid(p.author.handle, p.author.did); } return next; }); } catch { } }, [posts, resolvedUris]); const fetchNotifications = useCallback(async () => { try { setLoading(true); setError(null); const resp = await atprotoClient.listNotifications(50); setNotifications(resp.notifications); const g = groupNotifications(resp.notifications); setGrouped(g); setCursor(resp.cursor); fetchSubjectPosts(g); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load notifications'); } finally { setLoading(false); } }, [fetchSubjectPosts]); const markSeen = useCallback(async () => { try { await atprotoClient.updateSeen(); window.dispatchEvent(new CustomEvent('foxsky:notifications-seen')); } catch { } }, []); useEffect(() => { if (hasFetchedRef.current) return; hasFetchedRef.current = true; fetchNotifications().then(() => markSeen()); }, [fetchNotifications, markSeen]); useEffect(() => { if (!cursor || loadingMore) return; const sentinel = sentinelRef.current; if (!sentinel) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { setLoadingMore(true); atprotoClient .listNotifications(50, cursor) .then((resp) => { const allNotifs = [...notifications, ...resp.notifications]; setNotifications(allNotifs); const g = groupNotifications(allNotifs); setGrouped(g); setCursor(resp.cursor); fetchSubjectPosts(g); }) .catch(() => {}) .finally(() => setLoadingMore(false)); } }, { rootMargin: '600px' }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [cursor, loadingMore, notifications, fetchSubjectPosts]); const handleNotificationClick = useCallback( (group: GroupedNotification) => { if (group.reason === 'follow') { if (group.actors.length === 1) { navigate(`/profile/${group.actors[0].handle}`); } return; } if (group.reason === 'contact-match') { if (group.actors.length >= 1) { navigate(`/profile/${group.actors[0].handle}`); } return; } let postUri: string | undefined; if (group.reason === 'reply' || group.reason === 'mention' || group.reason === 'quote' || group.reason === 'subscribed-post') { postUri = group.notifUri ?? group.reasonSubject; } else if (group.reason === 'like-via-repost' || group.reason === 'repost-via-repost') { postUri = resolvedUris.get(group.reasonSubject ?? '') ?? group.reasonSubject; } else { postUri = group.reasonSubject; } if (postUri) { const subjectPost = posts.get(postUri); const postPath = buildPostPath( postUri, subjectPost?.author?.handle, subjectPost?.author?.did, ); if (viewMode === 'page') { navigate(postPath); } else { navigate(postPath); openPost(postUri, viewMode); } } }, [navigate, viewMode, openPost, posts, resolvedUris], ); if (loading) { return (

Notifications

Loading notifications…
); } const filteredGrouped = ignoreBlocks ? grouped : grouped.filter((group) => { return !group.actors.some((actor) => isBlocked(actor.did) || isMuted(actor.did)); }); return (

Notifications

{error && (
{error}
)} {filteredGrouped.length === 0 && !error && (

No notifications yet

)}
{filteredGrouped.map((group) => { const effectiveUri = (group.reason === 'like-via-repost' || group.reason === 'repost-via-repost') ? (resolvedUris.get(group.reasonSubject ?? '') ?? group.reasonSubject) : group.reasonSubject; return ( handleNotificationClick(group)} /> ); })} {cursor && (
{loadingMore && (
Loading more…
)}
)}
); }