Add in-app + real-time notifications (SignalR/mock, Iran-friendly)
- AppNotification + OnlineService.onNotification (hub event + mock periodic) — no FCM/APNs (blocked in Iran); uses the existing realtime channel - notification-store + pushNotification(); 🔔 bell with unread badge in TopBar, notifications screen, global toaster (plays notify sfx) - Wired events: daily reward, post-match achievements, friend requests - Closed-app push (Pushe/Najva/Chabok) noted as a later step (needs provider keys) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -226,6 +226,9 @@ const fa: Dict = {
|
||||
"reactions.title": "شکلک",
|
||||
"stickers.title": "استیکر",
|
||||
|
||||
"notif.title": "اعلانها",
|
||||
"notif.empty": "اعلانی ندارید",
|
||||
|
||||
"settings.audio": "تنظیمات صدا",
|
||||
"settings.sound": "افکت صدا",
|
||||
"settings.music": "موسیقی پسزمینه",
|
||||
@@ -449,6 +452,9 @@ const en: Dict = {
|
||||
"reactions.title": "Emoji",
|
||||
"stickers.title": "Stickers",
|
||||
|
||||
"notif.title": "Notifications",
|
||||
"notif.empty": "No notifications yet",
|
||||
|
||||
"settings.audio": "Audio",
|
||||
"settings.sound": "Sound effects",
|
||||
"settings.music": "Background music",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { getService } from "./online/service";
|
||||
import { AppNotification } from "./online/types";
|
||||
import { sound } from "./sound";
|
||||
|
||||
interface NotifStore {
|
||||
items: AppNotification[];
|
||||
unread: number;
|
||||
lastToast: AppNotification | null;
|
||||
add: (n: AppNotification) => void;
|
||||
markAllRead: () => void;
|
||||
dismissToast: () => void;
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
let unsub: (() => void) | null = null;
|
||||
let started = false;
|
||||
|
||||
export const useNotifStore = create<NotifStore>((set, get) => ({
|
||||
items: [],
|
||||
unread: 0,
|
||||
lastToast: null,
|
||||
|
||||
add: (n) => {
|
||||
const items = [n, ...get().items].slice(0, 50);
|
||||
set({ items, unread: items.filter((x) => !x.read).length, lastToast: n });
|
||||
sound.play("notify");
|
||||
},
|
||||
|
||||
markAllRead: () =>
|
||||
set({ items: get().items.map((x) => ({ ...x, read: true })), unread: 0 }),
|
||||
|
||||
dismissToast: () => set({ lastToast: null }),
|
||||
|
||||
init: () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
unsub = getService().onNotification((n) => get().add(n));
|
||||
},
|
||||
}));
|
||||
|
||||
export function pushNotification(n: Omit<AppNotification, "id" | "ts" | "read">) {
|
||||
useNotifStore.getState().add({
|
||||
...n,
|
||||
id: `n_${Math.random().toString(36).slice(2, 9)}`,
|
||||
ts: Date.now(),
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
|
||||
void unsub;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
|
||||
import { pushNotification } from "./notification-store";
|
||||
import {
|
||||
ChatMessage,
|
||||
Friend,
|
||||
@@ -49,6 +50,7 @@ let roomUnsub: (() => void) | null = null;
|
||||
let mmUnsub: (() => void) | null = null;
|
||||
let friendUnsub: (() => void) | null = null;
|
||||
let chatUnsub: (() => void) | null = null;
|
||||
const seenRequests = new Set<string>();
|
||||
|
||||
export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
||||
friends: [],
|
||||
@@ -61,6 +63,18 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
||||
const svc = getService();
|
||||
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
|
||||
set({ friends, requests });
|
||||
for (const r of requests) {
|
||||
if (seenRequests.has(r.id)) continue;
|
||||
seenRequests.add(r.id);
|
||||
pushNotification({
|
||||
kind: "friend_request",
|
||||
titleFa: "درخواست دوستی جدید",
|
||||
titleEn: "New friend request",
|
||||
bodyFa: r.from.displayName,
|
||||
bodyEn: r.from.displayName,
|
||||
icon: "👥",
|
||||
});
|
||||
}
|
||||
if (!friendUnsub) friendUnsub = svc.onFriends((f) => set({ friends: f }));
|
||||
},
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./service";
|
||||
import {
|
||||
AVATARS,
|
||||
AppNotification,
|
||||
AuthSession,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
@@ -463,6 +464,40 @@ export class MockOnlineService implements OnlineService {
|
||||
for (const cb of this.reactionCbs) cb(0, reaction);
|
||||
}
|
||||
|
||||
/* --------------------------- notifications ------------------------- */
|
||||
|
||||
private notifCbs = new Set<(n: AppNotification) => void>();
|
||||
private notifTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
|
||||
this.notifCbs.add(cb);
|
||||
if (this.notifTimer == null) {
|
||||
const samples: Array<Pick<AppNotification, "kind" | "titleFa" | "titleEn" | "icon">> = [
|
||||
{ kind: "system", titleFa: "یک دوست آنلاین شد", titleEn: "A friend is online", icon: "👋" },
|
||||
{ kind: "system", titleFa: "مسابقهی امروز شروع شد", titleEn: "Today's event is live", icon: "🏆" },
|
||||
{ kind: "invite", titleFa: "یک نفر دنبال همبازیه", titleEn: "Someone is looking for a partner", icon: "🎴" },
|
||||
];
|
||||
this.notifTimer = setInterval(() => {
|
||||
if (this.notifCbs.size === 0) return;
|
||||
const s = pick(samples);
|
||||
const n: AppNotification = {
|
||||
id: rid("ntf"),
|
||||
ts: Date.now(),
|
||||
read: false,
|
||||
...s,
|
||||
};
|
||||
for (const c of this.notifCbs) c(n);
|
||||
}, 35000);
|
||||
}
|
||||
return () => {
|
||||
this.notifCbs.delete(cb);
|
||||
if (this.notifCbs.size === 0 && this.notifTimer) {
|
||||
clearInterval(this.notifTimer);
|
||||
this.notifTimer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The mock drives the game locally (game-store), so these are no-ops.
|
||||
readonly live = false;
|
||||
onState(): Unsubscribe { return () => {}; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Suit } from "../hokm/types";
|
||||
import {
|
||||
AppNotification,
|
||||
AuthSession,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
@@ -98,6 +99,9 @@ export interface OnlineService {
|
||||
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
||||
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
||||
|
||||
/* ----- notifications (server-pushed, in-app) ----- */
|
||||
onNotification(cb: (n: AppNotification) => void): Unsubscribe;
|
||||
|
||||
/* ----- stats ----- */
|
||||
getOnlineCount(): Promise<number>;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Unsubscribe,
|
||||
} from "./service";
|
||||
import {
|
||||
AppNotification,
|
||||
AuthSession,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
@@ -48,6 +49,8 @@ export class SignalrService implements OnlineService {
|
||||
private mmCbs = new Set<(s: MatchmakingState) => void>();
|
||||
private stateCbs = new Set<(s: ServerGameState) => void>();
|
||||
private reactionCbs = new Set<(seat: number, reaction: string) => void>();
|
||||
private notifCbs = new Set<(n: AppNotification) => void>();
|
||||
private mockNotifUnsub?: () => void;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -89,6 +92,8 @@ export class SignalrService implements OnlineService {
|
||||
conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s)));
|
||||
conn.on("reaction", (r: { seat: number; reaction: string }) =>
|
||||
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
||||
conn.on("notification", (n: AppNotification) =>
|
||||
this.notifCbs.forEach((cb) => cb(n)));
|
||||
|
||||
this.conn = conn;
|
||||
try {
|
||||
@@ -262,6 +267,14 @@ export class SignalrService implements OnlineService {
|
||||
markRead(id: string) { return this.mock.markRead(id); }
|
||||
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
|
||||
|
||||
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
|
||||
this.notifCbs.add(cb);
|
||||
// also forward the mock's periodic notifications for liveliness
|
||||
if (!this.mockNotifUnsub)
|
||||
this.mockNotifUnsub = this.mock.onNotification((n) => this.notifCbs.forEach((c) => c(n)));
|
||||
return () => this.notifCbs.delete(cb);
|
||||
}
|
||||
|
||||
async getOnlineCount(): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`${SERVER}/api/stats/online`);
|
||||
|
||||
@@ -403,6 +403,27 @@ export interface ServerGameState {
|
||||
stake: number;
|
||||
}
|
||||
|
||||
/* --------------------------- Notifications --------------------------- */
|
||||
|
||||
export type NotificationKind =
|
||||
| "friend_request"
|
||||
| "invite"
|
||||
| "achievement"
|
||||
| "daily"
|
||||
| "system";
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
titleFa: string;
|
||||
titleEn: string;
|
||||
bodyFa?: string;
|
||||
bodyEn?: string;
|
||||
icon: string; // emoji
|
||||
ts: number;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------ Avatars ------------------------------ */
|
||||
|
||||
export const AVATARS: { id: string; emoji: string }[] = [
|
||||
|
||||
+3
-2
@@ -13,16 +13,17 @@ export type Screen =
|
||||
| "leaderboard"
|
||||
| "shop"
|
||||
| "chat"
|
||||
| "notifications"
|
||||
| "game"; // the table (used for both ai + online)
|
||||
|
||||
const ALL_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online",
|
||||
"room", "matchmaking", "leaderboard", "shop", "chat", "game",
|
||||
"room", "matchmaking", "leaderboard", "shop", "chat", "notifications", "game",
|
||||
];
|
||||
|
||||
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||
export const STATIC_SCREENS: Screen[] = [
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop",
|
||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "notifications",
|
||||
];
|
||||
|
||||
export function screenFromHash(): Screen {
|
||||
|
||||
Reference in New Issue
Block a user