From 6c875cdf39a54ec85942b9dbb3528a186268f8a9 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 21 Jun 2024 19:25:31 +0400 Subject: [PATCH] feat: add agora session --- package-lock.json | 284 ++++++++++++++- package.json | 1 + src/actions/hooks/useSessionDetails.ts | 42 +++ src/actions/hooks/useSessionTracking.ts | 46 +++ src/actions/sessions.ts | 26 ++ src/app/[locale]/account/(account)/layout.tsx | 17 +- .../account/(account)/sessions/page.tsx | 19 - src/app/[locale]/account/(simple)/layout.tsx | 17 + .../(simple)/sessions/[[...slug]]/page.tsx | 55 +++ src/components/Account/AccountMenu.tsx | 26 +- src/components/Account/agora/Agora.tsx | 61 ++++ .../agora/components/LocalUserPanel.tsx | 61 ++++ .../agora/components/RemoteUserPanel.tsx | 36 ++ .../Account/agora/components/RemoteUsers.tsx | 12 + .../agora/components/RemoteVideoPlayer.tsx | 69 ++++ .../Account/agora/components/UserCover.tsx | 56 +++ .../Account/agora/components/index.ts | 5 + src/components/Account/agora/icons/index.tsx | 61 ++++ src/components/Account/agora/index.tsx | 24 ++ .../Account/agora/view/MediaControl.tsx | 49 +++ src/components/Account/agora/view/index.ts | 1 + .../Account/sessions/SessionDetails.tsx | 327 ++++-------------- .../sessions/SessionDetailsContent.tsx | 299 ++++++++++++++++ .../Account/sessions/SessionsTabs.tsx | 25 +- src/components/Account/sessions/index.tsx | 27 +- src/components/Modals/AddCommentModal.tsx | 2 +- src/components/Modals/DeclineSessionModal.tsx | 19 +- .../Modals/authModalContent/EnterContent.tsx | 2 +- .../authModalContent/RegisterContent.tsx | 2 +- .../Page/Header/HeaderAuthLinks.tsx | 2 +- src/components/Page/Header/index.tsx | 4 +- src/styles/sessions/_agora.scss | 138 ++++++++ src/styles/sessions/style.scss | 1 + src/styles/view/_buttons.scss | 11 + src/types/sessions.ts | 13 +- src/utils/account.ts | 14 + src/utils/agora/helpers.ts | 108 ++++++ src/utils/agora/tools.ts | 163 +++++++++ 38 files changed, 1739 insertions(+), 386 deletions(-) create mode 100644 src/actions/hooks/useSessionDetails.ts create mode 100644 src/actions/hooks/useSessionTracking.ts delete mode 100644 src/app/[locale]/account/(account)/sessions/page.tsx create mode 100644 src/app/[locale]/account/(simple)/layout.tsx create mode 100644 src/app/[locale]/account/(simple)/sessions/[[...slug]]/page.tsx create mode 100644 src/components/Account/agora/Agora.tsx create mode 100644 src/components/Account/agora/components/LocalUserPanel.tsx create mode 100644 src/components/Account/agora/components/RemoteUserPanel.tsx create mode 100644 src/components/Account/agora/components/RemoteUsers.tsx create mode 100644 src/components/Account/agora/components/RemoteVideoPlayer.tsx create mode 100644 src/components/Account/agora/components/UserCover.tsx create mode 100644 src/components/Account/agora/components/index.ts create mode 100644 src/components/Account/agora/icons/index.tsx create mode 100644 src/components/Account/agora/index.tsx create mode 100644 src/components/Account/agora/view/MediaControl.tsx create mode 100644 src/components/Account/agora/view/index.ts create mode 100644 src/components/Account/sessions/SessionDetailsContent.tsx create mode 100644 src/styles/sessions/_agora.scss create mode 100644 src/utils/account.ts create mode 100644 src/utils/agora/helpers.ts create mode 100644 src/utils/agora/tools.ts diff --git a/package-lock.json b/package-lock.json index 1f8a617..943e6fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@ant-design/icons": "^5.2.6", "@ant-design/nextjs-registry": "^1.0.0", "agora-rtc-react": "^2.1.0", + "agora-rtc-sdk-ng": "^4.20.2", "antd": "^5.12.1", "antd-img-crop": "^4.21.0", "axios": "^1.6.5", @@ -49,6 +50,37 @@ "node": ">=0.10.0" } }, + "node_modules/@agora-js/media": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/media/-/media-4.20.2.tgz", + "integrity": "sha512-JLZ2faGwKxBjEhCG+LTsfj1n54+TKhV7cTwJdS3NFxNYM2+pVmBSvRR0LG6cidza1dbKkvd4/UcZUT1pvqkulg==", + "dependencies": { + "@agora-js/report": "4.20.2", + "@agora-js/shared": "4.20.2", + "agora-rte-extension": "^1.2.4", + "axios": "^1.6.7", + "pako": "^2.1.0", + "webrtc-adapter": "8.2.0" + } + }, + "node_modules/@agora-js/report": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/report/-/report-4.20.2.tgz", + "integrity": "sha512-uKaZkLNgzRBExwqB58plN704NgDkz0kfQJwopcVcfnYk/kEN0H1qwxNtoIXHtO8FBkBI2RD5506KL8Afj7FrOQ==", + "dependencies": { + "@agora-js/shared": "4.20.2", + "axios": "^1.6.7" + } + }, + "node_modules/@agora-js/shared": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/shared/-/shared-4.20.2.tgz", + "integrity": "sha512-vm5PtWSgbrNmH/RWQ6WfV7g/JCnjtMGc9qvnqMCRItQA1CIgdhNKtW4eH6uzZn3D6xCFS33WjWUkQ/VGo7NPnA==", + "dependencies": { + "axios": "^1.6.7", + "ua-parser-js": "^0.7.34" + } + }, "node_modules/@ant-design/colors": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.2.tgz", @@ -896,6 +928,26 @@ "react": ">=16.8" } }, + "node_modules/agora-rtc-sdk-ng": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/agora-rtc-sdk-ng/-/agora-rtc-sdk-ng-4.20.2.tgz", + "integrity": "sha512-1AFFfdSdzMu4XRV6JIg5K8oKFWSOUnwcpTGdscsXXI/cfEJMuOGvW7doeEqTWiwBkbinLOrYleotBoBfZMYNDA==", + "dependencies": { + "@agora-js/media": "4.20.2", + "@agora-js/report": "4.20.2", + "@agora-js/shared": "4.20.2", + "agora-rte-extension": "^1.2.4", + "axios": "^1.6.7", + "formdata-polyfill": "^4.0.7", + "ua-parser-js": "^0.7.34", + "webrtc-adapter": "8.2.0" + } + }, + "node_modules/agora-rte-extension": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/agora-rte-extension/-/agora-rte-extension-1.2.4.tgz", + "integrity": "sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw==" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1256,11 +1308,11 @@ } }, "node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -2296,6 +2348,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2357,9 +2431,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -2397,6 +2471,17 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3483,6 +3568,24 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -3686,6 +3789,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4698,6 +4806,11 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==" + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -5172,6 +5285,28 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz", + "integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -5256,6 +5391,26 @@ "node": ">=10.13.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webrtc-adapter": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.0.tgz", + "integrity": "sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==", + "dependencies": { + "sdp": "^3.0.2" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5379,6 +5534,37 @@ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", "dev": true }, + "@agora-js/media": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/media/-/media-4.20.2.tgz", + "integrity": "sha512-JLZ2faGwKxBjEhCG+LTsfj1n54+TKhV7cTwJdS3NFxNYM2+pVmBSvRR0LG6cidza1dbKkvd4/UcZUT1pvqkulg==", + "requires": { + "@agora-js/report": "4.20.2", + "@agora-js/shared": "4.20.2", + "agora-rte-extension": "^1.2.4", + "axios": "^1.6.7", + "pako": "^2.1.0", + "webrtc-adapter": "8.2.0" + } + }, + "@agora-js/report": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/report/-/report-4.20.2.tgz", + "integrity": "sha512-uKaZkLNgzRBExwqB58plN704NgDkz0kfQJwopcVcfnYk/kEN0H1qwxNtoIXHtO8FBkBI2RD5506KL8Afj7FrOQ==", + "requires": { + "@agora-js/shared": "4.20.2", + "axios": "^1.6.7" + } + }, + "@agora-js/shared": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/@agora-js/shared/-/shared-4.20.2.tgz", + "integrity": "sha512-vm5PtWSgbrNmH/RWQ6WfV7g/JCnjtMGc9qvnqMCRItQA1CIgdhNKtW4eH6uzZn3D6xCFS33WjWUkQ/VGo7NPnA==", + "requires": { + "axios": "^1.6.7", + "ua-parser-js": "^0.7.34" + } + }, "@ant-design/colors": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.0.2.tgz", @@ -5981,6 +6167,26 @@ "integrity": "sha512-3FGteA7FG51oK5MusbYNgAcKZaAQK+4sbEz4F0DPzcpDxqNANpocJDqOsmXoUAj5yDBsBZelmagU3abd++6RGA==", "requires": {} }, + "agora-rtc-sdk-ng": { + "version": "4.20.2", + "resolved": "https://registry.npmjs.org/agora-rtc-sdk-ng/-/agora-rtc-sdk-ng-4.20.2.tgz", + "integrity": "sha512-1AFFfdSdzMu4XRV6JIg5K8oKFWSOUnwcpTGdscsXXI/cfEJMuOGvW7doeEqTWiwBkbinLOrYleotBoBfZMYNDA==", + "requires": { + "@agora-js/media": "4.20.2", + "@agora-js/report": "4.20.2", + "@agora-js/shared": "4.20.2", + "agora-rte-extension": "^1.2.4", + "axios": "^1.6.7", + "formdata-polyfill": "^4.0.7", + "ua-parser-js": "^0.7.34", + "webrtc-adapter": "8.2.0" + } + }, + "agora-rte-extension": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/agora-rte-extension/-/agora-rte-extension-1.2.4.tgz", + "integrity": "sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw==" + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6244,11 +6450,11 @@ "dev": true }, "axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "requires": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -7043,6 +7249,15 @@ "reusify": "^1.0.4" } }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7089,9 +7304,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, "for-each": { "version": "0.3.3", @@ -7112,6 +7327,14 @@ "mime-types": "^2.1.12" } }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, "fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7884,6 +8107,11 @@ "use-intl": "^3.3.1" } }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, "node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -8033,6 +8261,11 @@ "p-limit": "^3.0.2" } }, + "pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8722,6 +8955,11 @@ "compute-scroll-into-view": "^3.0.2" } }, + "sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==" + }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -9057,6 +9295,11 @@ "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true }, + "ua-parser-js": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz", + "integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==" + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -9112,6 +9355,19 @@ "graceful-fs": "^4.1.2" } }, + "web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "webrtc-adapter": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.0.tgz", + "integrity": "sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==", + "requires": { + "sdp": "^3.0.2" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 8998103..fec2b54 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@ant-design/icons": "^5.2.6", "@ant-design/nextjs-registry": "^1.0.0", "agora-rtc-react": "^2.1.0", + "agora-rtc-sdk-ng": "^4.20.2", "antd": "^5.12.1", "antd-img-crop": "^4.21.0", "axios": "^1.6.5", diff --git a/src/actions/hooks/useSessionDetails.ts b/src/actions/hooks/useSessionDetails.ts new file mode 100644 index 0000000..b1c7bb0 --- /dev/null +++ b/src/actions/hooks/useSessionDetails.ts @@ -0,0 +1,42 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { AUTH_TOKEN_KEY } from '../../constants/common'; +import { Session } from '../../types/sessions'; +import { getSessionDetails } from '../sessions'; + +export const useSessionDetails = (locale: string, sessionId: number) => { + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const [session, setSession] = useState(); + const [errorData, setErrorData] = useState(); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(() => { + setLoading(true); + setErrorData(undefined); + setSession(undefined); + + getSessionDetails(locale, jwt, sessionId) + .then(({ data }) => { + setSession(data); + }) + .catch((err) => { + setErrorData(err); + }) + .finally(() => { + setLoading(false); + }) + }, []); + + useEffect(() => { + fetchData(); + }, []); + + return { + fetchData, + loading, + session, + errorData + }; +}; diff --git a/src/actions/hooks/useSessionTracking.ts b/src/actions/hooks/useSessionTracking.ts new file mode 100644 index 0000000..4f8018c --- /dev/null +++ b/src/actions/hooks/useSessionTracking.ts @@ -0,0 +1,46 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { AUTH_TOKEN_KEY } from '../../constants/common'; +import { trackingStartSession } from '../sessions'; + +const DURATION = 30000; + +export const useSessionTracking = (locale: string, sessionId: number) => { + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const timer = useRef(); + + const fetchData = useCallback(() => { + trackingStartSession(locale, jwt, sessionId) + .then(() => { + console.log('tracking success'); + }) + .catch((err) => { + console.log('tracking error', err); + }) + }, []); + + useEffect(() => { + return () => { + window.clearInterval(timer.current); + } + }, []); + + const start = () => { + window.clearInterval(timer.current); + + timer.current = window.setInterval(() => { + fetchData(); + }, DURATION); + }; + + const stop = () => { + window.clearInterval(timer.current); + }; + + return { + start, + stop + }; +}; diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index cee7b15..d1aac29 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -117,3 +117,29 @@ export const addSessionComment = (locale: string, jwt: string, data: SessionComm } ) ); + +export const trackingStartSession = (locale: string, jwt: string, id: number): Promise => ( + apiClient.post( + '/home/sessiontracking', + { id }, + { + headers: { + 'X-User-Language': locale, + Authorization: `Bearer ${jwt}` + } + } + ) +); + +export const finishSession = (locale: string, jwt: string, sessionId: number): Promise => ( + apiClient.post( + '/home/finishsession', + { sessionId }, + { + headers: { + 'X-User-Language': locale, + Authorization: `Bearer ${jwt}` + } + } + ) +); diff --git a/src/app/[locale]/account/(account)/layout.tsx b/src/app/[locale]/account/(account)/layout.tsx index 5c46a60..f6a63ab 100644 --- a/src/app/[locale]/account/(account)/layout.tsx +++ b/src/app/[locale]/account/(account)/layout.tsx @@ -2,34 +2,19 @@ import React, { ReactNode } from 'react'; import { AccountMenu } from '../../../../components/Account'; -import { i18nText } from '../../../../i18nKeys'; type AccountInnerLayoutProps = { children: ReactNode; params: { locale: string }; }; -const ROUTES = ['sessions', 'notifications', 'support', 'information', 'settings', 'messages', 'work-with-us']; -const COUNTS: Record = { - sessions: 12, - notifications: 5, - messages: 113 -}; - - export default function AccountInnerLayout({ children, params: { locale } }: AccountInnerLayoutProps) { - const getMenuConfig = () => ROUTES.map((path) => ({ - path, - title: i18nText(`accountMenu.${path}`, locale), - count: COUNTS[path] || undefined - })); - return (
- +
diff --git a/src/app/[locale]/account/(account)/sessions/page.tsx b/src/app/[locale]/account/(account)/sessions/page.tsx deleted file mode 100644 index c3ac0e5..0000000 --- a/src/app/[locale]/account/(account)/sessions/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { Suspense } from 'react'; -import type { Metadata } from 'next'; -import { useTranslations } from 'next-intl'; -import { SessionsAll } from '../../../../../components/Account'; - -export const metadata: Metadata = { - title: 'Bbuddy - Account - Sessions', - description: 'Bbuddy desc sessions' -}; - -export default function Sessions({ params: { locale } }: { params: { locale: string } }) { - const t = useTranslations('Account.Sessions'); - - return ( - Loading...

}> - -
- ); -} diff --git a/src/app/[locale]/account/(simple)/layout.tsx b/src/app/[locale]/account/(simple)/layout.tsx new file mode 100644 index 0000000..72872cd --- /dev/null +++ b/src/app/[locale]/account/(simple)/layout.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; + +type AccountSimpleLayoutProps = { + children: ReactNode; +}; + +export default function AccountSimpleLayout({ children }: AccountSimpleLayoutProps) { + return ( +
+
+
+ {children} +
+
+
+ ); +}; diff --git a/src/app/[locale]/account/(simple)/sessions/[[...slug]]/page.tsx b/src/app/[locale]/account/(simple)/sessions/[[...slug]]/page.tsx new file mode 100644 index 0000000..c687af6 --- /dev/null +++ b/src/app/[locale]/account/(simple)/sessions/[[...slug]]/page.tsx @@ -0,0 +1,55 @@ +import React, { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { AccountMenu, SessionDetails, SessionsTabs } from '../../../../../../components/Account'; +import { SessionType } from '../../../../../../types/sessions'; + +const SESSION_ROUTES = [SessionType.UPCOMING, SessionType.REQUESTED, SessionType.RECENT]; + +export async function generateStaticParams({ + params: { locale }, +}: { params: { locale: string } }) { + return [{ locale, slug: [SessionType.UPCOMING] }]; +} + +export default function SessionDetailItem({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) { + const sessionType: string = slug?.length > 0 && slug[0] || ''; + const sessionId: number | null = slug?.length > 1 && Number(slug[1]) || null; + + if (!slug?.length || slug?.length > 2) { + notFound(); + } + + if (SESSION_ROUTES.includes(sessionType as SessionType) && Number.isInteger(sessionId)) { + return ( + Loading...

}> + +
+ ); + } + + if (SESSION_ROUTES.includes(sessionType as SessionType) && !Number.isInteger(sessionId)) { + return ( + <> +
+ +
+
+
+ Loading...

}> + +
+
+
+ + ); + } + + return notFound(); +}; diff --git a/src/components/Account/AccountMenu.tsx b/src/components/Account/AccountMenu.tsx index 5e4899f..f7f61af 100644 --- a/src/components/Account/AccountMenu.tsx +++ b/src/components/Account/AccountMenu.tsx @@ -1,7 +1,6 @@ 'use client'; import React, { useState } from 'react'; -import styled from 'styled-components'; import { Button } from 'antd'; import { useSelectedLayoutSegment, usePathname } from 'next/navigation'; import { Link } from '../../navigation'; @@ -9,23 +8,14 @@ import { AUTH_TOKEN_KEY, AUTH_USER } from '../../constants/common'; import { deleteStorageKey } from '../../hooks/useLocalStorage'; import { i18nText } from '../../i18nKeys'; import { DeleteAccountModal } from '../Modals/DeleteAccountModal'; +import { getMenuConfig } from '../../utils/account'; -const Logout = styled(Button)` - width: 100%; - height: 49px !important; - color: #D93E5C !important; - font-style: normal; - font-weight: 600 !important; - padding: 0 !important; - font-size: 1.125rem !important; - text-align: left !important; -`; - -export const AccountMenu = ({ menu, locale }: { menu: { path: string, title: string, count?: number }[], locale: string }) => { +export const AccountMenu = ({ locale }: { locale: string }) => { const selectedLayoutSegment = useSelectedLayoutSegment(); const pathname = selectedLayoutSegment || ''; const paths = usePathname(); const [showDeleteModal, setShowDeleteModal] = useState(false); + const menu: { path: string, title: string, count?: number }[] = getMenuConfig(locale); const onLogout = () => { deleteStorageKey(AUTH_TOKEN_KEY); @@ -48,20 +38,22 @@ export const AccountMenu = ({ menu, locale }: { menu: { path: string, title: str ))}
  • - {i18nText('logout', locale)} - +
  • - {i18nText('deleteAcc', locale)} - + setShowDeleteModal(false)} diff --git a/src/components/Account/agora/Agora.tsx b/src/components/Account/agora/Agora.tsx new file mode 100644 index 0000000..ec6b7bd --- /dev/null +++ b/src/components/Account/agora/Agora.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useJoin } from 'agora-rtc-react'; +import { useEffect, useState } from 'react'; +import { MediaControl } from './view'; +import { LocalUserPanel, RemoteUserPanel } from './components'; +import { PublicUser } from '../../../types/sessions'; + +// const appId = process.env.AGORA_APPID; + +type AgoraProps = { + sessionId: number; + secret?: string; + stopCalling: () => void; + remoteUser?: PublicUser; +}; + +export const Agora = ({ sessionId, secret, stopCalling, remoteUser }: AgoraProps) => { + const [calling, setCalling] = useState(false); + const [micOn, setMic] = useState(false); + const [cameraOn, setCamera] = useState(false); + + useEffect(() => { + setCalling(true); + }, []); + + useJoin( + { + appid: 'ed90c9dc42634e5687d4e2e0766b363f', + channel: `${sessionId}-${secret}`, + token: null, + }, + calling, + ); + + const stop = () => { + stopCalling(); + setCalling(false); + }; + + return ( +
    + +
    + setCamera(a => !a)} + setMic={() => setMic(a => !a)} + /> + +
    +
    + ); +}; diff --git a/src/components/Account/agora/components/LocalUserPanel.tsx b/src/components/Account/agora/components/LocalUserPanel.tsx new file mode 100644 index 0000000..d76c908 --- /dev/null +++ b/src/components/Account/agora/components/LocalUserPanel.tsx @@ -0,0 +1,61 @@ +import { LocalUser, useLocalMicrophoneTrack, useLocalCameraTrack, usePublish, useIsConnected } from 'agora-rtc-react'; +import { useState, useEffect } from 'react'; +import { UserOutlined } from '@ant-design/icons'; +import { useLocalStorage } from '../../../../hooks/useLocalStorage'; +import { AUTH_USER } from '../../../../constants/common'; + +type LocalUserPanelProps = { + calling: boolean; + micOn: boolean; + cameraOn: boolean; +} + +export const LocalUserPanel = ({ + calling, + micOn, + cameraOn +}: LocalUserPanelProps) => { + const isConnected = useIsConnected(); + const [userData] = useLocalStorage(AUTH_USER, ''); + const { faceImageUrl: userImage = '' } = userData ? JSON.parse(userData) : {}; + + const [playVideo, setPlayVideo] = useState(false); + const [playAudio, setPlayAudio] = useState(false); + + const { localMicrophoneTrack } = useLocalMicrophoneTrack(micOn); + const { localCameraTrack } = useLocalCameraTrack(cameraOn); + usePublish([localMicrophoneTrack, localCameraTrack]); + + useEffect(() => { + if (calling) { + setPlayVideo(cameraOn) + } + }, [cameraOn]); + + useEffect(() => { + if (calling) { + setPlayAudio(micOn) + } + }, [micOn]); + + return calling && isConnected ? ( +
    + {!cameraOn && ( +
    +
    + {!userImage && ()} +
    +
    + )} + +
    + ) : null; +}; diff --git a/src/components/Account/agora/components/RemoteUserPanel.tsx b/src/components/Account/agora/components/RemoteUserPanel.tsx new file mode 100644 index 0000000..8f5e2e5 --- /dev/null +++ b/src/components/Account/agora/components/RemoteUserPanel.tsx @@ -0,0 +1,36 @@ +import { UserOutlined } from '@ant-design/icons'; +import { + useIsConnected, + useRemoteAudioTracks, + useRemoteUsers, + useRemoteVideoTracks +} from 'agora-rtc-react'; +import { RenderRemoteUsers } from './RemoteUsers'; +import { PublicUser } from '../../../../types/sessions'; + +export const RemoteUserPanel = ({ calling, user }: { calling: boolean, user?: PublicUser }) => { + const isConnected = useIsConnected(); + const remoteUsers = useRemoteUsers(); + const { videoTracks } = useRemoteVideoTracks(remoteUsers); + const { audioTracks } = useRemoteAudioTracks(remoteUsers); + audioTracks.map(track => track.play()); + + return calling && isConnected ? ( +
    + {videoTracks?.length > 0 ? ( + + ) : ( +
    + {remoteUsers?.length === 0 && ( +
    + Ожидайте подключения собеседника +
    + )} +
    + {!user?.faceImageUrl && ()} +
    +
    + )} +
    + ) : null; +} diff --git a/src/components/Account/agora/components/RemoteUsers.tsx b/src/components/Account/agora/components/RemoteUsers.tsx new file mode 100644 index 0000000..1829995 --- /dev/null +++ b/src/components/Account/agora/components/RemoteUsers.tsx @@ -0,0 +1,12 @@ +import type { IRemoteVideoTrack } from 'agora-rtc-react'; +import { RemoteVideoPlayer } from './RemoteVideoPlayer'; + +export function RenderRemoteUsers({ videoTracks }: { videoTracks: IRemoteVideoTrack[] }) { + return ( + <> + {videoTracks.map((track: IRemoteVideoTrack) => ( + + ))} + + ); +} diff --git a/src/components/Account/agora/components/RemoteVideoPlayer.tsx b/src/components/Account/agora/components/RemoteVideoPlayer.tsx new file mode 100644 index 0000000..b5014f5 --- /dev/null +++ b/src/components/Account/agora/components/RemoteVideoPlayer.tsx @@ -0,0 +1,69 @@ +import type { HTMLProps, ReactNode } from 'react'; +import type { IAgoraRTCClient, IRemoteVideoTrack } from 'agora-rtc-react'; +import { RemoteVideoTrack, useRTCClient } from 'agora-rtc-react'; +import { UserCover } from '../components'; + +export interface RemoteVideoPlayerProps extends HTMLProps { + /** + * A remote track + */ + readonly track?: IRemoteVideoTrack; + /** + * Whether to play the remote user's video track. Default follows `user.hasVideo`. + */ + readonly playVideo?: boolean; + /** + * Render cover image if playVideo is off. + */ + readonly cover?: string | (() => ReactNode); + /** + * Children is rendered on top of the video canvas. + */ + readonly children?: ReactNode; + /** + * client instance + */ + readonly client?: IAgoraRTCClient | null; +} + +/** + * Subscribe and play remote user video track. + * An `IRemoteVideoTrack` can only be own by one `RemoteVideoPlayer`. + */ +export function RemoteVideoPlayer({ + track, + playVideo, + cover, + client, + style, + children, + ...props +}: RemoteVideoPlayerProps) { + const resolvedClient = useRTCClient(client); + const hasVideo = resolvedClient.remoteUsers?.find( + user => user.uid === track?.getUserId(), + )?.hasVideo; + playVideo = playVideo ?? hasVideo; + return ( +
    + + {cover && !playVideo && } +
    {children}
    +
    + ); +} diff --git a/src/components/Account/agora/components/UserCover.tsx b/src/components/Account/agora/components/UserCover.tsx new file mode 100644 index 0000000..2cfde3f --- /dev/null +++ b/src/components/Account/agora/components/UserCover.tsx @@ -0,0 +1,56 @@ +import type { CSSProperties, ReactNode } from 'react'; + +export const FloatBoxStyle: CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + overflow: "hidden", + zIndex: 2, +}; + +const CoverBlurStyle: CSSProperties = { + width: "100%", + height: "100%", + background: "#1a1e21 center/cover no-repeat", + filter: "blur(16px) brightness(0.4)", +}; + +const CoverImgStyle: CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + maxWidth: "50%", + maxHeight: "50%", + aspectRatio: "1", + transform: "translate(-50%, -50%)", + borderRadius: "50%", + overflow: "hidden", + objectFit: "cover", +}; + +export interface UserCoverProps { + /** + * Cover image url or a custom render function. + */ + cover: string | (() => ReactNode); +} + +/** + * User Cover image with blur background + */ +export function UserCover({ cover }: UserCoverProps) { + return ( +
    + {typeof cover === "string" ? ( + <> +
    + + + ) : ( + cover() + )} +
    + ); +} diff --git a/src/components/Account/agora/components/index.ts b/src/components/Account/agora/components/index.ts new file mode 100644 index 0000000..99a5a9d --- /dev/null +++ b/src/components/Account/agora/components/index.ts @@ -0,0 +1,5 @@ +export * from './RemoteVideoPlayer'; +export * from './UserCover'; +export * from './RemoteUsers'; +export * from './LocalUserPanel'; +export * from './RemoteUserPanel'; diff --git a/src/components/Account/agora/icons/index.tsx b/src/components/Account/agora/icons/index.tsx new file mode 100644 index 0000000..76c609e --- /dev/null +++ b/src/components/Account/agora/icons/index.tsx @@ -0,0 +1,61 @@ +'use client' + +import React from 'react'; +import Icon from '@ant-design/icons'; +import type { GetProps } from 'antd'; + +type CustomIconComponentProps = GetProps; + +const MicOnSvg = () => ( + + + + +); + +const MicOffSvg = () => ( + + + + + +); + +const CameraOnSvg = () => ( + + + +); + +const CameraOffSvg = () => ( + + + + +); + +const PhoneSvg = () => ( + + + +); + +export const MicOnIcon = (props: Partial) => ( + +); + +export const MicOffIcon = (props: Partial) => ( + +); + +export const CameraOnIcon = (props: Partial) => ( + +); + +export const CameraOffIcon = (props: Partial) => ( + +); + +export const PhoneIcon = (props: Partial) => ( + +); diff --git a/src/components/Account/agora/index.tsx b/src/components/Account/agora/index.tsx new file mode 100644 index 0000000..d4d23f9 --- /dev/null +++ b/src/components/Account/agora/index.tsx @@ -0,0 +1,24 @@ +'use client' + +import AgoraRTC, { AgoraRTCProvider } from 'agora-rtc-react'; +import { Session } from '../../../types/sessions'; +import { Agora } from './Agora'; + +AgoraRTC.setLogLevel(0); + +export const AgoraClient = ({ session, stopCalling, isCoach }: { session?: Session, stopCalling: () => void, isCoach: boolean }) => { + const remoteUser = isCoach ? (session?.clients?.length ? session?.clients[0] : undefined) : session?.coach; + + return session ? ( + + {session && ( + + )} + + ) : null; +}; diff --git a/src/components/Account/agora/view/MediaControl.tsx b/src/components/Account/agora/view/MediaControl.tsx new file mode 100644 index 0000000..334e6c8 --- /dev/null +++ b/src/components/Account/agora/view/MediaControl.tsx @@ -0,0 +1,49 @@ +import { Button } from 'antd'; +import { MicOffIcon, MicOnIcon, CameraOnIcon, CameraOffIcon, PhoneIcon } from '../icons'; + +interface MediaControlProps { + calling: boolean; + micOn: boolean; + cameraOn: boolean; + setMic: () => void; + setCamera: () => void; + setCalling: () => void; +} + +export const MediaControl = ({ + calling, + micOn, + cameraOn, + setMic, + setCamera, + setCalling, +}: MediaControlProps) => ( +
    +
    +); diff --git a/src/components/Account/agora/view/index.ts b/src/components/Account/agora/view/index.ts new file mode 100644 index 0000000..dc4fa80 --- /dev/null +++ b/src/components/Account/agora/view/index.ts @@ -0,0 +1 @@ +export * from "./MediaControl"; diff --git a/src/components/Account/sessions/SessionDetails.tsx b/src/components/Account/sessions/SessionDetails.tsx index b7dfd91..b0f1c43 100644 --- a/src/components/Account/sessions/SessionDetails.tsx +++ b/src/components/Account/sessions/SessionDetails.tsx @@ -1,286 +1,77 @@ 'use client' -import React, { useCallback, useEffect, useState } from 'react'; -import {Tag, Button, notification, Empty} from 'antd'; -import { RightOutlined, PlusOutlined, LeftOutlined } from '@ant-design/icons'; -import Image from 'next/image'; -import dayjs from 'dayjs'; -import { Link } from '../../../navigation'; -import { i18nText } from '../../../i18nKeys'; -import { getDuration, getPrice } from '../../../utils/expert'; -import { PublicUser, Session } from '../../../types/sessions'; -import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common'; -import { approveRequestedSession, getSessionDetails } from '../../../actions/sessions'; +import React, { useState, useEffect } from 'react'; +import { SessionType } from '../../../types/sessions'; +import { AUTH_USER } from '../../../constants/common'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { Loader } from '../../view/Loader'; -import { DeclineSessionModal } from '../../Modals/DeclineSessionModal'; -import { AddCommentModal } from '../../Modals/AddCommentModal'; +import { useSessionDetails } from "../../../actions/hooks/useSessionDetails"; +import { AgoraClient } from '../agora'; +import { AccountMenu } from '../AccountMenu'; +import { SessionDetailsContent } from './SessionDetailsContent'; +import { useSessionTracking } from '../../../actions/hooks/useSessionTracking'; type SessionDetailsProps = { locale: string; sessionId: number; - goBack: () => void; - activeTab: number; + activeType: SessionType; }; -export const SessionDetails = ({ sessionId, locale, goBack, activeTab }: SessionDetailsProps) => { - const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); - const [userId] = useLocalStorage(AUTH_USER, ''); - const [loading, setLoading] = useState(false); - const [approveLoading, setApproveLoading] = useState(false); - const [errorData, setErrorData] = useState(); - const [session, setSession] = useState(); - const [openDeclineModal, setOpenDeclineModal] = useState(false); - const [openAddCommentModal, setOpenAddCommentModal] = useState(false); - - const fetchData = useCallback(() => { - setLoading(true); - setErrorData(undefined); - setSession(undefined); - - getSessionDetails(locale, jwt, sessionId) - .then(({ data }) => { - setSession(data); - }) - .catch((err) => { - setErrorData(err); - }) - .finally(() => { - setLoading(false); - }) - }, []); - - useEffect(() => { - fetchData(); - }, [sessionId]); - - const onApproveSession = (tab: typeof activeTab) => { - if (tab === 1) { - setApproveLoading(true); - approveRequestedSession(locale, jwt, sessionId) - .then(() => { - goBack(); - }) - .catch((err) => { - notification.error({ - message: 'Error', - description: err?.response?.data?.errMessage - }); - }) - .finally(() => { - setLoading(false); - }); - } - }; - - const startDate = session?.scheduledStartAtUtc ? dayjs(session?.scheduledStartAtUtc).locale(locale) : null; - const endDate = session?.scheduledEndAtUtc ? dayjs(session?.scheduledEndAtUtc).locale(locale) : null; - const today = startDate ? dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD') : false; - - const CoachCard = (coach?: PublicUser) => coach ? ( -
    -
    - -
    -
    - -
    {`${coach?.name} ${coach?.surname || ''}`}
    - - {/*
    -
    {coach?.specialityDesc}
    -
    - {coach?.coachLanguages?.map((lang) => ( - {lang} - ))} -
    -
    */} -
    - {getPrice(session?.cost)} / {getDuration(locale, session?.totalDuration)} -
    -
    - {today - ? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}` - : `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`} -
    -
    -
    - {session?.themesTags?.slice(0, 2).map((skill) => {skill?.name})} - {session?.themesTags?.length > 2 - ? ( - - - {`+${session?.themesTags?.length - 2}`} - - - ) : null } -
    -
    - {/*
    {coach?.description}
    */} - - {i18nText('details', locale)} - - -
    -
    - ) : null; - - const StudentCard = (student?: PublicUser | null) => student ? ( -
    -
    - -
    -
    -
    {`${student?.name} ${student?.surname || ''}`}
    -
    - {today - ? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}` - : `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`} -
    -
    -
    - {session?.themesTagName} -
    -
    - {/*
    {student?.description}
    */} -
    -
    - ) : null; +export const SessionDetails = ({ sessionId, locale, activeType }: SessionDetailsProps) => { + const { session, errorData, loading, fetchData } = useSessionDetails(locale, sessionId); + const tracking = useSessionTracking(locale, sessionId); + const [isCalling, setIsCalling] = useState(false); + const [userData] = useLocalStorage(AUTH_USER, ''); + const { id: userId = 0 } = userData ? JSON.parse(userData) : {}; const client = session?.clients?.length ? session?.clients[0] : null; const isCoach = +userId !== client?.id; - const Current = isCoach ? StudentCard(client) : CoachCard(session?.coach); - return ( - -
    -
    - + useEffect(() => { + if (isCalling) { + tracking.start(); + } else { + tracking.stop(); + } + }, [isCalling]); + + const stopCalling = () => { + setIsCalling(false); + fetchData() + } + + return isCalling + ? ( + + ) : ( + <> +
    +
    - {Current} - {(activeTab === 0 || activeTab === 1) && ( -
    - - - {session?.id && ( - setOpenDeclineModal(false)} - activeTab={activeTab} - locale={locale} - sessionId={session.id} - success={goBack} - /> - )} -
    - )} - {activeTab !== 1 && ( - <> - {activeTab === 2 && ( - <> -
    Course Info
    -
    - {/*
    -
    {current?.specialityDesc}
    -
    - {current?.coachLanguages?.map((lang) => ( - {lang} - ))} -
    -
    */} -
    - {getPrice(session?.cost)} / {getDuration(locale, session?.totalDuration)} -
    -
    -
    - {session?.themesTags?.slice(0, 2).map((skill) => {skill?.name})} - {session?.themesTags?.length > 2 - ? ( - - {`+${session?.themesTags?.length - 2}`} - - ) : null } -
    -
    - {/*
    {current?.description}
    */} -
    - - )} -
    -
    -
    - {session?.clientComments?.length === 0 && session?.coachComments?.length === 0 ? 'Comments' : 'My Comments'} -
    - {activeTab === 0 && ( - <> - - setOpenAddCommentModal(false)} - locale={locale} - sessionId={sessionId} - refresh={fetchData} - /> - - )} -
    - {(session?.clientComments?.length > 0 || session?.coachComments?.length > 0) ? ( - <> - {(isCoach ? session?.coachComments : session?.clientComments)?.map(({ id, comment }) => ( -
    - {comment} -
    - ))} - {(isCoach ? session?.clientComments : session?.coachComments)?.length > 0 && ( -
    - {isCoach ? 'Client Comments' : 'Coach Comments'} -
    - )} - {(isCoach ? session?.clientComments : session?.coachComments)?.map(({ id , comment }) => ( -
    - {comment} -
    - ))} - - ) : ( - <> - - + {session && ( + setIsCalling(true)} + refresh={fetchData} + isCoach={isCoach} + /> )} -
    - - )} -
    - - ); + +
    +
    + + ); }; diff --git a/src/components/Account/sessions/SessionDetailsContent.tsx b/src/components/Account/sessions/SessionDetailsContent.tsx new file mode 100644 index 0000000..e8333f8 --- /dev/null +++ b/src/components/Account/sessions/SessionDetailsContent.tsx @@ -0,0 +1,299 @@ +'use client' + +import React, {useState} from 'react'; +import {Button, Empty, notification, Tag} from 'antd'; +import {LeftOutlined, PlusOutlined, RightOutlined} from '@ant-design/icons'; +import Image from 'next/image'; +import dayjs from 'dayjs'; +import {Link, useRouter} from '../../../navigation'; +import {i18nText} from '../../../i18nKeys'; +import {getDuration, getPrice} from '../../../utils/expert'; +import {PublicUser, Session, SessionState, SessionType} from '../../../types/sessions'; +import {AUTH_TOKEN_KEY} from '../../../constants/common'; +import {approveRequestedSession, finishSession} from '../../../actions/sessions'; +import {useLocalStorage} from '../../../hooks/useLocalStorage'; +import {DeclineSessionModal} from '../../Modals/DeclineSessionModal'; +import {AddCommentModal} from '../../Modals/AddCommentModal'; + +type SessionDetailsContentProps = { + locale: string; + session: Session; + activeType: SessionType; + startSession: () => void; + refresh: () => void; + isCoach: boolean; +}; + +export const SessionDetailsContent = ({ session, locale, activeType, startSession, refresh, isCoach }: SessionDetailsContentProps) => { + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const [approveLoading, setApproveLoading] = useState(false); + const [finishLoading, setFinishLoading] = useState(false); + const [openDeclineModal, setOpenDeclineModal] = useState(false); + const [openAddCommentModal, setOpenAddCommentModal] = useState(false); + const router = useRouter(); + + const goBack = () => router.push(`/account/sessions/${activeType}`); + + const onApproveSession = () => { + if (activeType === SessionType.REQUESTED) { + setApproveLoading(true); + approveRequestedSession(locale, jwt, session.id) + .then(() => { + goBack(); + }) + .catch((err) => { + notification.error({ + message: 'Error approve session', + description: err?.response?.data?.errMessage + }); + }) + .finally(() => { + setApproveLoading(false); + }); + } else { + startSession(); + } + }; + + const onFinishSession = () => { + if (isCoach) { + setFinishLoading(true); + finishSession(locale, jwt, session.id) + .then(() => { + goBack(); + }) + .catch((err) => { + notification.error({ + message: 'Error finish session', + description: err?.response?.data?.errMessage + }); + }) + .finally(() => { + setFinishLoading(false); + }) + } + }; + + const startDate = session?.scheduledStartAtUtc ? dayjs(session?.scheduledStartAtUtc).locale(locale) : null; + const endDate = session?.scheduledEndAtUtc ? dayjs(session?.scheduledEndAtUtc).locale(locale) : null; + const today = startDate ? dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD') : false; + + const CoachCard = (coach?: PublicUser) => coach ? ( +
    +
    + +
    +
    + +
    {`${coach?.name} ${coach?.surname || ''}`}
    + + {/*
    +
    {coach?.specialityDesc}
    +
    + {coach?.coachLanguages?.map((lang) => ( + {lang} + ))} +
    +
    */} +
    + {getPrice(session?.cost)} / {getDuration(locale, session?.totalDuration)} +
    +
    + {today + ? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}` + : `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`} +
    +
    +
    + {session?.themesTags?.slice(0, 2).map((skill) => {skill?.name})} + {session?.themesTags?.length > 2 + ? ( + + + {`+${session?.themesTags?.length - 2}`} + + + ) : null } +
    +
    + {/*
    {coach?.description}
    */} + + {i18nText('details', locale)} + + +
    +
    + ) : null; + + const StudentCard = (student?: PublicUser | null) => student ? ( +
    +
    + +
    +
    +
    {`${student?.name} ${student?.surname || ''}`}
    +
    + {today + ? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}` + : `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`} +
    +
    +
    + {session?.themesTagName} +
    +
    + {/*
    {student?.description}
    */} + {activeType === SessionType.REQUESTED && session?.clientComment && ( +
    +
    + {session.clientComment} +
    +
    + )} +
    +
    + ) : null; + + const client = session?.clients?.length ? session?.clients[0] : null; + const Current = isCoach ? StudentCard(client) : CoachCard(session?.coach); + + return ( +
    +
    + +
    + {Current} + {(activeType === SessionType.UPCOMING || activeType === SessionType.REQUESTED) && + (session?.state === SessionState.CREATED || session?.state === SessionState.PAID + || session?.state === SessionState.COACH_APPROVED || session?.state === SessionState.STARTED) && ( +
    + + {session?.state === SessionState.STARTED && isCoach && ( + + )} + {session?.id && session?.state !== SessionState.STARTED && ( + <> + + setOpenDeclineModal(false)} + activeType={activeType} + locale={locale} + sessionId={session.id} + success={goBack} + /> + + )} +
    + )} + {activeType !== SessionType.REQUESTED && ( + <> + {activeType === SessionType.RECENT && ( + <> +
    Course Info
    +
    + {/*
    +
    {current?.specialityDesc}
    +
    + {current?.coachLanguages?.map((lang) => ( + {lang} + ))} +
    +
    */} +
    + {getPrice(session?.cost)} / {getDuration(locale, session?.totalDuration)} +
    +
    +
    + {session?.themesTags?.slice(0, 2).map((skill) => {skill?.name})} + {session?.themesTags?.length > 2 + ? ( + + {`+${session?.themesTags?.length - 2}`} + + ) : null } +
    +
    + {/*
    {current?.description}
    */} +
    + + )} +
    +
    +
    + {session?.clientComments?.length === 0 && session?.coachComments?.length === 0 ? 'Comments' : 'My Comments'} +
    + {activeType === SessionType.UPCOMING && ( + <> + + setOpenAddCommentModal(false)} + locale={locale} + sessionId={session.id} + refresh={refresh} + /> + + )} +
    + {(session?.clientComments?.length > 0 || session?.coachComments?.length > 0) ? ( + <> + {(isCoach ? session?.coachComments : session?.clientComments)?.map(({ id, comment }) => ( +
    + {comment} +
    + ))} + {(isCoach ? session?.clientComments : session?.coachComments)?.length > 0 && ( +
    + {isCoach ? 'Client Comments' : 'Coach Comments'} +
    + )} + {(isCoach ? session?.clientComments : session?.coachComments)?.map(({ id , comment }) => ( +
    + {comment} +
    + ))} + + ) : } +
    + + )} +
    + ); +}; diff --git a/src/components/Account/sessions/SessionsTabs.tsx b/src/components/Account/sessions/SessionsTabs.tsx index 6c80bc6..5816e2c 100644 --- a/src/components/Account/sessions/SessionsTabs.tsx +++ b/src/components/Account/sessions/SessionsTabs.tsx @@ -14,22 +14,23 @@ import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common'; import { getRecentSessions, getRequestedSessions, getUpcomingSessions } from '../../../actions/sessions'; import { Session, Sessions, SessionType } from '../../../types/sessions'; +import { useRouter } from '../../../navigation'; import { i18nText } from '../../../i18nKeys'; type SessionsTabsProps = { locale: string; - updateSession: (val: number) => void; - activeTab: number; - updateTab: (tab: number) => void; + activeTab: SessionType; }; -export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: SessionsTabsProps) => { +export const SessionsTabs = ({ locale, activeTab }: SessionsTabsProps) => { const [sort, setSort] = useState(); const [sessions, setSessions] = useState(); const [loading, setLoading] = useState(true); const [errorData, setErrorData] = useState(); const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); - const [userId] = useLocalStorage(AUTH_USER, ''); + const [userData] = useLocalStorage(AUTH_USER, ''); + const { id: userId = 0 } = userData ? JSON.parse(userData) : {}; + const router = useRouter(); const fetchData = () => { setErrorData(undefined); @@ -65,7 +66,7 @@ export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: Se const onClickSession = (event: MouseEvent, id: number) => { event.stopPropagation(); event.preventDefault(); - updateSession(id); + router.push(`${id}`); }; const getChildren = (list?: Session[]) => ( @@ -155,17 +156,17 @@ export const SessionsTabs = ({ locale, updateSession, activeTab, updateTab }: Se refresh={fetchData} >
    - {tabs.map((tab, index) => ( + {tabs.map(({ key, label }) => ( updateTab(index)} + key={key} + className={`tabs-session__item ${key === activeTab ? 'active' : ''}`} + onClick={() => router.push(`/account/sessions/${key}`)} > - {tab.label} + {label} ))}
    - {tabs[activeTab].children} + {tabs.filter(({ key }) => key === activeTab)[0].children} ); }; diff --git a/src/components/Account/sessions/index.tsx b/src/components/Account/sessions/index.tsx index 478420a..e173564 100644 --- a/src/components/Account/sessions/index.tsx +++ b/src/components/Account/sessions/index.tsx @@ -1,26 +1,5 @@ 'use client' -import React, { useState } from 'react'; -import { SessionDetails } from './SessionDetails'; -import { SessionsTabs } from './SessionsTabs'; - -export const SessionsAll = ({ locale }: { locale: string }) => { - const [customSession, setCustomSession] = useState(); - const [activeTab, setActiveTab] = useState(0); - - return customSession ? ( - setCustomSession(undefined)} - activeTab={activeTab} - /> - ) : ( - - ); -}; +export * from './SessionDetails'; +export * from './SessionsTabs'; +export * from './SessionDetailsContent'; diff --git a/src/components/Modals/AddCommentModal.tsx b/src/components/Modals/AddCommentModal.tsx index 6041871..43e4a0c 100644 --- a/src/components/Modals/AddCommentModal.tsx +++ b/src/components/Modals/AddCommentModal.tsx @@ -91,7 +91,7 @@ export const AddCommentModal: FC = ({ className="b-textarea" rows={4} maxLength={1000} - placeholder="Describe the reason for the rejection" + placeholder="Your comment" /> diff --git a/src/components/Modals/DeclineSessionModal.tsx b/src/components/Modals/DeclineSessionModal.tsx index cc25525..44f23b4 100644 --- a/src/components/Modals/DeclineSessionModal.tsx +++ b/src/components/Modals/DeclineSessionModal.tsx @@ -1,18 +1,19 @@ 'use client'; import React, { FC, useEffect, useState } from 'react'; -import { Modal, Form, Input, notification } from 'antd'; +import { Form, Input, Modal, notification } from 'antd'; import { CloseOutlined } from '@ant-design/icons'; -import { FilledButton } from '../view/FilledButton'; -import { i18nText } from '../../i18nKeys'; -import { cancelUpcomingSession, declineRequestedSession } from '../../actions/sessions'; -import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { SessionType } from '../../types/sessions'; import { AUTH_TOKEN_KEY } from '../../constants/common'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { cancelUpcomingSession, declineRequestedSession } from '../../actions/sessions'; +// import { i18nText } from '../../i18nKeys'; +import { FilledButton } from '../view/FilledButton'; type DeclineModalProps = { open: boolean; handleCancel: () => void; - activeTab: 0 | 1; + activeType: SessionType; locale: string; sessionId: number; success: () => void; @@ -21,7 +22,7 @@ type DeclineModalProps = { export const DeclineSessionModal: FC = ({ open, handleCancel, - activeTab, + activeType, locale, sessionId, success @@ -38,11 +39,11 @@ export const DeclineSessionModal: FC = ({ if (form) { form.resetFields(); } - }, [activeTab]); + }, [activeType]); const onDecline = () => { form.validateFields().then(({ reason }) => { - const fetchFunc = activeTab === 0 ? cancelUpcomingSession : declineRequestedSession; + const fetchFunc = activeType === SessionType.UPCOMING ? cancelUpcomingSession : declineRequestedSession; setLoading(true); fetchFunc(locale, jwt, { sessionId, reason }) diff --git a/src/components/Modals/authModalContent/EnterContent.tsx b/src/components/Modals/authModalContent/EnterContent.tsx index 2d132bd..2bc79c7 100644 --- a/src/components/Modals/authModalContent/EnterContent.tsx +++ b/src/components/Modals/authModalContent/EnterContent.tsx @@ -41,7 +41,7 @@ export const EnterContent: FC = ({ if (data.jwtToken) { getPersonalData(locale, data.jwtToken) .then(({ data: profile }) => { - localStorage.setItem(AUTH_USER, profile.id.toString()); + localStorage.setItem(AUTH_USER, JSON.stringify(profile)); updateToken(data.jwtToken); handleCancel(); }) diff --git a/src/components/Modals/authModalContent/RegisterContent.tsx b/src/components/Modals/authModalContent/RegisterContent.tsx index 4fb58a2..ce3775e 100644 --- a/src/components/Modals/authModalContent/RegisterContent.tsx +++ b/src/components/Modals/authModalContent/RegisterContent.tsx @@ -41,7 +41,7 @@ export const RegisterContent: FC = ({ setPersonData( { login, password, role: 'client', languagesLinks: [] }, locale, data.jwtToken) .then(({ data: profile }) => { updateToken(data.jwtToken); - localStorage.setItem(AUTH_USER, profile.userData.id.toString()); + localStorage.setItem(AUTH_USER, JSON.stringify(profile.userData)); handleCancel(); }) .catch((error) => { diff --git a/src/components/Page/Header/HeaderAuthLinks.tsx b/src/components/Page/Header/HeaderAuthLinks.tsx index c272738..e891919 100644 --- a/src/components/Page/Header/HeaderAuthLinks.tsx +++ b/src/components/Page/Header/HeaderAuthLinks.tsx @@ -38,7 +38,7 @@ export const HeaderAuthLinks: FC = ({ return token ? (
  • - + {i18nText('account', locale)}
  • diff --git a/src/components/Page/Header/index.tsx b/src/components/Page/Header/index.tsx index 0985d7f..6752313 100644 --- a/src/components/Page/Header/index.tsx +++ b/src/components/Page/Header/index.tsx @@ -27,7 +27,7 @@ export const Header: FC = ({ locale }) => { alt="" /> - + = ({ locale }) => {
    - + = { + sessions: 12, + notifications: 5, + messages: 113 +}; + +export const getMenuConfig = (locale: string) => ROUTES.map((path) => ({ + path, + title: i18nText(`accountMenu.${path}`, locale), + count: COUNTS[path] || undefined +})); diff --git a/src/utils/agora/helpers.ts b/src/utils/agora/helpers.ts new file mode 100644 index 0000000..07caefb --- /dev/null +++ b/src/utils/agora/helpers.ts @@ -0,0 +1,108 @@ +import type { MaybePromise } from 'agora-rtc-react'; + +export type Disposer = () => void; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Fn = (...args: any[]) => any; + +export function invoke(fn: () => T): T | void { + try { + return fn(); + } catch (e) { + console.error(e); + } +} + +export function joinDisposers(disposers: Disposer[]): Disposer { + return () => disposers.forEach(invoke); +} + +export function interval(fn: Fn, interval: number): Disposer { + const id = setInterval(fn, interval); + return () => clearInterval(id); +} + +export function timeout(fn: Fn, ms: number): Disposer { + const id = setTimeout(fn, ms); + return () => clearTimeout(id); +} + +export interface AsyncTaskRunner { + run: (this: void, task: () => MaybePromise MaybePromise)>) => void; + dispose: (this: void) => void; +} + +/** + * Chain async tasks. During the task running/stopping, if multiple tasks are triggered, only the last one will be executed. + */ +export function createAsyncTaskRunner(): AsyncTaskRunner { + let isRunning: boolean | undefined; + let nextTask: undefined | (() => MaybePromise); + let disposer: undefined | void | (() => MaybePromise); + + function runNextTask() { + if (nextTask) { + const _nextTask = nextTask; + nextTask = void 0; + _nextTask(); + } + } + + async function disposeEffect() { + if (disposer) { + const _disposer = disposer; + disposer = void 0; + try { + await _disposer(); + } catch (e) { + console.error(e); + } + } + } + + async function runTask(effect: () => MaybePromise MaybePromise)>) { + isRunning = true; + + await disposeEffect(); + + try { + disposer = await effect(); + } catch (e) { + console.error(e); + } + + isRunning = false; + + runNextTask(); + } + + async function stopTask() { + isRunning = true; + + await disposeEffect(); + + isRunning = false; + + runNextTask(); + } + + function run(task: () => MaybePromise MaybePromise)>): void { + if (isRunning) { + nextTask = () => runTask(task); + } else { + runTask(task); + } + } + + function dispose(): void { + if (isRunning) { + nextTask = stopTask; + } else { + stopTask(); + } + } + + return { + run, + dispose, + }; +} diff --git a/src/utils/agora/tools.ts b/src/utils/agora/tools.ts new file mode 100644 index 0000000..7623c91 --- /dev/null +++ b/src/utils/agora/tools.ts @@ -0,0 +1,163 @@ +import type { MutableRefObject, Ref, RefObject } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { MaybePromiseOrNull } from 'agora-rtc-react'; +import { AsyncTaskRunner, createAsyncTaskRunner } from './helpers'; + +export const useIsomorphicLayoutEffect = + typeof document !== 'undefined' ? useLayoutEffect : useEffect; + +export function isPromise(value: MaybePromiseOrNull): value is PromiseLike { + return value != null && typeof (value as PromiseLike).then === "function"; +} + +export function useForceUpdate() { + const [_, forceUpdate] = useState(0); + return useCallback(() => forceUpdate(n => (n + 1) | 0), []); +} + +export function useIsUnmounted(): RefObject { + const isUnmountRef = useRef(false); + useEffect(() => { + isUnmountRef.current = false; + return () => { + isUnmountRef.current = true; + }; + }, []); + return isUnmountRef; +} + +/** + * Leave promise unresolved when the component is unmounted. + * + * ```js + * const sp = useSafePromise() + * setLoading(true) + * try { + * const result1 = await sp(fetchData1()) + * const result2 = await sp(fetchData2(result1)) + * setData(result2) + * } catch(e) { + * setHasError(true) + * } + * setLoading(false) + * ``` + */ +export function useSafePromise() { + const isUnmountRef = useIsUnmounted(); + + function safePromise( + promise: PromiseLike, + onUnmountedError?: (error: E) => void, + ) { + // the async promise executor is intended + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + try { + const result = await promise; + if (!isUnmountRef.current) { + resolve(result); + } + // unresolved promises will be garbage collected. + } catch (error) { + if (!isUnmountRef.current) { + reject(error); + } else if (onUnmountedError) { + onUnmountedError(error as E); + } else { + if (process.env.NODE_ENV === 'development') { + console.error("An error occurs from a promise after a component is unmounted", error); + } + } + } + }); + } + + return useCallback(safePromise, [isUnmountRef]); +} + +export function applyRef(ref: Ref, value: T) { + if (typeof ref === "function") { + ref(value); + } else if (typeof ref === "object" && ref) { + (ref as MutableRefObject).current = value; + } +} + +/** + * Sugar to merge forwarded ref and produce a local ref (state). + * + * ```jsx + * const Button = forwardRef((props, ref) => { + * const [div, setDiv] = useForwardRef(ref) + * // use 'div' here + * return
    + * }) + * ``` + */ +export function useForwardRef(ref: Ref): [T | null, (value: T | null) => void] { + const [current, setCurrent] = useState(null); + const forwardedRef = useCallback( + (value: T | null) => { + setCurrent(value); + applyRef(ref, value); + }, + [ref, setCurrent], + ); + return [current, forwardedRef]; +} + +/** + * Await a promise or return the value directly. + */ +export function useAwaited(promise: MaybePromiseOrNull): T | undefined { + const sp = useSafePromise(); + const [value, setValue] = useState(); + + useIsomorphicLayoutEffect(() => { + if (isPromise(promise)) { + sp(promise).then(setValue); + } else { + setValue(promise); + } + }, [promise, sp]); + + return value; +} + +/** + * Accepts a function that contains imperative, possibly asynchronous effect-ful code. + * During the side-effect running/removing, if multiple effects are triggered, only the last one will be executed. + */ +export function useAsyncEffect( + effect: () => MaybePromise MaybePromise)>, + deps?: ReadonlyArray, +): void { + const runnerRef = useRef(); + useEffect(() => { + const { run, dispose } = (runnerRef.current ||= createAsyncTaskRunner()); + run(effect); + return dispose; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +export function compareVersion(v1: string, v2: string): number { + const v1Parts = v1.split("."); + const v2Parts = v2.split("."); + const maxLength = Math.max(v1Parts.length, v2Parts.length); + + for (let i = 0; i < maxLength; i++) { + const part1 = parseInt(v1Parts[i] || "0"); + const part2 = parseInt(v2Parts[i] || "0"); + + if (part1 > part2) { + return 1; + } + + if (part1 < part2) { + return -1; + } + } + + return 0; +}