Ajout d'un système de notifications

This commit is contained in:
Feror 2026-02-03 16:35:01 +01:00
parent 053d663096
commit 0bbeb129a6
19 changed files with 2075 additions and 111 deletions

View file

@ -625,9 +625,14 @@ function EntryPage({ manager }: { manager: ReturnType<typeof useSessionManager>
manager.setError(t("entry.alert.selectDummy")); manager.setError(t("entry.alert.selectDummy"));
return; return;
} }
const selectedDummy = dummyOptions.find(
(dummy) => dummy.id === takeoverDummyId,
);
const fallbackName =
takeoverName.trim() || selectedDummy?.name || t("common.guest");
const data = await manager.joinSession( const data = await manager.joinSession(
joinPreview.code, joinPreview.code,
takeoverName || t("common.guest"), fallbackName,
); );
if (data) { if (data) {
manager.setPendingTakeoverId(takeoverDummyId); manager.setPendingTakeoverId(takeoverDummyId);
@ -781,9 +786,14 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
manager.setError(t("entry.alert.selectDummy")); manager.setError(t("entry.alert.selectDummy"));
return; return;
} }
const selectedDummy = dummyOptions.find(
(dummy) => dummy.id === takeoverDummyId,
);
const fallbackName =
takeoverName.trim() || selectedDummy?.name || t("common.guest");
const data = await manager.joinSession( const data = await manager.joinSession(
joinPreview.code, joinPreview.code,
takeoverName || t("common.guest"), fallbackName,
); );
if (data) { if (data) {
manager.setPendingTakeoverId(takeoverDummyId); manager.setPendingTakeoverId(takeoverDummyId);
@ -841,6 +851,9 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
} }
const players = manager.players; const players = manager.players;
const pendingRequests = manager.session?.takeoverRequests.filter(
(request) => request.status === "pending",
);
return ( return (
<div className="play-shell"> <div className="play-shell">
@ -904,6 +917,49 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
)} )}
</section> </section>
{manager.isBanker && (pendingRequests ?? []).length > 0 && (
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
<h2>{t("banker.takeoverApprovals")}</h2>
<div className="list">
{(pendingRequests ?? []).map((request) => {
const requester = manager.session?.players.find(
(player) => player.id === request.requesterId,
);
const dummy = manager.session?.players.find(
(player) => player.id === request.dummyId,
);
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return (
<div key={request.id} className="list-item">
<div>
<strong>{requesterName}</strong>
<span>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</span>
</div>
<button
className="button small"
type="button"
onClick={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
{t("banker.approve")}
</button>
</div>
);
})}
</div>
</section>
)}
{manager.isBanker && ( {manager.isBanker && (
<> <>
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}> <section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
@ -1660,10 +1716,12 @@ function BankerPage({ manager }: { manager: ReturnType<typeof useSessionManager>
const dummy = manager.session?.players.find( const dummy = manager.session?.players.find(
(player) => player.id === request.dummyId, (player) => player.id === request.dummyId,
); );
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return ( return (
<div key={request.id} className="list-item"> <div key={request.id} className="list-item">
<div> <div>
<strong>{requester?.name ?? t("common.player")}</strong> <strong>{requesterName}</strong>
<span> <span>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })} {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</span> </span>

View file

@ -22,6 +22,7 @@
}, },
"android": { "android": {
"package": "fr.negopoly.app", "package": "fr.negopoly.app",
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
@ -41,6 +42,7 @@
"category": ["BROWSABLE", "DEFAULT"] "category": ["BROWSABLE", "DEFAULT"]
} }
] ]
} },
"plugins": ["expo-notifications"]
} }
} }

View file

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1011689140478",
"project_id": "negopoly-app",
"storage_bucket": "negopoly-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1011689140478:android:46ff0db61b4516fbeb35af",
"android_client_info": {
"package_name": "fr.negopoly.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDq0EcIFEnj3P2fLhS9mEXWF7eDYFUF5Fg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

534
mobile/package-lock.json generated
View file

@ -15,6 +15,7 @@
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "^6.0.20", "expo-dev-client": "^6.0.20",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@ -2066,6 +2067,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
"node_modules/@isaacs/balanced-match": { "node_modules/@isaacs/balanced-match": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@ -3053,12 +3060,40 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/async-limiter": { "node_modules/async-limiter": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"license": "MIT",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -3277,6 +3312,12 @@
"@babel/core": "^7.0.0" "@babel/core": "^7.0.0"
} }
}, },
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3473,6 +3514,53 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@ -3881,6 +3969,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": { "node_modules/define-lazy-prop": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@ -3890,6 +3995,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -3945,6 +4067,20 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -3990,6 +4126,36 @@
"stackframe": "^1.3.4" "stackframe": "^1.3.4"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -4106,6 +4272,15 @@
} }
} }
}, },
"node_modules/expo-application": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": { "node_modules/expo-asset": {
"version": "12.0.12", "version": "12.0.12",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
@ -4268,6 +4443,26 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-notifications": {
"version": "0.32.16",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-server": { "node_modules/expo-server": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@ -4438,6 +4633,21 @@
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"license": "MIT",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/freeport-async": { "node_modules/freeport-async": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@ -4485,6 +4695,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -4503,6 +4722,30 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": { "node_modules/get-package-type": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@ -4512,6 +4755,19 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/getenv": { "node_modules/getenv": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
@ -4565,6 +4821,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -4580,6 +4848,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -4767,12 +5074,40 @@
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
}, },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -4812,6 +5147,41 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -4830,6 +5200,39 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"license": "MIT",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-wsl": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -5576,6 +5979,15 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -6195,6 +6607,51 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/object.assign": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0",
"has-symbols": "^1.1.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -6521,6 +6978,15 @@
"node": ">=4.0.0" "node": ">=4.0.0"
} }
}, },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.49", "version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@ -7177,6 +7643,23 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": { "node_modules/sax": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
@ -7306,6 +7789,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -8039,6 +8539,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -8149,6 +8662,27 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wonka": { "node_modules/wonka": {
"version": "6.3.5", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",

View file

@ -16,6 +16,7 @@
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "^6.0.20", "expo-dev-client": "^6.0.20",
"expo-notifications": "~0.32.16",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Linking } from "react-native"; import { Linking } from "react-native";
import { import {
NavigationContainer, NavigationContainer,
@ -7,10 +7,12 @@ import {
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import * as Notifications from "expo-notifications";
import AppNavigator from "./navigation/AppNavigator"; import AppNavigator from "./navigation/AppNavigator";
import type { RootStackParamList } from "./navigation/types"; import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context"; import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme"; import { getNavigationTheme, useTheme } from "./theme";
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
function extractGameId(url: string): string | null { function extractGameId(url: string): string | null {
try { try {
@ -45,6 +47,8 @@ function RootNavigationGate() {
const [navReady, setNavReady] = useState(false); const [navReady, setNavReady] = useState(false);
const lastTargetRef = useRef<keyof RootStackParamList | null>(null); const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null); const lastLinkRef = useRef<string | null>(null);
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
const lastNotificationIdRef = useRef<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const navigationTheme = getNavigationTheme(theme); const navigationTheme = getNavigationTheme(theme);
const linking = useMemo<LinkingOptions<RootStackParamList>>( const linking = useMemo<LinkingOptions<RootStackParamList>>(
@ -78,6 +82,62 @@ function RootNavigationGate() {
[], [],
); );
const processPendingNotification = useCallback(() => {
const pending = pendingNotificationRef.current;
if (!pending) return;
if (!navReady || !navigationRef.isReady()) return;
if (manager.sessionId !== pending.sessionId) return;
if (!manager.session || manager.session.status !== "active") return;
pendingNotificationRef.current = null;
if (pending.type === "chat") {
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
navigationRef.navigate(
targetStack as never,
{
screen: targetTab,
params: {
screen: "ChatThread",
params: { chatId: pending.chatId },
},
} as never,
);
return;
}
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
navigationRef.navigate(
targetStack as never,
{ screen: targetTab } as never,
);
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
const handleNotificationResponse = useCallback(
(response: Notifications.NotificationResponse | null) => {
if (!response) return;
const identifier = response.notification.request.identifier;
if (identifier && lastNotificationIdRef.current === identifier) return;
if (identifier) {
lastNotificationIdRef.current = identifier;
}
const target = parseNotificationTarget(
response.notification.request.content.data,
);
if (!target) return;
if (__DEV__) {
console.log(
`[push] target=${target.type} session=${target.sessionId}` +
(target.type === "chat" ? ` chat=${target.chatId}` : ""),
);
}
pendingNotificationRef.current = target;
processPendingNotification();
},
[processPendingNotification],
);
useEffect(() => { useEffect(() => {
if (!navReady || !navigationRef.isReady()) return; if (!navReady || !navigationRef.isReady()) return;
@ -113,6 +173,27 @@ function RootNavigationGate() {
navigationRef, navigationRef,
]); ]);
useEffect(() => {
processPendingNotification();
}, [processPendingNotification]);
useEffect(() => {
let mounted = true;
Notifications.getLastNotificationResponseAsync().then((response) => {
if (!mounted) return;
handleNotificationResponse(response);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
handleNotificationResponse(response);
},
);
return () => {
mounted = false;
subscription.remove();
};
}, [handleNotificationResponse]);
return ( return (
<NavigationContainer <NavigationContainer
ref={navigationRef} ref={navigationRef}

View file

@ -42,12 +42,14 @@ const translations = {
"entry.yourNameOptional": "Your name (optional)", "entry.yourNameOptional": "Your name (optional)",
"entry.requestTakeover": "Request takeover", "entry.requestTakeover": "Request takeover",
"entry.noDummies": "No dummies available yet.", "entry.noDummies": "No dummies available yet.",
"entry.takeoverPending": "Waiting for the banker to approve your takeover.",
"entry.createTitle": "Create a session", "entry.createTitle": "Create a session",
"entry.bankerName": "Banker name", "entry.bankerName": "Banker name",
"entry.openVault": "Open the vault", "entry.openVault": "Open the vault",
"entry.alert.enterCode": "Enter a session code", "entry.alert.enterCode": "Enter a session code",
"entry.alert.sessionNotFound": "Session not found", "entry.alert.sessionNotFound": "Session not found",
"entry.alert.selectDummy": "Select a dummy player", "entry.alert.selectDummy": "Select a dummy player",
"entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
"lobby.title": "Lobby", "lobby.title": "Lobby",
"lobby.code": "Code: {code}", "lobby.code": "Code: {code}",
"lobby.startGame": "Start game", "lobby.startGame": "Start game",
@ -108,6 +110,9 @@ const translations = {
"banker.tools.note": "Note", "banker.tools.note": "Note",
"banker.tools.dummyName": "Dummy name", "banker.tools.dummyName": "Dummy name",
"banker.tools.startingBalance": "Starting balance", "banker.tools.startingBalance": "Starting balance",
"banker.takeoverApprovals": "Takeover approvals",
"banker.wants": "wants {name}",
"banker.approve": "Approve",
"banker.stateTitle": "GameState", "banker.stateTitle": "GameState",
"banker.stateSubtitle": "Export or restore the current session.", "banker.stateSubtitle": "Export or restore the current session.",
"banker.downloadState": "Export GameState", "banker.downloadState": "Export GameState",
@ -193,12 +198,14 @@ const translations = {
"entry.yourNameOptional": "Votre nom (optionnel)", "entry.yourNameOptional": "Votre nom (optionnel)",
"entry.requestTakeover": "Demander la reprise", "entry.requestTakeover": "Demander la reprise",
"entry.noDummies": "Aucun dummy disponible pour le moment.", "entry.noDummies": "Aucun dummy disponible pour le moment.",
"entry.takeoverPending": "En attente de l'approbation du banquier.",
"entry.createTitle": "Créer une session", "entry.createTitle": "Créer une session",
"entry.bankerName": "Nom du banquier", "entry.bankerName": "Nom du banquier",
"entry.openVault": "Ouvrir le coffre", "entry.openVault": "Ouvrir le coffre",
"entry.alert.enterCode": "Entrez un code de session", "entry.alert.enterCode": "Entrez un code de session",
"entry.alert.sessionNotFound": "Session introuvable", "entry.alert.sessionNotFound": "Session introuvable",
"entry.alert.selectDummy": "Sélectionnez un dummy", "entry.alert.selectDummy": "Sélectionnez un dummy",
"entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
"lobby.title": "Lobby", "lobby.title": "Lobby",
"lobby.code": "Code : {code}", "lobby.code": "Code : {code}",
"lobby.startGame": "Démarrer la partie", "lobby.startGame": "Démarrer la partie",
@ -259,6 +266,9 @@ const translations = {
"banker.tools.note": "Note", "banker.tools.note": "Note",
"banker.tools.dummyName": "Nom du dummy", "banker.tools.dummyName": "Nom du dummy",
"banker.tools.startingBalance": "Solde de départ", "banker.tools.startingBalance": "Solde de départ",
"banker.takeoverApprovals": "Approbations de reprise",
"banker.wants": "veut {name}",
"banker.approve": "Approuver",
"banker.stateTitle": "État de partie", "banker.stateTitle": "État de partie",
"banker.stateSubtitle": "Exportez ou restaurez la session.", "banker.stateSubtitle": "Exportez ou restaurez la session.",
"banker.downloadState": "Exporter l'état", "banker.downloadState": "Exporter l'état",

View file

@ -0,0 +1,75 @@
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
export type NotificationTarget =
| { type: "chat"; sessionId: string; chatId: string }
| { type: "transactions"; sessionId: string };
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export function parseNotificationTarget(data: unknown): NotificationTarget | null {
if (!data || typeof data !== "object") return null;
const record = data as Record<string, unknown>;
const type = record.type;
const sessionId = record.sessionId;
if (typeof type !== "string" || typeof sessionId !== "string" || !sessionId.trim()) {
return null;
}
if (type === "transactions") {
return { type: "transactions", sessionId };
}
if (type === "chat") {
const chatId = record.chatId;
if (typeof chatId !== "string" || !chatId.trim()) return null;
return { type: "chat", sessionId, chatId };
}
return null;
}
export async function registerForPushNotificationsAsync(): Promise<
| {
token: string;
platform: "ios" | "android";
}
| null
> {
try {
const existing = await Notifications.getPermissionsAsync();
let finalStatus = existing.status;
if (finalStatus !== "granted") {
const requested = await Notifications.requestPermissionsAsync();
finalStatus = requested.status;
}
if (finalStatus !== "granted") {
if (__DEV__) {
console.log("[push] permission not granted");
}
return null;
}
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
});
}
const deviceToken = await Notifications.getDevicePushTokenAsync();
if (deviceToken.type !== "ios" && deviceToken.type !== "android") {
return null;
}
if (__DEV__) {
console.log(`[push] registered device token=${deviceToken.data}`);
}
return { token: deviceToken.data, platform: deviceToken.type };
} catch (error) {
if (__DEV__) {
console.log("[push] registration failed", error);
}
return null;
}
}

View file

@ -127,6 +127,12 @@ export default function BankerToolsScreen() {
>([]); >([]);
const [importJson, setImportJson] = useState(""); const [importJson, setImportJson] = useState("");
const [autoSaveStatus, setAutoSaveStatus] = useState<string | null>(null); const [autoSaveStatus, setAutoSaveStatus] = useState<string | null>(null);
const pendingRequests = useMemo(
() =>
manager.session?.takeoverRequests.filter((request) => request.status === "pending") ??
[],
[manager.session?.takeoverRequests],
);
const autoSaveKey = `negopoly:autosave:${manager.sessionId}`; const autoSaveKey = `negopoly:autosave:${manager.sessionId}`;
const autoSaveSettingsKey = `${autoSaveKey}:settings`; const autoSaveSettingsKey = `${autoSaveKey}:settings`;
@ -617,6 +623,48 @@ export default function BankerToolsScreen() {
</> </>
) : ( ) : (
<> <>
{pendingRequests.length > 0 ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("banker.takeoverApprovals")}</Text>
<View style={styles.takeoverList}>
{pendingRequests.map((request) => {
const requester = manager.session?.players.find(
(player) => player.id === request.requesterId,
);
const dummy = manager.session?.players.find(
(player) => player.id === request.dummyId,
);
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return (
<View key={request.id} style={styles.takeoverRow}>
<View style={styles.takeoverMeta}>
<Text style={styles.takeoverName}>{requesterName}</Text>
<Text style={styles.takeoverSub}>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</Text>
</View>
<TouchableOpacity
style={styles.buttonSmall}
onPress={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
</TouchableOpacity>
</View>
);
})}
</View>
</View>
) : null}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>{t("banker.tools.blackout")}</Text> <Text style={styles.cardTitle}>{t("banker.tools.blackout")}</Text>
<View style={styles.switchRow}> <View style={styles.switchRow}>
@ -1035,6 +1083,30 @@ const createStyles = (theme: AppTheme) =>
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted, borderBottomColor: theme.colors.borderMuted,
}, },
takeoverList: {
gap: 10,
},
takeoverRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
},
takeoverMeta: {
flex: 1,
paddingRight: 12,
},
takeoverName: {
fontWeight: "600",
color: theme.colors.text,
},
takeoverSub: {
fontSize: 12,
color: theme.colors.textMuted,
marginTop: 2,
},
buttonSmall: { buttonSmall: {
backgroundColor: theme.colors.secondary, backgroundColor: theme.colors.secondary,
paddingHorizontal: 10, paddingHorizontal: 10,

View file

@ -49,6 +49,8 @@ export default function EntryScreen() {
const [takeoverName, setTakeoverName] = useState(""); const [takeoverName, setTakeoverName] = useState("");
const [takeoverDummyId, setTakeoverDummyId] = useState(""); const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [showDummyOptions, setShowDummyOptions] = useState(false); const [showDummyOptions, setShowDummyOptions] = useState(false);
const [takeoverToken, setTakeoverToken] = useState<string | null>(null);
const [takeoverWaiting, setTakeoverWaiting] = useState(false);
const dummyOptions = useMemo( const dummyOptions = useMemo(
() => joinPreview?.players.filter((player) => player.isDummy) ?? [], () => joinPreview?.players.filter((player) => player.isDummy) ?? [],
@ -100,6 +102,8 @@ export default function EntryScreen() {
setJoinName(""); setJoinName("");
setTakeoverName(""); setTakeoverName("");
setTakeoverDummyId(""); setTakeoverDummyId("");
setTakeoverToken(null);
setTakeoverWaiting(false);
manager.fetchSessionPreview(code).then((preview) => { manager.fetchSessionPreview(code).then((preview) => {
if (!preview) { if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound")); Alert.alert(t("entry.alert.sessionNotFound"));
@ -124,22 +128,54 @@ export default function EntryScreen() {
Alert.alert(t("entry.alert.selectDummy")); Alert.alert(t("entry.alert.selectDummy"));
return; return;
} }
const data = await manager.joinSession( setTakeoverWaiting(true);
const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId);
const fallbackName = takeoverName.trim() || selectedDummy?.name || "";
const token = await manager.requestTakeoverToken(
joinPreview.code, joinPreview.code,
takeoverName.trim() || t("common.guest"), takeoverDummyId,
fallbackName,
); );
if (data) { if (!token) {
manager.setPendingTakeoverId(takeoverDummyId); setTakeoverWaiting(false);
navigation.replace("Lobby"); if (manager.error) {
Alert.alert(manager.error);
} }
return;
}
setTakeoverToken(token);
} }
useEffect(() => { useEffect(() => {
if (joinStep === "code" || !joinPreview) { if (joinStep === "code" || !joinPreview) {
setShowDummyOptions(false); setShowDummyOptions(false);
setTakeoverToken(null);
setTakeoverWaiting(false);
} }
}, [joinStep, joinPreview]); }, [joinStep, joinPreview]);
useEffect(() => {
if (!takeoverToken || !joinPreview) return;
let cancelled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
const data = await manager.claimTakeover(joinPreview.code, takeoverToken);
if (cancelled) return;
if (data) {
setTakeoverWaiting(false);
setTakeoverToken(null);
navigation.replace("Lobby");
return;
}
timeout = setTimeout(poll, 2000);
};
poll();
return () => {
cancelled = true;
if (timeout) clearTimeout(timeout);
};
}, [takeoverToken, joinPreview, manager, navigation]);
return ( return (
<ScrollView style={styles.scroll} contentContainerStyle={contentStyle}> <ScrollView style={styles.scroll} contentContainerStyle={contentStyle}>
<Text style={styles.title}>{t("app.name")}</Text> <Text style={styles.title}>{t("app.name")}</Text>
@ -192,6 +228,21 @@ export default function EntryScreen() {
<Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text> <Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text>
{takeoverDisabled ? ( {takeoverDisabled ? (
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text> <Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
) : (
<>
{takeoverWaiting ? (
<View style={styles.pendingBox}>
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
<TouchableOpacity
style={styles.buttonSecondary}
onPress={() => {
setTakeoverToken(null);
setTakeoverWaiting(false);
}}
>
<Text style={styles.buttonSecondaryText}>{t("common.cancel")}</Text>
</TouchableOpacity>
</View>
) : ( ) : (
<> <>
<View style={styles.dropdown}> <View style={styles.dropdown}>
@ -215,7 +266,9 @@ export default function EntryScreen() {
key={player.id} key={player.id}
style={[ style={[
styles.dropdownItem, styles.dropdownItem,
player.id === takeoverDummyId ? styles.dropdownItemActive : null, player.id === takeoverDummyId
? styles.dropdownItemActive
: null,
]} ]}
onPress={() => { onPress={() => {
setTakeoverDummyId(player.id); setTakeoverDummyId(player.id);
@ -237,10 +290,14 @@ export default function EntryScreen() {
onChangeText={setTakeoverName} onChangeText={setTakeoverName}
/> />
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}> <TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
<Text style={styles.buttonSecondaryText}>{t("entry.requestTakeover")}</Text> <Text style={styles.buttonSecondaryText}>
{t("entry.requestTakeover")}
</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
</>
)}
{!takeoverDisabled && dummyOptions.length === 0 ? ( {!takeoverDisabled && dummyOptions.length === 0 ? (
<Text style={styles.helper}>{t("entry.noDummies")}</Text> <Text style={styles.helper}>{t("entry.noDummies")}</Text>
) : null} ) : null}
@ -333,6 +390,14 @@ const createStyles = (theme: AppTheme) =>
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surface,
overflow: "hidden", overflow: "hidden",
}, },
pendingBox: {
gap: 10,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
dropdownItem: { dropdownItem: {
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,

View file

@ -66,11 +66,21 @@ export default function LobbyScreen() {
} }
const canStart = manager.isBanker && manager.session.status === "lobby"; const canStart = manager.isBanker && manager.session.status === "lobby";
const pendingTakeover = manager.session.takeoverRequests.find(
(request) =>
request.requesterId === manager.playerId && request.status === "pending",
);
const pendingRequests = manager.isBanker
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
: [];
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
<Text style={styles.title}>{t("lobby.title")}</Text> <Text style={styles.title}>{t("lobby.title")}</Text>
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text> <Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
{pendingTakeover ? (
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
) : null}
<FlatList <FlatList
data={manager.session.players} data={manager.session.players}
@ -92,6 +102,47 @@ export default function LobbyScreen() {
)} )}
/> />
{manager.isBanker && pendingRequests.length > 0 ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("banker.takeoverApprovals")}</Text>
<View style={styles.takeoverList}>
{pendingRequests.map((request) => {
const requester =
manager.session.players.find((player) => player.id === request.requesterId) ??
null;
const dummy =
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return (
<View key={request.id} style={styles.takeoverRow}>
<View style={styles.takeoverMeta}>
<Text style={styles.takeoverName}>{requesterName}</Text>
<Text style={styles.takeoverSub}>
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
</Text>
</View>
<TouchableOpacity
style={styles.buttonSmall}
onPress={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
</TouchableOpacity>
</View>
);
})}
</View>
</View>
) : null}
{manager.isBanker && manager.session.status === "lobby" && ( {manager.isBanker && manager.session.status === "lobby" && (
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text> <Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
@ -232,4 +283,39 @@ const createStyles = (theme: AppTheme) =>
color: theme.colors.secondaryText, color: theme.colors.secondaryText,
fontWeight: "600", fontWeight: "600",
}, },
takeoverList: {
gap: 10,
},
takeoverRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
},
takeoverMeta: {
flex: 1,
paddingRight: 12,
},
takeoverName: {
fontWeight: "600",
color: theme.colors.text,
},
takeoverSub: {
fontSize: 12,
color: theme.colors.textMuted,
marginTop: 2,
},
buttonSmall: {
backgroundColor: theme.colors.secondary,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
},
buttonSmallText: {
color: theme.colors.secondaryText,
fontWeight: "600",
fontSize: 12,
},
}); });

View file

@ -48,6 +48,8 @@ export type TakeoverRequest = {
id: string; id: string;
dummyId: string; dummyId: string;
requesterId: string; requesterId: string;
requesterName?: string | null;
requesterToken?: string | null;
createdAt: number; createdAt: number;
status: "pending" | "approved" | "rejected"; status: "pending" | "approved" | "rejected";
}; };

View file

@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
import { getApiBaseUrl, getWsUrl } from "../config/api"; import { getApiBaseUrl, getWsUrl } from "../config/api";
import { tStatic } from "../i18n"; import { tStatic } from "../i18n";
import { registerForPushNotificationsAsync } from "../notifications";
const STORAGE_KEY = "negopoly:session"; const STORAGE_KEY = "negopoly:session";
@ -36,13 +37,17 @@ export function useSessionManager() {
const [playerId, setPlayerId] = useState(""); const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null); const [session, setSession] = useState<SessionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState< const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error" "idle" | "connecting" | "open" | "error"
>("idle"); >("idle");
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [pushToken, setPushToken] = useState<{
token: string;
platform: "ios" | "android";
} | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const lastPushRegistrationRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -57,11 +62,48 @@ export function useSessionManager() {
}; };
}, []); }, []);
useEffect(() => {
let mounted = true;
registerForPushNotificationsAsync().then((token) => {
if (!mounted) return;
setPushToken(token);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000); const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
useEffect(() => {
if (!pushToken || !sessionId || !playerId) return;
void registerPushTokenFor(sessionId, playerId);
}, [pushToken, sessionId, playerId]);
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
if (!pushToken) return;
const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`;
if (lastPushRegistrationRef.current === signature) return;
lastPushRegistrationRef.current = signature;
try {
await fetch(`${getApiBaseUrl()}/api/push/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: targetSessionId,
playerId: targetPlayerId,
token: pushToken.token,
platform: pushToken.platform,
}),
});
} catch {
// Ignore push registration failures.
}
}
useEffect(() => { useEffect(() => {
if (!sessionId || !playerId) { if (!sessionId || !playerId) {
setConnectionState("idle"); setConnectionState("idle");
@ -120,20 +162,55 @@ export function useSessionManager() {
}; };
}, [sessionId, playerId]); }, [sessionId, playerId]);
useEffect(() => { async function requestTakeover(
if (!pendingTakeoverId) return; dummyId: string,
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; overrideSessionId?: string,
if (!sessionId || !playerId) return; overridePlayerId?: string,
wsRef.current.send( ): Promise<string | null> {
JSON.stringify({ const targetSessionId = overrideSessionId ?? sessionId;
const targetPlayerId = overridePlayerId ?? playerId;
if (!targetSessionId || !targetPlayerId) {
const message = tStatic("entry.alert.takeoverFailed");
setError(message);
return message;
}
const payload = {
type: "takeover_request", type: "takeover_request",
sessionId, sessionId: targetSessionId,
playerId, playerId: targetPlayerId,
dummyId: pendingTakeoverId, dummyId,
}), };
if (
wsRef.current &&
wsRef.current.readyState === WebSocket.OPEN &&
targetSessionId === sessionId &&
targetPlayerId === playerId
) {
wsRef.current.send(JSON.stringify(payload));
return null;
}
try {
const response = await fetch(
`${getApiBaseUrl()}/api/session/${targetSessionId}/takeover`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId: targetPlayerId, dummyId }),
},
); );
setPendingTakeoverId(null); if (!response.ok) {
}, [pendingTakeoverId, sessionId, playerId]); const data = (await response.json()) as { message?: string };
const message = data.message ?? tStatic("entry.alert.takeoverFailed");
setError(message);
return message;
}
return null;
} catch {
const message = tStatic("error.connectionNotReady");
setError(message);
return message;
}
}
async function createSession(bankerName: string) { async function createSession(bankerName: string) {
setError(null); setError(null);
@ -193,6 +270,73 @@ export function useSessionManager() {
return data; return data;
} }
async function requestTakeoverToken(
code: string,
dummyId: string,
name: string,
): Promise<string | null> {
setError(null);
if (!code || !dummyId) {
setError(tStatic("entry.alert.selectDummy"));
return null;
}
try {
const response = await fetch(
`${getApiBaseUrl()}/api/session/${code}/takeover-request`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dummyId, name }),
},
);
if (!response.ok) {
const data = (await response.json()) as { message?: string };
setError(data.message ?? tStatic("entry.alert.takeoverFailed"));
return null;
}
const data = (await response.json()) as { token?: string };
return data.token ?? null;
} catch {
setError(tStatic("error.connectionNotReady"));
return null;
}
}
async function claimTakeover(code: string, token: string) {
try {
const response = await fetch(
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
},
);
if (response.status === 409) {
return null;
}
if (!response.ok) {
const data = (await response.json()) as { message?: string };
setError(data.message ?? tStatic("entry.alert.takeoverFailed"));
return null;
}
const data = (await response.json()) as JoinResponse;
setSessionId(data.sessionId);
setSessionCode(data.sessionCode);
setPlayerId(data.playerId);
await writeStoredSession({
sessionId: data.sessionId,
sessionCode: data.sessionCode,
playerId: data.playerId,
});
await registerPushTokenFor(data.sessionId, data.playerId);
return data;
} catch {
setError(tStatic("error.connectionNotReady"));
return null;
}
}
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> { async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
if (!code) return null; if (!code) return null;
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
@ -222,7 +366,6 @@ export function useSessionManager() {
setPlayerId(""); setPlayerId("");
setSession(null); setSession(null);
setError(null); setError(null);
setPendingTakeoverId(null);
setConnectionState("idle"); setConnectionState("idle");
} }
@ -256,12 +399,14 @@ export function useSessionManager() {
createSession, createSession,
joinSession, joinSession,
fetchSessionPreview, fetchSessionPreview,
requestTakeoverToken,
claimTakeover,
sendMessage, sendMessage,
resetSession, resetSession,
leaveSession, leaveSession,
setSessionId, setSessionId,
setPlayerId, setPlayerId,
setSession, setSession,
setPendingTakeoverId, requestTakeover,
}; };
} }

View file

@ -1,6 +1,13 @@
import type { SessionSnapshot } from "../shared/types"; import type { SessionSnapshot } from "../shared/types";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { applySnapshot, joinSession, snapshotSession } from "./domain"; import {
applySnapshot,
claimTakeover,
joinSession,
requestTakeover,
requestTakeoverByToken,
snapshotSession,
} from "./domain";
import { import {
createSession, createSession,
createTestPreview, createTestPreview,
@ -10,6 +17,7 @@ import {
isTestSessionCode, isTestSessionCode,
} from "./store"; } from "./store";
import { broadcastSessionState, startTestSimulation } from "./websocket"; import { broadcastSessionState, startTestSimulation } from "./websocket";
import { registerPushToken } from "./notifications";
function jsonResponse(data: unknown, status = 200): Response { function jsonResponse(data: unknown, status = 200): Response {
return Response.json(data, { status }); return Response.json(data, { status });
@ -184,4 +192,127 @@ export const apiRoutes = {
return jsonResponse(previewSession(snapshot)); return jsonResponse(previewSession(snapshot));
}, },
}, },
"/api/session/:id/takeover": {
async POST(req: BunRequest) {
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
let body: { playerId?: string; dummyId?: string };
try {
body = await readJson<{ playerId?: string; dummyId?: string }>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const playerId = body.playerId?.trim();
const dummyId = body.dummyId?.trim();
if (!playerId || !dummyId) {
return jsonResponse({ message: "Missing playerId or dummyId" }, 400);
}
try {
requestTakeover(session, playerId, dummyId);
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to request takeover";
return jsonResponse({ message }, 400);
}
broadcastSessionState(session.id);
return jsonResponse({ ok: true });
},
},
"/api/session/:id/takeover-request": {
async POST(req: BunRequest) {
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
let body: { dummyId?: string; name?: string };
try {
body = await readJson<{ dummyId?: string; name?: string }>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const dummyId = body.dummyId?.trim();
if (!dummyId) {
return jsonResponse({ message: "Missing dummyId" }, 400);
}
try {
const token = requestTakeoverByToken(session, dummyId, body.name);
broadcastSessionState(session.id);
return jsonResponse({ token });
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to request takeover";
return jsonResponse({ message }, 400);
}
},
},
"/api/session/:id/takeover-claim": {
async POST(req: BunRequest) {
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
let body: { token?: string };
try {
body = await readJson<{ token?: string }>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const token = body.token?.trim();
if (!token) {
return jsonResponse({ message: "Missing token" }, 400);
}
try {
const playerId = claimTakeover(session, token);
broadcastSessionState(session.id);
return jsonResponse({
sessionId: session.id,
sessionCode: session.code,
playerId,
role: "player",
status: session.status,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to claim takeover";
if (message === "Takeover not approved") {
return jsonResponse({ message }, 409);
}
return jsonResponse({ message }, 400);
}
},
},
"/api/push/register": {
async POST(req: BunRequest) {
let body: { sessionId?: string; playerId?: string; token?: string; platform?: string };
try {
body = await readJson<{
sessionId?: string;
playerId?: string;
token?: string;
platform?: string;
}>(req);
} catch {
return jsonResponse({ message: "Invalid request body" }, 400);
}
const sessionId = body.sessionId?.trim();
const playerId = body.playerId?.trim();
const token = body.token?.trim();
const platform = body.platform?.trim();
if (!sessionId || !playerId || !token || !platform) {
return jsonResponse({ message: "Missing sessionId, playerId, token, or platform" }, 400);
}
if (platform !== "ios" && platform !== "android") {
return jsonResponse({ message: "Invalid platform" }, 400);
}
const session = getSession(sessionId);
if (!session) {
return jsonResponse({ message: "Session not found" }, 404);
}
const player = session.players.get(playerId);
if (!player) {
return jsonResponse({ message: "Player not found" }, 404);
}
registerPushToken(sessionId, playerId, token, platform);
return jsonResponse({ ok: true });
},
},
}; };

View file

@ -426,6 +426,31 @@ export function requestTakeover(
return request; return request;
} }
export function requestTakeoverByToken(
session: Session,
dummyId: string,
requesterName?: string | null,
): string {
ensureOpenSession(session);
const dummy = getPlayer(session, dummyId);
if (!dummy.isDummy) {
throw new DomainError("Selected player is not available for takeover");
}
const token = createId();
const name = requesterName?.trim() || null;
const request: TakeoverRequest = {
id: createId(),
dummyId,
requesterId: token,
requesterName: name,
requesterToken: token,
createdAt: now(),
status: "pending",
};
session.takeoverRequests.unshift(request);
return token;
}
export function approveTakeover( export function approveTakeover(
session: Session, session: Session,
bankerId: string, bankerId: string,
@ -435,7 +460,6 @@ export function approveTakeover(
ensureOpenSession(session); ensureOpenSession(session);
ensureBanker(session, bankerId); ensureBanker(session, bankerId);
const dummy = getPlayer(session, dummyId); const dummy = getPlayer(session, dummyId);
const requester = getPlayer(session, requesterId);
if (!dummy.isDummy) { if (!dummy.isDummy) {
throw new DomainError("Selected player is not a dummy"); throw new DomainError("Selected player is not a dummy");
} }
@ -448,6 +472,23 @@ export function approveTakeover(
if (!targetRequest) { if (!targetRequest) {
throw new DomainError("No takeover request found"); throw new DomainError("No takeover request found");
} }
const requester = session.players.get(requesterId);
if (!requester && targetRequest.requesterToken) {
targetRequest.status = "approved";
session.takeoverRequests.forEach((request) => {
if (
request.dummyId === dummyId &&
request.status === "pending" &&
request.requesterId !== requesterId
) {
request.status = "rejected";
}
});
return dummy.id;
}
if (!requester) {
throw new DomainError("Requester not found");
}
targetRequest.status = "approved"; targetRequest.status = "approved";
// Reject any other pending requests for the same dummy. // Reject any other pending requests for the same dummy.
session.takeoverRequests.forEach((request) => { session.takeoverRequests.forEach((request) => {
@ -469,6 +510,29 @@ export function approveTakeover(
return dummy.id; return dummy.id;
} }
export function claimTakeover(session: Session, requesterToken: string): string {
ensureOpenSession(session);
const targetRequest = session.takeoverRequests.find(
(request) =>
request.requesterToken === requesterToken && request.status === "approved",
);
if (!targetRequest) {
throw new DomainError("Takeover not approved");
}
const dummy = getPlayer(session, targetRequest.dummyId);
if (!dummy.isDummy) {
throw new DomainError("Dummy already taken");
}
dummy.isDummy = false;
dummy.connected = true;
dummy.name = targetRequest.requesterName?.trim() || dummy.name;
dummy.lastActiveAt = now();
session.takeoverRequests = session.takeoverRequests.filter(
(request) => request.id !== targetRequest.id,
);
return dummy.id;
}
function ensureActiveSession(session: Session): void { function ensureActiveSession(session: Session): void {
ensureOpenSession(session); ensureOpenSession(session);
} }

582
server/notifications.ts Normal file
View file

@ -0,0 +1,582 @@
import type { ChatMessage, Transaction } from "../shared/types";
import type { Session } from "./types";
import { createPrivateKey, sign } from "crypto";
import { existsSync, readFileSync } from "fs";
import { connect } from "node:http2";
type Platform = "ios" | "android";
type PushMessage = {
to: string;
platform: Platform;
title?: string;
body: string;
data?: Record<string, string>;
};
type PlayerTokens = {
ios: Set<string>;
android: Set<string>;
};
const tokensBySession = new Map<string, Map<string, PlayerTokens>>();
const apnsKeyId = process.env.APNS_KEY_ID || "";
const apnsTeamId = process.env.APNS_TEAM_ID || "";
const apnsBundleId = process.env.APNS_BUNDLE_ID || "";
const apnsEnv = process.env.APNS_ENV === "production" ? "production" : "sandbox";
const apnsPrivateKey = process.env.APNS_PRIVATE_KEY || "";
const apnsPrivateKeyPath = process.env.APNS_PRIVATE_KEY_PATH || "";
const fcmProjectId = process.env.FCM_PROJECT_ID || "";
const fcmClientEmail = process.env.FCM_CLIENT_EMAIL || "";
const fcmPrivateKey = process.env.FCM_PRIVATE_KEY || "";
const fcmServerKey = process.env.FCM_SERVER_KEY || "";
const pushDebug = Boolean(process.env.PUSH_DEBUG);
let cachedApnsToken: { value: string; issuedAt: number } | null = null;
let cachedFcmToken: { value: string; expiresAt: number } | null = null;
let cachedApnsPrivateKey: string | null = null;
function formatMoney(amount: number): string {
const value = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(
Math.abs(amount),
);
return `${value}`;
}
function formatReason(note?: string | null): string {
const trimmed = note?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "No reason provided";
}
function compactText(value: string, maxLength: number): string {
const clean = value.replace(/\s+/g, " ").trim();
if (clean.length <= maxLength) return clean;
return `${clean.slice(0, maxLength - 1)}`;
}
function getSessionTokens(
sessionId: string,
playerIds: string[],
platform: Platform,
): string[] {
const sessionTokens = tokensBySession.get(sessionId);
if (!sessionTokens) return [];
const combined = new Set<string>();
playerIds.forEach((playerId) => {
const tokens = sessionTokens.get(playerId);
if (!tokens) return;
const bucket = platform === "ios" ? tokens.ios : tokens.android;
bucket.forEach((token) => combined.add(token));
});
return Array.from(combined);
}
function debugSessionTokens(sessionId: string): void {
const sessionTokens = tokensBySession.get(sessionId);
if (!sessionTokens) {
console.log(`[push][debug] session=${sessionId} tokens=none`);
return;
}
const dump: Record<string, { ios: string[]; android: string[] }> = {};
sessionTokens.forEach((tokens, playerId) => {
dump[playerId] = {
ios: Array.from(tokens.ios),
android: Array.from(tokens.android),
};
});
console.log(`[push][debug] session=${sessionId} tokens=${JSON.stringify(dump)}`);
}
function base64UrlEncode(value: string | Buffer): string {
const buffer = typeof value === "string" ? Buffer.from(value) : value;
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function normalizeKey(value: string): string {
if (!value) return value;
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
}
function normalizeData(data?: Record<string, unknown>): Record<string, string> | undefined {
if (!data) return undefined;
const entries = Object.entries(data).map(([key, value]) => [key, `${value ?? ""}`]);
return Object.fromEntries(entries);
}
function resolveApnsPrivateKey(): string {
if (cachedApnsPrivateKey !== null) return cachedApnsPrivateKey;
const candidates = [apnsPrivateKey, apnsPrivateKeyPath].filter(Boolean);
let resolved = "";
for (const candidate of candidates) {
if (candidate.includes("BEGIN")) {
resolved = candidate;
break;
}
if (existsSync(candidate)) {
try {
resolved = readFileSync(candidate, "utf8");
break;
} catch {
// Ignore read failures; fallback to raw string.
}
}
}
cachedApnsPrivateKey = resolved || apnsPrivateKey;
return cachedApnsPrivateKey;
}
function createApnsJwt(): string | null {
const resolvedKey = resolveApnsPrivateKey();
if (!apnsKeyId || !apnsTeamId || !apnsBundleId || !resolvedKey) {
if (pushDebug) {
const missing = [
!apnsKeyId && "APNS_KEY_ID",
!apnsTeamId && "APNS_TEAM_ID",
!apnsBundleId && "APNS_BUNDLE_ID",
!resolvedKey && "APNS_PRIVATE_KEY",
]
.filter(Boolean)
.join(", ");
console.log(`[push][apns] skipped: missing ${missing || "credentials"}`);
}
return null;
}
const now = Math.floor(Date.now() / 1000);
if (cachedApnsToken && now - cachedApnsToken.issuedAt < 50 * 60) {
return cachedApnsToken.value;
}
const header = base64UrlEncode(JSON.stringify({ alg: "ES256", kid: apnsKeyId }));
const payload = base64UrlEncode(JSON.stringify({ iss: apnsTeamId, iat: now }));
const unsigned = `${header}.${payload}`;
const key = createPrivateKey(normalizeKey(resolvedKey));
const signature = sign("SHA256", Buffer.from(unsigned), {
key,
dsaEncoding: "ieee-p1363",
});
const token = `${unsigned}.${base64UrlEncode(signature)}`;
cachedApnsToken = { value: token, issuedAt: now };
return token;
}
async function sendApnsMessage(message: PushMessage): Promise<void> {
let jwt: string | null = null;
try {
jwt = createApnsJwt();
} catch (error) {
if (pushDebug) {
console.warn("[push][apns] failed to build JWT", error);
}
return;
}
if (!jwt) return;
if (pushDebug) {
const shortToken = message.to.slice(0, 12);
console.log(`[push][apns] sending token=${shortToken}… env=${apnsEnv}`);
}
const payload: Record<string, unknown> = {
aps: {
alert: { title: message.title ?? "Negopoly", body: message.body },
sound: "default",
},
...message.data,
};
const host =
apnsEnv === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
await new Promise<void>((resolve) => {
const client = connect(`https://${host}`);
let responseStatus: number | null = null;
let responseText = "";
const req = client.request({
":method": "POST",
":path": `/3/device/${message.to}`,
authorization: `bearer ${jwt}`,
"apns-topic": apnsBundleId,
"apns-push-type": "alert",
"apns-priority": "10",
});
const timeout = setTimeout(() => {
if (pushDebug) {
console.warn("[push][apns] request timeout");
}
try {
req.close();
} catch {
// Ignore stream close errors.
}
try {
client.close();
} catch {
// Ignore client close errors.
}
resolve();
}, 7000);
req.setEncoding("utf8");
req.on("response", (headers) => {
const status = headers[":status"];
if (typeof status === "number") {
responseStatus = status;
} else if (typeof status === "string") {
const parsed = Number(status);
responseStatus = Number.isNaN(parsed) ? null : parsed;
}
});
req.on("data", (chunk) => {
responseText += chunk;
});
req.on("end", () => {
clearTimeout(timeout);
if (pushDebug && responseStatus) {
console.log(`[push][apns] ${responseStatus}`);
}
if (responseStatus && responseStatus >= 400) {
console.warn(
`[push][apns] ${responseStatus} ${responseText}`.trim(),
);
}
try {
client.close();
} catch {
// Ignore client close errors.
}
resolve();
});
req.on("error", (error) => {
clearTimeout(timeout);
if (pushDebug) {
console.warn("[push][apns] request failed", error);
}
try {
client.close();
} catch {
// Ignore client close errors.
}
resolve();
});
client.on("error", (error) => {
clearTimeout(timeout);
if (pushDebug) {
console.warn("[push][apns] client error", error);
}
try {
client.close();
} catch {
// Ignore client close errors.
}
resolve();
});
req.end(JSON.stringify(payload));
});
}
async function getFcmAccessToken(): Promise<string | null> {
if (!fcmProjectId || !fcmClientEmail || !fcmPrivateKey) {
return null;
}
const now = Math.floor(Date.now() / 1000);
if (cachedFcmToken && cachedFcmToken.expiresAt - now > 60) {
return cachedFcmToken.value;
}
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = base64UrlEncode(
JSON.stringify({
iss: fcmClientEmail,
scope: "https://www.googleapis.com/auth/firebase.messaging",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
}),
);
const unsigned = `${header}.${payload}`;
const key = createPrivateKey(normalizeKey(fcmPrivateKey));
const signature = sign("RSA-SHA256", Buffer.from(unsigned), key);
const assertion = `${unsigned}.${base64UrlEncode(signature)}`;
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}).toString(),
});
if (!response.ok) return null;
const data = (await response.json()) as { access_token?: string; expires_in?: number };
if (!data.access_token) return null;
cachedFcmToken = {
value: data.access_token,
expiresAt: now + (data.expires_in ?? 3600),
};
return data.access_token;
}
async function sendFcmMessage(message: PushMessage): Promise<void> {
if (fcmServerKey) {
const response = await fetch("https://fcm.googleapis.com/fcm/send", {
method: "POST",
headers: {
Authorization: `key=${fcmServerKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: message.to,
notification: { title: message.title ?? "Negopoly", body: message.body },
data: message.data ?? {},
}),
});
if (pushDebug) {
console.log(`[push][fcm-legacy] ${response.status} ${response.statusText}`);
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.warn(
`[push][fcm-legacy] ${response.status} ${response.statusText} ${detail}`.trim(),
);
}
return;
}
const accessToken = await getFcmAccessToken();
if (!accessToken) return;
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${fcmProjectId}/messages:send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: {
token: message.to,
notification: { title: message.title ?? "Negopoly", body: message.body },
data: message.data ?? {},
},
}),
},
);
if (pushDebug) {
console.log(`[push][fcm] ${response.status} ${response.statusText}`);
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.warn(
`[push][fcm] ${response.status} ${response.statusText} ${detail}`.trim(),
);
}
}
async function sendPushMessages(messages: PushMessage[]): Promise<void> {
if (messages.length === 0) return;
for (const message of messages) {
if (message.platform === "ios") {
await sendApnsMessage(message);
} else {
await sendFcmMessage(message);
}
}
}
function queuePushMessages(messages: PushMessage[]): void {
if (messages.length === 0) return;
if (pushDebug) {
const counts = messages.reduce(
(acc, message) => {
acc.total += 1;
if (message.platform === "ios") acc.ios += 1;
if (message.platform === "android") acc.android += 1;
return acc;
},
{ total: 0, ios: 0, android: 0 },
);
console.log(
`[push][debug] queued messages total=${counts.total} ios=${counts.ios} android=${counts.android}`,
);
}
void sendPushMessages(messages).catch(() => {
// Ignore push failures to avoid breaking gameplay flow.
});
}
export function registerPushToken(
sessionId: string,
playerId: string,
token: string,
platform: Platform,
): void {
const normalized = token.trim();
if (!normalized) return;
let sessionTokens = tokensBySession.get(sessionId);
if (!sessionTokens) {
sessionTokens = new Map();
tokensBySession.set(sessionId, sessionTokens);
}
let playerTokens = sessionTokens.get(playerId);
if (!playerTokens) {
playerTokens = { ios: new Set(), android: new Set() };
sessionTokens.set(playerId, playerTokens);
}
if (platform === "ios") {
playerTokens.ios.add(normalized);
} else {
playerTokens.android.add(normalized);
}
if (pushDebug) {
console.log(`[push] registered ${platform} token for ${sessionId}:${playerId}`);
}
}
export function clearPushTokensForSession(sessionId: string): void {
tokensBySession.delete(sessionId);
}
export function notifyTransaction(session: Session, transaction: Transaction): void {
const reason = formatReason(transaction.note);
if (transaction.kind === "banker_adjust") {
const targetId = transaction.toId;
if (!targetId) return;
if (pushDebug) {
debugSessionTokens(session.id);
console.log(`[push][debug] banker_adjust target=${targetId}`);
}
const signed = `${transaction.amount >= 0 ? "+" : "-"}${formatMoney(transaction.amount)}`;
const body = `Balance adjusted: ${signed} : ${reason}`;
const data = normalizeData({ type: "transactions", sessionId: session.id });
const iosTokens = getSessionTokens(session.id, [targetId], "ios");
const androidTokens = getSessionTokens(session.id, [targetId], "android");
if (pushDebug && iosTokens.length + androidTokens.length === 0) {
console.log(`[push] no tokens for banker_adjust target=${targetId}`);
}
const messages = [
...iosTokens.map((token) => ({
to: token,
platform: "ios" as const,
title: "Negopoly",
body,
data,
})),
...androidTokens.map((token) => ({
to: token,
platform: "android" as const,
title: "Negopoly",
body,
data,
})),
];
queuePushMessages(messages);
return;
}
const fromId = transaction.fromId;
const toId = transaction.toId;
if (!fromId || !toId) return;
const fromName = session.players.get(fromId)?.name ?? "Player";
const toName = session.players.get(toId)?.name ?? "Player";
const amount = formatMoney(transaction.amount);
const receiveBody = `Received ${amount} from ${fromName}: ${reason}`;
const sentBody = `Sent ${amount} to ${toName}: ${reason}`;
const data = normalizeData({ type: "transactions", sessionId: session.id });
const outgoingTokensIos = getSessionTokens(session.id, [fromId], "ios");
const incomingTokensIos = getSessionTokens(session.id, [toId], "ios");
const outgoingTokensAndroid = getSessionTokens(session.id, [fromId], "android");
const incomingTokensAndroid = getSessionTokens(session.id, [toId], "android");
if (pushDebug) {
debugSessionTokens(session.id);
console.log(
`[push][debug] transaction kind=${transaction.kind} from=${fromId} to=${toId} outgoing=${outgoingTokensIos.length + outgoingTokensAndroid.length} incoming=${incomingTokensIos.length + incomingTokensAndroid.length}`,
);
if (outgoingTokensIos.length + outgoingTokensAndroid.length === 0) {
console.log(`[push] no tokens for transfer sender=${fromId}`);
}
if (incomingTokensIos.length + incomingTokensAndroid.length === 0) {
console.log(`[push] no tokens for transfer recipient=${toId}`);
}
}
const messages = [
...outgoingTokensIos.map((token) => ({
to: token,
platform: "ios" as const,
title: "Negopoly",
body: sentBody,
data,
})),
...incomingTokensIos.map((token) => ({
to: token,
platform: "ios" as const,
title: "Negopoly",
body: receiveBody,
data,
})),
...outgoingTokensAndroid.map((token) => ({
to: token,
platform: "android" as const,
title: "Negopoly",
body: sentBody,
data,
})),
...incomingTokensAndroid.map((token) => ({
to: token,
platform: "android" as const,
title: "Negopoly",
body: receiveBody,
data,
})),
];
queuePushMessages(messages);
}
export function notifyChat(session: Session, message: ChatMessage): void {
const sender = session.players.get(message.fromId);
const senderName = sender?.name ?? "Player";
const chatId = message.groupId ?? "global";
let groupName = "Global";
let recipients: string[] = [];
if (message.groupId === null) {
recipients = Array.from(session.players.keys());
} else {
const group = session.groups.find((item) => item.id === message.groupId);
if (!group) return;
groupName = group.name;
recipients = group.memberIds.slice();
}
recipients = recipients.filter((id) => id !== message.fromId);
if (recipients.length === 0) return;
const preview = compactText(message.body, 90);
const body = `${senderName}#${groupName}: ${preview}`;
const data = normalizeData({ type: "chat", sessionId: session.id, chatId });
const tokensIos = getSessionTokens(session.id, recipients, "ios");
const tokensAndroid = getSessionTokens(session.id, recipients, "android");
if (pushDebug && tokensIos.length + tokensAndroid.length === 0) {
console.log(`[push] no tokens for chat recipients=${recipients.join(",")}`);
}
const messages = [
...tokensIos.map((token) => ({
to: token,
platform: "ios" as const,
title: "Negopoly",
body,
data,
})),
...tokensAndroid.map((token) => ({
to: token,
platform: "android" as const,
title: "Negopoly",
body,
data,
})),
];
queuePushMessages(messages);
}

View file

@ -1,6 +1,7 @@
import type { Session } from "./types"; import type { Session } from "./types";
import type { Player, SessionSnapshot } from "../shared/types"; import type { Player, SessionSnapshot } from "../shared/types";
import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util"; import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util";
import { clearPushTokensForSession } from "./notifications";
const sessions = new Map<string, Session>(); const sessions = new Map<string, Session>();
const sessionsByCode = new Map<string, string>(); const sessionsByCode = new Map<string, string>();
@ -170,6 +171,7 @@ export function removeSession(id: string): void {
} }
sessions.delete(id); sessions.delete(id);
sessionsByCode.delete(session.code); sessionsByCode.delete(session.code);
clearPushTokensForSession(id);
} }
export function listSessions(): Session[] { export function listSessions(): Session[] {

View file

@ -18,6 +18,7 @@ import {
} from "./domain"; } from "./domain";
import { getSession, removeSession } from "./store"; import { getSession, removeSession } from "./store";
import { now } from "./util"; import { now } from "./util";
import { notifyChat, notifyTransaction } from "./notifications";
const socketsBySession = new Map<string, Set<WebSocket>>(); const socketsBySession = new Map<string, Set<WebSocket>>();
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>(); const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
@ -65,7 +66,8 @@ function runTestTransfer(sessionId: string): void {
continue; continue;
} }
try { try {
transfer(session, from.id, to.id, amount, null); const transaction = transfer(session, from.id, to.id, amount, null);
notifyTransaction(session, transaction);
sendStateToSession(session); sendStateToSession(session);
break; break;
} catch { } catch {
@ -175,17 +177,36 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
function handleMessage(session: Session, message: ClientMessage): void { function handleMessage(session: Session, message: ClientMessage): void {
switch (message.type) { switch (message.type) {
case "chat_send": case "chat_send": {
addChatMessage(session, message.playerId, message.body, message.groupId); const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
notifyChat(session, chat);
return; return;
}
case "transfer": case "transfer":
transfer(session, message.playerId, message.toPlayerId, message.amount, message.note); {
const transaction = transfer(
session,
message.playerId,
message.toPlayerId,
message.amount,
message.note,
);
notifyTransaction(session, transaction);
return; return;
case "banker_adjust": }
bankerAdjust(session, message.bankerId, message.targetId, message.amount, message.note); case "banker_adjust": {
const transaction = bankerAdjust(
session,
message.bankerId,
message.targetId,
message.amount,
message.note,
);
notifyTransaction(session, transaction);
return; return;
case "banker_force_transfer": }
bankerForceTransfer( case "banker_force_transfer": {
const transaction = bankerForceTransfer(
session, session,
message.bankerId, message.bankerId,
message.fromId, message.fromId,
@ -193,7 +214,9 @@ function handleMessage(session: Session, message: ClientMessage): void {
message.amount, message.amount,
message.note, message.note,
); );
notifyTransaction(session, transaction);
return; return;
}
case "banker_blackout": case "banker_blackout":
setBlackout(session, message.bankerId, message.active, message.reason); setBlackout(session, message.bankerId, message.active, message.reason);
return; return;

View file

@ -48,6 +48,8 @@ export type TakeoverRequest = {
id: string; id: string;
dummyId: string; dummyId: string;
requesterId: string; requesterId: string;
requesterName?: string | null;
requesterToken?: string | null;
createdAt: number; createdAt: number;
status: "pending" | "approved" | "rejected"; status: "pending" | "approved" | "rejected";
}; };