diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index e7dbda9..f1a08ef 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -18,5 +18,6 @@ declare global { const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default + const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e6b841d..2bd2fa6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,16 +5,18 @@ import { isAtTopAtom } from "~/utils/atoms"; export function Header({ backButtonCallback, - title + title, + bottomBorderDisabled, }: { backButtonCallback?: () => void; title?: string; + bottomBorderDisabled?: boolean; }) { const router = useRouter(); const [isAtTop] = useAtom(isAtTopAtom); //const what = router.history. return ( -
+
{backButtonCallback ? ( +
{ + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.assign("/"); + } + }} + bottomBorderDisabled={true} + /> + +
+ ); +} export const Route = createFileRoute("/notifications")({ component: NotificationsComponent, }); -function NotificationsComponent() { - // /*mass comment*/ console.log("NotificationsComponent render"); - const { agent, status } = useAuth(); - const authed = !!agent?.did; - const authLoading = status === "loading"; - const [did, setDid] = useState(null); - const [resolving, setResolving] = useState(false); - const [error, setError] = useState(null); - const [responses, setResponses] = useState([null, null, null]); - const [loading, setLoading] = useState(false); - const inputRef = useRef(null); - - useEffect(() => { - if (authLoading) return; - if (authed && agent && agent.assertDid) { - setDid(agent.assertDid); - } - }, [authed, agent, authLoading]); - - async function handleSubmit() { - // /*mass comment*/ console.log("handleSubmit called"); - setError(null); - setResponses([null, null, null]); - const value = inputRef.current?.value?.trim() || ""; - if (!value) return; - if (value.startsWith("did:")) { - setDid(value); - setError(null); - return; - } - setResolving(true); - const cacheKey = `handleDid:${value}`; - const now = Date.now(); - const cached = undefined // await get(cacheKey); - // if ( - // cached && - // cached.value && - // cached.time && - // now - cached.time < HANDLE_DID_CACHE_TIMEOUT - // ) { - // try { - // const data = JSON.parse(cached.value); - // setDid(data.did); - // setResolving(false); - // return; - // } catch {} - // } - try { - const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`; - const res = await fetch(url); - if (!res.ok) throw new Error("Failed to resolve handle"); - const data = await res.json(); - //set(cacheKey, JSON.stringify(data)); - setDid(data.did); - } catch (e: any) { - setError("Failed to resolve handle: " + (e?.message || e)); - } finally { - setResolving(false); - } - } - const [constellationURL] = useAtom(constellationURLAtom) - - useEffect(() => { - if (!did) return; - setLoading(true); - setError(null); - const urls = [ - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, - ]; - let ignore = false; - Promise.all( - urls.map(async (url) => { - try { - const r = await fetch(url); - if (!r.ok) throw new Error("Failed to fetch"); - const text = await r.text(); - if (!text) return null; - try { - return JSON.parse(text); - } catch { - return null; +export default function NotificationsTabs() { + const [activeTab, setActiveTab] = React.useState("mentions"); + const [isAtTop] = useAtom(isAtTopAtom); + + const scrollPositions = React.useRef>({}); + + const handleValueChange = (newTab: string) => { + scrollPositions.current[activeTab] = window.scrollY; + setActiveTab(newTab); + }; + + React.useEffect(() => { + const savedY = scrollPositions.current[activeTab] ?? 0; + window.scrollTo(0, savedY); + }, [activeTab]); + + return ( + + + + Mentions + + Follows + + Post Interactions + + + + + {activeTab === "mentions" && } + + + + {activeTab === "follows" && } + + + + {activeTab === "postInteractions" && } + + + ); +} + +function MentionsTab() { + const { agent } = useAuth(); + const [constellationurl] = useAtom(constellationURLAtom); + const infinitequeryresults = useInfiniteQuery({ + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( + { + constellation: constellationurl, + method: "/links", + target: agent?.did, + collection: "app.bsky.feed.post", + path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", + } + ), + enabled: !!agent?.did, + }); + + const { + data: infiniteMentionsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + error, + } = infinitequeryresults; + + const mentionsAturis = React.useMemo(() => { + // Get all replies from the standard infinite query + return ( + infiniteMentionsData?.pages.flatMap( + (page) => + page?.linking_records.map( + (r) => `at://${r.did}/${r.collection}/${r.rkey}` + ) ?? [] + ) ?? [] + ); + }, [infiniteMentionsData]); + + if (isLoading) return ; + if (isError) return ; + + if (!mentionsAturis?.length) return ; + + return ( + <> + {mentionsAturis.map((m) => ( + + ))} + + {hasNextPage && ( + + )} + + ); +} + +function FollowsTab() { + const { agent } = useAuth(); + const [constellationurl] = useAtom(constellationURLAtom); + const infinitequeryresults = useInfiniteQuery({ + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( + { + constellation: constellationurl, + method: "/links", + target: agent?.did, + collection: "app.bsky.graph.follow", + path: ".subject", + } + ), + enabled: !!agent?.did, + }); + + const { + data: infiniteFollowsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + error, + } = infinitequeryresults; + + const followsAturis = React.useMemo(() => { + // Get all replies from the standard infinite query + return ( + infiniteFollowsData?.pages.flatMap( + (page) => + page?.linking_records.map( + (r) => `at://${r.did}/${r.collection}/${r.rkey}` + ) ?? [] + ) ?? [] + ); + }, [infiniteFollowsData]); + + if (isLoading) return ; + if (isError) return ; + + if (!followsAturis?.length) return ; + + return ( + <> + {followsAturis.map((m) => ( + + ))} + + {hasNextPage && ( + + )} + + ); +} + + +function PostInteractionsTab() { + const { agent } = useAuth(); + const { data: identity } = useQueryIdentity(agent?.did); + const queryClient = useQueryClient(); + const { + data: postsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); + + React.useEffect(() => { + if (postsData) { + postsData.pages.forEach((page) => { + page.records.forEach((record) => { + if (!queryClient.getQueryData(["post", record.uri])) { + queryClient.setQueryData(["post", record.uri], record); } - } catch (e: any) { - return { error: e?.message || String(e) }; - } - }) - ) - .then((results) => { - if (!ignore) setResponses(results); - }) - .catch((e) => { - if (!ignore) - setError("Failed to fetch notifications: " + (e?.message || e)); - }) - .finally(() => { - if (!ignore) setLoading(false); + }); }); - return () => { - ignore = true; + } + }, [postsData, queryClient]); + + const posts = React.useMemo( + () => postsData?.pages.flatMap((page) => page.records) ?? [], + [postsData] + ); + + return ( + <> + {posts.map((m) => ( + + ))} + + {hasNextPage && ( + + )} + + ); +} + +function PostInteractionsItem({ uri }: { uri: string }) { + const { data: links } = useQueryConstellation({ + method: "/links/all", + target: uri, + }); + + const interactions = React.useMemo(() => { + const likes = + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; + const replies = + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; + const reposts = + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; + const quotes1 = + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; + const quotes2 = + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] + ?.records || 0; + + const totals = { + likes, + replies, + reposts, + quotes: quotes1 + quotes2, }; - }, [did]); + const list = ( + [ + ["reply", totals.replies], + ["repost", totals.reposts], + ["like", totals.likes], + ["quote", totals.quotes], + ] as const + ).filter(([, count]) => count > 0); + + return { totals, list }; + }, [links]); + + return ( +
+
+ +
+
+ {interactions.list.map(([type, count]) => ( + + ))} +
+
+ ); +} + +function InteractionsButton({ + type, + uri, + count, +}: { + type: "reply" | "repost" | "like" | "quote"; + uri: string; + count: number; +}) { return ( -
-
- Notifications - {!authed && ( -
- - -
+
+ {type === "like" ? ( + + ) : type === "repost" ? ( + + ) : type === "reply" ? ( + + ) : ( + <> + )} + {type} + {/* bad grammar replys */} + {count > 1 ? "s" : ""}
{count} +
+ ); +} + +function NotificationItem({ notification }: { notification: string }) { + const aturi = new AtUri(notification); + const navigate = useNavigate(); + const { data: identity } = useQueryIdentity(aturi.host); + const resolvedDid = identity?.did; + const profileUri = resolvedDid + ? `at://${resolvedDid}/app.bsky.actor.profile/self` + : undefined; + const { data: profileRecord } = useQueryProfile(profileUri); + const profile = profileRecord?.value; + + const [imgcdn] = useAtom(imgCDNAtom); + + function getAvatarUrl(p: typeof profile) { + const link = p?.avatar?.ref?.["$link"]; + if (!link || !resolvedDid) return null; + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; + } + + const avatar = getAvatarUrl(profile); + + return ( +
+ aturi && + navigate({ + to: "/profile/$did", + params: { did: aturi.host }, + }) + } + > +
+ {aturi.collection === "app.bsky.graph.follow" ? ( + + ) : ( + <> )}
- {error &&
{error}
} - {loading && ( -
Loading notifications...
+ {profile ? ( + {identity?.handle} + ) : ( +
)} - {!loading && - !error && - responses.map((resp, i) => ( -
-
Query {i + 1}
- {!resp || - (typeof resp === "object" && Object.keys(resp).length === 0) || - (Array.isArray(resp) && resp.length === 0) ? ( -
No notifications found.
- ) : ( -
-                {JSON.stringify(resp, null, 2)}
-              
- )} -
- ))} - {/*
yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you
*/} +
+
+ + {profile?.displayName || identity?.handle || "Someone"} + + + @{identity?.handle} + +
+
+ {identity?.did && } + {/* + followed you + */} +
+
+
+ {identity?.did && }
); } + + +const EmptyState = ({ text }: { text: string }) => ( +
+ {text} +
+); + +const LoadingState = ({ text }: { text: string }) => ( +
+ {text} +
+); + +const ErrorState = ({ error }: { error: unknown }) => ( +
+ Error: {(error as Error)?.message || "Something went wrong."} +
+); \ No newline at end of file diff --git a/src/styles/app.css b/src/styles/app.css index 89bad45..b747af7 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -233,4 +233,40 @@ body { /* radix i love you but like cmon man */ body[data-scroll-locked]{ margin-left: var(--removed-body-scroll-bar-size) !important; +} + +/* radix tabs */ + +[data-radix-collection-item] { + flex: 1; + display: flex; + padding: 12px 8px; + align-items: center; + justify-content: center; + color: var(--color-gray-500); + font-weight: 500; + &[aria-selected="true"] { + color: var(--color-gray-950); + &::before{ + content: ""; + position: absolute; + width: min(80px, 80%); + border-radius: 99px 99px 0px 0px ; + height: 3px; + bottom: 0; + background-color: var(--color-gray-400); + } + } +} + +@media (prefers-color-scheme: dark) { + [data-radix-collection-item] { + color: var(--color-gray-400); + &[aria-selected="true"] { + color: var(--color-gray-50); + &::before{ + background-color: var(--color-gray-500); + } + } + } } \ No newline at end of file