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:
soroush.asadi
2026-06-04 15:52:06 +03:30
parent e02d976dda
commit 2d2352dfe8
13 changed files with 291 additions and 3 deletions
+35
View File
@@ -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
View File
@@ -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>;
+13
View File
@@ -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`);
+21
View File
@@ -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 }[] = [