feat: Implement rich media messaging for images, files, and GIFs, and add message read/delivery status indicators.

This commit is contained in:
pradeepkumar
2026-02-21 22:11:42 +05:30
parent 3d2171e0bb
commit c5dc139633
12 changed files with 1237 additions and 118 deletions

515
package-lock.json generated
View File

@@ -8,13 +8,19 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@giphy/js-fetch-api": "^5.7.0",
"@giphy/react-components": "^10.1.1",
"axios": "^1.13.2",
"emoji-mart": "^5.6.0",
"next": "16.1.0",
"next-auth": "^5.0.0-beta.30",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-select": "^5.10.2",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -341,6 +347,22 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
"license": "MIT"
},
"node_modules/@emoji-mart/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
"license": "MIT",
"peerDependencies": {
"emoji-mart": "^5.2",
"react": "^16.8 || ^17 || ^18"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -385,6 +407,15 @@
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0"
}
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
@@ -630,6 +661,70 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@giphy/colors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@giphy/colors/-/colors-1.0.1.tgz",
"integrity": "sha512-CMUY2UR8Ujl/wTRVjwu9spss6Cf2qkQNATKfoWCTWQp7CXdSfPWUDepp0c0/F0n7lYphLFvD9qzOC4NSdBt7nw==",
"license": "MIT"
},
"node_modules/@giphy/js-analytics": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@giphy/js-analytics/-/js-analytics-5.0.0.tgz",
"integrity": "sha512-jBZG6OqyMWB6meLi8Sz3iLplXYnhkbj+DJhS4ChmRX8Y6UA7i5dbbsUN/So1s7tTjhZOvu0rxA6rWJE73S1FvQ==",
"license": "MIT",
"dependencies": {
"@giphy/js-types": "*",
"@giphy/js-util": "*",
"append-query": "^2.1.0",
"throttle-debounce": "^3.0.1"
}
},
"node_modules/@giphy/js-fetch-api": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@giphy/js-fetch-api/-/js-fetch-api-5.7.0.tgz",
"integrity": "sha512-5W8j7QUEnWRhW2KKlRhwHNCz0HwUeW0SlFryKFrqzE5XB8kjoAwZ2GvwtLNnkpjN0cniLE+bSGAzMKcL5Bt5Vg==",
"license": "MIT",
"dependencies": {
"@giphy/js-types": "*",
"@giphy/js-util": "*"
}
},
"node_modules/@giphy/js-types": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@giphy/js-types/-/js-types-5.1.0.tgz",
"integrity": "sha512-BZYCDtYNRR7cUWkbDLB4wmm3qmWMsVCQdUiBNOfmZ3yAazCgygKJoDI/5Rq4CK5MBaOc5LVdF8viC2WtoBdaPA==",
"license": "MIT"
},
"node_modules/@giphy/js-util": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@giphy/js-util/-/js-util-5.2.0.tgz",
"integrity": "sha512-Qt7pGh2cqiNmXLeWAgb459wK8+BuMLtIxTfg4ZksnPHPsLthiHT9hhzs2QhqUh7Pp/HOq+Cbv2etGDfnq+xiKA==",
"license": "MIT",
"dependencies": {
"@giphy/js-types": "*",
"uuid": "^9.0.0"
}
},
"node_modules/@giphy/react-components": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.1.tgz",
"integrity": "sha512-K5Qnipj+QEoZCMFRZ3PqxMv7FgTIP474xIUFFGFa9x6zJIx0OpilKOFqwNtJS/CtVu9VFmHNkDO/Y9xsHfdplA==",
"license": "MIT",
"dependencies": {
"@giphy/colors": "*",
"@giphy/js-analytics": "*",
"@giphy/js-fetch-api": "*",
"@giphy/js-types": "*",
"@giphy/js-util": "*",
"intersection-observer": "^0.12.2",
"react-use": "17.6.0",
"throttle-debounce": "^3.0.1"
},
"peerDependencies": {
"react": "18 - 19",
"styled-components": ">= 5"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1719,6 +1814,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1778,6 +1879,12 @@
"@types/react": "*"
}
},
"node_modules/@types/stylis": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz",
"integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz",
@@ -2316,6 +2423,12 @@
"win32"
]
},
"node_modules/@xobotyi/scrollbar-width": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2372,6 +2485,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/append-query": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/append-query/-/append-query-2.1.1.tgz",
"integrity": "sha512-adm0E8o1o7ay+HbkWvGIpNNeciLB/rxJ0heThHuzSSVq5zcdQ5/ZubFnUoY0imFmk6gZVghSpwoubLVtwi9EHQ==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2766,6 +2888,15 @@
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001765",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
@@ -2855,6 +2986,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2886,6 +3026,57 @@
"node": ">= 8"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3076,6 +3267,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -3128,6 +3325,15 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
"license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -3747,11 +3953,16 @@
"node": ">=0.10.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -3798,6 +4009,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-shallow-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fastest-stable-stringify": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -4255,6 +4477,12 @@
"react-is": "^16.7.0"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4291,6 +4519,15 @@
"node": ">=0.8.19"
}
},
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
"license": "MIT",
"dependencies": {
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4306,6 +4543,13 @@
"node": ">= 0.4"
}
},
"node_modules/intersection-observer": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz",
"integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==",
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
"license": "Apache-2.0"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4777,6 +5021,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5239,6 +5489,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -5319,6 +5575,32 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nano-css": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.2.tgz",
"integrity": "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==",
"license": "Unlicense",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"css-tree": "^1.1.2",
"csstype": "^3.1.2",
"fastest-stable-stringify": "^2.0.2",
"inline-style-prefixer": "^7.0.1",
"rtl-css-js": "^1.16.1",
"stacktrace-js": "^2.0.2",
"stylis": "^4.3.0"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/nano-css/node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5797,6 +6079,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -5938,6 +6226,41 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-universal-interface": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
"integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
"peerDependencies": {
"react": "*",
"tslib": "*"
}
},
"node_modules/react-use": {
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/react-use/-/react-use-17.6.0.tgz",
"integrity": "sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==",
"license": "Unlicense",
"dependencies": {
"@types/js-cookie": "^2.2.6",
"@xobotyi/scrollbar-width": "^1.9.5",
"copy-to-clipboard": "^3.3.1",
"fast-deep-equal": "^3.1.3",
"fast-shallow-equal": "^1.0.0",
"js-cookie": "^2.2.1",
"nano-css": "^5.6.2",
"react-universal-interface": "^0.6.2",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.1.0",
"set-harmonic-interval": "^1.0.1",
"throttle-debounce": "^3.0.1",
"ts-easing": "^0.2.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5982,6 +6305,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6032,6 +6361,15 @@
"node": ">=0.10.0"
}
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6117,6 +6455,18 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/screenfull": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6161,6 +6511,15 @@
"node": ">= 0.4"
}
},
"node_modules/set-harmonic-interval": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
"integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==",
"license": "Unlicense",
"engines": {
"node": ">=6.9"
}
},
"node_modules/set-proto": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
@@ -6176,6 +6535,12 @@
"node": ">= 0.4"
}
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -6386,6 +6751,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/stack-generator": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz",
"integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==",
"license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
"license": "MIT"
},
"node_modules/stacktrace-gps": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz",
"integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==",
"license": "MIT",
"dependencies": {
"source-map": "0.5.6",
"stackframe": "^1.3.4"
}
},
"node_modules/stacktrace-gps/node_modules/source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stacktrace-js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
"license": "MIT",
"dependencies": {
"error-stack-parser": "^2.0.6",
"stack-generator": "^2.0.5",
"stacktrace-gps": "^3.0.4"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6536,6 +6946,73 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/styled-components": {
"version": "6.3.11",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.11.tgz",
"integrity": "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==",
"license": "MIT",
"dependencies": {
"@emotion/is-prop-valid": "1.4.0",
"@emotion/unitless": "0.10.0",
"@types/stylis": "4.2.7",
"css-to-react-native": "3.2.0",
"csstype": "3.2.3",
"postcss": "8.4.49",
"shallowequal": "1.1.0",
"stylis": "4.3.6",
"tslib": "2.8.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/styled-components"
},
"peerDependencies": {
"react": ">= 16.8.0",
"react-dom": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/styled-components/node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/styled-components/node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6611,6 +7088,15 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/throttle-debounce": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
"integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6654,6 +7140,12 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -6667,6 +7159,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-easing": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
"integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==",
"license": "Unlicense"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -6944,6 +7442,19 @@
}
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -9,13 +9,19 @@
"lint": "eslint"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@giphy/js-fetch-api": "^5.7.0",
"@giphy/react-components": "^10.1.1",
"axios": "^1.13.2",
"emoji-mart": "^5.6.0",
"next": "16.1.0",
"next-auth": "^5.0.0-beta.30",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-select": "^5.10.2",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -0,0 +1,4 @@
<svg width="16" height="11" viewBox="0 0 16 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5.5L4.5 9L11 1" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 5.5L8.5 9L15 1" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="11" viewBox="0 0 16 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5.5L4.5 9L11 1" stroke="#00293D" stroke-opacity="0.4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 5.5L8.5 9L15 1" stroke="#00293D" stroke-opacity="0.4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@@ -0,0 +1,3 @@
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5.5L4.5 9L10 1" stroke="#00293D" stroke-opacity="0.4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Footer } from '@/components/layout/Footer';
import { CommonHeader } from '@/components/layout/CommonHeader';
import { PresenceProvider } from '@/components/providers/presence-provider';
export default function AgentLayout({
children,
@@ -38,6 +39,7 @@ export default function AgentLayout({
}
return (
<PresenceProvider>
<div className="min-h-screen bg-white flex flex-col">
{/* Main Content Area */}
<main className="flex-1">
@@ -55,5 +57,6 @@ export default function AgentLayout({
{/* Footer */}
<Footer />
</div>
</PresenceProvider>
);
}

View File

@@ -5,6 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { Footer } from '@/components/layout/Footer';
import { CommonHeader } from '@/components/layout/CommonHeader';
import { PresenceProvider } from '@/components/providers/presence-provider';
// Pages that don't require authentication
const publicPaths = [
@@ -77,6 +78,7 @@ export default function UserLayout({
}
return (
<PresenceProvider>
<div className="min-h-screen flex flex-col">
{isDashboard ? (
<>
@@ -110,5 +112,6 @@ export default function UserLayout({
{/* Footer */}
<Footer />
</div>
</PresenceProvider>
);
}

View File

@@ -2,32 +2,55 @@
import Image from 'next/image';
import { useState, useRef, useEffect, useCallback } from 'react';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { GiphyFetch } from '@giphy/js-fetch-api';
import { Grid } from '@giphy/react-components';
const gf = new GiphyFetch(process.env.NEXT_PUBLIC_GIPHY_API_KEY || '');
interface AttachedFile {
file: File;
preview?: string; // Object URL for image preview
messageType: 'IMAGE' | 'FILE';
}
interface MessageInputProps {
onSend?: (message: string) => void;
onSendGif?: (gifUrl: string) => void;
onSendFile?: (file: File, messageType: 'IMAGE' | 'FILE') => void;
onTypingStart?: () => void;
onTypingStop?: () => void;
isUploading?: boolean;
}
export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInputProps) {
export function MessageInput({ onSend, onSendGif, onSendFile, onTypingStart, onTypingStop, isUploading }: MessageInputProps) {
const [message, setMessage] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showGifPicker, setShowGifPicker] = useState(false);
const [gifSearchQuery, setGifSearchQuery] = useState('');
const [attachedFile, setAttachedFile] = useState<AttachedFile | null>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isTypingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null);
const gifPickerRef = useRef<HTMLDivElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const gifButtonRef = useRef<HTMLButtonElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle typing indicator
const handleTyping = useCallback(() => {
// Start typing indicator
if (!isTypingRef.current && onTypingStart) {
isTypingRef.current = true;
onTypingStart();
}
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Set timeout to stop typing indicator after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
@@ -48,9 +71,90 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
};
}, [onTypingStop]);
// Close pickers on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
showEmojiPicker &&
emojiPickerRef.current &&
!emojiPickerRef.current.contains(e.target as Node) &&
emojiButtonRef.current &&
!emojiButtonRef.current.contains(e.target as Node)
) {
setShowEmojiPicker(false);
}
if (
showGifPicker &&
gifPickerRef.current &&
!gifPickerRef.current.contains(e.target as Node) &&
gifButtonRef.current &&
!gifButtonRef.current.contains(e.target as Node)
) {
setShowGifPicker(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showEmojiPicker, showGifPicker]);
// Clean up object URL on unmount or when file changes
useEffect(() => {
return () => {
if (attachedFile?.preview) {
URL.revokeObjectURL(attachedFile.preview);
}
};
}, [attachedFile]);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const preview = URL.createObjectURL(file);
setAttachedFile({ file, preview, messageType: 'IMAGE' });
// Reset input so same file can be selected again
e.target.value = '';
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAttachedFile({ file, messageType: 'FILE' });
e.target.value = '';
};
const removeAttachment = () => {
if (attachedFile?.preview) {
URL.revokeObjectURL(attachedFile.preview);
}
setAttachedFile(null);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const handleSend = () => {
// Send attached file
if (attachedFile && onSendFile) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
onTypingStop();
}
onSendFile(attachedFile.file, attachedFile.messageType);
removeAttachment();
setMessage('');
return;
}
// Send text message
if (message.trim() && onSend) {
// Stop typing indicator before sending
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
@@ -67,7 +171,7 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
if (!isUploading) handleSend();
}
};
@@ -76,11 +180,122 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
handleTyping();
};
const handleEmojiSelect = (emoji: { native: string }) => {
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newMessage = message.slice(0, start) + emoji.native + message.slice(end);
setMessage(newMessage);
// Restore cursor position after emoji
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + emoji.native.length;
textarea.focus();
}, 0);
} else {
setMessage((prev) => prev + emoji.native);
}
};
const handleGifSelect = (gif: { images: { original: { url: string } } }, e: React.SyntheticEvent) => {
e.preventDefault();
const gifUrl = gif.images.original.url;
if (onSendGif) {
onSendGif(gifUrl);
}
setShowGifPicker(false);
setGifSearchQuery('');
};
const toggleEmojiPicker = () => {
setShowEmojiPicker((prev) => !prev);
setShowGifPicker(false);
};
const toggleGifPicker = () => {
setShowGifPicker((prev) => !prev);
setShowEmojiPicker(false);
};
const fetchGifs = useCallback(
(offset: number) => {
if (gifSearchQuery.trim()) {
return gf.search(gifSearchQuery, { offset, limit: 10 });
}
return gf.trending({ offset, limit: 10 });
},
[gifSearchQuery]
);
const canSend = message.trim() || attachedFile;
return (
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
{/* Hidden file inputs */}
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleImageSelect}
className="hidden"
/>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={handleFileSelect}
className="hidden"
/>
{/* Attachment preview area */}
{attachedFile && (
<div className="border border-[#00293d]/10 rounded-[15px] p-3 mb-3 bg-[#00293d]/[0.02]">
<div className="flex items-center gap-3">
{attachedFile.messageType === 'IMAGE' && attachedFile.preview ? (
<img
src={attachedFile.preview}
alt="Preview"
className="w-[80px] h-[80px] object-cover rounded-[10px]"
/>
) : (
<div className="w-[50px] h-[50px] bg-[#e58625]/10 rounded-[10px] flex items-center justify-center flex-shrink-0">
<Image
src="/assets/icons/attachment-icon.svg"
alt="File"
width={22}
height={12}
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] truncate">
{attachedFile.file.name}
</p>
<p className="font-serif font-normal text-[12px] leading-[16px] text-[#00293D]/50">
{formatFileSize(attachedFile.file.size)}
</p>
</div>
<button
onClick={removeAttachment}
className="p-1 hover:bg-gray-100 rounded-full transition-colors cursor-pointer flex-shrink-0"
title="Remove attachment"
>
<Image
src="/assets/icons/close-icon.svg"
alt="Remove"
width={20}
height={20}
className="opacity-50"
/>
</button>
</div>
</div>
)}
{/* Text input area */}
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
<textarea
ref={textareaRef}
value={message}
onChange={handleChange}
onKeyDown={handleKeyDown}
@@ -93,7 +308,11 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
<div className="flex items-center justify-between">
{/* Attachment buttons */}
<div className="flex items-center gap-3">
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<button
onClick={() => imageInputRef.current?.click()}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
title="Attach image"
>
<Image
src="/assets/icons/gallery-icon.svg"
alt="Add image"
@@ -101,15 +320,26 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
height={24}
/>
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<button
onClick={() => fileInputRef.current?.click()}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
title="Attach file"
>
<Image
src="/assets/icons/chain-icon.svg"
alt="Add link"
alt="Attach file"
width={24}
height={24}
/>
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
{/* GIF button with picker */}
<div className="relative">
<button
ref={gifButtonRef}
onClick={toggleGifPicker}
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showGifPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
>
<Image
src="/assets/icons/gif-icon.svg"
alt="Add GIF"
@@ -117,7 +347,47 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
height={24}
/>
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
{showGifPicker && (
<div
ref={gifPickerRef}
className="absolute bottom-full left-0 mb-2 z-50 bg-white border border-[#00293d]/10 rounded-[15px] shadow-lg overflow-hidden"
style={{ width: '360px', height: '420px' }}
>
<div className="p-3 border-b border-[#00293d]/10">
<input
type="text"
value={gifSearchQuery}
onChange={(e) => setGifSearchQuery(e.target.value)}
placeholder="Search GIFs..."
className="w-full px-3 py-2 border border-[#00293d]/10 rounded-[10px] font-serif text-[14px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none focus:border-[#e58625]"
autoFocus
/>
</div>
<div className="overflow-y-auto" style={{ height: 'calc(100% - 56px)' }}>
<Grid
key={gifSearchQuery}
fetchGifs={fetchGifs}
width={356}
columns={2}
gutter={6}
noLink
onGifClick={handleGifSelect}
/>
</div>
<div className="absolute bottom-2 right-3 bg-white/80 rounded px-1.5 py-0.5">
<span className="font-serif text-[10px] text-[#00293D]/40">Powered by GIPHY</span>
</div>
</div>
)}
</div>
{/* Emoji button with picker */}
<div className="relative">
<button
ref={emojiButtonRef}
onClick={toggleEmojiPicker}
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showEmojiPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
>
<Image
src="/assets/icons/emoji-icon.svg"
alt="Add emoji"
@@ -125,20 +395,32 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
height={24}
/>
</button>
{showEmojiPicker && (
<div ref={emojiPickerRef} className="absolute bottom-full left-0 mb-2 z-50">
<Picker
data={data}
onEmojiSelect={handleEmojiSelect}
theme="light"
previewPosition="none"
skinTonePosition="search"
/>
</div>
)}
</div>
</div>
{/* Send button and more options */}
<div className="flex items-center gap-2">
<button
onClick={handleSend}
disabled={!message.trim()}
disabled={!canSend || isUploading}
className={`px-4 py-1.5 font-serif font-normal text-[14px] leading-[19px] rounded-[15px] transition-colors cursor-pointer ${
message.trim()
canSend && !isUploading
? 'bg-[#e58625] text-[#00293d] hover:bg-[#d47720]'
: 'bg-[#e58625]/50 text-[#00293d]/50 cursor-not-allowed'
}`}
>
Send
{isUploading ? 'Uploading...' : 'Send'}
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<Image

View File

@@ -8,7 +8,7 @@ import { MessageInput } from './MessageInput';
import { useMessaging } from '@/hooks/useMessaging';
import { connectionRequestsService, type ConnectionRequest } from '@/services/connection-requests.service';
import { uploadService } from '@/services/upload.service';
import type { Conversation, Message } from '@/types/messaging';
import type { Conversation, Message, MessageType } from '@/types/messaging';
// Helper to get a valid avatar URL (must start with http://, https://, or /)
function getValidAvatarUrl(avatar: string | null | undefined): string {
@@ -110,12 +110,15 @@ export function MessagingPage() {
} = useMessaging();
const [searchQuery, setSearchQuery] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [connectedAgents, setConnectedAgents] = useState<ConnectedAgent[]>([]);
const [isLoadingAgents, setIsLoadingAgents] = useState(false);
const [isStartingConversation, setIsStartingConversation] = useState<string | null>(null);
// Store converted avatar URLs by conversation ID
const [conversationAvatarUrls, setConversationAvatarUrls] = useState<Record<string, string>>({});
// Store converted file URLs by message ID (for S3-stored images/files)
const [messageFileUrls, setMessageFileUrls] = useState<Record<string, string>>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const justSwitchedConversationRef = useRef(false);
@@ -246,6 +249,39 @@ export function MessagingPage() {
convertCurrentAvatar();
}, [currentConversation]);
// Convert S3 keys to presigned download URLs for message attachments
useEffect(() => {
const convertFileUrls = async () => {
for (const msg of messages) {
if ((msg.messageType === 'IMAGE' || msg.messageType === 'FILE') && msg.fileUrl) {
// Skip if already converted
if (messageFileUrls[msg.id]) continue;
// Skip if it's already an accessible URL (e.g., Giphy GIF URLs)
if (msg.fileUrl.startsWith('http://') || msg.fileUrl.startsWith('https://')) {
setMessageFileUrls(prev => ({ ...prev, [msg.id]: msg.fileUrl! }));
continue;
}
// Convert S3 key to presigned download URL
try {
const presignedUrl = await uploadService.getPresignedDownloadUrl(msg.fileUrl);
setMessageFileUrls(prev => ({ ...prev, [msg.id]: presignedUrl }));
} catch {
// Keep original if conversion fails
}
}
}
};
if (messages.length > 0) {
convertFileUrls();
}
}, [messages]);
// Clear cached file URLs when switching conversations
useEffect(() => {
setMessageFileUrls({});
}, [currentConversation?.id]);
// Handle starting a new conversation with an agent
const handleStartConversation = useCallback(async (agentProfileId: string) => {
setIsStartingConversation(agentProfileId);
@@ -265,6 +301,35 @@ export function MessagingPage() {
await sendMessage(content);
}, [sendMessage]);
// Handle sending a GIF
const handleSendGif = useCallback(async (gifUrl: string) => {
await sendMessage(gifUrl, 'IMAGE' as MessageType, { fileUrl: gifUrl, mimeType: 'image/gif' });
}, [sendMessage]);
// Handle sending a file (image or document)
const handleSendFile = useCallback(async (file: File, messageType: 'IMAGE' | 'FILE') => {
setIsUploading(true);
try {
const uploaded = await uploadService.uploadMessageFile(file);
// uploaded.url is the S3 key (not a full URL)
const content = messageType === 'IMAGE' ? 'Sent an image' : 'Sent a file';
await sendMessage(
content,
messageType as MessageType,
{
fileUrl: uploaded.url,
mimeType: file.type,
fileName: file.name,
fileSize: file.size,
}
);
} catch (error) {
console.error('Failed to upload file:', error);
} finally {
setIsUploading(false);
}
}, [sendMessage]);
// Handle typing events
const handleTypingStart = useCallback(() => {
startTyping();
@@ -599,16 +664,64 @@ export function MessagingPage() {
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
className={`max-w-[70%] rounded-[15px] ${
message.messageType === 'IMAGE' ? 'p-1' : 'px-4 py-3'
} ${
isOwn
? 'bg-[#e58625] text-[#00293D]'
: 'bg-[#00293d]/10 text-[#00293D]'
}`}
>
{message.messageType === 'IMAGE' ? (
messageFileUrls[message.id] ? (
<img
src={messageFileUrls[message.id]}
alt={message.fileName || 'Image'}
className="rounded-[12px] max-w-full cursor-pointer"
style={{ maxHeight: '250px' }}
onClick={() => window.open(messageFileUrls[message.id], '_blank')}
/>
) : (
<div className="w-[200px] h-[150px] rounded-[12px] bg-black/5 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#00293d]" />
</div>
)
) : message.messageType === 'FILE' ? (
<a
href={messageFileUrls[message.id] || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 no-underline"
>
<div className={`w-[40px] h-[40px] rounded-[8px] flex items-center justify-center flex-shrink-0 ${isOwn ? 'bg-[#00293D]/10' : 'bg-[#e58625]/10'}`}>
<Image
src="/assets/icons/attachment-icon.svg"
alt="File"
width={20}
height={11}
/>
</div>
<div className="min-w-0">
<p className="font-serif font-medium text-[14px] leading-[19px] truncate">
{message.fileName || message.content}
</p>
{message.fileSize && (
<p className={`font-serif font-normal text-[12px] leading-[16px] ${isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'}`}>
{message.fileSize < 1024
? `${message.fileSize} B`
: message.fileSize < 1024 * 1024
? `${(message.fileSize / 1024).toFixed(1)} KB`
: `${(message.fileSize / (1024 * 1024)).toFixed(1)} MB`}
</p>
)}
</div>
</a>
) : (
<p className="font-serif font-normal text-[14px] leading-[19px]">
{message.content}
</p>
<div className={`flex items-center gap-2 mt-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
)}
<div className={`flex items-center gap-2 mt-1 ${message.messageType === 'IMAGE' ? 'px-2 pb-1' : ''} ${isOwn ? 'justify-end' : 'justify-start'}`}>
<p
className={`font-serif font-normal text-[12px] leading-[16px] ${
isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
@@ -617,8 +730,14 @@ export function MessagingPage() {
{formatMessageTime(message.createdAt)}
</p>
{isOwn && (
<span className={`text-[10px] ${message.status === 'READ' ? 'text-blue-500' : 'text-[#00293D]/50'}`}>
{message.status === 'READ' ? '✓✓' : message.status === 'DELIVERED' ? '✓✓' : '✓'}
<span className="inline-flex items-center">
{message.status === 'READ' ? (
<Image src="/assets/icons/tick-double-blue.svg" alt="Read" width={16} height={11} />
) : message.status === 'DELIVERED' ? (
<Image src="/assets/icons/tick-double-gray.svg" alt="Delivered" width={16} height={11} />
) : (
<Image src="/assets/icons/tick-single-gray.svg" alt="Sent" width={11} height={11} />
)}
</span>
)}
</div>
@@ -669,8 +788,11 @@ export function MessagingPage() {
{/* Message input */}
<MessageInput
onSend={handleSendMessage}
onSendGif={handleSendGif}
onSendFile={handleSendFile}
onTypingStart={handleTypingStart}
onTypingStop={handleTypingStop}
isUploading={isUploading}
/>
</>
) : (

View File

@@ -0,0 +1,166 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { socketService } from '@/services';
// Disconnect after 3 minutes of tab being hidden
const VISIBILITY_TIMEOUT = 3 * 60 * 1000;
// Disconnect after 5 minutes of no user interaction
const IDLE_TIMEOUT = 5 * 60 * 1000;
/**
* PresenceProvider handles user online/offline presence globally.
* - Connects socket on login (marks user online on the backend)
* - Disconnects socket when tab is hidden for too long or user is idle
* - Handles auth error recovery (token refresh + reconnect)
*/
export function PresenceProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const visibilityTimerRef = useRef<NodeJS.Timeout | null>(null);
const isIdleRef = useRef(false);
const isConnectedRef = useRef(false);
const hasInitializedRef = useRef(false);
const getAccessToken = useCallback(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('accessToken');
}
return null;
}, []);
const connectSocket = useCallback(async () => {
const token = getAccessToken();
if (!token) return;
try {
await socketService.connect(token);
} catch (error) {
console.error('[Presence] Socket connect failed:', error);
}
}, [getAccessToken]);
const disconnectForIdle = useCallback(() => {
if (!isConnectedRef.current) return;
isIdleRef.current = true;
socketService.disconnect();
}, []);
// Track socket connection state
useEffect(() => {
const unsubscribe = socketService.onConnectionChange((connected) => {
isConnectedRef.current = connected;
});
return unsubscribe;
}, []);
// Connect on authentication
useEffect(() => {
if (status === 'authenticated' && session?.user && !hasInitializedRef.current) {
hasInitializedRef.current = true;
// Small delay to ensure TokenSync has set localStorage tokens
const timer = setTimeout(connectSocket, 500);
return () => clearTimeout(timer);
}
if (status === 'unauthenticated') {
hasInitializedRef.current = false;
if (isConnectedRef.current) {
socketService.disconnect();
}
}
}, [status, session, connectSocket]);
// Handle auth errors (token expiry) — single global handler
useEffect(() => {
const unsubscribe = socketService.onAuthError(async () => {
console.log('[Presence] Auth error, refreshing token...');
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return;
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
}
);
const data = await res.json();
if (res.ok && data.success) {
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
socketService.reconnectWithToken(data.data.accessToken);
} else {
console.error('[Presence] Token refresh failed:', data.message);
}
} catch (error) {
console.error('[Presence] Token refresh error:', error);
}
});
return unsubscribe;
}, []);
// Browser visibility tracking — disconnect when tab hidden for too long
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
visibilityTimerRef.current = setTimeout(() => {
if (document.hidden) {
disconnectForIdle();
}
}, VISIBILITY_TIMEOUT);
} else {
// Tab visible again — clear timer and reconnect if was idle
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
visibilityTimerRef.current = null;
}
if (isIdleRef.current) {
isIdleRef.current = false;
connectSocket();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
}
};
}, [connectSocket, disconnectForIdle]);
// User activity tracking — disconnect when idle for too long
useEffect(() => {
const resetIdleTimer = () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
// Was idle and user became active — reconnect
if (isIdleRef.current) {
isIdleRef.current = false;
connectSocket();
}
idleTimerRef.current = setTimeout(disconnectForIdle, IDLE_TIMEOUT);
};
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach((e) => document.addEventListener(e, resetIdleTimer, { passive: true }));
resetIdleTimer();
return () => {
events.forEach((e) => document.removeEventListener(e, resetIdleTimer));
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
};
}, [connectSocket, disconnectForIdle]);
return <>{children}</>;
}

View File

@@ -35,7 +35,7 @@ interface UseMessagingReturn {
loadConversations: () => Promise<void>;
selectConversation: (conversationId: string) => Promise<void>;
startConversation: (agentProfileId: string) => Promise<Conversation>;
sendMessage: (content: string, messageType?: MessageType) => Promise<void>;
sendMessage: (content: string, messageType?: MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => Promise<void>;
loadMoreMessages: () => Promise<void>;
startTyping: () => void;
stopTyping: () => void;
@@ -179,12 +179,13 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
// Send a message
const sendMessage = useCallback(
async (content: string, messageType: MessageType = 'TEXT' as MessageType) => {
async (content: string, messageType: MessageType = 'TEXT' as MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => {
if (!currentConversationIdRef.current) return;
const dto: CreateMessageDto = {
content,
messageType,
...fileFields,
};
// Stop typing indicator
@@ -226,6 +227,11 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
// Update conversation in list
const conversationId = currentConversationIdRef.current;
const previewText = messageType === ('IMAGE' as MessageType)
? 'Sent an image'
: messageType === ('FILE' as MessageType)
? 'Sent a file'
: content;
setConversations((prev) =>
prev.map((c) =>
c.id === conversationId
@@ -233,7 +239,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
...c,
lastMessageAt: new Date().toISOString(),
lastMessageText:
content.length > 255 ? content.substring(0, 252) + '...' : content,
previewText.length > 255 ? previewText.substring(0, 252) + '...' : previewText,
}
: c
)
@@ -360,10 +366,17 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
if (c.id === message.conversationId) {
const isCurrentConversation =
c.id === currentConversationIdRef.current;
// Show friendly preview for non-text messages
let previewText = message.content.substring(0, 255);
if (message.messageType === 'IMAGE') {
previewText = 'Sent an image';
} else if (message.messageType === 'FILE') {
previewText = 'Sent a file';
}
return {
...c,
lastMessageAt: message.createdAt,
lastMessageText: message.content.substring(0, 255),
lastMessageText: previewText,
unreadCount: isCurrentConversation ? 0 : c.unreadCount + 1,
};
}
@@ -413,38 +426,8 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
}
);
// Handle auth errors (token expired, server disconnected us)
const unsubscribeAuthError = socketService.onAuthError(async () => {
console.log('Socket auth error detected, refreshing token via REST...');
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
console.error('No refresh token available for socket reconnection');
return;
}
// Refresh token using the same endpoint as the REST API interceptor
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
}
);
const data = await res.json();
if (res.ok && data.success) {
const newToken = data.data.accessToken;
localStorage.setItem('accessToken', newToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
console.log('Token refreshed, reconnecting socket...');
socketService.reconnectWithToken(newToken);
} else {
console.error('Token refresh failed:', data.message);
}
} catch (error) {
console.error('Failed to refresh token for socket reconnection:', error);
}
});
// Auth error handling (token refresh + reconnect) is handled globally
// by PresenceProvider — no duplicate handler needed here.
return () => {
unsubscribeConnection();
@@ -453,7 +436,6 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
unsubscribeTypingStop();
unsubscribeStatus();
unsubscribeRead();
unsubscribeAuthError();
};
}, [updateUserStatus]);

View File

@@ -21,7 +21,7 @@ interface PresignedUrlResponse {
key: string;
}
type UploadFolder = 'avatars' | 'documents' | 'properties' | 'verification';
type UploadFolder = 'avatars' | 'documents' | 'properties' | 'verification' | 'messages';
class UploadService {
private basePath = '/upload';
@@ -213,6 +213,39 @@ class UploadService {
};
}
/**
* Upload a message attachment (image or document) to S3
* Uses the message-presigned-url endpoint which allows USER and AGENT roles
* Returns the S3 key (not full URL) — use getPresignedDownloadUrl() to get a viewable URL
*/
async uploadMessageFile(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<UploadedFile> {
// Step 1: Get presigned URL from backend (messages folder)
const response = await api.post<{ data: PresignedUrlResponse }>(
`${this.basePath}/message-presigned-url`,
{
filename: file.name,
contentType: file.type,
}
);
const { uploadUrl, key } = response.data.data;
// Step 2: Upload directly to S3
await this.uploadToS3(uploadUrl, file, onProgress);
// Step 3: Return uploaded file info with KEY (not full URL) for database storage
return {
id: key,
name: file.name,
size: file.size,
type: file.type,
url: key, // Store the S3 key, not the full URL
uploadedAt: new Date().toISOString(),
};
}
/**
* Get a presigned download URL for an S3 key
*/