Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
20 kB · 621 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622import { 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<NotificationReason, { label: string; icon: typeof faHeart; color: string }> = { 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<string, GroupedNotification>();
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 ( <div className="notif-avatar-stack"> {shown.map((actor) => ( <div key={actor.did} className="notif-avatar-stack-item"> {actor.avatar ? ( <img src={actor.avatar} alt="" className="notif-avatar-img" /> ) : ( <div className="notif-avatar-placeholder"> {(actor.displayName || actor.handle)[0].toUpperCase()} </div> )} </div> ))} {remaining > 0 && ( <div className="notif-avatar-stack-overflow">+{remaining}</div> )} </div> );}
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 ( <span className="notif-actors"> {shown.map((actor, i) => ( <span key={actor.did}> {i > 0 && i < shown.length - 1 && ', '} {i > 0 && i === shown.length - 1 && remaining === 0 && ' and '} {i > 0 && i === shown.length - 1 && remaining > 0 && ', '} <HandleHoverCard handle={actor.handle} did={actor.did}> <span className="notif-author-name"> {actor.displayName || actor.handle} </span> <PdsFavicon did={actor.did} /> {isBlocked(actor.did) && <span className="blocked-badge">Blocked</span>} {isMuted(actor.did) && <span className="muted-badge">Muted</span>} </HandleHoverCard> </span> ))} {remaining > 0 && ( <span className="notif-others"> and {remaining} other{remaining > 1 ? 's' : ''}</span> )} </span> );}
function PostPreview({ post }: { post: PostView | null }) { const { isBlocked, isMuted } = useMuteBlock(); if (!post) { return ( <div className="notif-post-preview notif-post-preview--loading"> <div className="notif-post-preview-text">Loading post…</div> </div> ); }
return ( <div className="notif-post-preview"> <div className="notif-post-preview-header"> {post.author.avatar ? ( <img src={post.author.avatar} alt="" className="notif-post-preview-avatar" /> ) : ( <div className="notif-post-preview-avatar-placeholder"> {(post.author.displayName || post.author.handle)[0].toUpperCase()} </div> )} <span className="notif-post-preview-name"> {post.author.displayName || post.author.handle} </span> <PdsFavicon did={post.author.did} /> {isBlocked(post.author.did) && <span className="blocked-badge">Blocked</span>} {isMuted(post.author.did) && <span className="muted-badge">Muted</span>} </div> <div className="notif-post-preview-text">{truncate(post.record.text)}</div> </div> );}
function ReplyPreview({ text }: { text: string }) { return ( <div className="notif-reply-preview"> {truncate(text, 200)} </div> );}
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 ( <button className={`notif-item ${group.isUnread ? 'notif-item--unread' : ''}`} onClick={onClick} type="button" > <div className="notif-reason-icon" style={{ color: config.color }}> <FontAwesomeIcon icon={config.icon} /> </div> <div className="notif-content"> <div className="notif-header"> <AvatarStack actors={group.actors} /> <div className="notif-header-text"> <ActorNames actors={group.actors} /> <span className="notif-action">{actionLabel}</span> {group.reason === 'reply' && <span className="notif-action"> your {label('post')}</span>} </div> <span className="notif-time">{formatTimeAgo(group.indexedAt)}</span> </div>
{group.replyTexts && group.replyTexts.length > 0 && ( <div className="notif-replies"> {group.replyTexts.slice(0, 3).map((reply, i) => ( <ReplyPreview key={i} text={reply.text} /> ))} </div> )}
{showPostPreview && <PostPreview post={post} />} </div> {group.isUnread && <div className="notif-unread-dot" />} </button> );}
export default function NotificationsPage() { const navigate = useNavigate(); const { mode: viewMode } = usePostViewMode(); const { open: openPost } = usePostViewer(); const { isMuted, isBlocked, ignoreBlocks } = useMuteBlock(); const [notifications, setNotifications] = useState<Notification[]>([]); const [grouped, setGrouped] = useState<GroupedNotification[]>([]); const [posts, setPosts] = useState<Map<string, PostView>>(new Map()); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [cursor, setCursor] = useState<string | undefined>(); const [error, setError] = useState<string | null>(null); const sentinelRef = useRef<HTMLDivElement>(null); const hasFetchedRef = useRef(false); const [resolvedUris, setResolvedUris] = useState<Map<string, string>>(new Map());
const fetchSubjectPosts = useCallback(async (groups: GroupedNotification[]) => { const uris = new Set<string>();
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 ( <div className="notif-page"> <div className="notif-header-bar"> <h2 className="notif-title">Notifications</h2> </div> <div className="notif-loading"> <FontAwesomeIcon icon={faBell} spin /> Loading notifications… </div> </div> ); }
const filteredGrouped = ignoreBlocks ? grouped : grouped.filter((group) => { return !group.actors.some((actor) => isBlocked(actor.did) || isMuted(actor.did)); });
return ( <div className="notif-page"> <div className="notif-header-bar"> <h2 className="notif-title">Notifications</h2> </div>
{error && ( <div className="notif-error">{error}</div> )}
{filteredGrouped.length === 0 && !error && ( <div className="notif-empty"> <FontAwesomeIcon icon={faBell} className="notif-empty-icon" /> <p>No notifications yet</p> </div> )}
<div className="notif-list"> {filteredGrouped.map((group) => { const effectiveUri = (group.reason === 'like-via-repost' || group.reason === 'repost-via-repost') ? (resolvedUris.get(group.reasonSubject ?? '') ?? group.reasonSubject) : group.reasonSubject; return ( <NotificationGroupItem key={group.key} group={group} post={effectiveUri ? posts.get(effectiveUri) ?? null : null} onClick={() => handleNotificationClick(group)} /> ); })} {cursor && ( <div ref={sentinelRef} className="notif-sentinel"> {loadingMore && ( <div className="notif-loading-more"> <FontAwesomeIcon icon={faArrowDown} spin /> Loading more… </div> )} </div> )} </div> </div> );}