Notifications: deep-link on tap + swipe-to-dismiss
Each notification now navigates to its related screen when tapped (toast or list): friend_request/invite -> Friends, achievement/reward -> Achievements, daily -> opens the daily-reward modal, coin-purchase success -> Shop. An explicit per-notification 'route' overrides the kind default. List rows are swipeable (drag aside) and have an X to dismiss individually, plus a Clear-all button; the toast can be flicked up to dismiss or tapped to open. New store actions: markRead/remove/clearAll + openNotification navigator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -320,6 +320,9 @@ const fa: Dict = {
|
||||
|
||||
"notif.title": "اعلانها",
|
||||
"notif.empty": "اعلانی ندارید",
|
||||
"notif.clearAll": "پاک کردن همه",
|
||||
"notif.swipeHint": "برای حذف، اعلان را به کناری بکشید",
|
||||
"notif.tapToOpen": "برای مشاهده بزنید",
|
||||
|
||||
"settings.audio": "تنظیمات صدا",
|
||||
"settings.sound": "افکت صدا",
|
||||
@@ -653,6 +656,9 @@ const en: Dict = {
|
||||
|
||||
"notif.title": "Notifications",
|
||||
"notif.empty": "No notifications yet",
|
||||
"notif.clearAll": "Clear all",
|
||||
"notif.swipeHint": "Swipe a notification aside to dismiss it",
|
||||
"notif.tapToOpen": "Tap to open",
|
||||
|
||||
"settings.audio": "Audio",
|
||||
"settings.sound": "Sound effects",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { create } from "zustand";
|
||||
import { getService } from "./online/service";
|
||||
import { AppNotification } from "./online/types";
|
||||
import { sound } from "./sound";
|
||||
import { Screen, useUIStore } from "./ui-store";
|
||||
|
||||
interface NotifStore {
|
||||
items: AppNotification[];
|
||||
@@ -11,6 +12,9 @@ interface NotifStore {
|
||||
lastToast: AppNotification | null;
|
||||
add: (n: AppNotification) => void;
|
||||
markAllRead: () => void;
|
||||
markRead: (id: string) => void;
|
||||
remove: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
dismissToast: () => void;
|
||||
init: () => void;
|
||||
}
|
||||
@@ -18,6 +22,8 @@ interface NotifStore {
|
||||
let unsub: (() => void) | null = null;
|
||||
let started = false;
|
||||
|
||||
const countUnread = (items: AppNotification[]) => items.filter((x) => !x.read).length;
|
||||
|
||||
export const useNotifStore = create<NotifStore>((set, get) => ({
|
||||
items: [],
|
||||
unread: 0,
|
||||
@@ -25,13 +31,26 @@ export const useNotifStore = create<NotifStore>((set, get) => ({
|
||||
|
||||
add: (n) => {
|
||||
const items = [n, ...get().items].slice(0, 50);
|
||||
set({ items, unread: items.filter((x) => !x.read).length, lastToast: n });
|
||||
set({ items, unread: countUnread(items), lastToast: n });
|
||||
sound.play("notify");
|
||||
},
|
||||
|
||||
markAllRead: () =>
|
||||
set({ items: get().items.map((x) => ({ ...x, read: true })), unread: 0 }),
|
||||
|
||||
markRead: (id) => {
|
||||
const items = get().items.map((x) => (x.id === id ? { ...x, read: true } : x));
|
||||
set({ items, unread: countUnread(items) });
|
||||
},
|
||||
|
||||
remove: (id) => {
|
||||
const items = get().items.filter((x) => x.id !== id);
|
||||
const lastToast = get().lastToast?.id === id ? null : get().lastToast;
|
||||
set({ items, unread: countUnread(items), lastToast });
|
||||
},
|
||||
|
||||
clearAll: () => set({ items: [], unread: 0, lastToast: null }),
|
||||
|
||||
dismissToast: () => set({ lastToast: null }),
|
||||
|
||||
init: () => {
|
||||
@@ -50,4 +69,38 @@ export function pushNotification(n: Omit<AppNotification, "id" | "ts" | "read">)
|
||||
});
|
||||
}
|
||||
|
||||
/** Default destination for a notification kind when no explicit `route` is set. */
|
||||
function defaultRoute(kind: AppNotification["kind"]): Screen | "daily" | null {
|
||||
switch (kind) {
|
||||
case "friend_request":
|
||||
case "invite":
|
||||
return "friends";
|
||||
case "achievement":
|
||||
case "reward":
|
||||
return "achievements";
|
||||
case "daily":
|
||||
return "daily"; // opens the daily-reward modal
|
||||
case "system":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Act on a tapped notification: mark it read and deep-link to its related screen.
|
||||
* `daily` opens the daily-reward modal (mounted globally) over the current screen.
|
||||
*/
|
||||
export function openNotification(n: AppNotification) {
|
||||
useNotifStore.getState().markRead(n.id);
|
||||
useNotifStore.getState().dismissToast();
|
||||
const target = (n.route as Screen | "daily" | undefined) ?? defaultRoute(n.kind);
|
||||
if (!target) return;
|
||||
const ui = useUIStore.getState();
|
||||
if (target === "daily") {
|
||||
ui.openDaily();
|
||||
return;
|
||||
}
|
||||
ui.go(target);
|
||||
}
|
||||
|
||||
void unsub;
|
||||
|
||||
@@ -593,6 +593,7 @@ export type NotificationKind =
|
||||
| "friend_request"
|
||||
| "invite"
|
||||
| "achievement"
|
||||
| "reward"
|
||||
| "daily"
|
||||
| "system";
|
||||
|
||||
@@ -606,6 +607,10 @@ export interface AppNotification {
|
||||
icon: string; // emoji
|
||||
ts: number;
|
||||
read: boolean;
|
||||
/** explicit deep-link target screen name (overrides the kind default) */
|
||||
route?: string;
|
||||
/** optional payload, e.g. a user id (friend_request) the target screen can use */
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------ Avatars ------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user