improvement: notification feature polished. feature tabs following the same notification socket emmited data to be updated.

This commit is contained in:
2026-07-18 16:57:13 +03:30
parent 9f6ec193d2
commit 9941dca849
23 changed files with 506 additions and 63 deletions

View File

@@ -1,6 +1,7 @@
// src/lib/api/client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiError } from '@/types/api';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
@@ -79,6 +80,7 @@ apiClient.interceptors.response.use(
}
}
notifyAccessTokenRefreshed();
return apiClient(originalRequest);
} catch (refreshError) {
if (typeof window !== 'undefined') {

View File

@@ -0,0 +1,11 @@
/** Dispatched after a successful access-token refresh so realtime can reconnect with the new cookie. */
export const ACCESS_TOKEN_REFRESHED_EVENT = 'dyolink:access-token-refreshed';
export function notifyAccessTokenRefreshed() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(ACCESS_TOKEN_REFRESHED_EVENT));
}
export function accessTokenRefreshedEventName() {
return ACCESS_TOKEN_REFRESHED_EVENT;
}

View File

@@ -1,4 +1,5 @@
import { authApi } from '@/lib/api/auth';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
/** Refresh this long before the access JWT expires. */
@@ -91,6 +92,7 @@ async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
}
lastRefreshAt = Date.now();
notifyAccessTokenRefreshed();
return expiresAt;
} finally {
refreshInFlight = null;

View File

@@ -16,6 +16,7 @@ import {
rememberAccessTokenExpiresAt,
startProactiveSessionRefresh,
} from '@/lib/auth/proactiveRefresh';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
@@ -162,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgId) {
await authApi.selectOrganization(orgId);
}
notifyAccessTokenRefreshed();
const retry = await authApi.getProfile();
if (retry.success) {
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);

View File

@@ -12,6 +12,9 @@ import {
} from 'react';
import { io, type Socket } from 'socket.io-client';
import { useAuth } from '@/lib/hooks/useAuth';
import { accessTokenRefreshedEventName } from '@/lib/auth/accessTokenEvents';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import type { UserNotificationItem } from '@/types/notifications';
type RealtimeContextValue = {
@@ -37,13 +40,33 @@ function apiOrigin(): string {
}
}
function invalidateSidebarBadges(notification?: UserNotificationItem | null) {
// Sidebar Cases/Tasks/Treatment badges + open tab soft-refetch (via window event).
notifyTabBadgesChanged();
// Orgs pending badge/list — only for connection-related inbox events.
if (
notification?.type === 'CONNECTION_REQUEST' ||
notification?.href?.startsWith('/organizations')
) {
notifyPendingConnectionsChanged();
}
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();
const [connected, setConnected] = useState(false);
const [lastNotification, setLastNotification] = useState<UserNotificationItem | null>(null);
const [unreadCount, setUnreadCount] = useState<number | null>(null);
/** Bumped after access-token refresh so the socket reconnects with the new cookie. */
const [socketEpoch, setSocketEpoch] = useState(0);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const onRefreshed = () => setSocketEpoch((n) => n + 1);
window.addEventListener(accessTokenRefreshedEventName(), onRefreshed);
return () => window.removeEventListener(accessTokenRefreshedEventName(), onRefreshed);
}, []);
useEffect(() => {
if (!isAuthReady || !user || !currentOrganization?.id) {
socketRef.current?.disconnect();
@@ -63,6 +86,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
if (payload?.notification) {
setLastNotification(payload.notification);
invalidateSidebarBadges(payload.notification);
} else {
invalidateSidebarBadges(null);
}
});
socket.on('notification.unreadCount', (payload: { count?: number }) => {
@@ -76,7 +102,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
socketRef.current = null;
setConnected(false);
};
}, [isAuthReady, user, currentOrganization?.id]);
}, [isAuthReady, user, currentOrganization?.id, socketEpoch]);
const setUnreadCountStable = useCallback((count: number) => {
setUnreadCount(count);