Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 5 additions & 2 deletions components/global-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";

import { SearchCommand } from "@/components/search-command";
import { NavRankBadge } from "@/components/leaderboard/nav-rank-badge";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { WalletSheet } from "@/components/wallet/wallet-sheet";
import { mockWalletInfo } from "@/lib/mock-wallet";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -61,8 +62,8 @@ export function GlobalNavbar() {
href="/transparency"
className={`transition-colors hover:text-foreground/80 ${
pathname.startsWith("/transparency")
? "text-foreground"
: "text-foreground/60"
? "text-foreground"
: "text-foreground/60"
}`}
>
Transparency
Expand Down Expand Up @@ -94,6 +95,8 @@ export function GlobalNavbar() {
<NavRankBadge userId="user-1" className="hidden sm:flex" />
{/* TODO: Replace with actual auth user ID */}

<NotificationCenter />

<WalletSheet
walletInfo={mockWalletInfo}
trigger={
Expand Down
112 changes: 112 additions & 0 deletions components/notifications/notification-center.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { formatDistanceToNow } from "date-fns";
import { Bell, CheckCheck } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";

import { useNotifications } from "@/hooks/use-notifications";

export function NotificationCenter() {
const { notifications, isLoading, unreadCount, markAllAsRead, markAsRead } =
useNotifications();

return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative"
aria-label={`Notifications${unreadCount ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="h-4 w-4" />
{unreadCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
) : null}
</Button>
</PopoverTrigger>

<PopoverContent align="end" className="w-96 p-0">
<div className="flex items-center justify-between border-b px-4 py-3">
<div>
<div className="text-sm font-semibold">Notifications</div>
<div className="text-xs text-muted-foreground">
Real-time bounty and application activity.
</div>
</div>

<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs"
onClick={markAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
</div>

<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="space-y-2 px-4 py-4">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
</div>
) : notifications.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No notifications yet. New activity will appear here instantly.
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<button
key={`${notification.type}:${notification.id}`}
type="button"
onClick={() => markAsRead(notification.id, notification.type)}
className={cn(
"block w-full px-4 py-3 text-left transition-colors hover:bg-muted/50",
!notification.read && "bg-primary/5",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full",
notification.read
? "bg-muted-foreground/40"
: "bg-primary",
)}
/>

<div className="min-w-0 flex-1">
<div className="text-sm text-foreground">
{notification.message}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.timestamp), {
addSuffix: true,
})}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
111 changes: 58 additions & 53 deletions hooks/use-graphql-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,58 @@
import { useEffect, useRef } from 'react';
import { wsClient } from '@/lib/graphql/ws-client';
import { type DocumentNode, print } from 'graphql';

/**
* Generic GraphQL Subscription Hook.
* Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount.
*
* @template T - The response shape of the subscription
* @param query - The subscription document (gql DocumentNode or string)
* @param variables - Subscription variables
* @param onData - Callback triggered on each data event
* @param onError - Optional error callback
*/
export function useGraphQLSubscription<T>(
query: DocumentNode | string,
variables: Record<string, unknown>,
onData: (data: T) => void,
onError?: (error: unknown) => void
) {
// Hold latest callbacks in refs so we don't restart the subscription when they change
const onDataRef = useRef(onData);
const onErrorRef = useRef(onError);

useEffect(() => {
onDataRef.current = onData;
onErrorRef.current = onError;
}, [onData, onError]);

// Track variables as a string to avoid reference-based flapping
const variablesString = JSON.stringify(variables);

// Track query as a string
const queryString = typeof query === 'string' ? query : print(query);

useEffect(() => {
const unsubscribe = wsClient.subscribe<T>(
{ query: queryString, variables: JSON.parse(variablesString) },
{
next: ({ data }) => data && onDataRef.current(data),
error: (err) => {
console.error('[GraphQL Subscription] Error:', err);
onErrorRef.current?.(err);
},
complete: () => { },
}
);

return () => {
unsubscribe();
};
}, [queryString, variablesString]);
}
import { useEffect, useRef } from "react";
import { wsClient } from "@/lib/graphql/ws-client";
import { type DocumentNode, print } from "graphql";

/**
* Generic GraphQL Subscription Hook.
* Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount.
*
* @template T - The response shape of the subscription
* @param query - The subscription document (gql DocumentNode or string)
* @param variables - Subscription variables
* @param onData - Callback triggered on each data event
* @param onError - Optional error callback
*/
export function useGraphQLSubscription<T>(
query: DocumentNode | string,
variables: Record<string, unknown>,
onData: (data: T) => void,
onError?: (error: unknown) => void,
enabled = true,
) {
// Hold latest callbacks in refs so we don't restart the subscription when they change
const onDataRef = useRef(onData);
const onErrorRef = useRef(onError);

useEffect(() => {
onDataRef.current = onData;
onErrorRef.current = onError;
}, [onData, onError]);

// Track variables as a string to avoid reference-based flapping
const variablesString = JSON.stringify(variables);

// Track query as a string
const queryString = typeof query === "string" ? query : print(query);

useEffect(() => {
if (!enabled) {
return;
}

const unsubscribe = wsClient.subscribe<T>(
{ query: queryString, variables: JSON.parse(variablesString) },
{
next: ({ data }) => data && onDataRef.current(data),
error: (err) => {
console.error("[GraphQL Subscription] Error:", err);
onErrorRef.current?.(err);
},
complete: () => {},
},
);

return () => {
unsubscribe();
};
}, [enabled, queryString, variablesString]);
}
136 changes: 136 additions & 0 deletions hooks/use-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { useMemo, useRef, useState } from "react";

import { authClient } from "@/lib/auth-client";
import {
ON_BOUNTY_UPDATED_SUBSCRIPTION,
ON_NEW_APPLICATION_SUBSCRIPTION,
type OnBountyUpdatedData,
type OnNewApplicationData,
} from "@/lib/graphql/subscriptions";

import { useGraphQLSubscription } from "./use-graphql-subscription";

export type NotificationType = "bounty-updated" | "new-application";

export interface NotificationItem {
id: string;
message: string;
type: NotificationType;
timestamp: string;
read: boolean;
}

const MAX_NOTIFICATIONS = 25;

function normaliseTimestamp(value?: string | null): string {
if (!value) return new Date().toISOString();
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? new Date().toISOString()
: parsed.toISOString();
}

function notificationKey(item: Pick<NotificationItem, "id" | "type">): string {
return `${item.type}:${item.id}`;
}

function upsertNotification(
previous: NotificationItem[],
incoming: NotificationItem,
): NotificationItem[] {
const key = notificationKey(incoming);
const next = previous.filter((item) => notificationKey(item) !== key);

next.unshift({ ...incoming, read: false });
next.sort(
(left, right) =>
new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(),
);

return next.slice(0, MAX_NOTIFICATIONS);
}

export function useNotifications() {
const { data: session } = authClient.useSession();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);

const isEnabled = Boolean(session?.user);
const isLoading = session === undefined;
const userId = session?.user?.id ?? null;
const prevUserIdRef = useRef(userId);

if (prevUserIdRef.current !== userId) {
prevUserIdRef.current = userId;
setNotifications([]);
}

useGraphQLSubscription<OnBountyUpdatedData>(
ON_BOUNTY_UPDATED_SUBSCRIPTION,
{},
(data) => {
const bounty = data.bountyUpdated;

setNotifications((previous) =>
upsertNotification(previous, {
id: bounty.id,
message: `Bounty \"${bounty.title}\" was updated.`,
type: "bounty-updated",
timestamp: normaliseTimestamp(bounty.updatedAt),
read: false,
}),
);
},
undefined,
isEnabled,
);

useGraphQLSubscription<OnNewApplicationData>(
ON_NEW_APPLICATION_SUBSCRIPTION,
{},
(data) => {
const application = data.submissionCreated;
const actor = application.submittedByUser?.name || "A contributor";

setNotifications((previous) =>
upsertNotification(previous, {
id: application.id,
message: `${actor} submitted a new application for bounty ${application.bountyId}.`,
type: "new-application",
timestamp: normaliseTimestamp(application.createdAt),
read: false,
}),
);
},
undefined,
isEnabled,
);

const unreadCount = useMemo(
() => notifications.reduce((count, item) => count + (item.read ? 0 : 1), 0),
[notifications],
);

const markAsRead = (id: string, type: NotificationType) => {
setNotifications((previous) =>
previous.map((item) =>
item.id === id && item.type === type ? { ...item, read: true } : item,
),
);
};

const markAllAsRead = () => {
setNotifications((previous) =>
previous.map((item) => ({ ...item, read: true })),
);
};

return {
notifications,
isLoading,
unreadCount,
markAsRead,
markAllAsRead,
};
}
Loading
Loading