From 5b95394ece86dccf0533316d91bacbde7ecfc560 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 9 Apr 2025 22:19:54 -0600 Subject: [PATCH] start scroll to bottom Update ChatContent.tsx auto scroll hook scroll in use auto scroll is working --- web/package-lock.json | 243 +++++++++++++++++- web/package.json | 2 + web/src/hooks/useAutoScroll.stories.tsx | 188 ++++++++++++++ web/src/hooks/useAutoScroll.ts | 232 +++++++++++++++++ .../ChatContainer/ChatContainer.tsx | 4 +- .../ChatContainer/ChatContent/ChatContent.tsx | 19 +- 6 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 web/src/hooks/useAutoScroll.stories.tsx create mode 100644 web/src/hooks/useAutoScroll.ts diff --git a/web/package-lock.json b/web/package-lock.json index 11af5a7c7..0fa207eef 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -78,6 +78,7 @@ "react-dom": "^18", "react-hotkeys-hook": "^4.6.1", "react-markdown": "^10.1.0", + "react-scroll-to-bottom": "^4.2.0", "react-syntax-highlighter": "^15.6.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -112,6 +113,7 @@ "@types/pluralize": "^0.0.33", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-scroll-to-bottom": "^4.2.5", "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", "eslint-config-next": "15.2.4", @@ -2048,6 +2050,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.0.tgz", + "integrity": "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.26.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", @@ -2251,6 +2266,123 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.1.3.tgz", + "integrity": "sha512-RSQP59qtCNTf5NWD6xM08xsQdCZmVYnX/panPYvB6LQAPKQB6GL49Njf0EMbS3CyDtrlWsBcmqBtysFvfWT3rA==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.0.0", + "@emotion/cache": "^11.1.3", + "@emotion/serialize": "^1.0.0", + "@emotion/sheet": "^1.0.0", + "@emotion/utils": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -7472,7 +7604,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "dev": true, "license": "MIT" }, "node_modules/@types/phoenix": { @@ -7539,6 +7670,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-scroll-to-bottom": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/react-scroll-to-bottom/-/react-scroll-to-bottom-4.2.5.tgz", + "integrity": "sha512-gYMMxxhphzTNfKc4NIkEgu4XRiQjfj/6R7QK10Igz8jOUPNXBLSnK3RS7ofsNWnJDEgpLkNOwSLuASASiGsfHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.13", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", @@ -8970,6 +9111,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", @@ -9819,6 +9975,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -10115,7 +10277,6 @@ "version": "3.41.0", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.41.0.tgz", "integrity": "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==", - "dev": true, "hasInstallScript": true, "license": "MIT", "funding": { @@ -10134,7 +10295,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -11291,7 +11451,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12167,6 +12326,12 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -13310,7 +13475,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -15745,6 +15909,12 @@ "node": ">= 0.4" } }, + "node_modules/math-random": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-2.0.1.tgz", + "integrity": "sha512-oIEbWiVDxDpl5tIF4S6zYS9JExhh3bun3uLb3YAinHPTlRtW4g1S66LtJrJ4Npq8dgIa8CLK5iPVah5n4n0s2w==", + "license": "CC0-1.0" + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -17557,7 +17727,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -17701,7 +17870,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18706,6 +18874,53 @@ } } }, + "node_modules/react-scroll-to-bottom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-scroll-to-bottom/-/react-scroll-to-bottom-4.2.0.tgz", + "integrity": "sha512-1WweuumQc5JLzeAR81ykRdK/cEv9NlCPEm4vSwOGN1qS2qlpGVTyMgdI8Y7ZmaqRmzYBGV5/xPuJQtekYzQFGg==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.15.4", + "@emotion/css": "11.1.3", + "classnames": "2.3.1", + "core-js": "3.18.3", + "math-random": "2.0.1", + "prop-types": "15.7.2", + "simple-update-in": "2.2.0" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } + }, + "node_modules/react-scroll-to-bottom/node_modules/core-js": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.18.3.tgz", + "integrity": "sha512-tReEhtMReZaPFVw7dajMx0vlsz3oOb8ajgPoHVYGxr8ErnZ6PcYEvvmjGmXlfpnxpkYSdOQttjB+MvVbCGfvLw==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/react-scroll-to-bottom/node_modules/prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "node_modules/react-scroll-to-bottom/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -19282,7 +19497,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -19928,6 +20142,12 @@ "license": "MIT", "optional": true }, + "node_modules/simple-update-in": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/simple-update-in/-/simple-update-in-2.2.0.tgz", + "integrity": "sha512-FrW41lLiOs82jKxwq39UrE1HDAHOvirKWk4Nv8tqnFFFknVbTxcHZzDS4vt02qqdU/5+KNsQHWzhKHznDBmrww==", + "license": "MIT" + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -20515,6 +20735,12 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -22247,7 +22473,6 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, "license": "ISC", "engines": { "node": ">= 6" diff --git a/web/package.json b/web/package.json index d5fb59205..485b3bdc4 100644 --- a/web/package.json +++ b/web/package.json @@ -87,6 +87,7 @@ "react-dom": "^18", "react-hotkeys-hook": "^4.6.1", "react-markdown": "^10.1.0", + "react-scroll-to-bottom": "^4.2.0", "react-syntax-highlighter": "^15.6.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", @@ -121,6 +122,7 @@ "@types/pluralize": "^0.0.33", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-scroll-to-bottom": "^4.2.5", "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", "eslint-config-next": "15.2.4", diff --git a/web/src/hooks/useAutoScroll.stories.tsx b/web/src/hooks/useAutoScroll.stories.tsx new file mode 100644 index 000000000..d9f44b7d6 --- /dev/null +++ b/web/src/hooks/useAutoScroll.stories.tsx @@ -0,0 +1,188 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState, useCallback, useEffect } from 'react'; +import { useAutoScroll } from './useAutoScroll'; + +interface Message { + id: number; + text: string; + timestamp: string; +} + +const AutoScrollDemo = () => { + const containerRef = useRef(null); + const [messages, setMessages] = useState([]); + const [isAutoAddEnabled, setIsAutoAddEnabled] = useState(false); + const intervalRef = useRef(); + const { isAutoScrollEnabled, scrollToBottom, scrollToTop, enableAutoScroll, disableAutoScroll } = + useAutoScroll(containerRef); + + const addMessage = () => { + const newMessage: Message = { + id: messages.length + 1, + text: `Message ${messages.length + 1}: ${Lorem.generateSentences(1)}`, + timestamp: new Date().toLocaleTimeString() + }; + setMessages((prev) => [...prev, newMessage]); + }; + + const addManyMessages = () => { + const newMessages: Message[] = Array.from({ length: 10 }, (_, i) => ({ + id: messages.length + i + 1, + text: `Message ${messages.length + i + 1}: ${Lorem.generateSentences(1)}`, + timestamp: new Date().toLocaleTimeString() + })); + setMessages((prev) => [...prev, ...newMessages]); + }; + + const toggleAutoAdd = useCallback(() => { + if (isAutoAddEnabled) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + setIsAutoAddEnabled(false); + } else { + intervalRef.current = setInterval(addMessage, 1000); + setIsAutoAddEnabled(true); + } + }, [isAutoAddEnabled]); + + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + return ( +
+
+ + + +
+ +
+
+ Auto-scroll: + +
+
+ + + + +
+
+ +
+ {messages.map((message) => ( +
+
+ Message #{message.id} + {message.timestamp} +
+

{message.text}

+
+ ))} + {messages.length === 0 && ( +
+ No messages. Click "Add Message" to start. +
+ )} +
+
+ ); +}; + +// Lorem ipsum generator for demo purposes +const Lorem = { + words: [ + 'lorem', + 'ipsum', + 'dolor', + 'sit', + 'amet', + 'consectetur', + 'adipiscing', + 'elit', + 'sed', + 'do', + 'eiusmod', + 'tempor', + 'incididunt', + 'ut', + 'labore', + 'et', + 'dolore', + 'magna', + 'aliqua' + ], + generateSentences: (count: number) => { + const sentences = []; + for (let i = 0; i < count; i++) { + const wordCount = Math.floor(Math.random() * 10) + 5; + const words = Array.from( + { length: wordCount }, + () => Lorem.words[Math.floor(Math.random() * Lorem.words.length)] + ); + sentences.push(words.join(' ') + '.'); + } + return sentences.join(' '); + } +}; + +const meta: Meta = { + title: 'Hooks/useAutoScroll', + component: AutoScrollDemo, + parameters: { + layout: 'centered' + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/web/src/hooks/useAutoScroll.ts b/web/src/hooks/useAutoScroll.ts new file mode 100644 index 000000000..3c4eb8f7a --- /dev/null +++ b/web/src/hooks/useAutoScroll.ts @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import debounce from 'lodash/debounce'; + +interface UseAutoScrollOptions { + /** Debounce delay in milliseconds for scroll events */ + debounceDelay?: number; + /** Smooth scroll behavior duration in milliseconds */ + scrollBehavior?: ScrollBehavior; +} + +interface UseAutoScrollReturn { + /** Whether auto-scrolling is currently enabled */ + isAutoScrollEnabled: boolean; + /** Manually scroll to the bottom of the container */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Manually scroll to the top of the container */ + scrollToTop: (behavior?: ScrollBehavior) => void; + /** Manually scroll to a specific node */ + scrollToNode: (node: HTMLElement, behavior?: ScrollBehavior) => void; + + /** Enable auto-scrolling */ + enableAutoScroll: () => void; + /** Disable auto-scrolling */ + disableAutoScroll: () => void; +} + +const isAtBottom = (element: HTMLElement, threshold = 30) => { + const { scrollHeight, scrollTop, clientHeight } = element; + return scrollHeight - (scrollTop + clientHeight) <= threshold; +}; + +export const useAutoScroll = ( + containerRef: React.RefObject, + options: UseAutoScrollOptions = {} +): UseAutoScrollReturn => { + const { debounceDelay = 150, scrollBehavior = 'smooth' } = options; + + const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); + const wasAtBottom = useRef(true); + const isScrollingRef = useRef(false); + const mutationDebounceRef = useRef(); + const forceScrollRef = useRef(false); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = scrollBehavior) => { + if (!containerRef.current) return; + + // Set a flag to ignore scroll events while we're forcing a scroll + forceScrollRef.current = true; + isScrollingRef.current = false; + + // Use RAF to ensure we scroll after any pending updates + requestAnimationFrame(() => { + if (!containerRef.current) return; + + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior + }); + + // Always enable auto-scroll when manually scrolling to bottom + setIsAutoScrollEnabled(true); + wasAtBottom.current = true; + + // Reset the force scroll flag after the scroll completes + if (behavior === 'instant') { + forceScrollRef.current = false; + } else { + // For smooth scrolling, wait for the animation to complete + setTimeout(() => { + forceScrollRef.current = false; + }, 300); // Typical smooth scroll duration + } + }); + }, + [containerRef, scrollBehavior] + ); + + const scrollToTop = useCallback( + (behavior: ScrollBehavior = scrollBehavior) => { + if (!containerRef.current) return; + + containerRef.current.scrollTo({ + top: 0, + behavior + }); + }, + [containerRef, scrollBehavior] + ); + + const scrollToNode = useCallback( + (node: HTMLElement, behavior: ScrollBehavior = scrollBehavior) => { + if (!containerRef.current || !node) return; + + // Set a flag to ignore scroll events while we're forcing a scroll + forceScrollRef.current = true; + isScrollingRef.current = false; + + // Use RAF to ensure we scroll after any pending updates + requestAnimationFrame(() => { + if (!containerRef.current) return; + + // Get the node's position relative to the container + const containerRect = containerRef.current.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + const scrollTop = nodeRect.top - containerRect.top + containerRef.current.scrollTop; + + containerRef.current.scrollTo({ + top: scrollTop, + behavior + }); + + // Check if we're scrolling to the bottom + const isBottom = + Math.abs( + containerRef.current.scrollHeight - (scrollTop + containerRef.current.clientHeight) + ) <= 30; + + if (isBottom) { + setIsAutoScrollEnabled(true); + wasAtBottom.current = true; + } + + // Reset the force scroll flag after the scroll completes + if (behavior === 'instant') { + forceScrollRef.current = false; + } else { + // For smooth scrolling, wait for the animation to complete + setTimeout(() => { + forceScrollRef.current = false; + }, 300); // Typical smooth scroll duration + } + }); + }, + [containerRef, scrollBehavior] + ); + + // Debounced scroll handler + const handleScrollThrottled = useCallback( + debounce(() => { + if (!containerRef.current || forceScrollRef.current) return; + + const atBottom = isAtBottom(containerRef.current); + + if (wasAtBottom.current && !atBottom) { + // Only disable if we were at bottom and scrolled up + setIsAutoScrollEnabled(false); + } else if (atBottom) { + // Enable when we reach bottom + setIsAutoScrollEnabled(true); + } + + wasAtBottom.current = atBottom; + isScrollingRef.current = false; + }, debounceDelay), + [containerRef] + ); + + // Immediate scroll handler that calls the debounced version + const handleScroll = useCallback(() => { + if (forceScrollRef.current) return; + + if (!isScrollingRef.current) { + isScrollingRef.current = true; + } + handleScrollThrottled(); + }, [handleScrollThrottled]); + + // Handle content changes + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Debounced mutation handler to prevent rapid scroll updates + const handleMutation = () => { + if (mutationDebounceRef.current) { + window.cancelAnimationFrame(mutationDebounceRef.current); + } + + mutationDebounceRef.current = window.requestAnimationFrame(() => { + if (isAutoScrollEnabled && !isScrollingRef.current && !forceScrollRef.current) { + scrollToBottom(); + } + }); + }; + + const observer = new MutationObserver(handleMutation); + + observer.observe(container, { + childList: true, // Only observe direct children changes + subtree: false, // Don't observe deep changes + characterData: false // Don't observe text changes + }); + + return () => { + observer.disconnect(); + if (mutationDebounceRef.current) { + window.cancelAnimationFrame(mutationDebounceRef.current); + } + }; + }, [containerRef, isAutoScrollEnabled, scrollToBottom]); + + // Handle scroll events + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', handleScroll); + return () => { + container.removeEventListener('scroll', handleScroll); + handleScrollThrottled.cancel(); + }; + }, [containerRef, handleScroll, handleScrollThrottled]); + + const enableAutoScroll = useCallback(() => { + setIsAutoScrollEnabled(true); + scrollToBottom(); + }, [scrollToBottom]); + + const disableAutoScroll = useCallback(() => { + setIsAutoScrollEnabled(false); + }, []); + + return { + isAutoScrollEnabled, + scrollToBottom, + scrollToTop, + scrollToNode, + enableAutoScroll, + disableAutoScroll + }; +}; diff --git a/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx b/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx index d4726eb1a..f3203be18 100644 --- a/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx +++ b/web/src/layouts/ChatLayout/ChatContainer/ChatContainer.tsx @@ -9,9 +9,7 @@ export const ChatContainer = React.memo(() => { header={} headerBorderVariant="ghost" scrollable - className="flex h-full w-full min-w-[295px] flex-col" - // mainClassName="max-w-[calc(100%_-_12px)]" - > + className="flex h-full w-full min-w-[295px] flex-col"> ); diff --git a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx index e63552035..4efb9b96c 100644 --- a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx +++ b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx @@ -1,9 +1,12 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useChatIndividualContextSelector } from '../../ChatContext'; import { ChatMessageBlock } from './ChatMessageBlock'; import { ChatInput } from './ChatInput'; +import ScrollToBottom from 'react-scroll-to-bottom'; +import { faker } from '@faker-js/faker'; +import { cn } from '@/lib/classMerge'; const autoClass = 'mx-auto max-w-[600px] w-full'; @@ -11,6 +14,14 @@ export const ChatContent: React.FC<{}> = React.memo(() => { const chatId = useChatIndividualContextSelector((state) => state.chatId); const chatMessageIds = useChatIndividualContextSelector((state) => state.chatMessageIds); + // const [autoMessages, setAutoMessages] = useState([]); + + // useEffect(() => { + // setInterval(() => { + // setAutoMessages((prev) => [...prev, faker.lorem.sentence()]); + // }, 1500); + // }, []); + return ( <>
@@ -19,6 +30,12 @@ export const ChatContent: React.FC<{}> = React.memo(() => {
))} + + {/* {autoMessages.map((message, index) => ( +
+
{message}
+
+ ))} */}