diff --git a/front/play/app.tsx b/front/play/app.tsx index f8e8ef4..75dfbe4 100644 --- a/front/play/app.tsx +++ b/front/play/app.tsx @@ -625,9 +625,14 @@ function EntryPage({ manager }: { manager: ReturnType manager.setError(t("entry.alert.selectDummy")); return; } + const selectedDummy = dummyOptions.find( + (dummy) => dummy.id === takeoverDummyId, + ); + const fallbackName = + takeoverName.trim() || selectedDummy?.name || t("common.guest"); const data = await manager.joinSession( joinPreview.code, - takeoverName || t("common.guest"), + fallbackName, ); if (data) { manager.setPendingTakeoverId(takeoverDummyId); @@ -781,9 +786,14 @@ function LobbyPage({ manager }: { manager: ReturnType manager.setError(t("entry.alert.selectDummy")); return; } + const selectedDummy = dummyOptions.find( + (dummy) => dummy.id === takeoverDummyId, + ); + const fallbackName = + takeoverName.trim() || selectedDummy?.name || t("common.guest"); const data = await manager.joinSession( joinPreview.code, - takeoverName || t("common.guest"), + fallbackName, ); if (data) { manager.setPendingTakeoverId(takeoverDummyId); @@ -841,6 +851,9 @@ function LobbyPage({ manager }: { manager: ReturnType } const players = manager.players; + const pendingRequests = manager.session?.takeoverRequests.filter( + (request) => request.status === "pending", + ); return (
@@ -904,6 +917,49 @@ function LobbyPage({ manager }: { manager: ReturnType )} + {manager.isBanker && (pendingRequests ?? []).length > 0 && ( +
+

{t("banker.takeoverApprovals")}

+
+ {(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 ( +
+
+ {requesterName} + + {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })} + +
+ +
+ ); + })} +
+
+ )} + {manager.isBanker && ( <>
@@ -1660,10 +1716,12 @@ function BankerPage({ manager }: { manager: ReturnType const dummy = manager.session?.players.find( (player) => player.id === request.dummyId, ); + const requesterName = + requester?.name ?? request.requesterName ?? t("common.player"); return (
- {requester?.name ?? t("common.player")} + {requesterName} {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })} diff --git a/mobile/app.json b/mobile/app.json index 1c21f29..1757c91 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -22,6 +22,7 @@ }, "android": { "package": "fr.negopoly.app", + "googleServicesFile": "./google-services.json", "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" @@ -41,6 +42,7 @@ "category": ["BROWSABLE", "DEFAULT"] } ] - } + }, + "plugins": ["expo-notifications"] } } diff --git a/mobile/google-services.json b/mobile/google-services.json new file mode 100644 index 0000000..fb2918d --- /dev/null +++ b/mobile/google-services.json @@ -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" +} \ No newline at end of file diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 1f41816..bc652b9 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -15,6 +15,7 @@ "expo": "~54.0.33", "expo-constants": "~18.0.13", "expo-dev-client": "^6.0.20", + "expo-notifications": "~0.32.16", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", @@ -2066,6 +2067,12 @@ "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -3053,12 +3060,40 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "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": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3277,6 +3312,12 @@ "@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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3473,6 +3514,53 @@ "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": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3881,6 +3969,23 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -3890,6 +3995,23 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3945,6 +4067,20 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3990,6 +4126,36 @@ "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": { "version": "3.2.0", "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": { "version": "12.0.12", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", @@ -4268,6 +4443,26 @@ "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -4438,6 +4633,21 @@ "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -4485,6 +4695,15 @@ "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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4503,6 +4722,30 @@ "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4512,6 +4755,19 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -4565,6 +4821,18 @@ "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": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4580,6 +4848,45 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4767,12 +5074,40 @@ "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": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "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": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4812,6 +5147,41 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4830,6 +5200,39 @@ "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -5576,6 +5979,15 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -6195,6 +6607,51 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -6521,6 +6978,15 @@ "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": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -7177,6 +7643,23 @@ ], "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": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", @@ -7306,6 +7789,23 @@ "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": { "version": "1.2.0", "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" } }, + "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8149,6 +8662,27 @@ "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": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index 0e3fe03..7efd2f7 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -1,31 +1,32 @@ { - "name": "mobile", - "version": "1.0.0", - "main": "index.ts", - "scripts": { - "start": "expo start", - "dev": "sh ./scripts/start-dev.sh", - "android": "expo run:android", - "ios": "expo run:ios" - }, - "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", - "@react-navigation/bottom-tabs": "^7.12.0", - "@react-navigation/native": "^7.1.28", - "@react-navigation/native-stack": "^7.12.0", - "expo": "~54.0.33", - "expo-constants": "~18.0.13", - "expo-dev-client": "^6.0.20", - "expo-status-bar": "~3.0.9", - "react": "19.1.0", - "react-native": "0.81.5", - "react-native-gesture-handler": "~2.28.0", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" - }, - "devDependencies": { - "@types/react": "~19.1.0", - "typescript": "~5.9.2" - }, - "private": true + "name": "mobile", + "version": "1.0.0", + "main": "index.ts", + "scripts": { + "start": "expo start", + "dev": "sh ./scripts/start-dev.sh", + "android": "expo run:android", + "ios": "expo run:ios" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", + "@react-navigation/bottom-tabs": "^7.12.0", + "@react-navigation/native": "^7.1.28", + "@react-navigation/native-stack": "^7.12.0", + "expo": "~54.0.33", + "expo-constants": "~18.0.13", + "expo-dev-client": "^6.0.20", + "expo-notifications": "~0.32.16", + "expo-status-bar": "~3.0.9", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0" + }, + "devDependencies": { + "@types/react": "~19.1.0", + "typescript": "~5.9.2" + }, + "private": true } diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index abc906b..a297d72 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -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 { NavigationContainer, @@ -7,10 +7,12 @@ import { } from "@react-navigation/native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; +import * as Notifications from "expo-notifications"; import AppNavigator from "./navigation/AppNavigator"; import type { RootStackParamList } from "./navigation/types"; import { SessionProvider, useSession } from "./state/session-context"; import { getNavigationTheme, useTheme } from "./theme"; +import { parseNotificationTarget, type NotificationTarget } from "./notifications"; function extractGameId(url: string): string | null { try { @@ -45,6 +47,8 @@ function RootNavigationGate() { const [navReady, setNavReady] = useState(false); const lastTargetRef = useRef(null); const lastLinkRef = useRef(null); + const pendingNotificationRef = useRef(null); + const lastNotificationIdRef = useRef(null); const theme = useTheme(); const navigationTheme = getNavigationTheme(theme); const linking = useMemo>( @@ -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(() => { if (!navReady || !navigationRef.isReady()) return; @@ -113,6 +173,27 @@ function RootNavigationGate() { 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 ( ({ + 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; + 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; + } +} diff --git a/mobile/src/screens/BankerToolsScreen.tsx b/mobile/src/screens/BankerToolsScreen.tsx index 11a55a9..b4695ee 100644 --- a/mobile/src/screens/BankerToolsScreen.tsx +++ b/mobile/src/screens/BankerToolsScreen.tsx @@ -127,6 +127,12 @@ export default function BankerToolsScreen() { >([]); const [importJson, setImportJson] = useState(""); const [autoSaveStatus, setAutoSaveStatus] = useState(null); + const pendingRequests = useMemo( + () => + manager.session?.takeoverRequests.filter((request) => request.status === "pending") ?? + [], + [manager.session?.takeoverRequests], + ); const autoSaveKey = `negopoly:autosave:${manager.sessionId}`; const autoSaveSettingsKey = `${autoSaveKey}:settings`; @@ -617,6 +623,48 @@ export default function BankerToolsScreen() { ) : ( <> + {pendingRequests.length > 0 ? ( + + {t("banker.takeoverApprovals")} + + {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 ( + + + {requesterName} + + {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })} + + + + manager.sendMessage({ + type: "banker_takeover_approve", + sessionId: manager.sessionId, + bankerId: manager.me?.id, + dummyId: request.dummyId, + requesterId: request.requesterId, + }) + } + > + {t("banker.approve")} + + + ); + })} + + + ) : null} + {t("banker.tools.blackout")} @@ -1035,6 +1083,30 @@ const createStyles = (theme: AppTheme) => borderBottomWidth: 1, 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: { backgroundColor: theme.colors.secondary, paddingHorizontal: 10, diff --git a/mobile/src/screens/EntryScreen.tsx b/mobile/src/screens/EntryScreen.tsx index 9a41eb9..67f1b03 100644 --- a/mobile/src/screens/EntryScreen.tsx +++ b/mobile/src/screens/EntryScreen.tsx @@ -49,6 +49,8 @@ export default function EntryScreen() { const [takeoverName, setTakeoverName] = useState(""); const [takeoverDummyId, setTakeoverDummyId] = useState(""); const [showDummyOptions, setShowDummyOptions] = useState(false); + const [takeoverToken, setTakeoverToken] = useState(null); + const [takeoverWaiting, setTakeoverWaiting] = useState(false); const dummyOptions = useMemo( () => joinPreview?.players.filter((player) => player.isDummy) ?? [], @@ -100,6 +102,8 @@ export default function EntryScreen() { setJoinName(""); setTakeoverName(""); setTakeoverDummyId(""); + setTakeoverToken(null); + setTakeoverWaiting(false); manager.fetchSessionPreview(code).then((preview) => { if (!preview) { Alert.alert(t("entry.alert.sessionNotFound")); @@ -124,22 +128,54 @@ export default function EntryScreen() { Alert.alert(t("entry.alert.selectDummy")); 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, - takeoverName.trim() || t("common.guest"), + takeoverDummyId, + fallbackName, ); - if (data) { - manager.setPendingTakeoverId(takeoverDummyId); - navigation.replace("Lobby"); + if (!token) { + setTakeoverWaiting(false); + if (manager.error) { + Alert.alert(manager.error); + } + return; } + setTakeoverToken(token); } useEffect(() => { if (joinStep === "code" || !joinPreview) { setShowDummyOptions(false); + setTakeoverToken(null); + setTakeoverWaiting(false); } }, [joinStep, joinPreview]); + useEffect(() => { + if (!takeoverToken || !joinPreview) return; + let cancelled = false; + let timeout: ReturnType | 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 ( {t("app.name")} @@ -194,51 +230,72 @@ export default function EntryScreen() { {t("entry.alreadyConnected")} ) : ( <> - - { - if (dummyOptions.length === 0) return; - setShowDummyOptions((prev) => !prev); - }} - > - - {dummyOptions.find((player) => player.id === takeoverDummyId)?.name - ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}` - : t("entry.selectDummy")} - - - {showDummyOptions && dummyOptions.length > 0 ? ( - - {dummyOptions.map((player) => ( - { - setTakeoverDummyId(player.id); - setShowDummyOptions(false); - }} - > - {player.name} - {player.id} - - ))} + {takeoverWaiting ? ( + + {t("entry.takeoverPending")} + { + setTakeoverToken(null); + setTakeoverWaiting(false); + }} + > + {t("common.cancel")} + + + ) : ( + <> + + { + if (dummyOptions.length === 0) return; + setShowDummyOptions((prev) => !prev); + }} + > + + {dummyOptions.find((player) => player.id === takeoverDummyId)?.name + ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}` + : t("entry.selectDummy")} + + + {showDummyOptions && dummyOptions.length > 0 ? ( + + {dummyOptions.map((player) => ( + { + setTakeoverDummyId(player.id); + setShowDummyOptions(false); + }} + > + {player.name} + {player.id} + + ))} + + ) : null} - ) : null} - - - - {t("entry.requestTakeover")} - + + + + {t("entry.requestTakeover")} + + + + )} )} {!takeoverDisabled && dummyOptions.length === 0 ? ( @@ -333,6 +390,14 @@ const createStyles = (theme: AppTheme) => backgroundColor: theme.colors.surface, overflow: "hidden", }, + pendingBox: { + gap: 10, + backgroundColor: theme.colors.surfaceAlt, + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: theme.colors.borderMuted, + }, dropdownItem: { paddingHorizontal: 12, paddingVertical: 10, diff --git a/mobile/src/screens/LobbyScreen.tsx b/mobile/src/screens/LobbyScreen.tsx index 552b608..f56c54e 100644 --- a/mobile/src/screens/LobbyScreen.tsx +++ b/mobile/src/screens/LobbyScreen.tsx @@ -66,11 +66,21 @@ export default function LobbyScreen() { } 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 ( {t("lobby.title")} {t("lobby.code", { code: manager.session.code })} + {pendingTakeover ? ( + {t("entry.takeoverPending")} + ) : null} + {manager.isBanker && pendingRequests.length > 0 ? ( + + {t("banker.takeoverApprovals")} + + {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 ( + + + {requesterName} + + {t("banker.wants", { name: dummy?.name ?? t("common.dummy") })} + + + + manager.sendMessage({ + type: "banker_takeover_approve", + sessionId: manager.sessionId, + bankerId: manager.me?.id, + dummyId: request.dummyId, + requesterId: request.requesterId, + }) + } + > + {t("banker.approve")} + + + ); + })} + + + ) : null} + {manager.isBanker && manager.session.status === "lobby" && ( {t("lobby.addDummyTitle")} @@ -232,4 +283,39 @@ const createStyles = (theme: AppTheme) => color: theme.colors.secondaryText, 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, + }, }); diff --git a/mobile/src/shared/types.ts b/mobile/src/shared/types.ts index 26f4a1d..ca215e9 100644 --- a/mobile/src/shared/types.ts +++ b/mobile/src/shared/types.ts @@ -48,6 +48,8 @@ export type TakeoverRequest = { id: string; dummyId: string; requesterId: string; + requesterName?: string | null; + requesterToken?: string | null; createdAt: number; status: "pending" | "approved" | "rejected"; }; diff --git a/mobile/src/state/session.ts b/mobile/src/state/session.ts index e1a06d9..10b199f 100644 --- a/mobile/src/state/session.ts +++ b/mobile/src/state/session.ts @@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import { getApiBaseUrl, getWsUrl } from "../config/api"; import { tStatic } from "../i18n"; +import { registerForPushNotificationsAsync } from "../notifications"; const STORAGE_KEY = "negopoly:session"; @@ -36,13 +37,17 @@ export function useSessionManager() { const [playerId, setPlayerId] = useState(""); const [session, setSession] = useState(null); const [error, setError] = useState(null); - const [pendingTakeoverId, setPendingTakeoverId] = useState(null); const [connectionState, setConnectionState] = useState< "idle" | "connecting" | "open" | "error" >("idle"); const [tick, setTick] = useState(0); + const [pushToken, setPushToken] = useState<{ + token: string; + platform: "ios" | "android"; + } | null>(null); const wsRef = useRef(null); + const lastPushRegistrationRef = useRef(null); useEffect(() => { 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(() => { const timer = setInterval(() => setTick((value) => value + 1), 1000); 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(() => { if (!sessionId || !playerId) { setConnectionState("idle"); @@ -120,20 +162,55 @@ export function useSessionManager() { }; }, [sessionId, playerId]); - useEffect(() => { - if (!pendingTakeoverId) return; - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; - if (!sessionId || !playerId) return; - wsRef.current.send( - JSON.stringify({ - type: "takeover_request", - sessionId, - playerId, - dummyId: pendingTakeoverId, - }), - ); - setPendingTakeoverId(null); - }, [pendingTakeoverId, sessionId, playerId]); + async function requestTakeover( + dummyId: string, + overrideSessionId?: string, + overridePlayerId?: string, + ): Promise { + 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", + sessionId: targetSessionId, + playerId: targetPlayerId, + 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 }), + }, + ); + if (!response.ok) { + 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) { setError(null); @@ -193,6 +270,73 @@ export function useSessionManager() { return data; } + async function requestTakeoverToken( + code: string, + dummyId: string, + name: string, + ): Promise { + 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 { if (!code) return null; const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); @@ -222,7 +366,6 @@ export function useSessionManager() { setPlayerId(""); setSession(null); setError(null); - setPendingTakeoverId(null); setConnectionState("idle"); } @@ -256,12 +399,14 @@ export function useSessionManager() { createSession, joinSession, fetchSessionPreview, + requestTakeoverToken, + claimTakeover, sendMessage, resetSession, leaveSession, setSessionId, setPlayerId, setSession, - setPendingTakeoverId, + requestTakeover, }; } diff --git a/server/api.ts b/server/api.ts index 4d5f787..bca6341 100644 --- a/server/api.ts +++ b/server/api.ts @@ -1,6 +1,13 @@ import type { SessionSnapshot } from "../shared/types"; import type { BunRequest } from "bun"; -import { applySnapshot, joinSession, snapshotSession } from "./domain"; +import { + applySnapshot, + claimTakeover, + joinSession, + requestTakeover, + requestTakeoverByToken, + snapshotSession, +} from "./domain"; import { createSession, createTestPreview, @@ -10,6 +17,7 @@ import { isTestSessionCode, } from "./store"; import { broadcastSessionState, startTestSimulation } from "./websocket"; +import { registerPushToken } from "./notifications"; function jsonResponse(data: unknown, status = 200): Response { return Response.json(data, { status }); @@ -184,4 +192,127 @@ export const apiRoutes = { 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 }); + }, + }, }; diff --git a/server/domain.ts b/server/domain.ts index faf0554..f2e37ce 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -426,6 +426,31 @@ export function requestTakeover( 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( session: Session, bankerId: string, @@ -435,7 +460,6 @@ export function approveTakeover( ensureOpenSession(session); ensureBanker(session, bankerId); const dummy = getPlayer(session, dummyId); - const requester = getPlayer(session, requesterId); if (!dummy.isDummy) { throw new DomainError("Selected player is not a dummy"); } @@ -448,6 +472,23 @@ export function approveTakeover( if (!targetRequest) { 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"; // Reject any other pending requests for the same dummy. session.takeoverRequests.forEach((request) => { @@ -469,6 +510,29 @@ export function approveTakeover( 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 { ensureOpenSession(session); } diff --git a/server/notifications.ts b/server/notifications.ts new file mode 100644 index 0000000..88f3ca9 --- /dev/null +++ b/server/notifications.ts @@ -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; +}; + +type PlayerTokens = { + ios: Set; + android: Set; +}; + +const tokensBySession = new Map>(); + +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(); + 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 = {}; + 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): Record | 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 { + 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 = { + 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((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 { + 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 { + 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 { + 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); +} diff --git a/server/store.ts b/server/store.ts index 3e631b0..e2ce3f0 100644 --- a/server/store.ts +++ b/server/store.ts @@ -1,6 +1,7 @@ import type { Session } from "./types"; import type { Player, SessionSnapshot } from "../shared/types"; import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util"; +import { clearPushTokensForSession } from "./notifications"; const sessions = new Map(); const sessionsByCode = new Map(); @@ -170,6 +171,7 @@ export function removeSession(id: string): void { } sessions.delete(id); sessionsByCode.delete(session.code); + clearPushTokensForSession(id); } export function listSessions(): Session[] { diff --git a/server/websocket.ts b/server/websocket.ts index dfdeb27..5270f1c 100644 --- a/server/websocket.ts +++ b/server/websocket.ts @@ -18,6 +18,7 @@ import { } from "./domain"; import { getSession, removeSession } from "./store"; import { now } from "./util"; +import { notifyChat, notifyTransaction } from "./notifications"; const socketsBySession = new Map>(); const metaBySocket = new WeakMap(); @@ -65,7 +66,8 @@ function runTestTransfer(sessionId: string): void { continue; } try { - transfer(session, from.id, to.id, amount, null); + const transaction = transfer(session, from.id, to.id, amount, null); + notifyTransaction(session, transaction); sendStateToSession(session); break; } catch { @@ -175,17 +177,36 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v function handleMessage(session: Session, message: ClientMessage): void { switch (message.type) { - case "chat_send": - addChatMessage(session, message.playerId, message.body, message.groupId); + case "chat_send": { + const chat = addChatMessage(session, message.playerId, message.body, message.groupId); + notifyChat(session, chat); return; + } 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; + } + case "banker_adjust": { + const transaction = bankerAdjust( + session, + message.bankerId, + message.targetId, + message.amount, + message.note, + ); + notifyTransaction(session, transaction); return; - case "banker_adjust": - bankerAdjust(session, message.bankerId, message.targetId, message.amount, message.note); - return; - case "banker_force_transfer": - bankerForceTransfer( + } + case "banker_force_transfer": { + const transaction = bankerForceTransfer( session, message.bankerId, message.fromId, @@ -193,7 +214,9 @@ function handleMessage(session: Session, message: ClientMessage): void { message.amount, message.note, ); + notifyTransaction(session, transaction); return; + } case "banker_blackout": setBlackout(session, message.bankerId, message.active, message.reason); return; diff --git a/shared/types.ts b/shared/types.ts index bd8ee57..3505483 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -48,6 +48,8 @@ export type TakeoverRequest = { id: string; dummyId: string; requesterId: string; + requesterName?: string | null; + requesterToken?: string | null; createdAt: number; status: "pending" | "approved" | "rejected"; };