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:
@@ -11,12 +11,16 @@ import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen";
|
|||||||
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
|
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
|
||||||
import { ShopScreen } from "@/components/screens/ShopScreen";
|
import { ShopScreen } from "@/components/screens/ShopScreen";
|
||||||
import { ChatScreen } from "@/components/screens/ChatScreen";
|
import { ChatScreen } from "@/components/screens/ChatScreen";
|
||||||
|
import { NotificationsScreen } from "@/components/screens/NotificationsScreen";
|
||||||
import { AuthScreen } from "@/components/screens/AuthScreen";
|
import { AuthScreen } from "@/components/screens/AuthScreen";
|
||||||
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
||||||
|
import { NotificationToaster } from "@/components/online/NotificationToaster";
|
||||||
import { CapacitorBack } from "@/components/CapacitorBack";
|
import { CapacitorBack } from "@/components/CapacitorBack";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
|
import { useNotifStore, pushNotification } from "@/lib/notification-store";
|
||||||
|
import { getService } from "@/lib/online/service";
|
||||||
import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store";
|
import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store";
|
||||||
|
|
||||||
/** Transient screens can't be restored without their state — fall back to home. */
|
/** Transient screens can't be restored without their state — fall back to home. */
|
||||||
@@ -43,6 +47,20 @@ export default function Page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
init();
|
||||||
useUIStore.getState().initHistory();
|
useUIStore.getState().initHistory();
|
||||||
|
useNotifStore.getState().init();
|
||||||
|
// surface a daily-reward notification if it's available
|
||||||
|
getService()
|
||||||
|
.getDailyState()
|
||||||
|
.then((d) => {
|
||||||
|
if (d.available)
|
||||||
|
pushNotification({
|
||||||
|
kind: "daily",
|
||||||
|
titleFa: "پاداش روزانه آماده است",
|
||||||
|
titleEn: "Daily reward is ready",
|
||||||
|
icon: "🎁",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
const onPop = (e: PopStateEvent) => {
|
const onPop = (e: PopStateEvent) => {
|
||||||
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
||||||
@@ -56,6 +74,7 @@ export default function Page() {
|
|||||||
<>
|
<>
|
||||||
{renderScreen(screen)}
|
{renderScreen(screen)}
|
||||||
<DailyRewardModal />
|
<DailyRewardModal />
|
||||||
|
<NotificationToaster />
|
||||||
<CapacitorBack />
|
<CapacitorBack />
|
||||||
{loading && null}
|
{loading && null}
|
||||||
</>
|
</>
|
||||||
@@ -84,6 +103,8 @@ function renderScreen(screen: string) {
|
|||||||
return <ShopScreen />;
|
return <ShopScreen />;
|
||||||
case "chat":
|
case "chat":
|
||||||
return <ChatScreen />;
|
return <ChatScreen />;
|
||||||
|
case "notifications":
|
||||||
|
return <NotificationsScreen />;
|
||||||
default:
|
default:
|
||||||
return <HomeScreen />;
|
return <HomeScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNotifStore } from "@/lib/notification-store";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export function NotificationToaster() {
|
||||||
|
const toast = useNotifStore((s) => s.lastToast);
|
||||||
|
const dismiss = useNotifStore((s) => s.dismissToast);
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const id = setTimeout(dismiss, 4000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [toast, dismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
key={toast.id}
|
||||||
|
initial={{ opacity: 0, y: -24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -24 }}
|
||||||
|
onClick={dismiss}
|
||||||
|
className="fixed top-3 inset-x-0 z-[60] flex justify-center px-4 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-3 max-w-sm w-full pointer-events-auto shadow-xl">
|
||||||
|
<span className="text-2xl">{toast.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-cream truncate">
|
||||||
|
{locale === "fa" ? toast.titleFa : toast.titleEn}
|
||||||
|
</div>
|
||||||
|
{(toast.bodyFa || toast.bodyEn) && (
|
||||||
|
<div className="text-[11px] text-cream/55 truncate">
|
||||||
|
{locale === "fa" ? toast.bodyFa : toast.bodyEn}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Coins, Crown, Gift } from "lucide-react";
|
import { Bell, Coins, Crown, Gift } from "lucide-react";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
|
import { useNotifStore } from "@/lib/notification-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export function TopBar() {
|
|||||||
const profile = useSessionStore((s) => s.profile);
|
const profile = useSessionStore((s) => s.profile);
|
||||||
const go = useUIStore((s) => s.go);
|
const go = useUIStore((s) => s.go);
|
||||||
const openDaily = useUIStore((s) => s.openDaily);
|
const openDaily = useUIStore((s) => s.openDaily);
|
||||||
|
const unread = useNotifStore((s) => s.unread);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
|
|
||||||
@@ -34,6 +36,18 @@ export function TopBar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => go("notifications")}
|
||||||
|
className="glass rounded-full p-2 hover:bg-navy-800/80 transition relative"
|
||||||
|
title={t("notif.title")}
|
||||||
|
>
|
||||||
|
<Bell className="size-4 text-gold-400" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-0.5 ltr:-right-0.5 rtl:-left-0.5 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white flex items-center justify-center">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={openDaily}
|
onClick={openDaily}
|
||||||
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
|
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useGameStore } from "@/lib/game-store";
|
|||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { getService } from "@/lib/online/service";
|
import { getService } from "@/lib/online/service";
|
||||||
|
import { pushNotification } from "@/lib/notification-store";
|
||||||
import { MatchSummary, RewardResult } from "@/lib/online/types";
|
import { MatchSummary, RewardResult } from "@/lib/online/types";
|
||||||
|
|
||||||
export function GameScreen() {
|
export function GameScreen() {
|
||||||
@@ -45,6 +46,15 @@ export function GameScreen() {
|
|||||||
.then((r) => {
|
.then((r) => {
|
||||||
setReward(r);
|
setReward(r);
|
||||||
refreshProfile();
|
refreshProfile();
|
||||||
|
for (const a of r.newAchievements)
|
||||||
|
pushNotification({
|
||||||
|
kind: "achievement",
|
||||||
|
titleFa: "دستاورد جدید",
|
||||||
|
titleEn: "New achievement",
|
||||||
|
bodyFa: a.nameFa,
|
||||||
|
bodyEn: a.nameEn,
|
||||||
|
icon: a.icon,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
|
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||||
|
import { useNotifStore } from "@/lib/notification-store";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export function NotificationsScreen() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const items = useNotifStore((s) => s.items);
|
||||||
|
const markAllRead = useNotifStore((s) => s.markAllRead);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markAllRead();
|
||||||
|
}, [markAllRead]);
|
||||||
|
|
||||||
|
const fmtTime = (ts: number) =>
|
||||||
|
new Date(ts).toLocaleTimeString(locale === "fa" ? "fa-IR" : "en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenShell>
|
||||||
|
<ScreenHeader title={t("notif.title")} />
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="text-center text-cream/40 py-16">{t("notif.empty")}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 pb-6">
|
||||||
|
{items.map((n) => (
|
||||||
|
<div key={n.id} className="glass rounded-xl p-3 flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{n.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-cream">
|
||||||
|
{locale === "fa" ? n.titleFa : n.titleEn}
|
||||||
|
</div>
|
||||||
|
{(n.bodyFa || n.bodyEn) && (
|
||||||
|
<div className="text-[11px] text-cream/50">
|
||||||
|
{locale === "fa" ? n.bodyFa : n.bodyEn}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-cream/35 tabular-nums">{fmtTime(n.ts)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -226,6 +226,9 @@ const fa: Dict = {
|
|||||||
"reactions.title": "شکلک",
|
"reactions.title": "شکلک",
|
||||||
"stickers.title": "استیکر",
|
"stickers.title": "استیکر",
|
||||||
|
|
||||||
|
"notif.title": "اعلانها",
|
||||||
|
"notif.empty": "اعلانی ندارید",
|
||||||
|
|
||||||
"settings.audio": "تنظیمات صدا",
|
"settings.audio": "تنظیمات صدا",
|
||||||
"settings.sound": "افکت صدا",
|
"settings.sound": "افکت صدا",
|
||||||
"settings.music": "موسیقی پسزمینه",
|
"settings.music": "موسیقی پسزمینه",
|
||||||
@@ -449,6 +452,9 @@ const en: Dict = {
|
|||||||
"reactions.title": "Emoji",
|
"reactions.title": "Emoji",
|
||||||
"stickers.title": "Stickers",
|
"stickers.title": "Stickers",
|
||||||
|
|
||||||
|
"notif.title": "Notifications",
|
||||||
|
"notif.empty": "No notifications yet",
|
||||||
|
|
||||||
"settings.audio": "Audio",
|
"settings.audio": "Audio",
|
||||||
"settings.sound": "Sound effects",
|
"settings.sound": "Sound effects",
|
||||||
"settings.music": "Background music",
|
"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 { create } from "zustand";
|
||||||
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
|
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
|
||||||
|
import { pushNotification } from "./notification-store";
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Friend,
|
Friend,
|
||||||
@@ -49,6 +50,7 @@ let roomUnsub: (() => void) | null = null;
|
|||||||
let mmUnsub: (() => void) | null = null;
|
let mmUnsub: (() => void) | null = null;
|
||||||
let friendUnsub: (() => void) | null = null;
|
let friendUnsub: (() => void) | null = null;
|
||||||
let chatUnsub: (() => void) | null = null;
|
let chatUnsub: (() => void) | null = null;
|
||||||
|
const seenRequests = new Set<string>();
|
||||||
|
|
||||||
export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
||||||
friends: [],
|
friends: [],
|
||||||
@@ -61,6 +63,18 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
|||||||
const svc = getService();
|
const svc = getService();
|
||||||
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
|
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
|
||||||
set({ friends, requests });
|
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 }));
|
if (!friendUnsub) friendUnsub = svc.onFriends((f) => set({ friends: f }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./service";
|
} from "./service";
|
||||||
import {
|
import {
|
||||||
AVATARS,
|
AVATARS,
|
||||||
|
AppNotification,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -463,6 +464,40 @@ export class MockOnlineService implements OnlineService {
|
|||||||
for (const cb of this.reactionCbs) cb(0, reaction);
|
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.
|
// The mock drives the game locally (game-store), so these are no-ops.
|
||||||
readonly live = false;
|
readonly live = false;
|
||||||
onState(): Unsubscribe { return () => {}; }
|
onState(): Unsubscribe { return () => {}; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { Suit } from "../hokm/types";
|
import { Suit } from "../hokm/types";
|
||||||
import {
|
import {
|
||||||
|
AppNotification,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -98,6 +99,9 @@ export interface OnlineService {
|
|||||||
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
|
||||||
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
|
||||||
|
|
||||||
|
/* ----- notifications (server-pushed, in-app) ----- */
|
||||||
|
onNotification(cb: (n: AppNotification) => void): Unsubscribe;
|
||||||
|
|
||||||
/* ----- stats ----- */
|
/* ----- stats ----- */
|
||||||
getOnlineCount(): Promise<number>;
|
getOnlineCount(): Promise<number>;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "./service";
|
} from "./service";
|
||||||
import {
|
import {
|
||||||
|
AppNotification,
|
||||||
AuthSession,
|
AuthSession,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -48,6 +49,8 @@ export class SignalrService implements OnlineService {
|
|||||||
private mmCbs = new Set<(s: MatchmakingState) => void>();
|
private mmCbs = new Set<(s: MatchmakingState) => void>();
|
||||||
private stateCbs = new Set<(s: ServerGameState) => void>();
|
private stateCbs = new Set<(s: ServerGameState) => void>();
|
||||||
private reactionCbs = new Set<(seat: number, reaction: string) => void>();
|
private reactionCbs = new Set<(seat: number, reaction: string) => void>();
|
||||||
|
private notifCbs = new Set<(n: AppNotification) => void>();
|
||||||
|
private mockNotifUnsub?: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== "undefined") {
|
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("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s)));
|
||||||
conn.on("reaction", (r: { seat: number; reaction: string }) =>
|
conn.on("reaction", (r: { seat: number; reaction: string }) =>
|
||||||
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
||||||
|
conn.on("notification", (n: AppNotification) =>
|
||||||
|
this.notifCbs.forEach((cb) => cb(n)));
|
||||||
|
|
||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
try {
|
try {
|
||||||
@@ -262,6 +267,14 @@ export class SignalrService implements OnlineService {
|
|||||||
markRead(id: string) { return this.mock.markRead(id); }
|
markRead(id: string) { return this.mock.markRead(id); }
|
||||||
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
|
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> {
|
async getOnlineCount(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${SERVER}/api/stats/online`);
|
const res = await fetch(`${SERVER}/api/stats/online`);
|
||||||
|
|||||||
@@ -403,6 +403,27 @@ export interface ServerGameState {
|
|||||||
stake: number;
|
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 ------------------------------ */
|
/* ------------------------------ Avatars ------------------------------ */
|
||||||
|
|
||||||
export const AVATARS: { id: string; emoji: string }[] = [
|
export const AVATARS: { id: string; emoji: string }[] = [
|
||||||
|
|||||||
+3
-2
@@ -13,16 +13,17 @@ export type Screen =
|
|||||||
| "leaderboard"
|
| "leaderboard"
|
||||||
| "shop"
|
| "shop"
|
||||||
| "chat"
|
| "chat"
|
||||||
|
| "notifications"
|
||||||
| "game"; // the table (used for both ai + online)
|
| "game"; // the table (used for both ai + online)
|
||||||
|
|
||||||
const ALL_SCREENS: Screen[] = [
|
const ALL_SCREENS: Screen[] = [
|
||||||
"home", "auth", "profile", "friends", "online",
|
"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). */
|
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||||
export const STATIC_SCREENS: Screen[] = [
|
export const STATIC_SCREENS: Screen[] = [
|
||||||
"home", "auth", "profile", "friends", "online", "leaderboard", "shop",
|
"home", "auth", "profile", "friends", "online", "leaderboard", "shop", "notifications",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function screenFromHash(): Screen {
|
export function screenFromHash(): Screen {
|
||||||
|
|||||||
Reference in New Issue
Block a user