Skip to content
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ self.addEventListener("push", (event) => {
includeUncontrolled: true,
});
clients.forEach((client) => {
client.postMessage({ type: "PUSH_RECEIVED" });
client.postMessage({ type: "PUSH_RECEIVED", payload });
});
})(),
);
Expand Down
11 changes: 7 additions & 4 deletions src/hooks/customQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ export function useCoreMutation<

onError: (error, vars, ctx) => {
if (optimisticUpdate && ctx?.prevData !== undefined) {
queryClient.setQueryData<TCache>(
optimisticUpdate.key,
ctx.prevData as TCache,
);
queryClient.setQueryData(optimisticUpdate.key, ctx?.prevData);
}
// invalidateKeys가 존재하면 해당 키들을 무효화하여 데이터를 다시 가져오도록 함
if (invalidateKeys?.length) {
invalidateKeys.forEach((key) => {
void queryClient.invalidateQueries({ queryKey: key });
});
}
userOnError?.(error, vars, ctx);
},
Expand Down
43 changes: 41 additions & 2 deletions src/hooks/notification/useNotificationRead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { toast } from "sonner";
import type { IApiErrorResponse } from "@/types/common/common";
import type { TNotificationEmptyData } from "@/types/setting/notification";

import {
markAllNotificationRead,
markNotificationRead,
type TNotificationHistoryCache,
} from "@/utils/notification/historyCache";

import { useCoreMutation } from "@/hooks/customQuery";

import {
Expand All @@ -15,14 +21,34 @@ import useWorkspaceStore from "@/store/useWorkspaceStore";
export function useReadNotification() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);

return useCoreMutation(
return useCoreMutation<
TNotificationEmptyData,
number,
IApiErrorResponse,
{ prevData?: unknown },
TNotificationHistoryCache
>(
(useNotificationId: number) => {
if (orgId == null) {
return Promise.reject(new Error("워크스페이스를 선택해주세요"));
}
return readNotificationHistory(orgId, useNotificationId);
},
{
// 작동예시
// mutate(5) 호출
// 아직 서버 응답 전 updateFn이 캐시 id 5번을 isRead: true로 변경 -> 바로 뱃지 -1
// 동시에 PATCH 요청
// 실패하면 4번칸의 prevData로 캐시 복구 -> 뱃지 +1 (즉,원상복구)
// 성공하면 history 다시 GET해서 서버랑 맞춤
optimisticUpdate:
orgId != null
? {
key: QUERY_KEYS.notification.history(orgId),
updateFn: (old, userNotificationId) =>
markNotificationRead(old, userNotificationId),
}
Comment thread
jjjsun marked this conversation as resolved.
: undefined,
invalidateKeys:
orgId != null ? [QUERY_KEYS.notification.history(orgId)] : [],
userOnError: (error) => {
Expand All @@ -38,14 +64,27 @@ export function useReadNotification() {
export function useAllReadNotifications() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);

return useCoreMutation<TNotificationEmptyData, void>(
return useCoreMutation<
TNotificationEmptyData,
void,
IApiErrorResponse,
{ prevData?: unknown },
TNotificationHistoryCache
>(
() => {
if (orgId == null) {
return Promise.reject(new Error("워크스페이스를 선택해주세요"));
}
return readAllNotificationHistory(orgId);
},
{
optimisticUpdate:
orgId != null
? {
key: QUERY_KEYS.notification.history(orgId),
updateFn: (old) => markAllNotificationRead(old),
}
: undefined,
invalidateKeys:
orgId != null ? [QUERY_KEYS.notification.history(orgId)] : [],
userOnError: (error) => {
Expand Down
46 changes: 43 additions & 3 deletions src/hooks/notification/usePushNotificationRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";

import type { TNotificationType } from "@/types/notification/notification";
import type { IPushReceivedMessage } from "@/types/notification/push";

import {
prependNotification,
type TNotificationHistoryCache,
} from "@/utils/notification/historyCache";
import {
registerPushServiceWorker,
syncPushSubscription,
} from "@/utils/notification/webPush";

import { useMyNotificationSettings } from "@/hooks/setting/useMyNotificationSettings";

import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";

const NOTIFICATION_TYPES: TNotificationType[] = [
"BOT_CLICKS",
"CLICKS_INCREASE",
"REPORT",
];

function toNotificationType(value: unknown): TNotificationType {
if (
typeof value === "string" &&
NOTIFICATION_TYPES.includes(value as TNotificationType)
) {
return value as TNotificationType;
}
return "REPORT";
}

export function usePushNotificationRuntime() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
const { data: settings } = useMyNotificationSettings();
Expand All @@ -23,10 +47,26 @@ export function usePushNotificationRuntime() {
// 이 브라우저 객체에 serviceWorker라는 기능이 없으면 그만둠 / 있으면 이 브라우저는 SW 지원
if (!("serviceWorker" in navigator)) return;

const onMessage = (event: MessageEvent) => {
const onMessage = (event: MessageEvent<IPushReceivedMessage>) => {
if (event.data?.type !== "PUSH_RECEIVED") return;
if (orgId == null) return;

const payload = event.data.payload;
if (payload?.orgId != null && payload.orgId !== orgId) return;
Comment thread
jjjsun marked this conversation as resolved.

const historyKey = QUERY_KEYS.notification.history(orgId);
queryClient.setQueryData<TNotificationHistoryCache>(historyKey, (old) =>
prependNotification(old, {
userNotificationId: payload?.userNotificationId ?? -Date.now(),
title: payload?.title ?? "알림",
message: payload?.message ?? payload?.body ?? "",
createdAt: new Date().toISOString(),
type: toNotificationType(payload?.type),
isRead: false,
}),
);
void queryClient.invalidateQueries({
queryKey: ["notification", "history"],
queryKey: historyKey,
});
};

Expand All @@ -35,7 +75,7 @@ export function usePushNotificationRuntime() {
return () => {
navigator.serviceWorker.removeEventListener("message", onMessage);
};
}, [queryClient]);
}, [orgId, queryClient]);

useEffect(() => {
if (orgId == null) return;
Expand Down
19 changes: 19 additions & 0 deletions src/types/notification/push.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { TNotificationType } from "@/types/notification/notification";

export interface IVapidPublicKeyData {
publicKey: string;
}
Expand All @@ -21,3 +23,20 @@ export interface IPushSubscriptionRequest {
export interface IDeletePushSubscriptionReqest {
endpoint: string;
}

// payload를 받았을때, 앱도 어떤 거 왔는지 알게 한 뒤, 앱에서 처리할 수 있도록 하는 인터페이스.
// 기존에는 payload 왔다만 알고, 앱에서는 처리할 수 없어서 실시간 알림+1을 처리할 수 없었음.
// 그래서 payload를 받았을때, 브라우저도 어떤 알림이 왔는지 알 수 있도록 처리.
export interface IPushReceivedPayload {
title?: string;
body?: string;
message?: string;
type?: TNotificationType;
userNotificationId?: number;
orgId?: number;
}

export interface IPushReceivedMessage {
type: "PUSH_RECEIVED";
payload?: IPushReceivedPayload;
}
92 changes: 92 additions & 0 deletions src/utils/notification/historyCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { InfiniteData } from "@tanstack/react-query";

import type {
INotificationHistoryData,
INotificationHistoryItem,
} from "@/types/notification/notification";

export type TNotificationHistoryCache = InfiniteData<
INotificationHistoryData,
string | null
>;

export function emptyHistoryCache(): TNotificationHistoryCache {
return { pages: [], pageParams: [] };
}

//알림 단건 읽음 처리
export function markNotificationRead(
cache: TNotificationHistoryCache | undefined,
userNotificationId: number,
): TNotificationHistoryCache {
if (!cache) return emptyHistoryCache();
Comment thread
jjjsun marked this conversation as resolved.

return {
...cache,
pages: cache.pages.map((page) => ({
...page,
notifications: page.notifications.map((item) =>
item.userNotificationId === userNotificationId
? { ...item, isRead: true }
: item,
),
})),
};
}

//모두 읽음 처리
export function markAllNotificationRead(
cache: TNotificationHistoryCache | undefined,
): TNotificationHistoryCache {
if (!cache) return emptyHistoryCache();

return {
...cache,
pages: cache.pages.map((page) => ({
...page,
notifications: page.notifications.map((item) => ({
...item,
isRead: true,
})),
})),
};
}

//새알림 맨 위로 붙이기
export function prependNotification(
cache: TNotificationHistoryCache | undefined,
item: INotificationHistoryItem,
): TNotificationHistoryCache {
// 캐시가 없거나 페이지 0장이면 새로운 페이지 만들어서 알림 추가
if (!cache || cache.pages.length === 0) {
return {
pages: [{ hasNext: false, nextCursor: null, notifications: [item] }],
pageParams: [null],
};
}

// 이미 페이지가 있으면 페이지 맨위에 알림 추가
const alreadyExists = cache.pages.some((page) =>
page.notifications.some(
(n) => n.userNotificationId === item.userNotificationId,
),
);
// 이미 있는 알림이면 캐시 그대로 반환
if (alreadyExists) return cache;

//firstPage = 1페이지 (최신알람들 있는곳)
//restPages = 2페이지부터 나머지 다
const [firstPage, ...restPages] = cache.pages;
const [firstParam, ...restParams] = cache.pageParams;

return {
pages: [
{
...firstPage,
notifications: [item, ...firstPage.notifications], //[새알림, ...예전1페이지알림들]
},
...restPages,
],
pageParams: [firstParam, ...restParams],
};
}
Loading