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
+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`);