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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Notifications
+
+
+
Show desktop notifications when you receive messages
+
+
+
+
+
Play sounds with notifications
+
+
+
+
+
Enable or disable all notifications
+
+
+
+
+ Browser Permission:
+ Unknown
+
+
+
+
+
+
+
Sounds
+
+
+
Play sounds when messages are received or sent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drag & drop files here or click to browse
+
Max 5MB • Images, documents, audio
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enable notifications to get alerts when you receive messages
+
+
+
+
+
+
+
+
+
+
+
+ Users online: 1
+
+
+
+
+
+
+
+ Replying to:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 += `
+
+
+
+ `;
+ } 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Users online: 1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-