diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index f2c9e97..2b9eb1d 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -1,19 +1,14 @@ # Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages +name: Deploy browser build to GitHub Pages on: - # Runs on pushes targeting the default branch push: branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: - contents: read - pages: write - id-token: write + contents: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -22,22 +17,29 @@ concurrency: cancel-in-progress: false jobs: - # Single deploy job since we're just deploying deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: Build/package-lock.json + + - name: Install dependencies + working-directory: Build + run: npm ci + + - name: Build + working-directory: Build + run: npm run build + + - name: Deploy browser build to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 with: - # Upload entire repository - path: '.' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: Build/dist diff --git a/Build/.gitignore b/Build/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/Build/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Build/index.html b/Build/index.html new file mode 100644 index 0000000..1582ad2 --- /dev/null +++ b/Build/index.html @@ -0,0 +1,333 @@ + + + + + + HTMLChat + + + + + + + + + + + + + + + +
+
+ + Reply to Message +
+
+ + Delete Message +
+
+ + Private Message +
+
+ + Kick User +
+
+ + Ban User +
+
+ + + + + + + + + + + +
+ + + + +
+
+

HTMLChat

+

version v0.2.0

+
+ + + + +
+
+ +
+ + + + Users online: 1 +
+ +
+ + + + +
+
+
+
+
+ + +
+
+
+ +
+
Users
+
+
+
+ +
+
+
+ Connected +
+
+ + +
+
+
+ + + + + + + + + + + + + + diff --git a/Build/package-lock.json b/Build/package-lock.json new file mode 100644 index 0000000..b10abb8 --- /dev/null +++ b/Build/package-lock.json @@ -0,0 +1,960 @@ +{ + "name": "htmlchat", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "htmlchat", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "dompurify": "^3.2.6", + "lucide": "^0.544.0", + "marked": "^16.3.0", + "vite": "^7.1.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz", + "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz", + "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz", + "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz", + "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz", + "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz", + "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz", + "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz", + "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz", + "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz", + "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz", + "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz", + "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz", + "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz", + "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz", + "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz", + "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz", + "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz", + "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz", + "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz", + "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz", + "integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lucide": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.544.0.tgz", + "integrity": "sha512-U5ORwr5z9Sx7bNTDFaW55RbjVdQEnAcT3vws9uz3vRT1G4XXJUDAhRZdxhFoIyHEvjmTkzzlEhjSLYM5n4mb5w==" + }, + "node_modules/marked": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz", + "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.2", + "@rollup/rollup-android-arm64": "4.50.2", + "@rollup/rollup-darwin-arm64": "4.50.2", + "@rollup/rollup-darwin-x64": "4.50.2", + "@rollup/rollup-freebsd-arm64": "4.50.2", + "@rollup/rollup-freebsd-x64": "4.50.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", + "@rollup/rollup-linux-arm-musleabihf": "4.50.2", + "@rollup/rollup-linux-arm64-gnu": "4.50.2", + "@rollup/rollup-linux-arm64-musl": "4.50.2", + "@rollup/rollup-linux-loong64-gnu": "4.50.2", + "@rollup/rollup-linux-ppc64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-musl": "4.50.2", + "@rollup/rollup-linux-s390x-gnu": "4.50.2", + "@rollup/rollup-linux-x64-gnu": "4.50.2", + "@rollup/rollup-linux-x64-musl": "4.50.2", + "@rollup/rollup-openharmony-arm64": "4.50.2", + "@rollup/rollup-win32-arm64-msvc": "4.50.2", + "@rollup/rollup-win32-ia32-msvc": "4.50.2", + "@rollup/rollup-win32-x64-msvc": "4.50.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/Build/package.json b/Build/package.json new file mode 100644 index 0000000..23b9c32 --- /dev/null +++ b/Build/package.json @@ -0,0 +1,32 @@ +{ + "name": "htmlchat", + "version": "0.2.0", + "description": "A retro-styled, browser-only chat client.", + "main": "index.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/HTMLToolkit/HTMLChat.git" + }, + "keywords": [ + "serverless", + "chatroom", + "retro" + ], + "author": "NellowTCS", + "license": "MIT", + "bugs": { + "url": "https://github.com/HTMLToolkit/HTMLChat/issues" + }, + "homepage": "https://github.com/HTMLToolkit/HTMLChat#readme", + "dependencies": { + "dompurify": "^3.2.6", + "lucide": "^0.544.0", + "marked": "^16.3.0", + "vite": "^7.1.5" + } +} diff --git a/icons/icon-128x128.png b/Build/public/icons/icon-128x128.png similarity index 100% rename from icons/icon-128x128.png rename to Build/public/icons/icon-128x128.png diff --git a/icons/icon-144x144.png b/Build/public/icons/icon-144x144.png similarity index 100% rename from icons/icon-144x144.png rename to Build/public/icons/icon-144x144.png diff --git a/icons/icon-152x152.png b/Build/public/icons/icon-152x152.png similarity index 100% rename from icons/icon-152x152.png rename to Build/public/icons/icon-152x152.png diff --git a/icons/icon-192x192.png b/Build/public/icons/icon-192x192.png similarity index 100% rename from icons/icon-192x192.png rename to Build/public/icons/icon-192x192.png diff --git a/icons/icon-256x256.png b/Build/public/icons/icon-256x256.png similarity index 100% rename from icons/icon-256x256.png rename to Build/public/icons/icon-256x256.png diff --git a/icons/icon-384x384.png b/Build/public/icons/icon-384x384.png similarity index 100% rename from icons/icon-384x384.png rename to Build/public/icons/icon-384x384.png diff --git a/icons/icon-48x48.png b/Build/public/icons/icon-48x48.png similarity index 100% rename from icons/icon-48x48.png rename to Build/public/icons/icon-48x48.png diff --git a/icons/icon-512x512.png b/Build/public/icons/icon-512x512.png similarity index 100% rename from icons/icon-512x512.png rename to Build/public/icons/icon-512x512.png diff --git a/icons/icon-72x72.png b/Build/public/icons/icon-72x72.png similarity index 100% rename from icons/icon-72x72.png rename to Build/public/icons/icon-72x72.png diff --git a/icons/icon-96x96.png b/Build/public/icons/icon-96x96.png similarity index 100% rename from icons/icon-96x96.png rename to Build/public/icons/icon-96x96.png diff --git a/manifest.json b/Build/public/manifest.json similarity index 100% rename from manifest.json rename to Build/public/manifest.json diff --git a/Build/src/contextMenu.js b/Build/src/contextMenu.js new file mode 100644 index 0000000..f153a5d --- /dev/null +++ b/Build/src/contextMenu.js @@ -0,0 +1,265 @@ +export class ContextMenuManager { + constructor(app) { + this.app = app; + this.menu = document.getElementById('context-menu'); + this.currentMessage = null; + + this.setupEventListeners(); + } + + setupEventListeners() { + // Hide menu on click outside + document.addEventListener('click', (e) => { + if (!this.menu.contains(e.target)) { + this.hide(); + } + }); + + // Menu item clicks + this.menu.addEventListener('click', (e) => { + const action = e.target.dataset.action; + if (action && this.currentMessage) { + this.handleAction(action); + } + }); + + // Hide on escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.hide(); + } + }); + } + + show(event, messageElement) { + event.preventDefault(); + + // Get message ID from multiple sources for reliability + const messageId = messageElement.dataset.messageId || + messageElement.id || + messageElement.getAttribute('data-message-id'); + + console.log('Context menu - found message ID:', messageId); // Debug + + this.currentMessage = { + element: messageElement, + user: messageElement.dataset.user, + time: messageElement.dataset.time, + id: messageId, + text: messageElement.querySelector('.text').textContent + }; + + console.log('Context menu - current message:', this.currentMessage); // Debug + + // Update menu items based on context + this.updateMenuItems(); + + // Position menu + const rect = this.menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = event.clientX; + let y = event.clientY; + + // Adjust position if menu would go off-screen + if (x + rect.width > viewportWidth) { + x = viewportWidth - rect.width - 10; + } + if (y + rect.height > viewportHeight) { + y = viewportHeight - rect.height - 10; + } + + this.menu.style.left = x + 'px'; + this.menu.style.top = y + 'px'; + this.menu.style.display = 'block'; + } + + hide() { + this.menu.style.display = 'none'; + this.currentMessage = null; + } + + updateMenuItems() { + if (!this.currentMessage) return; + + const items = this.menu.querySelectorAll('.context-item'); + + items.forEach(item => { + const action = item.dataset.action; + let show = true; + + switch (action) { + case 'reply': + show = true; // Always show reply + break; + case 'delete': + show = this.canDeleteMessage(); + break; + case 'pm': + show = this.currentMessage.user !== this.app.user; + break; + case 'kick': + case 'ban': + show = this.canModerateUser(); + break; + } + + item.style.display = show ? 'block' : 'none'; + }); + } + + canDeleteMessage() { + // Can delete own messages or if moderator + return this.currentMessage.user === this.app.user || + this.app.modTools.isModerator(this.app.user); + } + + canModerateUser() { + // Can moderate if user is moderator and target is not self or another moderator + return this.app.modTools.isModerator(this.app.user) && + this.currentMessage.user !== this.app.user && + !this.app.modTools.isModerator(this.currentMessage.user); + } + + handleAction(action) { + if (!this.currentMessage) return; + + switch (action) { + case 'reply': + this.replyToMessage(); + break; + case 'delete': + this.deleteMessage(); + break; + case 'pm': + this.openPrivateMessage(); + break; + case 'kick': + this.kickUser(); + break; + case 'ban': + this.banUser(); + break; + } + + this.hide(); + } + + replyToMessage() { + this.app.setReplyTo( + this.currentMessage.id, + this.currentMessage.user, + this.currentMessage.text + ); + } + + async deleteMessage() { + if (!this.canDeleteMessage()) return; + + const confirmMsg = this.currentMessage.user === this.app.user + ? 'Delete your message?' + : `Delete message from ${this.currentMessage.user}?`; + + if (confirm(confirmMsg)) { + try { + const room = this.app.elements.roomSelect.value; + const messageId = this.currentMessage.id; + + console.log('Deleting message:', messageId); // Debug log + + const res = await fetch( + `${this.app.baseURL}/chat/${room}?user=${encodeURIComponent(this.app.user)}&messageId=${encodeURIComponent(messageId)}`, + { + method: "DELETE", + headers: this.app.getAuthHeaders(true) // Include Content-Type for DELETE + } + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`${res.status}: ${errorText}`); + } + + const result = await res.json(); + console.log('Delete result:', result); // Debug log + + // Refresh messages to show deletion + await this.app.fetchMessages(true); + + } catch(e) { + console.error('Delete failed:', e); + alert('Failed to delete message: ' + e.message); + } + } + } + + openPrivateMessage() { + this.app.pmManager.openPrivateMessage(this.currentMessage.user); + } + + async kickUser() { + if (!this.canModerateUser()) return; + + const reason = prompt(`Kick ${this.currentMessage.user}? Enter reason (optional):`); + if (reason !== null) { // null means cancelled + try { + const res = await fetch(`${this.app.baseURL}/mod/${this.app.elements.roomSelect.value}?user=${encodeURIComponent(this.app.user)}`, { + method: "POST", + headers: this.app.getAuthHeaders(true), // Include Content-Type and auth headers + body: JSON.stringify({ + action: 'kick', + targetUser: this.currentMessage.user, + reason: reason + }) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText); + } + + // Refresh messages and users + await this.app.fetchMessages(true); + + } catch(e) { + console.error('Kick failed:', e); + alert('Failed to kick user: ' + e.message); + } + } + } + + async banUser() { + if (!this.canModerateUser()) return; + + const reason = prompt(`Ban ${this.currentMessage.user}? Enter reason (optional):`); + if (reason !== null) { + const duration = prompt('Ban duration (minutes, or leave empty for permanent):'); + + try { + const res = await fetch(`${this.app.baseURL}/mod/${this.app.elements.roomSelect.value}?user=${encodeURIComponent(this.app.user)}`, { + method: "POST", + headers: this.app.getAuthHeaders(true), // Include Content-Type and auth headers + body: JSON.stringify({ + action: 'ban', + targetUser: this.currentMessage.user, + reason: reason, + duration: duration ? parseInt(duration) : null + }) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText); + } + + // Refresh messages and users + await this.app.fetchMessages(true); + + } catch(e) { + console.error('Ban failed:', e); + alert('Failed to ban user: ' + e.message); + } + } + } +} \ No newline at end of file diff --git a/Build/src/fileUpload.js b/Build/src/fileUpload.js new file mode 100644 index 0000000..7a3ba98 --- /dev/null +++ b/Build/src/fileUpload.js @@ -0,0 +1,381 @@ +import { Image, Music, FileText, Paperclip } from 'lucide'; + +export class FileUploadManager { + constructor(app) { + this.app = app; + this.modal = document.getElementById('upload-modal'); + this.uploadArea = document.getElementById('upload-area'); + this.fileInput = document.getElementById('file-input'); + this.preview = document.getElementById('upload-preview'); + this.selectedFiles = []; + this.uploading = false; // Prevent multiple uploads + + this.setupEventListeners(); + } + + // Helper method to escape HTML to prevent injection + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Helper method to create lucide icons + createIcon(iconName, options = {}) { + const iconMap = { + 'image': Image, + 'music': Music, + 'file-text': FileText, + 'paperclip': Paperclip + }; + + const IconComponent = iconMap[iconName]; + if (!IconComponent) { + console.warn(`Icon "${iconName}" not found`); + return ''; + } + + const size = options.size || 16; + const strokeWidth = options.strokeWidth || 2; + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', size); + svg.setAttribute('height', size); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', strokeWidth); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + // Add paths from the icon component + IconComponent.forEach(pathData => { + if (pathData && pathData[0] === 'path') { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData[1].d || ''); + svg.appendChild(path); + } else if (pathData && pathData[0] === 'circle') { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', pathData[1].cx || ''); + circle.setAttribute('cy', pathData[1].cy || ''); + circle.setAttribute('r', pathData[1].r || ''); + svg.appendChild(circle); + } else if (pathData && pathData[0] === 'line') { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', pathData[1].x1 || ''); + line.setAttribute('y1', pathData[1].y1 || ''); + line.setAttribute('x2', pathData[1].x2 || ''); + line.setAttribute('y2', pathData[1].y2 || ''); + svg.appendChild(line); + } + }); + + return svg.outerHTML; + } + + setupEventListeners() { + // File input change + this.fileInput.addEventListener('change', (e) => { + this.handleFiles(e.target.files); + }); + + // Drag and drop + this.uploadArea.addEventListener('click', () => { + this.fileInput.click(); + }); + + this.uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + this.uploadArea.classList.add('drag-over'); + }); + + this.uploadArea.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.uploadArea.classList.remove('drag-over'); + }); + + this.uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + this.uploadArea.classList.remove('drag-over'); + this.handleFiles(e.dataTransfer.files); + }); + + // Modal background click to close + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) { + this.closeModal(); + } + }); + } + + openModal() { + this.modal.style.display = 'block'; + this.selectedFiles = []; + this.updatePreview(); + } + + closeModal() { + this.modal.style.display = 'none'; + this.selectedFiles = []; + this.fileInput.value = ''; + this.updatePreview(); + } + + handleFiles(fileList) { + const maxSize = 5 * 1024 * 1024; // 5MB + const allowedTypes = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'audio/mpeg', 'audio/wav', 'audio/ogg', + 'application/pdf', 'text/plain', + 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + + for (const file of fileList) { + if (file.size > maxSize) { + alert(`File "${file.name}" is too large. Maximum size is 5MB.`); + continue; + } + + if (!allowedTypes.includes(file.type)) { + alert(`File type "${file.type}" is not supported.`); + continue; + } + + // Check if file already selected + if (this.selectedFiles.find(f => f.name === file.name && f.size === file.size)) { + continue; + } + + this.selectedFiles.push(file); + } + + this.updatePreview(); + } + + updatePreview() { + if (this.selectedFiles.length === 0) { + this.preview.style.display = 'none'; + return; + } + + this.preview.style.display = 'block'; + + const html = this.selectedFiles.map((file, index) => { + const icon = this.getFileIcon(file.type); + const size = this.formatFileSize(file.size); + const escapedName = this.escapeHtml(file.name); + + return ` +
+
${icon}
+
+
${escapedName}
+
${size}
+
+ +
+ `; + }).join(''); + + const uploadBtn = ` +
+ +
+ `; + + this.preview.innerHTML = html + uploadBtn; + + // Add event listeners for remove buttons + this.preview.querySelectorAll('.preview-remove').forEach(btn => { + btn.addEventListener('click', () => { + const index = parseInt(btn.getAttribute('data-file-index')); + this.removeFile(index); + }); + }); + + // Add event listener for upload button + const uploadButton = document.getElementById('uploadBtn'); + if (uploadButton) { + uploadButton.addEventListener('click', () => { + this.uploadFiles(); + }); + } + } + + removeFile(index) { + this.selectedFiles.splice(index, 1); + this.updatePreview(); + } + + getFileIcon(mimeType) { + if (mimeType.startsWith('image/')) return this.createIcon('image'); + if (mimeType.startsWith('audio/')) return this.createIcon('music'); + if (mimeType === 'application/pdf') return this.createIcon('file-text'); + if (mimeType.includes('word')) return this.createIcon('file-text'); + if (mimeType === 'text/plain') return this.createIcon('file-text'); + return this.createIcon('paperclip'); + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + async uploadFiles() { + if (this.selectedFiles.length === 0) return; + + // Prevent multiple uploads + if (this.uploading) { + console.warn('Upload already in progress'); + return; + } + + this.uploading = true; + + try { + // Show upload progress + this.updatePreview(); + const uploadBtn = document.getElementById('uploadBtn'); + if (uploadBtn) { + uploadBtn.disabled = true; + uploadBtn.textContent = 'Uploading...'; + } + + // Upload each file to the server + for (let i = 0; i < this.selectedFiles.length; i++) { + const file = this.selectedFiles[i]; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('user', this.app.user); + formData.append('room', this.app.elements.roomSelect.value); + + // Upload to server + const uploadRes = await fetch(`${this.app.baseURL}/upload`, { + method: 'POST', + body: formData + }); + + if (!uploadRes.ok) { + const error = await uploadRes.json(); + throw new Error(error.error || 'Upload failed'); + } + + const uploadData = await uploadRes.json(); + + // Create file message data + const fileData = { + name: uploadData.originalName || file.name, + type: file.type, + size: file.size, + url: uploadData.url, + filename: uploadData.filename, + uploadedBy: this.app.user, + uploadedAt: uploadData.uploadedAt + }; + + // Send as special FILE message + const fileMessage = `FILE:${JSON.stringify(fileData)}`; + + const room = this.app.elements.roomSelect.value; + const res = await fetch(`${this.app.baseURL}/chat/${room}?user=${encodeURIComponent(this.app.user)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: fileMessage }), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to send file message: ${errorText}`); + } + + // Update progress + if (uploadBtn) { + uploadBtn.textContent = `Uploaded ${i + 1}/${this.selectedFiles.length}...`; + } + + } catch (fileError) { + console.error(`Failed to upload ${file.name}:`, fileError); + alert(`Failed to upload ${file.name}: ${fileError.message}`); + // Continue with other files + } + } + + // Refresh messages to show uploaded files + await this.app.fetchMessages(true); + + // Capture the number of processed files before closing modal + const processedCount = this.selectedFiles.length; + + // Close modal + this.closeModal(); + + // Show success message + alert(`Upload completed! ${processedCount} file(s) processed.`); + + } catch(e) { + console.error('Upload failed:', e); + alert('File upload failed: ' + e.message); + } finally { + this.uploading = false; + + // Reset upload button + const uploadBtn = document.getElementById('uploadBtn'); + if (uploadBtn) { + uploadBtn.disabled = false; + uploadBtn.textContent = 'Upload Files'; + } + } + } + + fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + } + + // Handle dropped files in chat area (drag & drop anywhere) + setupChatDropZone() { + const chatBox = this.app.elements.chatBox; + + chatBox.addEventListener('dragover', (e) => { + e.preventDefault(); + chatBox.style.background = '#f0f8ff'; + }); + + chatBox.addEventListener('dragleave', (e) => { + e.preventDefault(); + chatBox.style.background = ''; + }); + + chatBox.addEventListener('drop', (e) => { + e.preventDefault(); + chatBox.style.background = ''; + + if (e.dataTransfer.files.length > 0) { + this.openModal(); + setTimeout(() => { + this.handleFiles(e.dataTransfer.files); + }, 100); + } + }); + } +} \ No newline at end of file diff --git a/Build/src/main.js b/Build/src/main.js new file mode 100644 index 0000000..765f399 --- /dev/null +++ b/Build/src/main.js @@ -0,0 +1,901 @@ +// Import modules +import { SoundManager } from "./soundManager.js"; +import { + Volume2, VolumeX, Search, Reply, Trash2, Mail, UserX, Ban, X, + Folder, Paperclip, Bell, Image, Music, FileText, Settings +} from 'lucide'; +import { MessageRenderer } from "./messageRenderer.js"; +import { PrivateMessageManager } from "./privateMessages.js"; +import { FileUploadManager } from "./fileUpload.js"; +import { SearchManager } from "./search.js"; +import { NotificationManager } from "./notifications.js"; +import { ContextMenuManager } from "./contextMenu.js"; +import { ModeratorTools } from "./moderatorTools.js"; + +// Global app state +class HTMLChatApp { + constructor() { + // Core properties + this.user = null; + this.baseURL = "https://htmlchat.neeljaiswal23.workers.dev"; + this.refreshTimer = null; + this.isVisible = !document.hidden; + this.lastActivity = Date.now(); + this.lastMessageTime = 0; + this.lastFetchTime = 0; + this.currentReplyTo = null; + + // Icon mappings for lucide + this.icons = { + 'volume-2': Volume2, + 'volume-x': VolumeX, + 'search': Search, + 'reply': Reply, + 'trash-2': Trash2, + 'mail': Mail, + 'user-x': UserX, + 'ban': Ban, + 'x': X, + 'folder': Folder, + 'paperclip': Paperclip, + 'bell': Bell, + 'image': Image, + 'music': Music, + 'file-text': FileText, + 'settings': Settings + }; + + // Initialize managers + this.soundManager = new SoundManager(); + this.messageRenderer = new MessageRenderer(this); + this.pmManager = new PrivateMessageManager(this); + this.fileManager = new FileUploadManager(this); + this.searchManager = new SearchManager(this); + this.notificationManager = new NotificationManager(this); + this.contextMenu = new ContextMenuManager(this); + this.modTools = new ModeratorTools(this); + + // Server-side moderator status (authoritative) + this.serverIsModerator = false; + + // DOM elements + this.elements = { + roomSelect: document.getElementById("room-select"), + welcomeDiv: document.getElementById("welcome"), + chatBox: document.getElementById("chat"), + input: document.getElementById("msg"), + sendBtn: document.getElementById("send-btn"), + usersDiv: document.getElementById("users"), + replyPreview: document.getElementById("reply-preview"), + soundToggle: document.getElementById("sound-toggle"), + }; + + this.init(); + } + + // Helper method to create lucide icons + createIcon(iconName, options = {}) { + const IconComponent = this.icons[iconName]; + if (!IconComponent) { + console.warn(`Icon "${iconName}" not found`); + return null; + } + + const size = options.size || 16; + const strokeWidth = options.strokeWidth || 2; + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', size); + svg.setAttribute('height', size); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', strokeWidth); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + // Add paths from the icon component + IconComponent.forEach(pathData => { + if (pathData && pathData[0] === 'path') { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData[1].d || ''); + svg.appendChild(path); + } else if (pathData && pathData[0] === 'circle') { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', pathData[1].cx || ''); + circle.setAttribute('cy', pathData[1].cy || ''); + circle.setAttribute('r', pathData[1].r || ''); + svg.appendChild(circle); + } else if (pathData && pathData[0] === 'line') { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', pathData[1].x1 || ''); + line.setAttribute('y1', pathData[1].y1 || ''); + line.setAttribute('x2', pathData[1].x2 || ''); + line.setAttribute('y2', pathData[1].y2 || ''); + svg.appendChild(line); + } + }); + + if (options.className) { + svg.setAttribute('class', options.className); + } + + if (options.style) { + Object.assign(svg.style, options.style); + } + + return svg; + } + + // Security utility to escape HTML + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Initialize all icons in the DOM + initializeIcons() { + // Find all elements with data-lucide attributes and replace them + const elementsWithIcons = document.querySelectorAll('[data-lucide]'); + elementsWithIcons.forEach(element => { + const iconName = element.getAttribute('data-lucide'); + const existingStyles = { + width: element.style.width || '16px', + height: element.style.height || '16px' + }; + + const iconElement = this.createIcon(iconName, { + size: parseInt(existingStyles.width) || 16, + className: element.className, + style: existingStyles + }); + + if (iconElement) { + element.parentNode.replaceChild(iconElement, element); + } + }); + } + + // Simple storage helpers + saveToStorage(key, data) { + try { + localStorage.setItem(key, JSON.stringify(data)); + } catch (e) { + console.warn("Storage failed:", e); + } + } + + loadFromStorage(key) { + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; + } catch (e) { + console.warn("Load failed:", e); + return null; + } + } + + async init() { + // Get or prompt for username + this.user = this.loadFromStorage("htmlchat_user"); + this.authToken = this.loadFromStorage("htmlchat_auth_token"); + + if (!this.user) { + do { + this.user = prompt("Enter your nickname:") || ""; + this.user = this.user.trim().substring(0, 20); + } while (!this.user); + this.saveToStorage("htmlchat_user", this.user); + + // If user is NellowTCS, prompt for moderator password + if (this.user.toLowerCase() === 'nellowtcs') { + const authPassword = prompt("Enter moderator password:"); + if (authPassword) { + this.authToken = authPassword; + this.saveToStorage("htmlchat_auth_token", this.authToken); + } + } + } else if (this.user.toLowerCase() === 'nellowtcs' && !this.authToken) { + // Existing NellowTCS user without saved auth token + const authPassword = prompt("Enter moderator password:"); + if (authPassword) { + this.authToken = authPassword; + this.saveToStorage("htmlchat_auth_token", this.authToken); + } + } + + // Set up room + const savedRoom = this.loadFromStorage("htmlchat_room") || "default"; + this.elements.roomSelect.value = savedRoom; + + this.updateWelcome(); + this.setupEventListeners(); + + // Initialize managers + await this.notificationManager.init(); + + // Initialize Lucide icons (npm module) + this.initializeIcons(); + + // Set initial sound toggle state + const soundsEnabled = this.soundManager.isSoundEnabled(); + const soundToggle = this.elements.soundToggle; + + // Wait a bit for icons to initialize, then set the state + setTimeout(() => { + const soundOnIcon = soundToggle.querySelector('.sound-on-icon'); + const soundOffIcon = soundToggle.querySelector('.sound-off-icon'); + + console.log("Initial sound state:", soundsEnabled); // Debug + console.log("Initial icons found:", { soundOnIcon, soundOffIcon }); // Debug + + if (soundsEnabled) { + if (soundOnIcon) { + soundOnIcon.style.display = "inline"; + soundOnIcon.style.visibility = "visible"; + } + if (soundOffIcon) { + soundOffIcon.style.display = "none"; + soundOffIcon.style.visibility = "hidden"; + } + soundToggle.classList.remove("muted"); + } else { + if (soundOnIcon) { + soundOnIcon.style.display = "none"; + soundOnIcon.style.visibility = "hidden"; + } + if (soundOffIcon) { + soundOffIcon.style.display = "inline"; + soundOffIcon.style.visibility = "visible"; + } + soundToggle.classList.add("muted"); + } + }, 100); // Small delay to ensure Lucide has initialized + + // Start the app + await this.fetchMessages(true); + this.scheduleNextRefresh(15000); + this.elements.input.focus(); + + // Set up activity tracking and heartbeat + this.setupActivityTracking(); + setTimeout(() => this.scheduleHeartbeat(), 60000); + } + + setupEventListeners() { + // Send message events + this.elements.sendBtn.addEventListener("click", () => this.sendMessage()); + this.elements.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + // Room change + this.elements.roomSelect.addEventListener("change", () => + this.changeRoom() + ); + + // Page visibility + document.addEventListener("visibilitychange", () => { + this.isVisible = !document.hidden; + + if (this.isVisible) { + this.fetchMessages(true); + this.scheduleNextRefresh(15000); + } else { + this.scheduleNextRefresh(60000); + } + }); + + // Activity tracking + ["click", "keypress", "scroll", "mousemove"].forEach((event) => { + document.addEventListener( + event, + () => { + this.lastActivity = Date.now(); + }, + { passive: true } + ); + }); + + // Enter key focus + document.addEventListener("keydown", (e) => { + if ( + e.key === "Enter" && + document.activeElement !== this.elements.input && + e.target.tagName !== "BUTTON" + ) { + this.elements.input.focus(); + } + }); + + // Window unload + window.addEventListener("beforeunload", () => this.leaveRoom()); + + // Sound toggle + this.elements.soundToggle.addEventListener("click", () => + this.toggleSounds() + ); + } + + setupActivityTracking() { + // Activity tracking for smart refresh + ["click", "keypress", "scroll", "mousemove"].forEach((event) => { + document.addEventListener( + event, + () => { + this.lastActivity = Date.now(); + }, + { passive: true } + ); + }); + } + + attachMessageEventListeners() { + // Add context menu event listeners for messages + const messages = this.elements.chatBox.querySelectorAll('.msg'); + messages.forEach(msgEl => { + msgEl.addEventListener('contextmenu', (e) => { + this.contextMenu.show(e, msgEl); + }); + }); + + // Add click event listeners for reply references + const replyRefs = this.elements.chatBox.querySelectorAll('.reply-reference'); + replyRefs.forEach(replyEl => { + replyEl.addEventListener('click', () => { + const messageId = replyEl.getAttribute('data-message-id'); + if (messageId) { + this.messageRenderer.jumpToMessage(messageId); + } + }); + }); + + // Add double-click event listeners for user spans to open private messages + const userSpans = this.elements.chatBox.querySelectorAll('.user'); + userSpans.forEach(userSpan => { + userSpan.addEventListener('dblclick', () => { + const user = userSpan.getAttribute('data-user'); + if (user && user !== this.user) { + this.pmManager.openPrivateMessage(user); + } + }); + }); + + // Add click event listeners for clickable images + const images = this.elements.chatBox.querySelectorAll('.clickable-image'); + images.forEach(img => { + img.addEventListener('click', () => { + const url = img.getAttribute('data-url'); + if (url && this.isValidUrl(url)) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }); + }); + } + + // URL validation helper + isValidUrl(string) { + try { + const url = new URL(string); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch (_) { + return false; + } + } + + updateWelcome() { + // Clear existing content + this.elements.welcomeDiv.innerHTML = ''; + + // Create text node with safe content + const welcomeText = document.createTextNode('Welcome to HTMLChat, '); + const userBold = document.createElement('b'); + userBold.textContent = this.user; + const middleText = document.createTextNode('! You are now in room '); + const roomBold = document.createElement('b'); + roomBold.textContent = this.elements.roomSelect.value; + const endText = document.createTextNode('.'); + + // Append all elements + this.elements.welcomeDiv.appendChild(welcomeText); + this.elements.welcomeDiv.appendChild(userBold); + this.elements.welcomeDiv.appendChild(middleText); + this.elements.welcomeDiv.appendChild(roomBold); + this.elements.welcomeDiv.appendChild(endText); + } + + updateStatus(connected) { + const dot = document.getElementById("status-dot"); + const text = document.getElementById("status-text"); + + if (connected) { + dot.className = "status-dot"; + text.textContent = "Connected"; + } else { + dot.className = "status-dot disconnected"; + text.textContent = "Disconnected"; + } + } + + updateUserList(users = null, userCount = null) { + if (users && Array.isArray(users)) { + document.getElementById("user-count").textContent = + userCount || users.length; + + // Create a document fragment for efficient DOM manipulation + const fragment = document.createDocumentFragment(); + + // Create user items programmatically + users.forEach((u) => { + const userDiv = document.createElement('div'); + userDiv.classList.add('user-item'); + if (this.modTools.isModerator(u)) { + userDiv.classList.add('moderator'); + } + userDiv.style.color = this.messageRenderer.getUserColor(u); + userDiv.dataset.user = u; + userDiv.textContent = u; + + // Add event listener instead of inline handler + userDiv.addEventListener('dblclick', () => { + this.pmManager.openPrivateMessage(u); + }); + + fragment.appendChild(userDiv); + }); + + // Replace usersDiv contents efficiently + this.elements.usersDiv.innerHTML = ''; + this.elements.usersDiv.appendChild(fragment); + } else { + // Fallback to fake users + const fakeUsers = [this.user, "ChatBot", "Guest123"]; + document.getElementById("user-count").textContent = fakeUsers.length; + + // Create a document fragment for efficient DOM manipulation + const fragment = document.createDocumentFragment(); + + // Create fake user items programmatically + fakeUsers.forEach((u) => { + const userDiv = document.createElement('div'); + userDiv.classList.add('user-item'); + userDiv.style.color = this.messageRenderer.getUserColor(u); + userDiv.textContent = u; + + fragment.appendChild(userDiv); + }); + + // Replace usersDiv contents efficiently + this.elements.usersDiv.innerHTML = ''; + this.elements.usersDiv.appendChild(fragment); + } + } + + // Helper to get auth headers for moderator actions + getAuthHeaders(includeContentType = false) { + const headers = {}; + + if (includeContentType) { + headers['Content-Type'] = 'application/json'; + } + + if (this.user && this.user.toLowerCase() === 'nellowtcs' && this.authToken) { + headers['X-Auth-Token'] = this.authToken; + headers['X-Auth-User'] = this.user; + console.log('Adding auth headers:', { user: this.user, hasToken: !!this.authToken, includeContentType }); + } else { + console.log('No auth headers added:', { + user: this.user, + isNellowTCS: this.user && this.user.toLowerCase() === 'nellowtcs', + hasToken: !!this.authToken + }); + } + + return headers; + } + + scrollToBottom() { + this.elements.chatBox.scrollTop = this.elements.chatBox.scrollHeight; + } + + async fetchMessages(forceRefresh = false) { + try { + if (!forceRefresh) { + const cached = this.loadFromStorage( + `htmlchat_${this.elements.roomSelect.value}` + ); + if (cached && Array.isArray(cached)) { + this.elements.chatBox.innerHTML = + this.messageRenderer.renderMessages(cached); + this.scrollToBottom(); + // Attach event listeners for cached content + this.attachMessageEventListeners(); + } + } + + const url = `${this.baseURL}/chat/${this.elements.roomSelect.value}`; + const headers = this.getAuthHeaders(false); // No Content-Type for GET requests + + console.log('Fetching messages:', { url, headers }); + + const res = await fetch(url, { headers }); + + console.log('Fetch response:', { + ok: res.ok, + status: res.status, + statusText: res.statusText, + headers: Object.fromEntries(res.headers.entries()) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + const data = await res.json(); + console.log("Received data:", data); + + const messages = data.messages || []; + const users = data.users || []; + const userCount = data.userCount || users.length; + + // Store server's moderator status for current user + if (typeof data.isModerator === 'boolean') { + this.serverIsModerator = data.isModerator; + console.log('Server moderator status:', this.serverIsModerator); + } + + // Check for new messages for notifications and update stored message IDs + const lastMessageCount = + this.loadFromStorage( + `htmlchat_${this.elements.roomSelect.value}_count` + ) || 0; + if ( + messages.length > lastMessageCount && + !this.isVisible && + lastMessageCount > 0 + ) { + const newMessages = messages.slice(lastMessageCount); + newMessages.forEach((msg) => { + if (msg.user !== this.user) { + this.notificationManager.showNotification(msg.user, msg.text); + this.soundManager.playSound("message"); + } + }); + } + this.saveToStorage( + `htmlchat_${this.elements.roomSelect.value}_count`, + messages.length + ); + + // Store messages with proper IDs for deletion + this.elements.chatBox.innerHTML = + this.messageRenderer.renderMessages(messages); + this.scrollToBottom(); + + // Re-initialize Lucide icons for new messages + this.initializeIcons(); + + // Attach secure event listeners for interactive elements + this.attachMessageEventListeners(); + + this.updateUserList(users, userCount); + + this.saveToStorage( + `htmlchat_${this.elements.roomSelect.value}`, + messages + ); + this.saveToStorage("htmlchat_messages", messages); + this.updateStatus(true); + } catch (e) { + console.error("Fetch failed:", e); + console.error("Error details:", { + name: e.name, + message: e.message, + stack: e.stack, + cause: e.cause + }); + + // Check if it's a network error vs server error + if (e.message.includes('Failed to fetch')) { + console.error('Network error - possible CORS or connectivity issue'); + console.error('Current URL:', `${this.baseURL}/chat/${this.elements.roomSelect.value}`); + console.error('Expected Worker URL format: https://your-worker.your-subdomain.workers.dev'); + } + + this.updateStatus(false); + + if (this.elements.chatBox.innerHTML === "") { + // Create system error message safely + const errorDiv = document.createElement('div'); + errorDiv.className = 'msg system'; + + const timeSpan = document.createElement('span'); + timeSpan.className = 'time'; + timeSpan.textContent = '[--:--]'; + + const userSpan = document.createElement('span'); + userSpan.className = 'user'; + userSpan.textContent = '*** System ***'; + + const textSpan = document.createElement('span'); + textSpan.className = 'text'; + textSpan.textContent = 'Unable to connect to server. Please check your connection.'; + + errorDiv.appendChild(timeSpan); + errorDiv.appendChild(userSpan); + errorDiv.appendChild(textSpan); + + this.elements.chatBox.innerHTML = ''; + this.elements.chatBox.appendChild(errorDiv); + } + } + } + + async sendMessage() { + const messageText = this.elements.input.value.trim(); + if (!messageText) return; + + this.elements.sendBtn.disabled = true; + this.elements.sendBtn.textContent = "..."; + + try { + let finalMessage = messageText; + + // Add reply reference if replying + if (this.currentReplyTo) { + finalMessage = `@reply:${this.currentReplyTo.id}:${this.currentReplyTo.user}: ${messageText}`; + this.cancelReply(); + } + + // Generate message ID for tracking + const messageId = `msg_${Date.now()}_${Math.random() + .toString(36) + .substr(2, 9)}`; + + const room = this.elements.roomSelect.value; + const res = await fetch( + `${this.baseURL}/chat/${room}?user=${encodeURIComponent(this.user)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: finalMessage, messageId }), + } + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`${res.status}: ${errorText}`); + } + + this.elements.input.value = ""; + + // Play send sound + this.soundManager.playSound("message"); + + // Fetch new messages + await this.fetchMessages(true); + this.scheduleNextRefresh(15000); + } catch (e) { + console.error("Send failed:", e); + + if (e.message.includes("403")) { + alert("Message blocked: " + e.message.split(": ")[1]); + } else { + alert("Message failed to send. Please try again."); + } + + this.updateStatus(false); + } finally { + this.elements.sendBtn.disabled = false; + this.elements.sendBtn.textContent = "Send"; + this.elements.input.focus(); + } + } + + async changeRoom() { + // Clear current timers + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + // Leave current room + const oldRoom = this.loadFromStorage("htmlchat_room") || "default"; + if (oldRoom !== this.elements.roomSelect.value) { + try { + await fetch( + `${this.baseURL}/chat/${oldRoom}?user=${encodeURIComponent( + this.user + )}`, + { + method: "DELETE", + } + ); + } catch (e) { + console.warn("Failed to leave room:", e); + } + } + + this.saveToStorage("htmlchat_room", this.elements.roomSelect.value); + this.updateWelcome(); + this.elements.chatBox.innerHTML = + '
[--:--]*** System ***Loading messages...
'; + + this.lastMessageTime = 0; + this.lastFetchTime = 0; + + await this.fetchMessages(true); + this.scheduleNextRefresh(15000); + } + + // Manually trigger a refresh from UI + async manualRefresh() { + try { + // cancel any pending timer so we don't double-fetch + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + // force fetch latest + await this.fetchMessages(true); + // schedule next automatic refresh + this.scheduleNextRefresh(15000); + } catch (e) { + console.error('Manual refresh failed:', e); + } + } + + scheduleNextRefresh(delay = 20000) { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + if (!this.isVisible) { + delay = Math.min(delay * 3, 60000); + } + + this.refreshTimer = setTimeout(async () => { + await this.fetchMessages(); + this.scheduleNextRefresh(this.isVisible ? 20000 : 45000); + }, delay); + } + + async sendHeartbeat() { + try { + await fetch( + `${this.baseURL}/chat/${ + this.elements.roomSelect.value + }?user=${encodeURIComponent(this.user)}`, + { + method: "PUT", + } + ); + } catch (e) { + console.warn("Heartbeat failed:", e); + } + } + + scheduleHeartbeat() { + setTimeout(async () => { + const timeSinceActivity = Date.now() - this.lastActivity; + if (this.isVisible && timeSinceActivity < 300000) { + await this.sendHeartbeat(); + } + this.scheduleHeartbeat(); + }, 120000); + } + + async leaveRoom() { + const url = `${this.baseURL}/chat/${ + this.elements.roomSelect.value + }?user=${encodeURIComponent(this.user)}`; + try { + await fetch(url, { method: "DELETE", keepalive: true }); + } catch (e) { + // Fallback: try again without awaiting in case of network issues + fetch(url, { method: "DELETE", keepalive: true }).catch(() => {}); + } + } + + // Reply functionality + setReplyTo(messageId, user, text) { + this.currentReplyTo = { id: messageId, user, text }; + this.elements.replyPreview.style.display = "flex"; + this.elements.replyPreview.querySelector( + ".reply-text" + ).textContent = `${user}: ${text.substring(0, 50)}${ + text.length > 50 ? "..." : "" + }`; + this.elements.input.focus(); + } + + cancelReply() { + this.currentReplyTo = null; + this.elements.replyPreview.style.display = "none"; + } + + toggleSounds() { + const isEnabled = this.soundManager.toggleSounds(); + const soundToggle = this.elements.soundToggle; + + console.log("Sound toggle clicked, enabled:", isEnabled); // Debug + + // Update icons + const soundOnIcon = soundToggle.querySelector('.sound-on-icon'); + const soundOffIcon = soundToggle.querySelector('.sound-off-icon'); + + console.log("Found icons:", { soundOnIcon, soundOffIcon }); // Debug + + if (isEnabled) { + if (soundOnIcon) { + soundOnIcon.style.display = "inline"; + soundOnIcon.style.visibility = "visible"; + } + if (soundOffIcon) { + soundOffIcon.style.display = "none"; + soundOffIcon.style.visibility = "hidden"; + } + soundToggle.classList.remove("muted"); + console.log("Showing sound ON icon"); // Debug + } else { + if (soundOnIcon) { + soundOnIcon.style.display = "none"; + soundOnIcon.style.visibility = "hidden"; + } + if (soundOffIcon) { + soundOffIcon.style.display = "inline"; + soundOffIcon.style.visibility = "visible"; + } + soundToggle.classList.add("muted"); + console.log("Showing sound OFF icon"); // Debug + } + } +} + +// Global functions for HTML onclick handlers +window.app = null; + +window.openSearchModal = () => window.app.searchManager.openModal(); +window.closeSearchModal = () => window.app.searchManager.closeModal(); +window.openUploadModal = () => window.app.fileManager.openModal(); +window.closeUploadModal = () => window.app.fileManager.closeModal(); +window.openSettingsModal = () => window.app.notificationManager.showSettings(); +window.closeSettingsModal = () => window.app.notificationManager.closeSettings(); +window.cancelReply = () => window.app.cancelReply(); +window.toggleSounds = () => window.app?.toggleSounds(); +window.requestNotificationPermission = () => window.app.notificationManager.requestPermission(); +window.dismissNotificationBanner = () => window.app.notificationManager.dismissBanner(); +window.showNotificationSettings = () => window.app.notificationManager.showSettings(); +// Manual reload handler for status bar button +window.reloadChats = () => window.app?.manualRefresh(); + +// Export chat function +window.exportChat = function () { + const messages = window.app.loadFromStorage("htmlchat_messages") || []; + if (messages.length === 0) { + alert("No messages to export."); + return; + } + + const exportData = { + room: window.app.elements.roomSelect.value, + exported: new Date().toISOString(), + messages: messages, + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `htmlchat-export-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +}; + +// Initialize app when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + window.app = new HTMLChatApp(); +}); \ No newline at end of file diff --git a/Build/src/messageRenderer.js b/Build/src/messageRenderer.js new file mode 100644 index 0000000..6fdb383 --- /dev/null +++ b/Build/src/messageRenderer.js @@ -0,0 +1,291 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { Image, Music, FileText, Paperclip } from 'lucide'; + +export class MessageRenderer { + constructor(app) { + this.app = app; + this.userColors = [ + '#cc0000', '#00cc00', '#0000cc', '#cc6600', '#cc00cc', + '#006666', '#990099', '#009900', '#990000', '#000099' + ]; + } + + // Security utility to escape HTML + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Escape for attribute contexts (adds quote escaping) + escapeAttr(s) { + return this.escapeHtml(String(s)) + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Helper method to create lucide icons + createIcon(iconName, options = {}) { + const iconMap = { + 'image': Image, + 'music': Music, + 'file-text': FileText, + 'paperclip': Paperclip + }; + + const IconComponent = iconMap[iconName]; + if (!IconComponent) { + console.warn(`Icon "${iconName}" not found`); + return ''; + } + + const size = options.size || 16; + const strokeWidth = options.strokeWidth || 2; + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', size); + svg.setAttribute('height', size); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', strokeWidth); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + // Add paths from the icon component + IconComponent.forEach(pathData => { + if (pathData && pathData[0] === 'path') { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData[1].d || ''); + svg.appendChild(path); + } else if (pathData && pathData[0] === 'circle') { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', pathData[1].cx || ''); + circle.setAttribute('cy', pathData[1].cy || ''); + circle.setAttribute('r', pathData[1].r || ''); + svg.appendChild(circle); + } else if (pathData && pathData[0] === 'line') { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', pathData[1].x1 || ''); + line.setAttribute('y1', pathData[1].y1 || ''); + line.setAttribute('x2', pathData[1].x2 || ''); + line.setAttribute('y2', pathData[1].y2 || ''); + svg.appendChild(line); + } + }); + + if (options.style) { + Object.assign(svg.style, options.style); + } + + return svg.outerHTML; + } + + getUserColor(user) { + let hash = 0; + for (let i = 0; i < user.length; i++) { + hash = user.charCodeAt(i) + ((hash << 5) - hash); + } + return this.userColors[Math.abs(hash) % this.userColors.length]; + } + + processText(text) { + // Handle reply references + let processedText = text; + const replyMatch = text.match(/^@reply:(\d+):([^:]+):\s*(.*)/); + + if (replyMatch) { + const [, messageId, replyUser, actualMessage] = replyMatch; + processedText = actualMessage; + // We'll handle the reply display in renderMessages + } + + // 1. Convert Markdown to HTML + let html = marked.parse(processedText); + + // 2. Sanitize the HTML to prevent XSS + html = DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + 'b','i','em','strong','u','a','p','ul','ol','li','code', + 'pre','img','h1','h2','h3','h4','h5','h6','br','span','div' + ], + ALLOWED_ATTR: ['href','src','alt','title','target','style','rel'] + }); + + // 3. Convert remaining plain URLs into clickable links + html = html.replace(/(?])\bhttps?:\/\/[^\s<]+/g, (url) => { + const safeHref = this.escapeAttr(url); + const safeText = this.escapeHtml(url); + return `${safeText}`; + }); + // Defense-in-depth: sanitize again + html = DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['b','i','em','strong','u','a','p','ul','ol','li','code','pre','img','h1','h2','h3','h4','h5','h6','br','span','div'], + ALLOWED_ATTR: ['href','src','alt','title','target','style','rel'] + }); + + return html; + } + + renderMessages(messages) { + if (!Array.isArray(messages)) { + console.error('renderMessages expects an array, got:', typeof messages); + return '
[--:--]*** System ***Error loading messages.
'; + } + + return messages.map((message, index) => { + const { user, text, time } = message; + const color = this.getUserColor(user); + const date = new Date(time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + + // Check if this is a reply and extract the actual message + const replyMatch = text.match(/^@reply:([^:]+):([^:]+):\s*(.*)/); + let replyInfo = null; + let actualText = text; + + if (replyMatch) { + const [fullMatch, messageId, replyUser, messageText] = replyMatch; + replyInfo = { messageId, replyUser }; + actualText = messageText.trim(); + console.log('Reply parsed:', { messageId, replyUser, messageText }); // Debug + } + + // Check for file attachments + let fileAttachment = null; + if (actualText.startsWith('FILE:')) { + try { + const fileData = JSON.parse(actualText.substring(5)); + fileAttachment = fileData; + // Use appropriate icon based on file type + const iconName = this.getFileIconName(fileData.type); + const iconHtml = this.createIcon(iconName, { + style: { width: '16px', height: '16px', display: 'inline' } + }); + actualText = `${iconHtml} ${fileData.name}`; + } catch(e) { + // Not a valid file attachment + } + } + + const processedText = this.processText(actualText); + const messageId = message.id || `msg-${time}-${index}`; + const isModerator = this.app.modTools.isModerator(user); + + let messageClass = 'msg'; + if (replyInfo) messageClass += ' reply-msg'; + if (message.system) messageClass += ' system'; + if (message.system) messageClass += ' system'; + + let messageHtml = ` +
+ `; + + // Add reply reference if this is a reply (before timestamp and user) + if (replyInfo) { + messageHtml += ` +
+ ↳ Replying to ${this.escapeHtml(replyInfo.replyUser)} +
+ `; + } + + messageHtml += ` + [${this.escapeHtml(date)}] + <${this.escapeHtml(user)}> + `; + + // Add the message content + if (fileAttachment) { + if (fileAttachment.type.startsWith('image/')) { + const imageUrl = this.escapeAttr(fileAttachment.url || fileAttachment.data); + const imageName = this.escapeAttr(fileAttachment.name); + const uploadedBy = this.escapeHtml(fileAttachment.uploadedBy || 'Unknown'); + const uploadedAt = fileAttachment.uploadedAt ? this.escapeHtml(new Date(fileAttachment.uploadedAt).toLocaleString()) : ''; + const titleText = `Uploaded by ${uploadedBy}${uploadedAt ? ' on ' + uploadedAt : ''}`; + + messageHtml += ` + + ${imageName} + + `; + } else { + const iconName = this.getFileIconName(fileAttachment.type); + const iconHtml = this.createIcon(iconName, { + style: { width: '16px', height: '16px', marginRight: '4px' } + }); + const fileUrl = this.escapeAttr(fileAttachment.url || fileAttachment.data); + const fileName = this.escapeHtml(fileAttachment.name); + const uploadedBy = this.escapeHtml(fileAttachment.uploadedBy || 'Unknown'); + const uploadedAt = fileAttachment.uploadedAt ? this.escapeHtml(new Date(fileAttachment.uploadedAt).toLocaleString()) : ''; + const titleText = `Uploaded by ${uploadedBy}${uploadedAt ? ' on ' + uploadedAt : ''}`; + const fileSize = this.formatFileSize(fileAttachment.size); + + messageHtml += ` + + + ${iconHtml} + ${this.escapeHtml(fileName)} (${this.escapeHtml(fileSize)}) + + + `; + } + } else { + messageHtml += `${processedText}`; + } + + messageHtml += '
'; + + return messageHtml; + }).join(''); + } + + getFileIconName(mimeType) { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'music'; + if (mimeType === 'application/pdf') return 'file-text'; + if (mimeType.includes('word')) return 'file-text'; + if (mimeType === 'text/plain') return 'file-text'; + return 'paperclip'; + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + highlightMessage(messageId) { + const element = document.getElementById(messageId); + if (element) { + element.classList.add('highlighted'); + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + setTimeout(() => { + element.classList.remove('highlighted'); + }, 3000); + } + } + + jumpToMessage(messageId) { + this.highlightMessage(messageId); + } +} \ No newline at end of file diff --git a/Build/src/moderatorTools.js b/Build/src/moderatorTools.js new file mode 100644 index 0000000..78cad01 --- /dev/null +++ b/Build/src/moderatorTools.js @@ -0,0 +1,306 @@ +export class ModeratorTools { + constructor(app) { + this.app = app; + this.moderators = this.loadModerators(); + this.bannedUsers = this.loadBannedUsers(); + } + + loadModerators() { + try { + const saved = localStorage.getItem('htmlchat_moderators'); + return saved ? JSON.parse(saved) : ['nellowtcs']; // Default moderators + } catch (e) { + console.warn('Failed to load moderators:', e); + return ['nellowtcs']; + } + } + + saveModerators() { + try { + localStorage.setItem('htmlchat_moderators', JSON.stringify(this.moderators)); + } catch(e) { + console.warn('Failed to save moderators:', e); + } + } + + loadBannedUsers() { + try { + const saved = localStorage.getItem('htmlchat_banned_users'); + return saved ? JSON.parse(saved) : {}; + } catch(e) { + return {}; + } + } + + saveBannedUsers() { + try { + localStorage.setItem('htmlchat_banned_users', JSON.stringify(this.bannedUsers)); + } catch(e) { + console.warn('Failed to save banned users:', e); + } + } + + isModerator(username) { + // For the current user, use server's authoritative response + if (username === this.app.user && typeof this.app.serverIsModerator === 'boolean') { + return this.app.serverIsModerator; + } + + // For other users, fall back to local moderator list + return this.moderators.includes(username.toLowerCase()); + } + + addModerator(username) { + if (!this.isModerator(this.app.user)) return false; + + const normalizedName = username.toLowerCase(); + if (!this.moderators.includes(normalizedName)) { + this.moderators.push(normalizedName); + this.saveModerators(); + return true; + } + return false; + } + + removeModerator(username) { + if (!this.isModerator(this.app.user)) return false; + + const normalizedName = username.toLowerCase(); + const index = this.moderators.indexOf(normalizedName); + if (index > -1) { + this.moderators.splice(index, 1); + this.saveModerators(); + return true; + } + return false; + } + + isBanned(username) { + const ban = this.bannedUsers[username.toLowerCase()]; + if (!ban) return false; + + // Check if ban has expired + if (ban.expires && Date.now() > ban.expires) { + delete this.bannedUsers[username.toLowerCase()]; + this.saveBannedUsers(); + return false; + } + + return true; + } + + banUser(username, reason = '', durationMinutes = null) { + if (!this.isModerator(this.app.user)) return false; + + const normalizedName = username.toLowerCase(); + const ban = { + bannedBy: this.app.user, + reason: reason, + timestamp: Date.now(), + expires: durationMinutes ? Date.now() + (durationMinutes * 60 * 1000) : null + }; + + this.bannedUsers[normalizedName] = ban; + this.saveBannedUsers(); + + return true; + } + + unbanUser(username) { + if (!this.isModerator(this.app.user)) return false; + + const normalizedName = username.toLowerCase(); + if (this.bannedUsers[normalizedName]) { + delete this.bannedUsers[normalizedName]; + this.saveBannedUsers(); + return true; + } + return false; + } + + getBanInfo(username) { + return this.bannedUsers[username.toLowerCase()] || null; + } + + // Get all current bans + getAllBans() { + const activeBans = {}; + const now = Date.now(); + + for (const [username, ban] of Object.entries(this.bannedUsers)) { + // Check if ban is still active + if (!ban.expires || now <= ban.expires) { + activeBans[username] = ban; + } else { + // Remove expired ban + delete this.bannedUsers[username]; + } + } + + this.saveBannedUsers(); + return activeBans; + } + + // Clean up expired bans + cleanupExpiredBans() { + const now = Date.now(); + let changed = false; + + for (const [username, ban] of Object.entries(this.bannedUsers)) { + if (ban.expires && now > ban.expires) { + delete this.bannedUsers[username]; + changed = true; + } + } + + if (changed) { + this.saveBannedUsers(); + } + } + + // Moderate message (check for banned words, spam, etc.) + moderateMessage(message, username) { + // Check if user is banned + if (this.isBanned(username)) { + return { + allowed: false, + reason: 'User is banned' + }; + } + + // Check for banned words (simple implementation) + const bannedWords = ['spam', 'badword']; // In real app, this would be more comprehensive + const lowercaseMessage = message.toLowerCase(); + + for (const word of bannedWords) { + if (lowercaseMessage.includes(word)) { + return { + allowed: false, + reason: 'Message contains inappropriate content' + }; + } + } + + // Check for spam (repeated messages) + if (this.isSpam(message, username)) { + return { + allowed: false, + reason: 'Spam detected' + }; + } + + return { + allowed: true, + reason: null + }; + } + + isSpam(message, username) { + // Simple spam detection - check if same message was sent recently + const key = `last_messages_${username}`; + const lastMessages = this.app.loadFromStorage(key) || []; + + // Check if identical message was sent in last 30 seconds + const now = Date.now(); + const recentMessages = lastMessages.filter(msg => now - msg.time < 30000); + + if (recentMessages.some(msg => msg.text === message)) { + return true; + } + + // Store this message + recentMessages.push({ text: message, time: now }); + + // Keep only last 5 messages + const messagesToKeep = recentMessages.slice(-5); + this.app.saveToStorage(key, messagesToKeep); + + return false; + } + + // Show moderation panel + showModerationPanel() { + if (!this.isModerator(this.app.user)) { + alert('You do not have moderator privileges.'); + return; + } + + const bans = this.getAllBans(); + const banList = Object.entries(bans).map(([username, ban]) => { + const expires = ban.expires ? new Date(ban.expires).toLocaleString() : 'Never'; + const reason = ban.reason || 'No reason given'; + return `${username} - Expires: ${expires} - Reason: ${reason}`; + }).join('\n') || 'No active bans'; + + const panel = ` +MODERATION PANEL + +Moderators: ${this.moderators.join(', ')} + +Active Bans: +${banList} + +Available Commands: +- Right-click messages for moderation options +- Double-click usernames to send private messages + `; + + alert(panel); + } + + // Process moderator commands + processModCommand(command, args) { + if (!this.isModerator(this.app.user)) return false; + + switch (command.toLowerCase()) { + case 'ban': + if (args.length > 0) { + const username = args[0]; + const duration = args[1] ? parseInt(args[1]) : null; + const reason = args.slice(2).join(' ') || 'No reason given'; + + if (this.banUser(username, reason, duration)) { + return `Banned ${username}${duration ? ` for ${duration} minutes` : ' permanently'}`; + } + } + break; + + case 'unban': + if (args.length > 0) { + const username = args[0]; + if (this.unbanUser(username)) { + return `Unbanned ${username}`; + } + } + break; + + case 'mod': + if (args.length > 0) { + const username = args[0]; + if (this.addModerator(username)) { + return `Added ${username} as moderator`; + } + } + break; + + case 'demod': + if (args.length > 0) { + const username = args[0]; + if (this.removeModerator(username)) { + return `Removed ${username} as moderator`; + } + } + break; + } + + return false; + } + + // Initialize mod tools (run periodic cleanup) + init() { + // Clean up expired bans every 5 minutes + setInterval(() => { + this.cleanupExpiredBans(); + }, 5 * 60 * 1000); + } +} \ No newline at end of file diff --git a/Build/src/notifications.js b/Build/src/notifications.js new file mode 100644 index 0000000..05f2d3a --- /dev/null +++ b/Build/src/notifications.js @@ -0,0 +1,363 @@ +export class NotificationManager { + constructor(app) { + this.app = app; + // Guard access to Notification API + this.permission = (typeof Notification !== 'undefined' && Notification.permission) + ? Notification.permission + : 'default'; + this.enabled = this.loadSetting('notifications_enabled', true); + this.showDesktop = this.loadSetting('desktop_notifications', true); + this.playSound = this.loadSetting('notification_sounds', true); + this.banner = document.getElementById('notification-banner'); + this.settingsModal = null; + } + + loadSetting(key, defaultValue) { + try { + const saved = localStorage.getItem(`htmlchat_${key}`); + return saved !== null ? JSON.parse(saved) : defaultValue; + } catch(e) { + return defaultValue; + } + } + + saveSetting(key, value) { + try { + localStorage.setItem(`htmlchat_${key}`, JSON.stringify(value)); + } catch(e) { + console.warn('Failed to save notification setting:', e); + } + } + + async init() { + // Check if notifications are supported + if (!('Notification' in window)) { + console.warn('This browser does not support notifications'); + return; + } + + // Show banner if permission not granted and user hasn't dismissed it + if (this.permission !== 'granted' && !this.loadSetting('banner_dismissed', false)) { + this.showBanner(); + } + + // Update permission status + this.permission = Notification.permission; + + // Initialize settings modal + this.initializeSettingsModal(); + } + + initializeSettingsModal() { + this.settingsModal = document.getElementById('settings-modal'); + + // Get toggle elements + const desktopToggle = document.getElementById('desktop-notifications-toggle'); + const soundsToggle = document.getElementById('notification-sounds-toggle'); + const allNotificationsToggle = document.getElementById('all-notifications-toggle'); + const messageSoundsToggle = document.getElementById('message-sounds-toggle'); + const permissionBtn = document.getElementById('request-permission-btn'); + + // Set initial states + if (desktopToggle) { + desktopToggle.checked = this.showDesktop; + desktopToggle.addEventListener('change', () => { + this.toggleDesktopNotifications(); + this.updatePermissionStatus(); + }); + } + + if (soundsToggle) { + soundsToggle.checked = this.playSound; + soundsToggle.addEventListener('change', () => { + this.toggleNotificationSounds(); + }); + } + + if (allNotificationsToggle) { + allNotificationsToggle.checked = this.enabled; + allNotificationsToggle.addEventListener('change', () => { + this.toggleAllNotifications(); + this.updateToggleStates(); + }); + } + + if (messageSoundsToggle) { + messageSoundsToggle.checked = this.app.soundManager.isSoundEnabled(); + messageSoundsToggle.addEventListener('change', () => { + this.app.soundManager.toggleSounds(); + }); + } + + if (permissionBtn) { + permissionBtn.addEventListener('click', () => { + this.requestPermission(); + }); + } + + this.updatePermissionStatus(); + this.updateToggleStates(); + } + + updatePermissionStatus() { + const statusText = document.getElementById('permission-status-text'); + const requestBtn = document.getElementById('request-permission-btn'); + + if (statusText) { + statusText.className = ''; + + switch (this.permission) { + case 'granted': + statusText.textContent = 'Granted'; + statusText.classList.add('granted'); + if (requestBtn) requestBtn.style.display = 'none'; + break; + case 'denied': + statusText.textContent = 'Denied'; + statusText.classList.add('denied'); + if (requestBtn) requestBtn.style.display = 'none'; + break; + default: + statusText.textContent = 'Not requested'; + statusText.classList.add('default'); + if (requestBtn) requestBtn.style.display = 'inline-block'; + break; + } + } + } + + updateToggleStates() { + const desktopToggle = document.getElementById('desktop-notifications-toggle'); + const soundsToggle = document.getElementById('notification-sounds-toggle'); + + // Disable individual toggles if all notifications are disabled + if (desktopToggle) { + desktopToggle.disabled = !this.enabled; + } + if (soundsToggle) { + soundsToggle.disabled = !this.enabled; + } + } + + showBanner() { + if (this.banner) { + this.banner.style.display = 'flex'; + } + } + + dismissBanner() { + if (this.banner) { + this.banner.style.display = 'none'; + this.saveSetting('banner_dismissed', true); + } + } + + async requestPermission() { + if (!('Notification' in window)) { + alert('This browser does not support notifications'); + return false; + } + + try { + const permission = await Notification.requestPermission(); + this.permission = permission; + + if (permission === 'granted') { + this.dismissBanner(); + this.showTestNotification(); + this.updatePermissionStatus(); + return true; + } else { + alert('Notifications were denied. You can enable them later in your browser settings.'); + this.updatePermissionStatus(); + return false; + } + } catch(e) { + console.error('Error requesting notification permission:', e); + return false; + } + } + + showTestNotification() { + this.showNotification('HTMLChat', 'Notifications are now enabled!', { + icon: 'icons/icon-512x512.png', + tag: 'test-notification' + }); + } + + showNotification(title, body, options = {}) { + if (!this.enabled) return; + + // If tab is visible, show in-page notification instead + if (this.app.isVisible) { + this.showInPageNotification(title, body); + return; + } + + // Only attempt desktop notification if enabled and permission granted + if (this.showDesktop && this.permission === 'granted') { + try { + // Generate unique tag to prevent notification collapse + const uniqueTag = `htmlchat-message-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const defaultOptions = { + body: body, + icon: 'icons/icon-512x512.png', + tag: uniqueTag, + requireInteraction: false, + silent: !this.playSound + }; + + const notification = new Notification(title, { ...defaultOptions, ...options }); + + // Auto-close after 5 seconds + setTimeout(() => { + notification.close(); + }, 5000); + + // Click to focus window + notification.onclick = () => { + window.focus(); + notification.close(); + }; + + } catch(e) { + console.error('Error showing notification:', e); + this.showInPageNotification(title, body); + } + } else { + // Fallback to in-page notification when desktop notifications are disabled + this.showInPageNotification(title, body); + } + } + + showInPageNotification(title, body) { + // Create temporary in-page notification + const notification = document.createElement('div'); + notification.className = 'desktop-notification'; + notification.innerHTML = ` +
${this.escapeHtml(title)}
+
${this.escapeHtml(body)}
+ `; + + document.body.appendChild(notification); + + // Animate in + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + + setTimeout(() => { + notification.style.transition = 'all 0.3s ease'; + notification.style.opacity = '1'; + notification.style.transform = 'translateX(0)'; + }, 100); + + // Auto-remove after 4 seconds + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + }, 4000); + + // Click to remove + notification.addEventListener('click', () => { + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 300); + }); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + showSettings() { + if (this.settingsModal) { + this.settingsModal.style.display = 'flex'; + this.updatePermissionStatus(); + this.updateToggleStates(); + + // Update message sounds toggle to reflect current state + const messageSoundsToggle = document.getElementById('message-sounds-toggle'); + if (messageSoundsToggle) { + messageSoundsToggle.checked = this.app.soundManager.isSoundEnabled(); + } + + // Re-initialize icons in the modal + this.app.initializeIcons(); + } + } + + closeSettings() { + if (this.settingsModal) { + this.settingsModal.style.display = 'none'; + } + } + + // Notification for new messages + notifyNewMessage(username, message) { + if (username === this.app.user) return; // Don't notify for own messages + + const truncatedMessage = message.length > 50 ? message.substring(0, 50) + '...' : message; + this.showNotification(`${username} in ${this.app.elements.roomSelect.value}`, truncatedMessage); + } + + // Notification for private messages + notifyPrivateMessage(username, message) { + if (username === this.app.user) return; + + const truncatedMessage = message.length > 50 ? message.substring(0, 50) + '...' : message; + this.showNotification(`Private message from ${username}`, truncatedMessage, { + tag: 'private-message', + requireInteraction: true + }); + } + + // Notification for user join/leave + notifyUserActivity(username, action) { + if (username === this.app.user) return; + + const message = action === 'join' ? 'joined the room' : 'left the room'; + this.showNotification('User Activity', `${username} ${message}`, { + tag: 'user-activity', + silent: true // Less intrusive for user activity + }); + } + + // Toggle notification settings + toggleDesktopNotifications() { + this.showDesktop = !this.showDesktop; + this.saveSetting('desktop_notifications', this.showDesktop); + + // If enabling desktop notifications, check for permission + if (this.showDesktop && this.permission !== 'granted') { + this.requestPermission(); + } + + return this.showDesktop; + } + + toggleNotificationSounds() { + this.playSound = !this.playSound; + this.saveSetting('notification_sounds', this.playSound); + return this.playSound; + } + + toggleAllNotifications() { + this.enabled = !this.enabled; + this.saveSetting('notifications_enabled', this.enabled); + return this.enabled; + } +} \ No newline at end of file diff --git a/Build/src/privateMessages.js b/Build/src/privateMessages.js new file mode 100644 index 0000000..d728218 --- /dev/null +++ b/Build/src/privateMessages.js @@ -0,0 +1,349 @@ +import { X } from 'lucide'; + +export class PrivateMessageManager { + constructor(app) { + this.app = app; + this.windows = new Map(); + this.windowContainer = document.getElementById('pm-windows'); + this.windowZIndex = 1600; + } + + escapeHTML(s) { + return String(s).replace(/&/g, '&') + .replace(//g, '>'); + } + + escapeAttr(s) { + return this.escapeHTML(String(s)) + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + openPrivateMessage(username) { + if (username === this.app.user) return; // Can't PM yourself + + // Check if window already exists + if (this.windows.has(username)) { + this.bringToFront(username); + return; + } + + // Create new PM window + const window = this.createPMWindow(username); + this.windows.set(username, window); + this.windowContainer.appendChild(window.element); + + // Load PM history + this.loadPMHistory(username); + + // Focus input + window.input.focus(); + } + + createPMWindow(username) { + const safeName = this.escapeAttr(username); + const windowId = `pm-${safeName}`; + const windowElement = document.createElement('div'); + windowElement.className = 'pm-window'; + windowElement.id = windowId; + windowElement.style.zIndex = this.windowZIndex++; + windowElement.dataset.username = safeName; + + // Position window (cascade effect) + const offset = this.windows.size * 30; + windowElement.style.left = `${100 + offset}px`; + windowElement.style.top = `${100 + offset}px`; + + // Create close icon + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + // Add paths from X icon + X.forEach(pathData => { + if (pathData && pathData[0] === 'path') { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData[1].d || ''); + svg.appendChild(path); + } else if (pathData && pathData[0] === 'line') { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', pathData[1].x1 || ''); + line.setAttribute('y1', pathData[1].y1 || ''); + line.setAttribute('x2', pathData[1].x2 || ''); + line.setAttribute('y2', pathData[1].y2 || ''); + svg.appendChild(line); + } + }); + + const closeIconHtml = svg.outerHTML; + + // Create header + const header = document.createElement('div'); + header.className = 'pm-header'; + + const headerSpan = document.createElement('span'); + headerSpan.textContent = `Private Message - ${username}`; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'pm-close-btn'; + closeBtn.innerHTML = closeIconHtml; + closeBtn.addEventListener('click', () => this.closePMWindow(username)); + + header.appendChild(headerSpan); + header.appendChild(closeBtn); + + // Create chat area + const chatArea = document.createElement('div'); + chatArea.className = 'pm-chat'; + chatArea.id = `${windowId}-chat`; + + // Create input area + const inputArea = document.createElement('div'); + inputArea.className = 'pm-input-area'; + + const inputContainer = document.createElement('div'); + inputContainer.className = 'pm-input-container'; + + const input = document.createElement('input'); + input.className = 'pm-input'; + input.id = `${windowId}-input`; + input.placeholder = 'Type private message...'; + input.maxLength = 1000; + + const sendBtn = document.createElement('button'); + sendBtn.className = 'pm-send-btn'; + sendBtn.textContent = 'Send'; + sendBtn.addEventListener('click', () => this.sendPM(username)); + + inputContainer.appendChild(input); + inputContainer.appendChild(sendBtn); + inputArea.appendChild(inputContainer); + + // Clear and assemble window + windowElement.innerHTML = ''; + windowElement.appendChild(header); + windowElement.appendChild(chatArea); + windowElement.appendChild(inputArea); + + // Make window draggable + this.makeDraggable(windowElement, header); + + // Enter key to send + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendPM(username); + } + }); + + // Click to bring to front + windowElement.addEventListener('mousedown', () => { + this.bringToFront(username); + }); + + return { + element: windowElement, + chat: chatArea, + input: input, + sendBtn: sendBtn, + messages: [] + }; + } + + makeDraggable(element, handle) { + let isDragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + + handle.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + startY = e.clientY; + startLeft = parseInt(window.getComputedStyle(element).left, 10); + startTop = parseInt(window.getComputedStyle(element).top, 10); + + document.addEventListener('mousemove', drag); + document.addEventListener('mouseup', stopDrag); + e.preventDefault(); + }); + + function drag(e) { + if (!isDragging) return; + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + element.style.left = (startLeft + deltaX) + 'px'; + element.style.top = (startTop + deltaY) + 'px'; + } + + function stopDrag() { + isDragging = false; + document.removeEventListener('mousemove', drag); + document.removeEventListener('mouseup', stopDrag); + } + } + + bringToFront(username) { + const window = this.windows.get(username); + if (window) { + window.element.style.zIndex = this.windowZIndex++; + } + } + + closePMWindow(username) { + const window = this.windows.get(username); + if (window) { + window.element.remove(); + this.windows.delete(username); + } + } + + async sendPM(username) { + const window = this.windows.get(username); + if (!window) return; + + const message = window.input.value.trim(); + if (!message) return; + + window.sendBtn.disabled = true; + window.sendBtn.textContent = '...'; + + try { + // Generate conversation ID (sorted usernames for consistency) + const conversationId = [this.app.user, username].sort().join('_'); + + // Send PM to server - encode conversationId to handle special characters + const res = await fetch(`${this.app.baseURL}/pm/${encodeURIComponent(conversationId)}?user=${encodeURIComponent(this.app.user)}`, { + method: 'POST', + headers: this.app.getAuthHeaders(true), // Include Content-Type and auth headers + body: JSON.stringify({ text: message, to: username }) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText); + } + + // Clear input + window.input.value = ''; + + // Play PM sound + this.app.soundManager.playSound('pm'); + + // Refresh PM messages + await this.loadPMHistory(username); + + } catch(e) { + console.error('PM send failed:', e); + alert('Failed to send private message: ' + e.message); + } finally { + window.sendBtn.disabled = false; + window.sendBtn.textContent = 'Send'; + window.input.focus(); + } + } + + renderPMMessages(username) { + const window = this.windows.get(username); + if (!window) return; + + const html = window.messages.map(msg => { + const date = new Date(msg.time).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + const isFromMe = msg.from === this.app.user; + const color = this.app.messageRenderer.getUserColor(msg.from); + const processedText = this.app.messageRenderer.processText(msg.text); + + return ` +
+ [${this.escapeHTML(date)}] + <${this.escapeHTML(msg.from)}> + ${processedText} +
+ `; + }).join(''); + + window.chat.innerHTML = html; + window.chat.scrollTop = window.chat.scrollHeight; + } + + async loadPMHistory(username) { + try { + // Generate conversation ID (sorted usernames for consistency) + const conversationId = [this.app.user, username].sort().join('_'); + + // Fetch from server - encode conversationId to handle special characters + const res = await fetch(`${this.app.baseURL}/pm/${encodeURIComponent(conversationId)}?user=${encodeURIComponent(this.app.user)}`, { + headers: this.app.getAuthHeaders(false) // No Content-Type for GET requests + }); + + if (res.ok) { + const data = await res.json(); + const window = this.windows.get(username); + if (window) { + window.messages = data.messages || []; + this.renderPMMessages(username); + } + } else { + console.warn('Failed to load PM history:', res.status); + // Fallback to local storage + const history = this.app.loadFromStorage(`pm_history_${username}`) || []; + const window = this.windows.get(username); + if (window) { + window.messages = history; + this.renderPMMessages(username); + } + } + } catch(e) { + console.warn('Failed to load PM history:', e); + // Fallback to local storage + const history = this.app.loadFromStorage(`pm_history_${username}`) || []; + const window = this.windows.get(username); + if (window) { + window.messages = history; + this.renderPMMessages(username); + } + } + } + + savePMHistory(username, messages) { + try { + // Keep only last 100 messages + const messagesToSave = messages.slice(-100); + this.app.saveToStorage(`pm_history_${username}`, messagesToSave); + } catch(e) { + console.warn('Failed to save PM history:', e); + } + } + + // Check if user has unread PMs (for future notification features) + hasUnreadPMs(username) { + // This would be implemented with proper backend support + return false; + } + + // Get all PM conversations + getPMConversations() { + const conversations = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('pm_history_')) { + const username = key.replace('pm_history_', ''); + conversations.push(username); + } + } + return conversations; + } +} \ No newline at end of file diff --git a/Build/src/search.js b/Build/src/search.js new file mode 100644 index 0000000..0a2ff40 --- /dev/null +++ b/Build/src/search.js @@ -0,0 +1,285 @@ +export class SearchManager { + constructor(app) { + this.app = app; + this.modal = document.getElementById('search-modal'); + this.searchInput = document.getElementById('search-input'); + this.searchResults = document.getElementById('search-results'); + this.userFilter = document.getElementById('search-user'); + this.usernameFilter = document.getElementById('search-username'); + + this.setupEventListeners(); + } + + // Security utility to escape HTML + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + setupEventListeners() { + // Search input with debouncing + let searchTimeout; + this.searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.performSearch(); + }, 300); // 300ms debounce + }); + + // User filter checkbox + this.userFilter.addEventListener('change', () => { + this.usernameFilter.disabled = !this.userFilter.checked; + if (!this.userFilter.checked) { + this.usernameFilter.value = ''; + } + this.performSearch(); + }); + + // Username filter with debouncing + let userTimeout; + this.usernameFilter.addEventListener('input', () => { + if (this.userFilter.checked) { + clearTimeout(userTimeout); + userTimeout = setTimeout(() => { + this.performSearch(); + }, 300); + } + }); + + // Enter key to search + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + clearTimeout(searchTimeout); + this.performSearch(); + } + }); + + // Modal background click to close + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) { + this.closeModal(); + } + }); + + // Escape key to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal.style.display === 'block') { + this.closeModal(); + } + }); + } + + openModal() { + this.modal.style.display = 'block'; + this.searchInput.focus(); + this.searchResults.innerHTML = '
Enter search terms above
'; + } + + closeModal() { + this.modal.style.display = 'none'; + this.searchInput.value = ''; + this.usernameFilter.value = ''; + this.userFilter.checked = false; + this.usernameFilter.disabled = true; + this.searchResults.innerHTML = ''; + } + + async performSearch() { + const query = this.searchInput.value.trim().toLowerCase(); + if (!query) { + this.searchResults.innerHTML = '
Enter search terms above
'; + return; + } + + // Show loading + this.searchResults.innerHTML = '
Searching...
'; + + try { + // Use setTimeout to make search non-blocking + await new Promise(resolve => setTimeout(resolve, 0)); + + // Get all messages from current room + const currentRoom = this.app.elements.roomSelect.value; + const messages = this.app.loadFromStorage(`htmlchat_${currentRoom}`) || []; + + // Process in chunks to avoid blocking + const chunkSize = 50; + let filteredMessages = []; + + // Precompute username filter needle once for performance + const usernameFilterNeedle = this.userFilter.checked && this.usernameFilter.value.trim() + ? this.usernameFilter.value.trim().toLowerCase() + : null; + + for (let i = 0; i < messages.length; i += chunkSize) { + const chunk = messages.slice(i, i + chunkSize); + + const chunkFiltered = chunk.filter(msg => { + // Text search with null-safety + const safeText = String(msg.text || '').toLowerCase(); + const safeUser = String(msg.user || '').toLowerCase(); + const textMatch = safeText.includes(query) || safeUser.includes(query); + + // User filter + if (usernameFilterNeedle) { + const userMatch = safeUser.includes(usernameFilterNeedle); + return textMatch && userMatch; + } + + return textMatch; + }); + + filteredMessages = filteredMessages.concat(chunkFiltered); + + // Allow UI to update between chunks + if (i % (chunkSize * 4) === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + // Sort by time (most recent first) + filteredMessages = filteredMessages.sort((a, b) => b.time - a.time); + + // Limit results + filteredMessages = filteredMessages.slice(0, 100); + + this.displayResults(filteredMessages, query); + } catch (error) { + console.error('Search error:', error); + this.searchResults.innerHTML = '
Search failed. Please try again.
'; + } + } + + displayResults(messages, query) { + if (messages.length === 0) { + this.searchResults.innerHTML = '
No messages found
'; + return; + } + + const html = messages.map((msg, index) => { + const date = new Date(msg.time).toLocaleString(); + const color = this.app.messageRenderer.getUserColor(msg.user || ''); + + // Highlight search terms with null-safety + let highlightedText = this.highlightSearchTerms(msg.text || '', query); + let highlightedUser = this.highlightSearchTerms(msg.user || '', query); + + const messageId = `msg-${msg.time}-${index}`; + + return ` +
+
+ ${highlightedUser} + ${this.escapeHtml(date)} +
+
${highlightedText}
+
+ `; + }).join(''); + + const headerHtml = ` +
+ Found ${messages.length} message${messages.length === 1 ? '' : 's'} +
+ `; + + this.searchResults.innerHTML = headerHtml + html; + + // Add event listeners to search result items + this.searchResults.querySelectorAll('.search-result-item').forEach(item => { + item.addEventListener('click', () => { + const messageId = item.getAttribute('data-message-id'); + const timestamp = parseInt(item.getAttribute('data-timestamp')); + this.jumpToMessage(messageId, timestamp); + }); + }); + } + + highlightSearchTerms(text, query) { + if (!query) return this.escapeHtml(text); + + // First escape the text to prevent XSS + const escapedText = this.escapeHtml(text); + + // Escape special regex characters in query + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${this.escapeHtml(escapedQuery)})`, 'gi'); + + return escapedText.replace(regex, '$1'); + } + + jumpToMessage(messageId, timestamp) { + // Close search modal + this.closeModal(); + + // Try to find the message in current chat + let existingMessage = document.getElementById(messageId); + + // Also try with data-message-id attribute + if (!existingMessage) { + existingMessage = document.querySelector(`[data-message-id="${messageId}"]`); + } + + if (existingMessage) { + this.app.messageRenderer.highlightMessage(existingMessage.id); + return; + } + + // If not found, refresh messages and then try to highlight + this.app.fetchMessages(true).then(() => { + setTimeout(() => { + // Try to find by messageId again + let foundMessage = document.getElementById(messageId); + if (!foundMessage) { + foundMessage = document.querySelector(`[data-message-id="${messageId}"]`); + } + + if (foundMessage) { + this.app.messageRenderer.highlightMessage(foundMessage.id); + } else { + // Fallback: try to find by timestamp + const messages = this.app.loadFromStorage(`htmlchat_${this.app.elements.roomSelect.value}`) || []; + const messageIndex = messages.findIndex(msg => msg.time === timestamp); + if (messageIndex !== -1) { + const generatedId = `msg-${timestamp}-${messageIndex}`; + this.app.messageRenderer.highlightMessage(generatedId); + } + } + }, 500); + }); + } + + // Search within specific time range (future feature) + searchByTimeRange(startDate, endDate) { + // This could be implemented for advanced search features + console.log('Time range search not implemented yet'); + } + + // Search by message type (future feature) + searchByType(type) { + // Could search for files, images, URLs, etc. + console.log('Type search not implemented yet'); + } + + // Export search results + exportSearchResults(messages, query) { + const exportData = { + query: query, + room: this.app.elements.roomSelect.value, + searchDate: new Date().toISOString(), + resultCount: messages.length, + results: messages + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json" + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `search-results-${query.replace(/[^a-zA-Z0-9]/g, '_')}.json`; + a.click(); + URL.revokeObjectURL(url); + } +} \ No newline at end of file diff --git a/Build/src/soundManager.js b/Build/src/soundManager.js new file mode 100644 index 0000000..795e200 --- /dev/null +++ b/Build/src/soundManager.js @@ -0,0 +1,69 @@ +export class SoundManager { + constructor() { + this.soundsEnabled = this.loadSetting('sounds_enabled', true); + this.volume = this.loadSetting('volume', 0.5); + + this.sounds = { + message: document.getElementById('sound-message'), + pm: document.getElementById('sound-pm'), + join: document.getElementById('sound-join') + }; + + // Set initial volumes + Object.values(this.sounds).forEach(audio => { + if (audio) audio.volume = this.volume; + }); + } + + loadSetting(key, defaultValue) { + try { + const saved = localStorage.getItem(`htmlchat_${key}`); + return saved !== null ? JSON.parse(saved) : defaultValue; + } catch(e) { + return defaultValue; + } + } + + saveSetting(key, value) { + try { + localStorage.setItem(`htmlchat_${key}`, JSON.stringify(value)); + } catch(e) { + console.warn('Failed to save sound setting:', e); + } + } + + playSound(type) { + if (!this.soundsEnabled || !this.sounds[type]) return; + + try { + const audio = this.sounds[type]; + audio.currentTime = 0; + audio.play().catch(e => { + // Ignore play errors (usually due to user interaction requirements) + console.debug('Sound play failed:', e); + }); + } catch(e) { + console.warn('Sound error:', e); + } + } + + toggleSounds() { + this.soundsEnabled = !this.soundsEnabled; + this.saveSetting('sounds_enabled', this.soundsEnabled); + console.log('Sound manager toggled to:', this.soundsEnabled); // Debug + return this.soundsEnabled; + } + + setVolume(volume) { + this.volume = Math.max(0, Math.min(1, volume)); + this.saveSetting('volume', this.volume); + + Object.values(this.sounds).forEach(audio => { + if (audio) audio.volume = this.volume; + }); + } + + isSoundEnabled() { + return this.soundsEnabled; + } +} \ No newline at end of file diff --git a/Build/src/styles.css b/Build/src/styles.css new file mode 100644 index 0000000..50ef9c0 --- /dev/null +++ b/Build/src/styles.css @@ -0,0 +1,1063 @@ +/* Base Styles - Original HTMLChat */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Trebuchet MS', Arial, sans-serif; + background: #f0f0f0; + color: #333; + font-size: 16px; +} + +.container { + width: 90%; + max-width: 1600px; + margin: 20px auto; + background: #fff; + border: 2px solid #ccc; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.774); +} + +.header { + background: linear-gradient(90deg, #2196F3, #21CBF3); + color: white; + padding: 12px 16px; + font-weight: bold; + font-size: 18px; + border-bottom: 1px solid #0066cc; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 18px; + font-weight: bold; +} + +.header-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.header-btn { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; +} + +.header-btn:hover { + background: rgba(255,255,255,0.2); +} + +.header-btn svg { + width: 16px; + height: 16px; +} + +.header-btn.muted { + opacity: 0.5; +} + +.minimize-btn { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 20px; + padding: 4px 8px; + border-radius: 2px; +} + +.minimize-btn:hover { + background: rgba(255,255,255,0.2); +} + +.toolbar { + background: #e8e8e8; + border-bottom: 1px solid #ccc; + padding: 8px 12px; + display: flex; + gap: 15px; + align-items: center; + font-size: 14px; +} + +.room-select { + padding: 4px 6px; + border: 1px inset #ccc; + background: white; + font-size: 14px; + font-family: inherit; +} + +.toolbar-btn { + background: #f0f0f0; + border: 1px outset #ccc; + padding: 4px 8px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.toolbar-btn:hover { + background: #e0e0e0; +} + +.toolbar-btn:active { + border: 1px inset #ccc; +} + +.toolbar-btn svg { + width: 14px; + height: 14px; +} + +.user-count { + color: #666; + margin-left: auto; +} + +/* Notification Banner */ +.notification-banner { + background: #fff3cd; + border-bottom: 1px solid #ffc107; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; +} + +.notification-banner button { + background: #ffc107; + border: 1px outset #ffc107; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; +} + +.notification-banner button:hover { + background: #e0a800; +} + +.notification-banner button:active { + border: 1px inset #ffc107; +} + +.main-content { + display: flex; + height: 600px; +} + +.chat-area { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 300px; + max-width: calc(100% - 200px); +} + +.chat-box { + flex: 1; + background: white; + border-right: 1px solid #ccc; + overflow-y: auto; + font-size: 14px; + line-height: 1.4; + position: relative; +} + +/* Message Styles */ +.msg { + padding: 4px 8px; + border-bottom: 1px dotted #ddd; + position: relative; +} + +.msg:hover { + background: #f5f5f5; +} + +.msg.highlighted { + background: #ffffcc !important; + border-left: 3px solid #ffc107; +} + +.msg.reply-msg { + border-left: 3px solid #2196F3; + margin-left: 20px; + background: #f0f8ff; +} + +.msg .time { + color: #666; + font-size: 12px; +} + +.msg .user { + font-weight: bold; + margin-right: 6px; + cursor: pointer; +} + +.msg .user.moderator::after { + content: "👑"; + margin-left: 4px; + font-size: 10px; +} + +.msg .text { + margin-left: 6px; +} + +.msg .reply-reference { + font-size: 11px; + color: #666; + font-style: italic; + margin-bottom: 2px; + cursor: pointer; + padding: 2px 4px; + background: rgba(33, 150, 243, 0.1); + border-radius: 3px; + display: inline-block; +} + +.msg .reply-reference:hover { + text-decoration: underline; + background: rgba(33, 150, 243, 0.2); +} + +/* File/Image in Messages */ +.msg .file-attachment { + display: inline-block; + margin: 4px 0; + padding: 4px 8px; + background: #f0f0f0; + border: 1px solid #ccc; + border-radius: 4px; + text-decoration: none; + color: #0066cc; +} + +.msg .file-attachment:hover { + background: #e0e0e0; +} + +.msg .image-attachment { + display: block; + margin: 4px 0; + max-width: 300px; + max-height: 200px; + border: 2px inset #ccc; + cursor: pointer; +} + +/* Reply Preview */ +.reply-preview { + background: #f0f8ff; + border: 1px solid #2196F3; + padding: 8px 12px; + margin: 4px 8px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; +} + +.reply-content { + flex: 1; +} + +.reply-label { + color: #666; + margin-right: 8px; +} + +.reply-text { + font-style: italic; +} + +.reply-cancel { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 16px; + padding: 0 4px; +} + +.reply-cancel:hover { + color: #333; +} + +.user-list { + width: 180px; + background: #f8f8f8; + border-left: 1px solid #ccc; + overflow-y: auto; +} + +.user-list-header { + background: #e0e0e0; + padding: 6px 8px; + font-size: 14px; + font-weight: bold; + border-bottom: 1px solid #ccc; +} + +.user-item { + padding: 4px 8px; + font-size: 14px; + border-bottom: 1px dotted #ddd; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.user-item:hover { + background: #e8e8e8; +} + +.user-item.moderator::after { + content: "👑"; + font-size: 10px; +} + +.input-area { + border-top: 1px solid #ccc; + padding: 8px; + background: #f0f0f0; +} + +.input-container { + display: flex; + gap: 6px; +} + +.msg-input { + flex: 1; + padding: 6px 8px; + border: 1px inset #ccc; + font-size: 14px; + font-family: inherit; +} + +.send-btn { + padding: 6px 16px; + background: linear-gradient(90deg, #2196F3, #21CBF3); + color: white; + border: 1px outset #2196F3; + cursor: pointer; + font-size: 14px; + font-family: inherit; +} + +.send-btn:active { + border: 1px inset #2196F3; +} + +.send-btn:disabled { + background: #ccc; + border: 1px outset #ccc; + cursor: not-allowed; +} + +.status-bar { + background: #e8e8e8; + border-top: 1px solid #ccc; + padding: 4px 8px; + font-size: 12px; + color: #666; + display: flex; + justify-content: space-between; +} + +.connection-status { + display: flex; + align-items: center; + gap: 6px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #00cc00; +} + +.status-dot.disconnected { + background: #cc0000; +} + +.status-btn { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 10px; + margin-left: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.status-btn:hover { + color: #333; +} + +.status-btn svg { + width: 12px; + height: 12px; +} + +/* Context Menu */ +.context-menu { + position: absolute; + background: #f0f0f0; + border: 2px outset #ccc; + box-shadow: 2px 2px 5px rgba(0,0,0,0.3); + z-index: 1000; + display: none; + min-width: 150px; +} + +.context-item { + padding: 6px 12px; + cursor: pointer; + font-size: 14px; + border-bottom: 1px dotted #ddd; + display: flex; + align-items: center; + gap: 8px; +} + +.context-item:hover { + background: #e0e0e0; +} + +.context-item:last-child { + border-bottom: none; +} + +.context-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 2000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + align-items: center; + justify-content: center; +} + +.modal-content { + position: relative; + background: #f0f0f0; + width: 90%; + max-width: 600px; + max-height: 80vh; + border: 2px outset #ccc; + box-shadow: 2px 2px 5px rgba(0,0,0,0.3); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-header { + background: linear-gradient(90deg, #2196F3, #21CBF3); + color: white; + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; + flex-shrink: 0; +} + +.modal-header h3 { + margin: 0; + font-size: 16px; +} + +.close-btn { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 20px; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.close-btn:hover { + background: rgba(255,255,255,0.2); + border-radius: 2px; +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +/* Search Modal */ +#search-input { + width: 100%; + padding: 6px 8px; + border: 1px inset #ccc; + font-size: 14px; + margin-bottom: 12px; +} + +.search-filters { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 12px; + font-size: 14px; +} + +.search-filters input[type="text"] { + padding: 4px 6px; + border: 1px inset #ccc; + font-size: 14px; +} + +.search-results { + max-height: 300px; + overflow-y: auto; + border: 1px inset #ccc; + background: white; +} + +.search-result-item { + padding: 8px; + border-bottom: 1px dotted #ddd; + cursor: pointer; + font-size: 14px; +} + +.search-result-item:hover { + background: #f5f5f5; +} + +.search-result-item:last-child { + border-bottom: none; +} + +/* Upload Modal */ +.upload-area { + border: 2px dashed #ccc; + padding: 40px; + text-align: center; + cursor: pointer; + background: #fafafa; +} + +.upload-area.drag-over { + border-color: #2196F3; + background: #f0f8ff; +} + +.upload-text { + color: #666; +} + +.upload-icon { + font-size: 48px; + margin-bottom: 12px; + display: flex; + justify-content: center; + align-items: center; +} + +.upload-icon svg { + width: 48px; + height: 48px; + color: #666; +} + +.upload-limit { + font-size: 12px; + margin-top: 8px; +} + +#file-input { + display: none; +} + +.upload-preview { + margin-top: 16px; + padding: 12px; + background: white; + border: 1px inset #ccc; + display: none; +} + +.preview-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + background: #f5f5f5; + border: 1px solid #ddd; + margin-bottom: 8px; +} + +.preview-icon { + font-size: 24px; + min-width: 32px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-icon svg { + width: 24px; + height: 24px; + color: #666; +} + +.preview-info { + flex: 1; +} + +.preview-name { + font-weight: bold; + margin-bottom: 4px; +} + +.preview-size { + font-size: 12px; + color: #666; +} + +.preview-remove { + background: #cc0000; + color: white; + border: 1px outset #cc0000; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; +} + +.preview-remove:hover { + background: #990000; +} + +.preview-remove:active { + border: 1px inset #cc0000; +} + +/* Private Message Windows */ +.pm-window { + position: fixed; + width: 400px; + height: 300px; + background: #fff; + border: 2px solid #ccc; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.774); + z-index: 1500; + display: flex; + flex-direction: column; + resize: both; + overflow: hidden; +} + +.pm-header { + background: linear-gradient(90deg, #2196F3, #21CBF3); + color: white; + padding: 8px 12px; + font-weight: bold; + font-size: 14px; + border-bottom: 1px solid #0066cc; + display: flex; + justify-content: space-between; + align-items: center; + cursor: move; +} + +.pm-chat { + flex: 1; + overflow-y: auto; + background: white; + font-size: 14px; + line-height: 1.4; +} + +.pm-input-area { + border-top: 1px solid #ccc; + padding: 6px; + background: #f0f0f0; +} + +.pm-input-container { + display: flex; + gap: 4px; +} + +.pm-input { + flex: 1; + padding: 4px 6px; + border: 1px inset #ccc; + font-size: 12px; + font-family: inherit; +} + +.pm-send-btn { + padding: 4px 12px; + background: linear-gradient(90deg, #2196F3, #21CBF3); + color: white; + border: 1px outset #2196F3; + cursor: pointer; + font-size: 12px; + font-family: inherit; +} + +.pm-send-btn:active { + border: 1px inset #2196F3; +} + +.pm-close-btn { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 16px; + padding: 0 4px; +} + +.pm-close-btn:hover { + background: rgba(255,255,255,0.2); + border-radius: 2px; +} + +/* Scrollbar styling for retro feel */ +.chat-box::-webkit-scrollbar, +.user-list::-webkit-scrollbar, +.pm-chat::-webkit-scrollbar, +.search-results::-webkit-scrollbar { + width: 16px; +} + +.chat-box::-webkit-scrollbar-track, +.user-list::-webkit-scrollbar-track, +.pm-chat::-webkit-scrollbar-track, +.search-results::-webkit-scrollbar-track { + background: #f0f0f0; + border: 1px inset #ccc; +} + +.chat-box::-webkit-scrollbar-thumb, +.user-list::-webkit-scrollbar-thumb, +.pm-chat::-webkit-scrollbar-thumb, +.search-results::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, #2196F3, #21CBF3); + border: 1px outset #2196F3; +} + +.chat-box::-webkit-scrollbar-button, +.user-list::-webkit-scrollbar-button, +.pm-chat::-webkit-scrollbar-button, +.search-results::-webkit-scrollbar-button { + background: #e0e0e0; + border: 1px outset #ccc; + height: 16px; +} + +/* Mobile responsiveness */ +@media (max-width: 640px) { + .container { + margin: 10px; + max-width: none; + } + + .main-content { + height: 500px; + } + + .user-list { + display: none; + } + + .toolbar { + flex-wrap: wrap; + gap: 8px; + } + + .chat-area { + max-width: 100%; + min-width: 100%; + } + + .pm-window { + width: 90%; + max-width: 350px; + height: 250px; + } + + .modal-content { + width: 95%; + margin: 5% auto; + } +} + +/* System message styling */ +.msg.system { + background: #fff3cd; + border-left: 3px solid #ffc107; +} + +.msg.system .user { + color: #856404; +} + +/* Welcome message */ +.welcome { + background: #d4edda; + border: 1px solid #c3e6cb; + padding: 10px; + margin: 6px; + font-size: 14px; + border-radius: 2px; +} + +/* Sound toggle states */ +.header-btn.muted { + opacity: 0.5; +} + +/* Notification styles */ +.desktop-notification { + position: fixed; + top: 20px; + right: 20px; + background: #f0f0f0; + border: 2px outset #ccc; + box-shadow: 2px 2px 5px rgba(0,0,0,0.3); + padding: 12px 16px; + z-index: 3000; + max-width: 300px; + font-size: 14px; +} + +.notification-title { + font-weight: bold; + margin-bottom: 4px; +} + +.notification-body { + color: #666; +} + +/* Settings Modal Styles */ +.settings-section { + margin-bottom: 32px; +} + +.settings-section:last-child { + margin-bottom: 0; +} + +.settings-section h4 { + margin: 0 0 20px 0; + font-size: 18px; + color: #333; + border-bottom: 2px solid #e0e0e0; + padding-bottom: 10px; + font-weight: 600; +} + +.setting-item { + margin-bottom: 20px; + padding: 16px; + background: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 6px; + transition: background-color 0.2s; +} + +.setting-item:hover { + background: #f5f5f5; +} + +.setting-label { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + font-weight: 500; + margin-bottom: 8px; + font-size: 15px; + color: #333; +} + +.setting-description { + margin: 8px 0 0 0; + font-size: 13px; + color: #777; + line-height: 1.5; +} + +.permission-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.permission-status span:first-child { + font-weight: 500; + color: #555; +} + +#permission-status-text { + font-weight: 600; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; +} + +.small-btn { + background: #2196F3; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background-color 0.2s; +} + +.small-btn:hover { + background: #1976D2; +} + +/* Toggle Switch Styles */ +.toggle-switch { + position: relative; + width: 48px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: 0.3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.toggle-switch input:checked + .toggle-slider { + background-color: #4CAF50; +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(24px); +} + +.toggle-switch input:disabled + .toggle-slider { + background-color: #ddd; + cursor: not-allowed; +} + +.toggle-switch input:disabled + .toggle-slider:before { + background-color: #f5f5f5; +} + +/* Permission Status */ +.permission-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +#permission-status-text { + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; +} + +#permission-status-text.granted { + background-color: #d4edda; + color: #155724; +} + +#permission-status-text.denied { + background-color: #f8d7da; + color: #721c24; +} + +#permission-status-text.default { + background-color: #fff3cd; + color: #856404; +} + +.small-btn { + padding: 4px 8px; + font-size: 12px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.small-btn:hover { + background: #0056b3; +} \ No newline at end of file diff --git a/Build/vite.config.js b/Build/vite.config.js new file mode 100644 index 0000000..59a9829 --- /dev/null +++ b/Build/vite.config.js @@ -0,0 +1,9 @@ +// vite.config.js +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "./", + // server: { + // allowedHosts: true, + // } +}); diff --git a/README.md b/README.md index 50783a5..4a09017 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # HTMLChat 💬 A retro-styled, browser-only chat client. -Version: **v0.1.0-beta** +Version: **v0.2.0** ## What this is HTMLChat is a super simple chat system I hacked together: -* Frontend is pure HTML/CSS/JS (retro style, scrollbars included). +* Frontend is plain HTML/CSS/JS built with Vite (retro style, scrollbars included). * Backend is a tiny API (yes, it’s alive, and yes, it works). * LocalStorage keeps your name + cached messages. * No cookies. No nonsense. Just **chat**. @@ -17,7 +17,13 @@ HTMLChat is a super simple chat system I hacked together: - Multiple rooms (#general, #random, #offtopic, #computers) - Nicknames + color-coding - Connection status + heartbeat (so you look online) -- Export chat logs as JSON +- Export chat logs as JSON (plus a handy Reload button next to Export) +- File uploads (images/docs) with previews +- Replies (click to reply, threaded context) +- Search (fast, non-blocking) +- Moderator tools (delete/ban) +- Settings modal (desktop notifications + sounds toggles) +- Lucide icons via npm (no CDN, crisp SVGs) - Mobile-friendly (user list hides on small screens) - Retro scrollbars (obviously) @@ -33,7 +39,9 @@ This is **beta**, so expect bugs and jank. Stuff I *might* add: ## Running it -- Frontend is just static HTML. +- Frontend lives in `Build/` and uses Vite. + - Dev: `cd Build && npm install && npm run dev` + - Build: `cd Build && npm install && npm run build` - Backend is currently deployed at: ``` https://htmlchat.neeljaiswal23.workers.dev diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5b55ad8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,26 @@ +# Todo + +## Done + +### v0.2.0 + +- [X] delete messages, +- [X] search freezes page (make async/non blocking) +- [X] prevent infinite loop file upload +- [X] fix reply formatting (looks like: + +```text + [12:22 AM] **** + @reply:msg-1757996435434-95:Neel hi + ) +``` + +- [X] make sound toggle work +- [X] use lucide icons +- [X] Modularize +- [X] Vite + NPM +- [X] File support +- [X] Replies +- [X] Search +- [X] Moderator +- [X] Better UI \ No newline at end of file diff --git a/Worker/src/chatRoom.js b/Worker/src/chatRoom.js index e501dd6..e1e7167 100644 --- a/Worker/src/chatRoom.js +++ b/Worker/src/chatRoom.js @@ -3,6 +3,8 @@ function jsonResponse(data) { return new Response(JSON.stringify(data), { headers: { 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, X-Auth-Token, X-Auth-User', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Content-Type': 'application/json' } }); @@ -13,6 +15,8 @@ function textResponse(text, status = 200) { status, headers: { 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, X-Auth-Token, X-Auth-User', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Content-Type': 'text/plain' } }); @@ -22,8 +26,24 @@ export default class ChatRoom { constructor(state, env) { this.state = state; this.env = env; - // Clean up expired users every 30 seconds this.cleanupTimer = setInterval(() => this.cleanupUsers(), 30000); + + // Initialize moderators and banned users + this.initializeModerationData(); + } + + async initializeModerationData() { + // Set default moderators if none exist + const moderators = await this.state.storage.get('moderators'); + if (!moderators) { + await this.state.storage.put('moderators', ['admin', 'mod']); + } + + // Initialize banned users if none exist + const bannedUsers = await this.state.storage.get('banned_users'); + if (!bannedUsers) { + await this.state.storage.put('banned_users', {}); + } } async cleanupUsers() { @@ -52,12 +72,64 @@ export default class ChatRoom { } } } + + // Clean up expired bans + await this.cleanupExpiredBans(); + + // Clean up expired kicks + await this.cleanupExpiredKicks(); } catch (error) { console.error('Cleanup error:', error); } } + async cleanupExpiredKicks() { + const kickLists = await this.state.storage.list({ prefix: 'kicked_users:' }); + const now = Date.now(); + + for (const [key, kickedUsers] of kickLists) { + let changed = false; + + for (const [username, kick] of Object.entries(kickedUsers)) { + if (now > kick.expires) { + delete kickedUsers[username]; + changed = true; + } + } + + if (changed) { + if (Object.keys(kickedUsers).length > 0) { + await this.state.storage.put(key, kickedUsers); + } else { + await this.state.storage.delete(key); + } + } + } + } + + async cleanupExpiredBans() { + const bannedUsers = await this.state.storage.get('banned_users') || {}; + const now = Date.now(); + let changed = false; + + for (const [username, ban] of Object.entries(bannedUsers)) { + if (ban.expires && now > ban.expires) { + delete bannedUsers[username]; + changed = true; + } + } + + if (changed) { + await this.state.storage.put('banned_users', bannedUsers); + } + } + async updateUserPresence(room, username) { + // Check if user is kicked before allowing presence update + if (await this.isKicked(username, room)) { + return { error: 'User is kicked from this room' }; + } + const key = `users:${room}`; const users = await this.state.storage.get(key) || {}; users[username] = Date.now(); @@ -91,6 +163,72 @@ export default class ChatRoom { return Object.keys(activeUsers); } + async isModerator(username) { + // Only NellowTCS is allowed moderator access + return username && username.toLowerCase() === 'nellowtcs'; + } + + async isKicked(username, room) { + const kickKey = `kicked_users:${room}`; + const kickedUsers = await this.state.storage.get(kickKey) || {}; + const kick = kickedUsers[username.toLowerCase()]; + + if (!kick) return false; + + // Check if kick has expired + if (Date.now() > kick.expires) { + delete kickedUsers[username.toLowerCase()]; + await this.state.storage.put(kickKey, kickedUsers); + return false; + } + + return true; + } + + async isBanned(username) { + const bannedUsers = await this.state.storage.get('banned_users') || {}; + const ban = bannedUsers[username.toLowerCase()]; + + if (!ban) return false; + + // Check if ban has expired + if (ban.expires !== null && ban.expires !== undefined && Date.now() > ban.expires) { + delete bannedUsers[username.toLowerCase()]; + await this.state.storage.put('banned_users', bannedUsers); + return false; + } + + return true; + } + + async moderateMessage(text, user, room) { + // Check if user is banned + if (await this.isBanned(user)) { + return { allowed: false, reason: 'User is banned' }; + } + + // Check if user is kicked + if (await this.isKicked(user, room)) { + return { allowed: false, reason: 'User is kicked from this room' }; + } + + // Simple spam detection - check for repeated messages + const userMessages = await this.state.storage.get(`user_messages:${user}`) || []; + const now = Date.now(); + const recentMessages = userMessages.filter(msg => now - msg.time < 30000); // 30 seconds + + // Check for identical message in recent history + if (recentMessages.some(msg => msg.text === text)) { + return { allowed: false, reason: 'Spam detected' }; + } + + // Store this message + recentMessages.push({ text, time: now }); + await this.state.storage.put(`user_messages:${user}`, recentMessages.slice(-10)); // Keep last 10 + + return { allowed: true }; + } + async fetch(request) { if (request.method === 'OPTIONS') { return new Response('', { @@ -98,38 +236,80 @@ export default class ChatRoom { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' + 'Access-Control-Allow-Headers': 'Content-Type, X-Auth-Token, X-Auth-User' } }); } const url = new URL(request.url); - // Extract room from URL path, e.g. /chat/roomname const parts = url.pathname.split('/'); - const room = parts.length > 2 ? parts[2] : 'default'; const user = url.searchParams.get('user') || 'anon'; + // Handle private messages + if (parts[1] === 'pm') { + return this.handlePrivateMessages(request, parts[2], user); + } + + // Handle moderation + if (parts[1] === 'mod') { + return this.handleModeration(request, parts[2], user); + } + + // Regular chat room handling + const room = parts.length > 2 ? parts[2] : 'default'; + // GET /chat/roomname - Get messages and users if (request.method === 'GET') { const messages = await this.state.storage.get(`messages:${room}`) || []; const users = await this.getUsers(room); + const moderators = ['NellowTCS']; // Only NellowTCS is moderator >:D + + // Check if user is authenticated as moderator + let isModerator = false; + if (this.env.AUTH_SECRET) { + const authenticatedUser = this.authenticateUser(request); + isModerator = authenticatedUser && authenticatedUser.toLowerCase() === 'nellowtcs'; + console.log('Moderator check:', { + authConfigured: true, + authenticatedUser, + authenticatedUserLower: authenticatedUser?.toLowerCase(), + isModerator + }); + } else { + // Fallback to client-supplied user if no auth configured + isModerator = user && user.toLowerCase() === 'nellowtcs'; + console.log('Moderator check (no auth):', { + authConfigured: false, + user, + isModerator + }); + } return jsonResponse({ messages, users, - userCount: users.length + userCount: users.length, + moderators, + isModerator }); } // POST /chat/roomname - Send message if (request.method === 'POST') { - const { text } = await request.json(); + const { text, messageId } = await request.json(); if (!text || typeof text !== 'string') { return textResponse('Invalid message', 400); } + // Moderate message + const moderation = await this.moderateMessage(text, user, room); + if (!moderation.allowed) { + return textResponse(moderation.reason, 403); + } + const message = { + id: messageId || `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, user, text, time: Date.now() @@ -139,13 +319,13 @@ export default class ChatRoom { const key = `messages:${room}`; const messages = await this.state.storage.get(key) || []; messages.push(message); - if (messages.length > 100) messages.shift(); + if (messages.length > 1000) messages.shift(); // Keep last 1000 messages await this.state.storage.put(key, messages); // Update user presence await this.updateUserPresence(room, user); - return textResponse('OK'); + return jsonResponse({ success: true, messageId: message.id }); } // PUT /chat/roomname?user=username - Update user presence (heartbeat) @@ -159,21 +339,309 @@ export default class ChatRoom { }); } - // DELETE /chat/roomname?user=username - User leaving + // DELETE /chat/roomname?user=username - User leaving or delete message if (request.method === 'DELETE') { - const key = `users:${room}`; - const users = await this.state.storage.get(key) || {}; - delete users[user]; + const messageId = url.searchParams.get('messageId'); - if (Object.keys(users).length > 0) { - await this.state.storage.put(key, users); + if (messageId) { + // Delete specific message + return this.deleteMessage(room, messageId, user, request); } else { - await this.state.storage.delete(key); + // User leaving room + const key = `users:${room}`; + const users = await this.state.storage.get(key) || {}; + delete users[user]; + + if (Object.keys(users).length > 0) { + await this.state.storage.put(key, users); + } else { + await this.state.storage.delete(key); + } + + return textResponse('OK'); } + } - return textResponse('OK'); + return textResponse('Method not allowed', 405); + } + + // Authenticate user from request headers/session + authenticateUser(request) { + // Simple auth via X-Auth-Token header matching server secret + const authToken = request.headers.get('X-Auth-Token'); + const expectedToken = this.env.AUTH_SECRET; // Cloudflare secret + + console.log('Auth check:', { + hasToken: !!authToken, + hasSecret: !!expectedToken, + tokensMatch: authToken === expectedToken + }); + + if (!authToken || !expectedToken) { + return null; // No auth configured or provided } + + if (authToken !== expectedToken) { + return null; // Invalid token + } + + // Extract authenticated user from X-Auth-User header + const authenticatedUser = request.headers.get('X-Auth-User'); + console.log('Authenticated user:', authenticatedUser); + return authenticatedUser || null; + } + // Verify user has permission for action (either owns resource or is authenticated) + async verifyUserPermission(request, targetUser, requireModerator = false) { + const authenticatedUser = this.authenticateUser(request); + + // If authentication is configured, require it + if (this.env.AUTH_SECRET) { + if (!authenticatedUser) { + throw new Error('Authentication required'); + } + + // Use authenticated identity instead of trusting client-supplied user + const userToCheck = authenticatedUser; + + if (requireModerator) { + const isMod = await this.isModerator(userToCheck); + if (!isMod) { + throw new Error('Moderator privileges required'); + } + } + + return userToCheck; + } else { + // Fallback to old behavior if auth not configured (for development) + console.warn('AUTH_SECRET not configured - using client-supplied user (insecure)'); + return targetUser; + } + } + + async deleteMessage(room, messageId, user, request) { + try { + // Verify user permission with authentication + const verifiedUser = await this.verifyUserPermission(request, user, false); + + const key = `messages:${room}`; + const messages = await this.state.storage.get(key) || []; + const messageIndex = messages.findIndex(msg => msg.id === messageId); + + console.log(`Delete request: room=${room}, messageId=${messageId}, verifiedUser=${verifiedUser}`); + + if (messageIndex === -1) { + return textResponse('Message not found', 404); + } + + const message = messages[messageIndex]; + + // Check permissions - can delete own message or if moderator + const isMod = await this.isModerator(verifiedUser); + if (message.user !== verifiedUser && !isMod) { + console.log(`Permission denied: verifiedUser=${verifiedUser}, messageUser=${message.user}, isMod=${isMod}`); + return textResponse('Unauthorized - can only delete own messages or need moderator privileges', 403); + } + + // Store original message info for system message + const originalUser = message.user; + const originalText = message.text.length > 50 ? message.text.substring(0, 50) + '...' : message.text; + + // Remove message + messages.splice(messageIndex, 1); + + // Add system message about deletion + const systemMessage = { + id: `sys_del_${Date.now()}`, + user: '*** System ***', + text: `Message from ${originalUser} deleted by ${verifiedUser}${originalUser !== verifiedUser ? ' (moderator action)' : ''}`, + time: Date.now(), + system: true + }; + + messages.push(systemMessage); + await this.state.storage.put(key, messages); + + console.log('Message deleted successfully'); + return jsonResponse({ + success: true, + deleted: true, + systemMessage: systemMessage + }); + } catch (error) { + console.error('Delete message error:', error); + return textResponse(error.message, 403); + } + } + + async handlePrivateMessages(request, conversationId, user) { + const key = `pm:${conversationId}`; + + if (request.method === 'GET') { + const messages = await this.state.storage.get(key) || []; + return jsonResponse({ messages }); + } + + if (request.method === 'POST') { + const { text, to } = await request.json(); + + if (!text || !to) { + return textResponse('Missing text or recipient', 400); + } + + const message = { + id: `pm_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + from: user, + to: to, + text: text, + time: Date.now() + }; + + const messages = await this.state.storage.get(key) || []; + messages.push(message); + + // Keep last 100 PM messages + if (messages.length > 100) messages.shift(); + + await this.state.storage.put(key, messages); + + return jsonResponse({ success: true, messageId: message.id }); + } + return textResponse('Method not allowed', 405); } + + async handleModeration(request, room, user) { + try { + // Verify user permission with authentication (requires moderator) + const verifiedUser = await this.verifyUserPermission(request, user, true); + + if (request.method === 'POST') { + const { action, targetUser, reason, duration } = await request.json(); + + switch (action) { + case 'ban': + return this.banUser(targetUser, verifiedUser, reason, duration); + case 'unban': + return this.unbanUser(targetUser, verifiedUser); + case 'kick': + return this.kickUser(room, targetUser, verifiedUser, reason); + case 'addMod': + return textResponse('Cannot modify moderators - NellowTCS is the only moderator', 403); + case 'removeMod': + return textResponse('Cannot modify moderators - NellowTCS is the only moderator', 403); + } + } + + if (request.method === 'GET') { + const bannedUsers = await this.state.storage.get('banned_users') || {}; + const moderators = ['NellowTCS']; // Only NellowTCS is moderator + + return jsonResponse({ + bannedUsers, + moderators + }); + } + + return textResponse('Method not allowed', 405); + } catch (error) { + console.error('Moderation error:', error); + return textResponse(error.message, 403); + } + } + + async banUser(targetUser, moderator, reason = '', durationMinutes = null) { + const bannedUsers = await this.state.storage.get('banned_users') || {}; + + const ban = { + bannedBy: moderator, + reason: reason, + timestamp: Date.now(), + expires: durationMinutes ? Date.now() + (durationMinutes * 60 * 1000) : null + }; + + bannedUsers[targetUser.toLowerCase()] = ban; + await this.state.storage.put('banned_users', bannedUsers); + + return jsonResponse({ success: true, message: `User ${targetUser} banned` }); + } + + async unbanUser(targetUser, moderator) { + const bannedUsers = await this.state.storage.get('banned_users') || {}; + + if (bannedUsers[targetUser.toLowerCase()]) { + delete bannedUsers[targetUser.toLowerCase()]; + await this.state.storage.put('banned_users', bannedUsers); + return jsonResponse({ success: true, message: `User ${targetUser} unbanned` }); + } + + return textResponse('User not banned', 400); + } + + async kickUser(room, targetUser, moderator, reason = '') { + // Add user to temporary kick list (5 minute kick) + const kickKey = `kicked_users:${room}`; + const kickedUsers = await this.state.storage.get(kickKey) || {}; + + kickedUsers[targetUser.toLowerCase()] = { + kickedBy: moderator, + reason: reason, + timestamp: Date.now(), + expires: Date.now() + (5 * 60 * 1000) // 5 minutes + }; + + await this.state.storage.put(kickKey, kickedUsers); + + // Remove user from room + const usersKey = `users:${room}`; + const users = await this.state.storage.get(usersKey) || {}; + delete users[targetUser]; + await this.state.storage.put(usersKey, users); + + // Add system message + const messagesKey = `messages:${room}`; + const messages = await this.state.storage.get(messagesKey) || []; + + const systemMessage = { + id: `sys_kick_${Date.now()}`, + user: '*** System ***', + text: `${targetUser} was kicked by ${moderator}${reason ? ` (${reason})` : ''} - banned for 5 minutes`, + time: Date.now(), + system: true + }; + + messages.push(systemMessage); + await this.state.storage.put(messagesKey, messages); + + return jsonResponse({ + success: true, + message: `User ${targetUser} kicked for 5 minutes`, + systemMessage: systemMessage + }); + } + + async addModerator(targetUser, moderator) { + const moderators = await this.state.storage.get('moderators') || []; + + if (!moderators.includes(targetUser.toLowerCase())) { + moderators.push(targetUser.toLowerCase()); + await this.state.storage.put('moderators', moderators); + return jsonResponse({ success: true, message: `${targetUser} added as moderator` }); + } + + return textResponse('User already moderator', 400); + } + + async removeModerator(targetUser, moderator) { + const moderators = await this.state.storage.get('moderators') || []; + const index = moderators.indexOf(targetUser.toLowerCase()); + + if (index > -1) { + moderators.splice(index, 1); + await this.state.storage.put('moderators', moderators); + return jsonResponse({ success: true, message: `${targetUser} removed as moderator` }); + } + + return textResponse('User not moderator', 400); + } } \ No newline at end of file diff --git a/Worker/src/index.js b/Worker/src/index.js index 4a82810..cfb8dee 100644 --- a/Worker/src/index.js +++ b/Worker/src/index.js @@ -7,13 +7,253 @@ export default { const url = new URL(request.url); const pathname = url.pathname; - const match = pathname.match(/^\/chat\/([\w-]+)$/); - if (!match) return new Response("Not found", { status: 404 }); + // Handle CORS preflight + if (request.method === 'OPTIONS') { + return new Response('', { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Auth-Token, X-Auth-User' + } + }); + } - const roomId = match[1]; - const id = env.CHAT_ROOM.idFromName(roomId); - const stub = env.CHAT_ROOM.get(id); + // Chat room endpoints + const roomMatch = pathname.match(/^\/chat\/([\w-]+)$/); + if (roomMatch) { + const roomId = roomMatch[1]; + const id = env.CHAT_ROOM.idFromName(roomId); + const stub = env.CHAT_ROOM.get(id); + return stub.fetch(request); + } - return stub.fetch(request); + // Private message endpoints + const pmMatch = pathname.match(/^\/pm\/([\w-]+)$/); + if (pmMatch) { + const conversationId = pmMatch[1]; + const id = env.CHAT_ROOM.idFromName(`pm_${conversationId}`); + const stub = env.CHAT_ROOM.get(id); + return stub.fetch(request); + } + + // File upload endpoint + if (pathname === '/upload') { + return handleFileUpload(request, env); + } + + // File serving endpoint + const fileMatch = pathname.match(/^\/files\/([\w.-]+)$/); + if (fileMatch && env.FILE_BUCKET) { + const filename = fileMatch[1]; + const object = await env.FILE_BUCKET.get(filename); + + if (!object) { + return new Response('File not found', { status: 404 }); + } + + // Sanitize filename for Content-Disposition header + const sanitizeFilename = (name) => { + // Remove path characters and unsafe characters + const sanitized = name.replace(/[\/\\:*?"<>|]/g, '_').replace(/\s+/g, '_'); + return sanitized || 'download'; + }; + + // Use original filename from metadata or fallback to sanitized version + const originalName = object.httpMetadata?.contentDisposition?.match(/filename="([^"]+)"/) + ? object.httpMetadata.contentDisposition.match(/filename="([^"]+)"/)[1] + : filename; + + const safeFilename = sanitizeFilename(originalName); + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', + 'Content-Disposition': `inline; filename="${safeFilename}"`, + 'X-Content-Type-Options': 'nosniff', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=31536000' // 1 year cache + } + }); + } + + // Moderation endpoints + const modMatch = pathname.match(/^\/mod\/([\w-]+)$/); + if (modMatch) { + const roomId = modMatch[1]; + const id = env.CHAT_ROOM.idFromName(roomId); + const stub = env.CHAT_ROOM.get(id); + return stub.fetch(request); + } + + // Health check endpoint + if (pathname === '/health') { + return new Response(JSON.stringify({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '2.0.0' + }), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + return new Response("Not found", { + status: 404, + headers: { + 'Access-Control-Allow-Origin': '*' + } + }); } }; + +// File upload handler +async function handleFileUpload(request, env) { + if (request.method !== 'POST') { + return new Response('Method not allowed', { + status: 405, + headers: { 'Access-Control-Allow-Origin': '*' } + }); + } + + try { + const formData = await request.formData(); + const file = formData.get('file'); + const user = formData.get('user'); + const room = formData.get('room'); + + if (!file || !user || !room) { + return new Response(JSON.stringify({ + error: 'Missing required fields: file, user, room' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // Check file size (5MB limit) + if (file.size > 5 * 1024 * 1024) { + return new Response(JSON.stringify({ + error: 'File too large. Maximum size is 5MB.' + }), { + status: 413, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // Validate file type + const allowedTypes = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'audio/mpeg', 'audio/wav', 'audio/ogg', + 'application/pdf', 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + + if (!allowedTypes.includes(file.type)) { + return new Response(JSON.stringify({ + error: `File type ${file.type} is not allowed` + }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // Generate unique filename with sanitized user + const timestamp = Date.now(); + const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_'); + + // Sanitize username to prevent path traversal and injection + let sanitizedUser = user.replace(/[^a-zA-Z0-9.-]/g, '_').replace(/_+/g, '_'); + if (!sanitizedUser || sanitizedUser === '_') { + sanitizedUser = 'unknown'; + } + + const filename = `${timestamp}_${sanitizedUser}_${sanitizedName}`; + + // Store file in R2 bucket (if available) or convert to base64 + let fileUrl; + let storedSuccessfully = false; + + if (env.FILE_BUCKET) { + try { + await env.FILE_BUCKET.put(filename, file.stream(), { + httpMetadata: { + contentType: file.type + }, + customMetadata: { + uploadedBy: user, + uploadedAt: new Date().toISOString(), + originalName: file.name + } + }); + fileUrl = `/files/${filename}`; + storedSuccessfully = true; + } catch (error) { + console.error('R2 upload failed:', error); + } + } + + if (!storedSuccessfully) { + // Check file size before base64 conversion to prevent memory/payload bloat + const maxBase64Size = 1024 * 1024; // 1MB threshold + if (file.size > maxBase64Size) { + return new Response(JSON.stringify({ + success: false, + error: `File too large for fallback storage (${Math.round(file.size / 1024 / 1024)}MB). Please contact admin to enable R2 bucket or use smaller files.`, + maxSizeSupported: `${maxBase64Size / 1024 / 1024}MB` + }), { + status: 413, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // Fallback: convert to base64 data URL (small files only) + const arrayBuffer = await file.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + fileUrl = `data:${file.type};base64,${base64}`; + } + + return new Response(JSON.stringify({ + success: true, + filename: filename, + url: fileUrl, + size: file.size, + type: file.type, + originalName: file.name, + uploadedBy: user, + uploadedAt: new Date().toISOString() + }), { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + console.error('Upload error:', error); + return new Response(JSON.stringify({ + error: 'Upload failed: ' + error.message + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } +} \ No newline at end of file diff --git a/Worker/wrangler.toml b/Worker/wrangler.toml index 205af56..25d42f0 100644 --- a/Worker/wrangler.toml +++ b/Worker/wrangler.toml @@ -11,5 +11,4 @@ class_name = "ChatRoom" [[migrations]] tag = "v1" -new_sqlite_classes = ["ChatRoom"] - +new_sqlite_classes = ["ChatRoom"] \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index ed27a0b..0000000 --- a/index.html +++ /dev/null @@ -1,753 +0,0 @@ - - - - - - HTMLChat - - - - - - - - - - - - -
-
-

HTMLChat

-

version v0.1.0

- -
- -
- - - Users online: 1 -
- -
- -
-
-
-
-
- - -
-
-
- -
-
Users
-
-
-
- -
-
-
- Connected -
-
- -
-
-
- - - - - - - - - -