Compare commits
9 Commits
e55881b38b
...
4f21c89103
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f21c89103 | ||
|
|
8140fddc8b | ||
|
|
13cdb605ca | ||
|
|
45ab939b7f | ||
|
|
23035b6ab4 | ||
|
|
3f267e3b13 | ||
|
|
be99a02e78 | ||
|
|
a8d79cf138 | ||
|
|
d1ce5d0951 |
336
package-lock.json
generated
336
package-lock.json
generated
@@ -11,11 +11,16 @@
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.94.0",
|
||||
"@tanstack/svelte-virtual": "^3.13.18",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
"google-auth-library": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1",
|
||||
"matrix-js-sdk": "^40.2.0-rc.0",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
@@ -26,6 +31,8 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/twemoji": "^13.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.58.0",
|
||||
"svelte": "^5.48.2",
|
||||
@@ -37,6 +44,15 @@
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
@@ -546,7 +562,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -557,7 +572,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -568,7 +582,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -578,14 +591,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -632,6 +643,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-17.1.0.tgz",
|
||||
"integrity": "sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -1251,7 +1271,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
@@ -1655,6 +1674,32 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/svelte-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-BHh8WkFK58eE9KzLctPQkCkvCj46LnM9tIGkpwo5Unx5YaBPf0uBJBqvSdc2jMwdT8gLXLHFHtCnSujlZP69BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/svelte-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz",
|
||||
@@ -2087,7 +2132,12 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/events": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
@@ -2106,6 +2156,13 @@
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/marked": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
|
||||
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
@@ -2134,6 +2191,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/twemoji": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/twemoji/-/twemoji-13.1.1.tgz",
|
||||
"integrity": "sha512-0qnUqLhaSSGsvLXiwWnmcuOza9oGnGwXpxXauB6rEHsU30Dfmvizzwx2TzwUjlsY4ox+39tdG89CpJ/i3J/Cvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"twemoji": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -2316,7 +2383,6 @@
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
@@ -2335,6 +2401,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/another-json": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
||||
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
@@ -2369,7 +2441,6 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2396,7 +2467,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2408,6 +2478,12 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -2446,6 +2522,15 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -2482,7 +2567,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -2548,6 +2632,15 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -2663,7 +2756,6 @@
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
@@ -2778,7 +2870,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
@@ -2799,7 +2890,6 @@
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
|
||||
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
@@ -2812,6 +2902,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -2897,6 +2996,29 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
@@ -3003,7 +3125,6 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
@@ -3032,6 +3153,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -3096,6 +3226,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@@ -3166,6 +3308,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
|
||||
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^0.1.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -3187,6 +3341,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -3488,9 +3651,21 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@@ -3501,7 +3676,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -3524,6 +3698,59 @@
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-events-sdk": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
||||
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "40.2.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-40.2.0-rc.0.tgz",
|
||||
"integrity": "sha512-0c3rm+poCraYmxmQ/9QnfRiZEikriarHZCt1ukQl+xKny2tYLEFcFkASdE/ce6QCMPIwMZLJOVyOw+LvS2Xqtw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"p-retry": "7",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
|
||||
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
"events": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
@@ -3658,12 +3885,39 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oidc-client-ts": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
|
||||
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-network-error": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -4170,6 +4424,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
|
||||
@@ -4381,7 +4644,6 @@
|
||||
"version": "5.49.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
|
||||
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -4433,7 +4695,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -4521,6 +4782,24 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/twemoji": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
|
||||
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fs-extra": "^8.0.1",
|
||||
"jsonfile": "^5.0.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"universalify": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/twemoji-parser": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
|
||||
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -4548,6 +4827,21 @@
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unhomoglyph": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
@@ -4582,7 +4876,6 @@
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -4969,7 +5262,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/twemoji": "^13.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.58.0",
|
||||
"svelte": "^5.48.2",
|
||||
@@ -36,10 +38,15 @@
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.94.0",
|
||||
"@tanstack/svelte-virtual": "^3.13.18",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
"google-auth-library": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1",
|
||||
"matrix-js-sdk": "^40.2.0-rc.0",
|
||||
"twemoji": "^14.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
663
src/lib/cache/index.ts
vendored
Normal file
663
src/lib/cache/index.ts
vendored
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* IndexedDB-based cache for Matrix client data
|
||||
* Stores messages, room state, and media blobs for offline access and faster loading
|
||||
*/
|
||||
|
||||
import type { Message, RoomSummary } from '$lib/matrix/types';
|
||||
|
||||
const DB_NAME = 'matrix-cache';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
// Store names
|
||||
const STORES = {
|
||||
MESSAGES: 'messages',
|
||||
ROOMS: 'rooms',
|
||||
MEDIA: 'media',
|
||||
SYNC_STATE: 'syncState',
|
||||
AVATARS: 'avatars',
|
||||
} as const;
|
||||
|
||||
interface CachedMessage extends Message {
|
||||
roomId: string;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface CachedRoom {
|
||||
roomId: string;
|
||||
summary: RoomSummary;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface CachedMedia {
|
||||
url: string;
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
cachedAt: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CachedAvatar {
|
||||
mxcUrl: string;
|
||||
httpUrl: string;
|
||||
blob: Blob;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface SyncStateCache {
|
||||
key: string;
|
||||
syncToken: string | null;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the IndexedDB database
|
||||
*/
|
||||
export async function initCache(): Promise<void> {
|
||||
if (db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Messages store with room index
|
||||
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||
const messageStore = database.createObjectStore(STORES.MESSAGES, {
|
||||
keyPath: 'eventId',
|
||||
});
|
||||
messageStore.createIndex('roomId', 'roomId', { unique: false });
|
||||
messageStore.createIndex('roomId_timestamp', ['roomId', 'timestamp'], { unique: false });
|
||||
}
|
||||
|
||||
// Rooms store
|
||||
if (!database.objectStoreNames.contains(STORES.ROOMS)) {
|
||||
database.createObjectStore(STORES.ROOMS, { keyPath: 'roomId' });
|
||||
}
|
||||
|
||||
// Media blob cache
|
||||
if (!database.objectStoreNames.contains(STORES.MEDIA)) {
|
||||
const mediaStore = database.createObjectStore(STORES.MEDIA, { keyPath: 'url' });
|
||||
mediaStore.createIndex('cachedAt', 'cachedAt', { unique: false });
|
||||
}
|
||||
|
||||
// Avatar cache
|
||||
if (!database.objectStoreNames.contains(STORES.AVATARS)) {
|
||||
const avatarStore = database.createObjectStore(STORES.AVATARS, { keyPath: 'mxcUrl' });
|
||||
avatarStore.createIndex('cachedAt', 'cachedAt', { unique: false });
|
||||
}
|
||||
|
||||
// Sync state cache
|
||||
if (!database.objectStoreNames.contains(STORES.SYNC_STATE)) {
|
||||
database.createObjectStore(STORES.SYNC_STATE, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a transaction for the specified stores
|
||||
*/
|
||||
function getTransaction(storeNames: string | string[], mode: IDBTransactionMode = 'readonly'): IDBTransaction {
|
||||
if (!db) throw new Error('Cache not initialized');
|
||||
return db.transaction(storeNames, mode);
|
||||
}
|
||||
|
||||
// ============ MESSAGE CACHE ============
|
||||
|
||||
/**
|
||||
* Convert a nested Map to a serializable object for IndexedDB storage
|
||||
* reactions: Map<emoji, Map<userId, eventId>> -> { emoji: { userId: eventId } }
|
||||
*/
|
||||
function serializeReactions(reactions: Map<string, Map<string, string>>): Record<string, Record<string, string>> {
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
for (const [emoji, userMap] of reactions.entries()) {
|
||||
result[emoji] = {};
|
||||
for (const [userId, eventId] of userMap.entries()) {
|
||||
result[emoji][userId] = eventId;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a serialized reactions object back to nested Map
|
||||
*/
|
||||
function deserializeReactions(obj: Record<string, Record<string, string>> | undefined): Map<string, Map<string, string>> {
|
||||
const result = new Map<string, Map<string, string>>();
|
||||
if (!obj || typeof obj !== 'object') return result;
|
||||
|
||||
for (const [emoji, userObj] of Object.entries(obj)) {
|
||||
const userMap = new Map<string, string>();
|
||||
if (userObj && typeof userObj === 'object') {
|
||||
for (const [userId, eventId] of Object.entries(userObj)) {
|
||||
userMap.set(userId, eventId);
|
||||
}
|
||||
}
|
||||
result.set(emoji, userMap);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache messages for a room
|
||||
*/
|
||||
export async function cacheMessages(roomId: string, messages: Message[]): Promise<void> {
|
||||
if (!db || messages.length === 0) return;
|
||||
|
||||
const tx = getTransaction(STORES.MESSAGES, 'readwrite');
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
const now = Date.now();
|
||||
|
||||
for (const message of messages) {
|
||||
// Serialize reactions Map to plain object for IndexedDB storage
|
||||
const cached = {
|
||||
...message,
|
||||
reactions: serializeReactions(message.reactions),
|
||||
roomId,
|
||||
cachedAt: now,
|
||||
};
|
||||
store.put(cached);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached messages for a room
|
||||
*/
|
||||
export async function getCachedMessages(roomId: string, limit = 500): Promise<Message[]> {
|
||||
if (!db) return [];
|
||||
|
||||
const tx = getTransaction(STORES.MESSAGES, 'readonly');
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
const index = store.index('roomId');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.getAll(IDBKeyRange.only(roomId));
|
||||
request.onsuccess = () => {
|
||||
const cachedMessages = request.result as CachedMessage[];
|
||||
const messages = cachedMessages
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-limit)
|
||||
.map(cached => ({
|
||||
...cached,
|
||||
// Deserialize reactions from plain object back to nested Map
|
||||
// IndexedDB stores Maps as plain objects, so we need to restore them
|
||||
reactions: deserializeReactions(cached.reactions as unknown as Record<string, Record<string, string>>),
|
||||
}));
|
||||
resolve(messages);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest cached message timestamp for a room
|
||||
*/
|
||||
export async function getLatestMessageTimestamp(roomId: string): Promise<number | null> {
|
||||
if (!db) return null;
|
||||
|
||||
const messages = await getCachedMessages(roomId, 1);
|
||||
return messages.length > 0 ? messages[messages.length - 1].timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached messages for a room
|
||||
*/
|
||||
export async function clearRoomMessages(roomId: string): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.MESSAGES, 'readwrite');
|
||||
const store = tx.objectStore(STORES.MESSAGES);
|
||||
const index = store.index('roomId');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.openKeyCursor(IDBKeyRange.only(roomId));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
store.delete(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ ROOM CACHE ============
|
||||
|
||||
// Track last cached state for diff-based updates
|
||||
let _lastCachedRoomHashes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Simple hash function for detecting changes
|
||||
*/
|
||||
function hashRoomSummary(room: RoomSummary): number {
|
||||
// Hash based on mutable fields that indicate a meaningful change
|
||||
const str = `${room.name}|${room.lastActivity}|${room.unreadCount}|${room.memberCount}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache room summaries (full replacement)
|
||||
*/
|
||||
export async function cacheRooms(rooms: RoomSummary[]): Promise<void> {
|
||||
if (!db || rooms.length === 0) return;
|
||||
|
||||
const tx = getTransaction(STORES.ROOMS, 'readwrite');
|
||||
const store = tx.objectStore(STORES.ROOMS);
|
||||
const now = Date.now();
|
||||
|
||||
// Update hash cache
|
||||
_lastCachedRoomHashes.clear();
|
||||
for (const room of rooms) {
|
||||
const cached: CachedRoom = {
|
||||
roomId: room.roomId,
|
||||
summary: room,
|
||||
cachedAt: now,
|
||||
};
|
||||
store.put(cached);
|
||||
_lastCachedRoomHashes.set(room.roomId, hashRoomSummary(room));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff-based room cache update - only writes changed rooms
|
||||
* Returns number of rooms actually written
|
||||
*/
|
||||
export async function cacheRoomsDiff(rooms: RoomSummary[]): Promise<number> {
|
||||
if (!db || rooms.length === 0) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
const changedRooms: RoomSummary[] = [];
|
||||
const newHashes = new Map<string, number>();
|
||||
|
||||
// Detect changes using hash comparison
|
||||
for (const room of rooms) {
|
||||
const newHash = hashRoomSummary(room);
|
||||
newHashes.set(room.roomId, newHash);
|
||||
|
||||
const oldHash = _lastCachedRoomHashes.get(room.roomId);
|
||||
if (oldHash !== newHash) {
|
||||
changedRooms.push(room);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect removed rooms
|
||||
const currentRoomIds = new Set(rooms.map(r => r.roomId));
|
||||
const removedRoomIds: string[] = [];
|
||||
for (const roomId of _lastCachedRoomHashes.keys()) {
|
||||
if (!currentRoomIds.has(roomId)) {
|
||||
removedRoomIds.push(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no changes
|
||||
if (changedRooms.length === 0 && removedRoomIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tx = getTransaction(STORES.ROOMS, 'readwrite');
|
||||
const store = tx.objectStore(STORES.ROOMS);
|
||||
|
||||
// Write changed rooms
|
||||
for (const room of changedRooms) {
|
||||
const cached: CachedRoom = {
|
||||
roomId: room.roomId,
|
||||
summary: room,
|
||||
cachedAt: now,
|
||||
};
|
||||
store.put(cached);
|
||||
}
|
||||
|
||||
// Remove deleted rooms
|
||||
for (const roomId of removedRoomIds) {
|
||||
store.delete(roomId);
|
||||
}
|
||||
|
||||
// Update hash cache
|
||||
_lastCachedRoomHashes = newHashes;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve(changedRooms.length + removedRoomIds.length);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a single room (for incremental updates)
|
||||
*/
|
||||
export async function cacheRoom(room: RoomSummary): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.ROOMS, 'readwrite');
|
||||
const store = tx.objectStore(STORES.ROOMS);
|
||||
|
||||
const cached: CachedRoom = {
|
||||
roomId: room.roomId,
|
||||
summary: room,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
store.put(cached);
|
||||
_lastCachedRoomHashes.set(room.roomId, hashRoomSummary(room));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single room from cache
|
||||
*/
|
||||
export async function uncacheRoom(roomId: string): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.ROOMS, 'readwrite');
|
||||
tx.objectStore(STORES.ROOMS).delete(roomId);
|
||||
_lastCachedRoomHashes.delete(roomId);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached rooms
|
||||
*/
|
||||
export async function getCachedRooms(): Promise<RoomSummary[]> {
|
||||
if (!db) return [];
|
||||
|
||||
const tx = getTransaction(STORES.ROOMS, 'readonly');
|
||||
const store = tx.objectStore(STORES.ROOMS);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rooms = (request.result as CachedRoom[]).map((r) => r.summary);
|
||||
resolve(rooms);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ MEDIA CACHE ============
|
||||
|
||||
/**
|
||||
* Cache a media blob
|
||||
*/
|
||||
export async function cacheMedia(url: string, blob: Blob): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.MEDIA, 'readwrite');
|
||||
const store = tx.objectStore(STORES.MEDIA);
|
||||
|
||||
const cached: CachedMedia = {
|
||||
url,
|
||||
blob,
|
||||
mimeType: blob.type,
|
||||
cachedAt: Date.now(),
|
||||
size: blob.size,
|
||||
};
|
||||
|
||||
store.put(cached);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached media blob
|
||||
*/
|
||||
export async function getCachedMedia(url: string): Promise<Blob | null> {
|
||||
if (!db) return null;
|
||||
|
||||
const tx = getTransaction(STORES.MEDIA, 'readonly');
|
||||
const store = tx.objectStore(STORES.MEDIA);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(url);
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedMedia | undefined;
|
||||
resolve(cached?.blob ?? null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ AVATAR CACHE ============
|
||||
|
||||
/**
|
||||
* Cache an avatar blob
|
||||
*/
|
||||
export async function cacheAvatar(mxcUrl: string, httpUrl: string, blob: Blob): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.AVATARS, 'readwrite');
|
||||
const store = tx.objectStore(STORES.AVATARS);
|
||||
|
||||
const cached: CachedAvatar = {
|
||||
mxcUrl,
|
||||
httpUrl,
|
||||
blob,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
store.put(cached);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached avatar
|
||||
*/
|
||||
export async function getCachedAvatar(mxcUrl: string): Promise<{ httpUrl: string; blobUrl: string } | null> {
|
||||
if (!db) return null;
|
||||
|
||||
const tx = getTransaction(STORES.AVATARS, 'readonly');
|
||||
const store = tx.objectStore(STORES.AVATARS);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(mxcUrl);
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedAvatar | undefined;
|
||||
if (cached) {
|
||||
const blobUrl = URL.createObjectURL(cached.blob);
|
||||
resolve({ httpUrl: cached.httpUrl, blobUrl });
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ SYNC STATE CACHE ============
|
||||
|
||||
/**
|
||||
* Cache the sync token for resuming sync
|
||||
*/
|
||||
export async function cacheSyncToken(userId: string, token: string | null): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const tx = getTransaction(STORES.SYNC_STATE, 'readwrite');
|
||||
const store = tx.objectStore(STORES.SYNC_STATE);
|
||||
|
||||
const cached: SyncStateCache = {
|
||||
key: userId,
|
||||
syncToken: token,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
store.put(cached);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached sync token
|
||||
*/
|
||||
export async function getCachedSyncToken(userId: string): Promise<string | null> {
|
||||
if (!db) return null;
|
||||
|
||||
const tx = getTransaction(STORES.SYNC_STATE, 'readonly');
|
||||
const store = tx.objectStore(STORES.SYNC_STATE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(userId);
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as SyncStateCache | undefined;
|
||||
resolve(cached?.syncToken ?? null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ CACHE MANAGEMENT ============
|
||||
|
||||
/**
|
||||
* Get total cache size in bytes
|
||||
*/
|
||||
export async function getCacheSize(): Promise<number> {
|
||||
if (!db) return 0;
|
||||
|
||||
let totalSize = 0;
|
||||
|
||||
// Media size
|
||||
const mediaTx = getTransaction(STORES.MEDIA, 'readonly');
|
||||
const mediaStore = mediaTx.objectStore(STORES.MEDIA);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = mediaStore.openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
totalSize += (cursor.value as CachedMedia).size;
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries
|
||||
*/
|
||||
export async function cleanupCache(maxAgeMs = 7 * 24 * 60 * 60 * 1000): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
|
||||
// Clean old media
|
||||
const mediaTx = getTransaction(STORES.MEDIA, 'readwrite');
|
||||
const mediaStore = mediaTx.objectStore(STORES.MEDIA);
|
||||
const mediaIndex = mediaStore.index('cachedAt');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = mediaIndex.openCursor(IDBKeyRange.upperBound(cutoff));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
mediaTx.oncomplete = () => resolve();
|
||||
mediaTx.onerror = () => reject(mediaTx.error);
|
||||
});
|
||||
|
||||
// Clean old avatars
|
||||
const avatarTx = getTransaction(STORES.AVATARS, 'readwrite');
|
||||
const avatarStore = avatarTx.objectStore(STORES.AVATARS);
|
||||
const avatarIndex = avatarStore.index('cachedAt');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = avatarIndex.openCursor(IDBKeyRange.upperBound(cutoff));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
avatarTx.oncomplete = () => resolve();
|
||||
avatarTx.onerror = () => reject(avatarTx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache data
|
||||
*/
|
||||
export async function clearAllCache(): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
const storeNames = Object.values(STORES);
|
||||
const tx = getTransaction(storeNames, 'readwrite');
|
||||
|
||||
for (const storeName of storeNames) {
|
||||
tx.objectStore(storeName).clear();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific user (on logout)
|
||||
*/
|
||||
export async function clearUserCache(userId: string): Promise<void> {
|
||||
if (!db) return;
|
||||
|
||||
// Clear sync state for user
|
||||
const syncTx = getTransaction(STORES.SYNC_STATE, 'readwrite');
|
||||
syncTx.objectStore(STORES.SYNC_STATE).delete(userId);
|
||||
|
||||
// Clear all messages, rooms, media, avatars (full reset)
|
||||
await clearAllCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is available
|
||||
*/
|
||||
export function isCacheAvailable(): boolean {
|
||||
return db !== null;
|
||||
}
|
||||
195
src/lib/cache/mediaCache.ts
vendored
Normal file
195
src/lib/cache/mediaCache.ts
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Media caching utilities
|
||||
* Provides cached access to avatars and media with blob storage
|
||||
*/
|
||||
|
||||
import { getCachedMedia, cacheMedia, getCachedAvatar, cacheAvatar, isCacheAvailable } from './index';
|
||||
|
||||
// In-memory cache for blob URLs to avoid creating duplicates
|
||||
const blobUrlCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Fetch media with caching support
|
||||
* Returns a blob URL that can be used directly in img/video/audio elements
|
||||
*/
|
||||
export async function fetchMediaCached(url: string): Promise<string> {
|
||||
// Check in-memory cache first
|
||||
const memCached = blobUrlCache.get(url);
|
||||
if (memCached) return memCached;
|
||||
|
||||
// Check IndexedDB cache
|
||||
if (isCacheAvailable()) {
|
||||
const cachedBlob = await getCachedMedia(url);
|
||||
if (cachedBlob) {
|
||||
const blobUrl = URL.createObjectURL(cachedBlob);
|
||||
blobUrlCache.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Cache in IndexedDB
|
||||
if (isCacheAvailable()) {
|
||||
cacheMedia(url, blob).catch(() => { });
|
||||
}
|
||||
|
||||
// Create and cache blob URL
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(url, blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
} catch {
|
||||
// Return original URL as fallback
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch avatar with caching support
|
||||
* Handles mxc:// URLs with authenticated requests
|
||||
*/
|
||||
export async function fetchAvatarCached(
|
||||
mxcUrl: string | null,
|
||||
homeserverUrl: string,
|
||||
size = 40
|
||||
): Promise<string | null> {
|
||||
if (!mxcUrl) return null;
|
||||
|
||||
// Check in-memory cache first (fastest)
|
||||
const memCached = blobUrlCache.get(mxcUrl);
|
||||
if (memCached) return memCached;
|
||||
|
||||
// Check IndexedDB cache
|
||||
if (isCacheAvailable()) {
|
||||
const cached = await getCachedAvatar(mxcUrl);
|
||||
if (cached) {
|
||||
blobUrlCache.set(mxcUrl, cached.blobUrl);
|
||||
return cached.blobUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Get auth token for authenticated fetch
|
||||
let accessToken: string | null = null;
|
||||
try {
|
||||
const creds = localStorage.getItem('matrix_credentials');
|
||||
if (creds) {
|
||||
accessToken = JSON.parse(creds).accessToken;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// Convert mxc:// to authenticated HTTP URL
|
||||
const httpUrl = mxcToHttpAuth(mxcUrl, homeserverUrl, size);
|
||||
if (!httpUrl) return null;
|
||||
|
||||
// Fetch from network with auth
|
||||
try {
|
||||
const headers: HeadersInit = {};
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(httpUrl, { headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Cache in IndexedDB
|
||||
if (isCacheAvailable()) {
|
||||
cacheAvatar(mxcUrl, httpUrl, blob).catch(() => { });
|
||||
}
|
||||
|
||||
// Create and cache blob URL
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(mxcUrl, blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert mxc:// URL to authenticated HTTP thumbnail URL
|
||||
* Uses /_matrix/client/v1/media/ which requires auth but is the modern standard
|
||||
*/
|
||||
function mxcToHttpAuth(mxcUrl: string, homeserverUrl: string, size: number): string | null {
|
||||
if (!mxcUrl.startsWith('mxc://')) return null;
|
||||
|
||||
const parts = mxcUrl.slice(6).split('/');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const [serverName, mediaId] = parts;
|
||||
|
||||
// Use authenticated thumbnail endpoint
|
||||
if (size <= 96) {
|
||||
return `${homeserverUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
|
||||
}
|
||||
|
||||
return `${homeserverUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert mxc:// URL to HTTP thumbnail URL (legacy, unauthenticated)
|
||||
*/
|
||||
function mxcToHttp(mxcUrl: string, homeserverUrl: string, size: number): string | null {
|
||||
if (!mxcUrl.startsWith('mxc://')) return null;
|
||||
|
||||
const parts = mxcUrl.slice(6).split('/');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const [serverName, mediaId] = parts;
|
||||
|
||||
// Use thumbnail endpoint for smaller sizes
|
||||
if (size <= 96) {
|
||||
return `${homeserverUrl}/_matrix/media/v3/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
|
||||
}
|
||||
|
||||
// Use download endpoint for larger sizes
|
||||
return `${homeserverUrl}/_matrix/media/v3/download/${serverName}/${mediaId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload avatars for a list of users
|
||||
* Call this when loading a room to cache avatars in advance
|
||||
*/
|
||||
export async function preloadAvatars(
|
||||
avatarUrls: (string | null)[],
|
||||
homeserverUrl: string
|
||||
): Promise<void> {
|
||||
const uniqueUrls = [...new Set(avatarUrls.filter(Boolean))] as string[];
|
||||
|
||||
// Preload in parallel, but limit concurrency
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < uniqueUrls.length; i += batchSize) {
|
||||
const batch = uniqueUrls.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(url => fetchAvatarCached(url, homeserverUrl).catch(() => null))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear blob URL cache (call on logout)
|
||||
*/
|
||||
export function clearBlobUrlCache(): void {
|
||||
for (const blobUrl of blobUrlCache.values()) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
blobUrlCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export function getBlobCacheStats(): { count: number; urls: string[] } {
|
||||
return {
|
||||
count: blobUrlCache.size,
|
||||
urls: [...blobUrlCache.keys()],
|
||||
};
|
||||
}
|
||||
300
src/lib/components/chat-layout/ChatArea.svelte
Normal file
300
src/lib/components/chat-layout/ChatArea.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import {
|
||||
MessageList,
|
||||
MessageInput,
|
||||
TypingIndicator,
|
||||
MemberList,
|
||||
RoomInfoPanel,
|
||||
} from "$lib/components/matrix";
|
||||
import type { Message, RoomSummary, RoomMember } from "$lib/matrix/types";
|
||||
|
||||
interface Props {
|
||||
room: RoomSummary | null;
|
||||
messages: Message[];
|
||||
typingUsers: string[];
|
||||
members: RoomMember[];
|
||||
roomId: string;
|
||||
replyToMessage: Message | null;
|
||||
editingMessage: Message | null;
|
||||
isLoadingMore: boolean;
|
||||
onReact: (messageId: string, emoji: string) => void;
|
||||
onEdit: (message: Message) => void;
|
||||
onDelete: (messageId: string) => void;
|
||||
onReply: (message: Message) => void;
|
||||
onCancelReply: () => void;
|
||||
onSaveEdit: (content: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onLoadMore: () => void;
|
||||
onDragOver: (e: DragEvent) => void;
|
||||
onDragLeave: (e: DragEvent) => void;
|
||||
onDrop: (e: DragEvent) => void;
|
||||
isDraggingFile: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
room,
|
||||
messages,
|
||||
typingUsers,
|
||||
members,
|
||||
roomId,
|
||||
replyToMessage,
|
||||
editingMessage,
|
||||
isLoadingMore,
|
||||
onReact,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onLoadMore,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
isDraggingFile,
|
||||
}: Props = $props();
|
||||
|
||||
let showMessageSearch = $state(false);
|
||||
let messageSearchQuery = $state("");
|
||||
let showRoomInfo = $state(false);
|
||||
let showMemberList = $state(false);
|
||||
|
||||
// Simple local search (could be moved to a prop if needed)
|
||||
const messageSearchResults = $derived(
|
||||
messageSearchQuery.trim()
|
||||
? messages.filter((m) =>
|
||||
m.content.toLowerCase().includes(messageSearchQuery.toLowerCase()),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Room Header -->
|
||||
{#if room}
|
||||
<header
|
||||
class="h-16 px-6 flex items-center border-b border-light/10 bg-dark/50"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="md" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-light">{room.name}</h2>
|
||||
{#if room.isEncrypted}
|
||||
<span class="text-green-400" title="End-to-end encrypted">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-light/50">
|
||||
{room.memberCount}
|
||||
{room.memberCount === 1 ? "member" : "members"}{room.isEncrypted
|
||||
? " • Encrypted"
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search button -->
|
||||
<button
|
||||
class="ml-auto w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showMessageSearch = !showMessageSearch)}
|
||||
title="Search messages"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Room info toggle button -->
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showRoomInfo = !showRoomInfo)}
|
||||
title="Room info"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Member list toggle button -->
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showMemberList = !showMemberList)}
|
||||
title="Toggle member list"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Message search panel -->
|
||||
{#if showMessageSearch}
|
||||
<div class="border-b border-light/10 p-3 bg-dark/50">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={messageSearchQuery}
|
||||
placeholder="Search messages in this room..."
|
||||
class="w-full pl-9 pr-8 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
|
||||
onclick={() => {
|
||||
showMessageSearch = false;
|
||||
messageSearchQuery = "";
|
||||
}}
|
||||
title="Close search"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if messageSearchQuery && messageSearchResults.length > 0}
|
||||
<div class="mt-2 max-h-48 overflow-y-auto">
|
||||
<p class="text-xs text-light/40 mb-2">
|
||||
{messageSearchResults.length} result{messageSearchResults.length !==
|
||||
1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
{#each messageSearchResults.slice(0, 20) as result}
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 hover:bg-light/5 rounded transition-colors"
|
||||
onclick={() => {
|
||||
showMessageSearch = false;
|
||||
messageSearchQuery = "";
|
||||
}}
|
||||
>
|
||||
<p class="text-xs text-primary">{result.senderName}</p>
|
||||
<p class="text-sm text-light truncate">{result.content}</p>
|
||||
<p class="text-xs text-light/30">
|
||||
{new Date(result.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if messageSearchQuery}
|
||||
<p class="text-sm text-light/40 mt-2">No results found</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content area with optional member panel -->
|
||||
<div
|
||||
class="flex-1 flex min-h-0 overflow-hidden relative"
|
||||
ondragover={onDragOver}
|
||||
ondragleave={onDragLeave}
|
||||
ondrop={onDrop}
|
||||
role="region"
|
||||
>
|
||||
<!-- Drag overlay -->
|
||||
{#if isDraggingFile}
|
||||
<div
|
||||
class="absolute inset-0 z-50 bg-primary/20 border-2 border-dashed border-primary rounded-lg flex items-center justify-center backdrop-blur-sm"
|
||||
>
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p class="text-xl font-semibold text-primary">Drop to upload</p>
|
||||
<p class="text-sm text-light/60 mt-1">Release to send file</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Messages column -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<MessageList
|
||||
{messages}
|
||||
onReact={(msgId, emoji) => onReact(msgId, emoji)}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onReply}
|
||||
{onLoadMore}
|
||||
isLoading={isLoadingMore}
|
||||
/>
|
||||
<TypingIndicator userNames={typingUsers} />
|
||||
<MessageInput
|
||||
{roomId}
|
||||
replyTo={replyToMessage}
|
||||
{onCancelReply}
|
||||
{editingMessage}
|
||||
{onSaveEdit}
|
||||
{onCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Side panels -->
|
||||
{#if showRoomInfo && room}
|
||||
<aside class="w-72 border-l border-light/10 bg-dark/30">
|
||||
<RoomInfoPanel
|
||||
{room}
|
||||
{members}
|
||||
onClose={() => (showRoomInfo = false)}
|
||||
/>
|
||||
</aside>
|
||||
{:else if showMemberList}
|
||||
<aside class="w-64 border-l border-light/10 bg-dark/30">
|
||||
<MemberList {members} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
340
src/lib/components/chat-layout/Sidebar.svelte
Normal file
340
src/lib/components/chat-layout/Sidebar.svelte
Normal file
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import type { RoomSummary } from "$lib/matrix/types";
|
||||
import type { AuthState } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
rooms: RoomSummary[];
|
||||
selectedRoomId: string | null;
|
||||
syncState: string;
|
||||
auth: AuthState;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
onCreateRoom: () => void;
|
||||
onCreateSpace: () => void;
|
||||
onStartDM: () => void;
|
||||
onLogout: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
syncState,
|
||||
auth,
|
||||
onRoomSelect,
|
||||
onCreateRoom,
|
||||
onCreateSpace,
|
||||
onStartDM,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state("");
|
||||
let expandedSpaces = $state<Set<string>>(new Set());
|
||||
|
||||
// Filter rooms based on search
|
||||
const filteredRooms = $derived(
|
||||
searchQuery.trim()
|
||||
? rooms.filter(
|
||||
(room) =>
|
||||
room.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
room.topic?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: rooms,
|
||||
);
|
||||
|
||||
// Get spaces (organizations)
|
||||
const spaces = $derived(filteredRooms.filter((room) => room.isSpace));
|
||||
|
||||
// Get rooms belonging to each space
|
||||
const roomsBySpace = $derived(() => {
|
||||
const map = new Map<string, RoomSummary[]>();
|
||||
spaces.forEach((space) => {
|
||||
map.set(
|
||||
space.roomId,
|
||||
filteredRooms.filter(
|
||||
(room) => !room.isSpace && room.parentSpaceId === space.roomId,
|
||||
),
|
||||
);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// Get orphan rooms (messages) - rooms not belonging to any space and not spaces themselves
|
||||
const orphanRooms = $derived(
|
||||
filteredRooms.filter((room) => !room.isSpace && !room.parentSpaceId),
|
||||
);
|
||||
|
||||
const isConnected = $derived(
|
||||
syncState === "SYNCING" || syncState === "PREPARED",
|
||||
);
|
||||
|
||||
function toggleSpace(spaceId: string) {
|
||||
expandedSpaces = new Set(expandedSpaces);
|
||||
if (expandedSpaces.has(spaceId)) {
|
||||
expandedSpaces.delete(spaceId);
|
||||
} else {
|
||||
expandedSpaces.add(spaceId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="w-64 bg-dark flex flex-col border-r border-light/10">
|
||||
<!-- Header -->
|
||||
<header class="p-4 border-b border-light/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary">Root</h1>
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full {isConnected
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'}"
|
||||
>
|
||||
{isConnected ? "Connected" : syncState}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Room List -->
|
||||
<nav class="flex-1 overflow-y-auto p-2">
|
||||
<!-- Search input -->
|
||||
<div class="px-2 pb-2">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search rooms..."
|
||||
class="w-full pl-9 pr-3 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
|
||||
onclick={() => (searchQuery = "")}
|
||||
title="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organizations (Spaces) Section -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Spaces
|
||||
</span>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={onCreateSpace}
|
||||
title="Create space"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if spaces.length > 0}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each spaces as space (space.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left
|
||||
{selectedRoomId === space.roomId
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-light hover:bg-light/5'}"
|
||||
onclick={() => toggleSpace(space.roomId)}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {expandedSpaces.has(
|
||||
space.roomId,
|
||||
)
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9,18 15,12 9,6" />
|
||||
</svg>
|
||||
<Avatar src={space.avatarUrl} name={space.name} size="sm" />
|
||||
<span class="font-medium truncate flex-1">{space.name}</span>
|
||||
</button>
|
||||
|
||||
<!-- Child rooms of this space -->
|
||||
{#if expandedSpaces.has(space.roomId)}
|
||||
<ul class="ml-6 mt-1 flex flex-col gap-1">
|
||||
{#each roomsBySpace().get(space.roomId) || [] as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors text-left text-sm
|
||||
{selectedRoomId === room.roomId
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-light/80 hover:bg-light/5'}"
|
||||
onclick={() => onRoomSelect(room.roomId)}
|
||||
>
|
||||
<span class="text-light/40">#</span>
|
||||
<span class="truncate flex-1">{room.name}</span>
|
||||
{#if room.unreadCount > 0}
|
||||
<span
|
||||
class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center"
|
||||
>
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-light/30 text-xs text-center py-2 px-2">
|
||||
No spaces yet. Create one to organize your rooms.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Messages (Orphan Rooms) Section -->
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Messages {searchQuery ? `(${orphanRooms.length})` : ""}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={onStartDM}
|
||||
title="Start direct message"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={onCreateRoom}
|
||||
title="Create room"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if orphanRooms.length === 0 && spaces.length === 0}
|
||||
<p class="text-light/40 text-sm text-center py-8">
|
||||
{searchQuery ? "No matching rooms" : "No rooms yet"}
|
||||
</p>
|
||||
{:else if orphanRooms.length > 0}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each orphanRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left
|
||||
{selectedRoomId === room.roomId
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-light hover:bg-light/5'}"
|
||||
onclick={() => onRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="md" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium truncate">{room.name}</span>
|
||||
{#if room.unreadCount > 0}
|
||||
<span
|
||||
class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center"
|
||||
>
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.lastMessage}
|
||||
<p class="text-xs text-light/40 truncate">
|
||||
{room.lastMessage.senderName}: {room.lastMessage.content}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- User Section -->
|
||||
<footer class="p-4 border-t border-light/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar name={auth.userId || "User"} size="sm" status="online" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-light truncate">
|
||||
{auth.userId}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-light/50 hover:text-light p-2 rounded-lg hover:bg-light/10 transition-colors"
|
||||
onclick={onLogout}
|
||||
title="Logout"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16,17 21,12 16,7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
2
src/lib/components/chat-layout/index.ts
Normal file
2
src/lib/components/chat-layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './Sidebar.svelte';
|
||||
export { default as ChatArea } from './ChatArea.svelte';
|
||||
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal file
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
theme,
|
||||
isDarkMode,
|
||||
primaryColor,
|
||||
PRESET_COLORS,
|
||||
} from "$lib/stores/theme";
|
||||
import { auth } from "$lib/stores/matrix";
|
||||
import { getClient } from "$lib/matrix/client";
|
||||
import { Avatar, Button, Input } from "$lib/components/ui";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
// User profile state
|
||||
let displayName = $state("");
|
||||
let activeTab = $state<"profile" | "appearance" | "security">("profile");
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
// Derived values
|
||||
const currentUserId = $derived($auth.userId || "@user");
|
||||
const dark = $derived($isDarkMode);
|
||||
const currentPrimary = $derived($primaryColor);
|
||||
|
||||
// Load user profile on open
|
||||
$effect(() => {
|
||||
if (open && currentUserId && currentUserId !== "@user") {
|
||||
const client = getClient();
|
||||
if (client) {
|
||||
const user = client.getUser(currentUserId);
|
||||
if (user) {
|
||||
displayName = user.displayName || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
if (!displayName.trim()) return;
|
||||
|
||||
saving = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
const client = getClient();
|
||||
if (client) {
|
||||
await client.setDisplayName(displayName.trim());
|
||||
}
|
||||
handleClose();
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : "Failed to save profile";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleColorSelect(color: string) {
|
||||
theme.setPrimaryColor(color);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="User Settings"
|
||||
tabindex="-1"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<div
|
||||
class="bg-night rounded-[24px] w-[90vw] max-w-[480px] max-h-[85vh] overflow-hidden flex flex-col"
|
||||
role="document"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-5 border-b border-light/10"
|
||||
>
|
||||
<h2 class="text-xl font-heading text-light">Settings</h2>
|
||||
<button
|
||||
class="flex items-center justify-center size-8 rounded-full hover:bg-light/10 transition-colors"
|
||||
onclick={handleClose}
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-light"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-light/10">
|
||||
<button
|
||||
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
|
||||
'profile'
|
||||
? 'text-primary bg-primary/10 border-b-2 border-primary'
|
||||
: 'text-light hover:bg-light/5'}"
|
||||
onclick={() => (activeTab = "profile")}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
|
||||
'appearance'
|
||||
? 'text-primary bg-primary/10 border-b-2 border-primary'
|
||||
: 'text-light hover:bg-light/5'}"
|
||||
onclick={() => (activeTab = "appearance")}
|
||||
>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
|
||||
'security'
|
||||
? 'text-primary bg-primary/10 border-b-2 border-primary'
|
||||
: 'text-light hover:bg-light/5'}"
|
||||
onclick={() => (activeTab = "security")}
|
||||
>
|
||||
Security
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
{#if activeTab === "profile"}
|
||||
<!-- Profile Tab -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Avatar name={displayName || currentUserId} size="xl" />
|
||||
<p class="text-text-muted text-sm">{currentUserId}</p>
|
||||
{#if error}
|
||||
<p class="text-error text-sm">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profile Fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Display Name"
|
||||
bind:value={displayName}
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === "appearance"}
|
||||
<!-- Appearance Tab -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Theme Mode -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-light font-bold text-sm">Theme Mode</h3>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {!dark
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
|
||||
onclick={() => theme.setMode("light")}
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 {!dark ? 'text-primary' : 'text-light'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-bold {!dark
|
||||
? 'text-primary'
|
||||
: 'text-light'}">Light</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {dark
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
|
||||
onclick={() => theme.setMode("dark")}
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 {dark ? 'text-primary' : 'text-light'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-bold {dark
|
||||
? 'text-primary'
|
||||
: 'text-light'}">Dark</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accent Color -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-light font-bold text-sm">Accent Color</h3>
|
||||
<div class="grid grid-cols-6 gap-3">
|
||||
{#each PRESET_COLORS as color (color.primary)}
|
||||
<button
|
||||
class="size-10 rounded-full cursor-pointer border-2 transition-all hover:scale-110 flex items-center justify-center {currentPrimary ===
|
||||
color.primary
|
||||
? 'border-white ring-2 ring-white/30'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color.primary}"
|
||||
title={color.name}
|
||||
onclick={() => handleColorSelect(color.primary)}
|
||||
>
|
||||
{#if currentPrimary === color.primary}
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Color -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-text-muted text-sm" for="custom-color"
|
||||
>Custom Color</label
|
||||
>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input
|
||||
id="custom-color"
|
||||
type="color"
|
||||
value={currentPrimary}
|
||||
onchange={(e) => handleColorSelect(e.currentTarget.value)}
|
||||
class="size-10 rounded-lg cursor-pointer border-none"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
value={currentPrimary}
|
||||
placeholder="#00A3E0"
|
||||
oninput={(e) =>
|
||||
handleColorSelect((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === "security"}
|
||||
<!-- Security Tab -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Device Info -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-light font-bold text-sm">This Device</h3>
|
||||
<div class="flex flex-col gap-2 p-4 bg-dark/50 rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-text-muted text-sm">Device ID</span>
|
||||
<code class="text-light text-sm bg-night px-2 py-1 rounded">
|
||||
{$auth.deviceId || "Unknown"}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-text-muted text-sm">User ID</span>
|
||||
<code class="text-light text-sm bg-night px-2 py-1 rounded">
|
||||
{currentUserId}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-text-muted text-sm">Homeserver</span>
|
||||
<code
|
||||
class="text-light text-sm bg-night px-2 py-1 rounded truncate max-w-[200px]"
|
||||
>
|
||||
{$auth.homeserverUrl || "Unknown"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encryption Status -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-light font-bold text-sm">
|
||||
End-to-End Encryption
|
||||
</h3>
|
||||
<div class="flex items-center gap-3 p-4 bg-dark/50 rounded-xl">
|
||||
<svg
|
||||
class="w-6 h-6 text-success"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-light font-medium">Encryption Enabled</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Your messages are end-to-end encrypted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 p-5 border-t border-light/10">
|
||||
<Button variant="secondary" onclick={handleClose}>Cancel</Button>
|
||||
<Button onclick={handleSaveProfile} loading={saving}
|
||||
>Save Changes</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
1
src/lib/components/chat-settings/index.ts
Normal file
1
src/lib/components/chat-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as UserSettingsModal } from './UserSettingsModal.svelte';
|
||||
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input } from "$lib/components/ui";
|
||||
import { createRoom } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent, selectRoom } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
let roomName = $state("");
|
||||
let isDirect = $state(false);
|
||||
let isCreating = $state(false);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!roomName.trim()) {
|
||||
toasts.error("Please enter a room name");
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
|
||||
try {
|
||||
const result = await createRoom(roomName.trim(), isDirect);
|
||||
toasts.success("Room created!");
|
||||
|
||||
// Add new room to list and select it
|
||||
syncRoomsFromEvent("join", result.room_id);
|
||||
selectRoom(result.room_id);
|
||||
|
||||
// Reset and close
|
||||
roomName = "";
|
||||
isDirect = false;
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create room:", e);
|
||||
toasts.error(e.message || "Failed to create room");
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeyDown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-light mb-4">Create New Room</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
bind:value={roomName}
|
||||
label="Room Name"
|
||||
placeholder="My awesome room"
|
||||
required
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-3 text-light cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isDirect}
|
||||
class="w-4 h-4 rounded border-light/30 bg-night text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Direct message (private 1:1 chat)</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Room"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input } from "$lib/components/ui";
|
||||
import { createSpace, getSpaces } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
parentSpaceId?: string | null;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, parentSpaceId = null }: Props = $props();
|
||||
|
||||
let spaceName = $state("");
|
||||
let spaceTopic = $state("");
|
||||
let isPublic = $state(false);
|
||||
let isCreating = $state(false);
|
||||
|
||||
// Get existing spaces for parent selection
|
||||
const existingSpaces = $derived(getSpaces());
|
||||
|
||||
async function handleCreate() {
|
||||
if (!spaceName.trim()) {
|
||||
toasts.error("Please enter a space name");
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
|
||||
try {
|
||||
const result = await createSpace(spaceName.trim(), {
|
||||
topic: spaceTopic.trim() || undefined,
|
||||
isPublic,
|
||||
parentSpaceId: parentSpaceId || undefined,
|
||||
});
|
||||
|
||||
toasts.success("Space created!");
|
||||
|
||||
// Sync the new space
|
||||
syncRoomsFromEvent("join", result.room_id);
|
||||
|
||||
// Reset and close
|
||||
spaceName = "";
|
||||
spaceTopic = "";
|
||||
isPublic = false;
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create space:", e);
|
||||
toasts.error(e.message || "Failed to create space");
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeyDown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-space-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9,22 9,12 15,12 15,22" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="create-space-title" class="text-xl font-semibold text-light">Create Space</h2>
|
||||
<p class="text-sm text-light/60">Organize your rooms and team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
bind:value={spaceName}
|
||||
label="Space Name"
|
||||
placeholder="My Organization"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="space-topic" class="block text-sm font-medium text-light/80 mb-1.5">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="space-topic"
|
||||
bind:value={spaceTopic}
|
||||
placeholder="What is this space for?"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40 focus:outline-none focus:border-primary
|
||||
focus:ring-1 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-light/80">Visibility</span>
|
||||
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic ? 'border-primary bg-primary/5' : ''}">
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
checked={!isPublic}
|
||||
onchange={() => isPublic = false}
|
||||
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-light font-medium">Private</span>
|
||||
<p class="text-sm text-light/60">Only invited members can join</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic ? 'border-primary bg-primary/5' : ''}">
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
checked={isPublic}
|
||||
onchange={() => isPublic = true}
|
||||
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-light font-medium">Public</span>
|
||||
<p class="text-sm text-light/60">Anyone can find and join this space</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if parentSpaceId}
|
||||
<div class="px-3 py-2 bg-light/5 rounded-lg text-sm text-light/60">
|
||||
<span class="text-light/40">Creating inside:</span>
|
||||
<span class="text-light ml-1">
|
||||
{existingSpaces.find(s => s.roomId === parentSpaceId)?.name || 'Parent Space'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Space"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Twemoji from '$lib/components/ui/Twemoji.svelte';
|
||||
import { searchEmojis, type EmojiItem } from '$lib/utils/emojiData';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
query,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
// Filter emojis based on query
|
||||
const filteredEmojis = $derived(
|
||||
searchEmojis(query).slice(0, 10)
|
||||
);
|
||||
|
||||
// Reset selection when query changes
|
||||
$effect(() => {
|
||||
query;
|
||||
selectedIndex = 0;
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (filteredEmojis.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filteredEmojis.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filteredEmojis.length) % filteredEmojis.length;
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (filteredEmojis[selectedIndex]) {
|
||||
onSelect(filteredEmojis[selectedIndex].emoji);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose keyboard handler for parent to call
|
||||
export { handleKeyDown };
|
||||
</script>
|
||||
|
||||
{#if filteredEmojis.length > 0}
|
||||
<div
|
||||
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
|
||||
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 280px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-light/50 border-b border-light/10">
|
||||
Emojis matching :{query}
|
||||
</div>
|
||||
{#each filteredEmojis as emoji, i}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => onSelect(emoji.emoji)}
|
||||
onmouseenter={() => selectedIndex = i}
|
||||
>
|
||||
<div class="w-6 h-6 flex items-center justify-center">
|
||||
<Twemoji emoji={emoji.emoji} size={20} />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light">:{emoji.names[0]}:</p>
|
||||
{#if emoji.names.length > 1}
|
||||
<p class="text-xs text-light/40 truncate">
|
||||
Also: {emoji.names.slice(1, 4).map(n => `:${n}:`).join(' ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import { setMatrixContext } from "$lib/matrix/context";
|
||||
import { setupSyncHandlers, removeSyncHandlers } from "$lib/matrix/sync";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { client, children }: Props = $props();
|
||||
|
||||
// Store client reference for cleanup
|
||||
let clientRef = client;
|
||||
|
||||
// Set the context during component initialization
|
||||
setMatrixContext(clientRef);
|
||||
|
||||
// Setup sync handlers when provider mounts
|
||||
onMount(() => {
|
||||
setupSyncHandlers(clientRef);
|
||||
});
|
||||
|
||||
// Cleanup when provider unmounts
|
||||
onDestroy(() => {
|
||||
removeSyncHandlers(clientRef);
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
102
src/lib/components/matrix/MemberList.svelte
Normal file
102
src/lib/components/matrix/MemberList.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import UserProfileModal from "./UserProfileModal.svelte";
|
||||
import type { RoomMember } from "$lib/matrix/types";
|
||||
import { userPresence } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
members: RoomMember[];
|
||||
onMemberClick?: (member: RoomMember) => void;
|
||||
onStartDM?: (roomId: string) => void;
|
||||
}
|
||||
|
||||
let { members, onMemberClick, onStartDM }: Props = $props();
|
||||
|
||||
let selectedMember = $state<RoomMember | null>(null);
|
||||
|
||||
function handleMemberClick(member: RoomMember) {
|
||||
if (onMemberClick) {
|
||||
onMemberClick(member);
|
||||
} else {
|
||||
selectedMember = member;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: online first, then admins, then by name
|
||||
const sortedMembers = $derived(
|
||||
[...members].sort((a, b) => {
|
||||
// Online status first
|
||||
const aOnline = $userPresence.get(a.userId) === "online" ? 1 : 0;
|
||||
const bOnline = $userPresence.get(b.userId) === "online" ? 1 : 0;
|
||||
if (bOnline !== aOnline) return bOnline - aOnline;
|
||||
|
||||
// Power level descending
|
||||
if (b.powerLevel !== a.powerLevel) {
|
||||
return b.powerLevel - a.powerLevel;
|
||||
}
|
||||
// Then alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
);
|
||||
|
||||
function getRoleBadge(
|
||||
powerLevel: number,
|
||||
): { label: string; color: string } | null {
|
||||
if (powerLevel >= 100) return { label: "Admin", color: "text-red-400" };
|
||||
if (powerLevel >= 50) return { label: "Mod", color: "text-yellow-400" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPresenceStatus(userId: string): "online" | "offline" | null {
|
||||
const presence = $userPresence.get(userId);
|
||||
if (presence === "online") return "online";
|
||||
if (presence === "offline" || presence === "unavailable") return "offline";
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<header class="p-4 border-b border-light/10">
|
||||
<h3 class="font-semibold text-light">Members ({members.length})</h3>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each sortedMembers as member}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
|
||||
onclick={() => handleMemberClick(member)}
|
||||
>
|
||||
<Avatar
|
||||
src={member.avatarUrl}
|
||||
name={member.name}
|
||||
size="sm"
|
||||
status={getPresenceStatus(member.userId)}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-light truncate">{member.name}</span>
|
||||
{#if getRoleBadge(member.powerLevel)}
|
||||
{@const badge = getRoleBadge(member.powerLevel)}
|
||||
<span class="text-xs {badge?.color}">{badge?.label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if members.length === 0}
|
||||
<div class="p-4 text-center text-light/40">
|
||||
<p>No members</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedMember}
|
||||
<UserProfileModal
|
||||
member={selectedMember}
|
||||
onClose={() => (selectedMember = null)}
|
||||
{onStartDM}
|
||||
/>
|
||||
{/if}
|
||||
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from '$lib/components/ui';
|
||||
import type { RoomMember } from '$lib/matrix/types';
|
||||
|
||||
interface Props {
|
||||
members: RoomMember[];
|
||||
query: string;
|
||||
onSelect: (member: RoomMember) => void;
|
||||
onClose: () => void;
|
||||
position?: { top: number; left: number };
|
||||
}
|
||||
|
||||
let {
|
||||
members,
|
||||
query,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
}: Props = $props();
|
||||
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
// Filter members based on query
|
||||
const filteredMembers = $derived(
|
||||
members
|
||||
.filter(m =>
|
||||
m.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
m.userId.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
// Reset selection when query changes
|
||||
$effect(() => {
|
||||
query;
|
||||
selectedIndex = 0;
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (filteredMembers.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (filteredMembers[selectedIndex]) {
|
||||
onSelect(filteredMembers[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose keyboard handler for parent to call
|
||||
export { handleKeyDown };
|
||||
</script>
|
||||
|
||||
{#if filteredMembers.length > 0}
|
||||
<div
|
||||
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
|
||||
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 250px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-light/50 border-b border-light/10">
|
||||
Members matching @{query}
|
||||
</div>
|
||||
{#each filteredMembers as member, i}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => onSelect(member)}
|
||||
onmouseenter={() => selectedIndex = i}
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light truncate">{member.name}</p>
|
||||
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
761
src/lib/components/matrix/MessageInput.svelte
Normal file
761
src/lib/components/matrix/MessageInput.svelte
Normal file
@@ -0,0 +1,761 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from "svelte";
|
||||
import {
|
||||
sendMessage,
|
||||
setTyping,
|
||||
uploadFile,
|
||||
sendFileMessage,
|
||||
getRoomMembers,
|
||||
} from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import {
|
||||
auth,
|
||||
addPendingMessage,
|
||||
confirmPendingMessage,
|
||||
removePendingMessage,
|
||||
} from "$lib/stores/matrix";
|
||||
import type { Message, RoomMember } from "$lib/matrix/types";
|
||||
import MentionAutocomplete from "./MentionAutocomplete.svelte";
|
||||
import EmojiAutocomplete from "./EmojiAutocomplete.svelte";
|
||||
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
|
||||
import { convertEmojiShortcodes } from "$lib/utils/emojiData";
|
||||
import { getTwemojiUrl } from "$lib/utils/twemoji";
|
||||
|
||||
// Emoji detection regex
|
||||
const emojiRegex =
|
||||
/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
|
||||
|
||||
// Check if text contains emojis
|
||||
function hasEmoji(text: string): boolean {
|
||||
return emojiRegex.test(text);
|
||||
}
|
||||
|
||||
// Render emojis as Twemoji images for preview
|
||||
function renderEmojiPreview(text: string): string {
|
||||
// Escape HTML first
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>");
|
||||
|
||||
// Replace emojis with Twemoji images
|
||||
return escaped.replace(emojiRegex, (emoji) => {
|
||||
const url = getTwemojiUrl(emoji);
|
||||
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roomId: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
replyTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
editingMessage?: Message | null;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
onCancelEdit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
roomId,
|
||||
placeholder = "Send a message...",
|
||||
disabled = false,
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
editingMessage = null,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: Props = $props();
|
||||
|
||||
let message = $state("");
|
||||
let isSending = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let inputRef: HTMLTextAreaElement;
|
||||
let fileInputRef: HTMLInputElement;
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Mention autocomplete state
|
||||
let showMentions = $state(false);
|
||||
let mentionQuery = $state("");
|
||||
let mentionStartIndex = $state(0);
|
||||
let autocompleteRef:
|
||||
| { handleKeyDown: (e: KeyboardEvent) => void }
|
||||
| undefined;
|
||||
|
||||
// Emoji picker state
|
||||
let showEmojiPicker = $state(false);
|
||||
let emojiButtonRef: HTMLButtonElement;
|
||||
|
||||
// Emoji autocomplete state
|
||||
let showEmojiAutocomplete = $state(false);
|
||||
let emojiQuery = $state("");
|
||||
let emojiStartIndex = $state(0);
|
||||
let emojiAutocompleteRef:
|
||||
| { handleKeyDown: (e: KeyboardEvent) => void }
|
||||
| undefined;
|
||||
|
||||
// Get room members for autocomplete
|
||||
const roomMembers = $derived(getRoomMembers(roomId));
|
||||
|
||||
// Cleanup typing timeout on component destroy
|
||||
onDestroy(() => {
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
setTyping(roomId, false).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Populate message when editing starts
|
||||
$effect(() => {
|
||||
if (editingMessage) {
|
||||
message = editingMessage.content;
|
||||
setTimeout(() => {
|
||||
autoResize();
|
||||
inputRef?.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
function autoResize() {
|
||||
if (!inputRef) return;
|
||||
inputRef.style.height = "auto";
|
||||
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
|
||||
}
|
||||
|
||||
// Handle typing indicator
|
||||
function handleTyping() {
|
||||
// Clear existing timeout
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
}
|
||||
|
||||
// Send typing indicator
|
||||
setTyping(roomId, true).catch(console.error);
|
||||
|
||||
// Stop typing after 3 seconds of no input
|
||||
typingTimeout = setTimeout(() => {
|
||||
setTyping(roomId, false).catch(console.error);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Handle input
|
||||
function handleInput() {
|
||||
autoResize();
|
||||
if (message.trim()) {
|
||||
handleTyping();
|
||||
}
|
||||
|
||||
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
|
||||
autoConvertShortcodes();
|
||||
|
||||
// Check for @ mentions and : emoji shortcodes
|
||||
checkForMention();
|
||||
checkForEmoji();
|
||||
}
|
||||
|
||||
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
|
||||
function autoConvertShortcodes() {
|
||||
if (!inputRef) return;
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
|
||||
// Look for completed shortcodes like :name:
|
||||
const converted = convertEmojiShortcodes(message);
|
||||
if (converted !== message) {
|
||||
// Calculate cursor offset based on length difference
|
||||
const lengthDiff = message.length - converted.length;
|
||||
message = converted;
|
||||
|
||||
// Restore cursor position (adjusted for shorter string)
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
const newPos = Math.max(0, cursorPos - lengthDiff);
|
||||
inputRef.selectionStart = inputRef.selectionEnd = newPos;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is typing an emoji shortcode
|
||||
function checkForEmoji() {
|
||||
if (!inputRef) return;
|
||||
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
|
||||
// Find the last : before cursor
|
||||
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
|
||||
|
||||
if (lastColonIndex >= 0) {
|
||||
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
|
||||
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
|
||||
const charBeforeColon =
|
||||
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
|
||||
|
||||
if (
|
||||
(charBeforeColon === " " ||
|
||||
charBeforeColon === "\n" ||
|
||||
lastColonIndex === 0) &&
|
||||
!textAfterColon.includes(" ") &&
|
||||
!textAfterColon.includes(":") &&
|
||||
textAfterColon.length >= 2
|
||||
) {
|
||||
showEmojiAutocomplete = true;
|
||||
emojiQuery = textAfterColon;
|
||||
emojiStartIndex = lastColonIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showEmojiAutocomplete = false;
|
||||
emojiQuery = "";
|
||||
}
|
||||
|
||||
// Handle emoji selection from autocomplete
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
// Replace :query with the emoji
|
||||
const beforeEmoji = message.slice(0, emojiStartIndex);
|
||||
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
|
||||
message = `${beforeEmoji}${emoji}${afterEmoji}`;
|
||||
|
||||
showEmojiAutocomplete = false;
|
||||
emojiQuery = "";
|
||||
|
||||
// Focus back on textarea
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
// Check if user is typing a mention
|
||||
function checkForMention() {
|
||||
if (!inputRef) return;
|
||||
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
|
||||
// Find the last @ before cursor that's not part of a completed mention
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex >= 0) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
// Check if there's a space before @ (or it's at start) and no space after
|
||||
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
|
||||
|
||||
if (
|
||||
(charBeforeAt === " " || charBeforeAt === "\n" || lastAtIndex === 0) &&
|
||||
!textAfterAt.includes(" ")
|
||||
) {
|
||||
showMentions = true;
|
||||
mentionQuery = textAfterAt;
|
||||
mentionStartIndex = lastAtIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showMentions = false;
|
||||
mentionQuery = "";
|
||||
}
|
||||
|
||||
// Handle mention selection
|
||||
function handleMentionSelect(member: RoomMember) {
|
||||
// Replace @query with userId (userId already has @ prefix)
|
||||
const beforeMention = message.slice(0, mentionStartIndex);
|
||||
const afterMention = message.slice(
|
||||
mentionStartIndex + mentionQuery.length + 1,
|
||||
);
|
||||
message = `${beforeMention}${member.userId} ${afterMention}`;
|
||||
|
||||
showMentions = false;
|
||||
mentionQuery = "";
|
||||
|
||||
// Focus back on textarea
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
// Handle key press
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// If mention autocomplete is open, let it handle navigation keys
|
||||
if (
|
||||
showMentions &&
|
||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||
) {
|
||||
autocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter with mention autocomplete open selects the mention
|
||||
if (showMentions && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
autocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// If emoji autocomplete is open, let it handle navigation keys
|
||||
if (
|
||||
showEmojiAutocomplete &&
|
||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||
) {
|
||||
emojiAutocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter with emoji autocomplete open selects the emoji
|
||||
if (showEmojiAutocomplete && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
emojiAutocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-continue lists on Shift+Enter or regular Enter with list
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
const cursorPos = inputRef?.selectionStart || 0;
|
||||
const textBefore = message.slice(0, cursorPos);
|
||||
const currentLine = textBefore.split("\n").pop() || "";
|
||||
|
||||
// Check for numbered list (1. 2. etc)
|
||||
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
|
||||
if (numberedMatch) {
|
||||
e.preventDefault();
|
||||
const indent = numberedMatch[1];
|
||||
const nextNum = parseInt(numberedMatch[2]) + 1;
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${nextNum}. ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + String(nextNum).length + 4;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for bullet list (- or *)
|
||||
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
|
||||
if (bulletMatch) {
|
||||
e.preventDefault();
|
||||
const indent = bulletMatch[1];
|
||||
const bullet = bulletMatch[2];
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${bullet} ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + 4;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for lettered sub-list (a. b. etc)
|
||||
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
|
||||
if (letteredMatch) {
|
||||
e.preventDefault();
|
||||
const indent = letteredMatch[1];
|
||||
const nextLetter = String.fromCharCode(
|
||||
letteredMatch[2].charCodeAt(0) + 1,
|
||||
);
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${nextLetter}. ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + 5;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message or save edit
|
||||
async function handleSend() {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || isSending || disabled) return;
|
||||
|
||||
// Convert emoji shortcodes like :heart: to actual emojis
|
||||
const processedMessage = convertEmojiShortcodes(trimmed);
|
||||
|
||||
// Handle edit mode
|
||||
if (editingMessage) {
|
||||
if (processedMessage === editingMessage.content) {
|
||||
// No changes, just cancel
|
||||
onCancelEdit?.();
|
||||
message = "";
|
||||
return;
|
||||
}
|
||||
onSaveEdit?.(processedMessage);
|
||||
message = "";
|
||||
if (inputRef) {
|
||||
inputRef.style.height = "auto";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
// Clear typing indicator
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
typingTimeout = null;
|
||||
}
|
||||
setTyping(roomId, false).catch(console.error);
|
||||
|
||||
// Create a temporary event ID for the pending message
|
||||
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Add pending message immediately (optimistic update)
|
||||
const pendingMessage: Message = {
|
||||
eventId: tempEventId,
|
||||
roomId,
|
||||
sender: $auth.userId || "",
|
||||
senderName: $auth.userId?.split(":")[0]?.replace("@", "") || "You",
|
||||
senderAvatar: null,
|
||||
content: processedMessage,
|
||||
timestamp: Date.now(),
|
||||
type: "text",
|
||||
isEdited: false,
|
||||
isRedacted: false,
|
||||
isPending: true,
|
||||
replyTo: replyTo?.eventId,
|
||||
reactions: new Map(),
|
||||
};
|
||||
|
||||
addPendingMessage(roomId, pendingMessage);
|
||||
message = "";
|
||||
|
||||
// Clear reply
|
||||
onCancelReply?.();
|
||||
|
||||
// Reset textarea height
|
||||
if (inputRef) {
|
||||
inputRef.style.height = "auto";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendMessage(
|
||||
roomId,
|
||||
processedMessage,
|
||||
replyTo?.eventId,
|
||||
);
|
||||
// Confirm the pending message with the real event ID
|
||||
if (result?.event_id) {
|
||||
confirmPendingMessage(roomId, tempEventId, result.event_id);
|
||||
} else {
|
||||
// If no event ID returned, just mark as not pending
|
||||
confirmPendingMessage(roomId, tempEventId, tempEventId);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Failed to send message:", e);
|
||||
// Remove the pending message on failure
|
||||
removePendingMessage(roomId, tempEventId);
|
||||
toasts.error(e.message || "Failed to send message");
|
||||
} finally {
|
||||
isSending = false;
|
||||
// Refocus after DOM settles from optimistic update
|
||||
await tick();
|
||||
inputRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || disabled) return;
|
||||
|
||||
// Reset input
|
||||
input.value = "";
|
||||
|
||||
// Check file size (50MB limit)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toasts.error("File too large. Maximum size is 50MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
toasts.info(`Uploading ${file.name}...`);
|
||||
const contentUri = await uploadFile(file);
|
||||
await sendFileMessage(roomId, file, contentUri);
|
||||
toasts.success("File sent!");
|
||||
} catch (e: any) {
|
||||
console.error("Failed to upload file:", e);
|
||||
toasts.error(e.message || "Failed to upload file");
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-t border-light/10">
|
||||
<!-- Edit preview -->
|
||||
{#if editingMessage}
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
|
||||
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
||||
onclick={() => {
|
||||
onCancelEdit?.();
|
||||
message = "";
|
||||
}}
|
||||
title="Cancel edit"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply preview -->
|
||||
{#if replyTo && !editingMessage}
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-medium">
|
||||
Replying to {replyTo.senderName}
|
||||
</p>
|
||||
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
||||
onclick={() => onCancelReply?.()}
|
||||
title="Cancel reply"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4 flex items-end gap-3">
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.zip"
|
||||
/>
|
||||
|
||||
<!-- Attachment button -->
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
|
||||
class:animate-pulse={isUploading}
|
||||
title="Add attachment"
|
||||
onclick={openFilePicker}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
{#if isUploading}
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="16" />
|
||||
<line x1="8" y1="12" x2="16" y2="12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="flex-1 relative">
|
||||
<!-- Mention autocomplete -->
|
||||
{#if showMentions}
|
||||
<MentionAutocomplete
|
||||
bind:this={autocompleteRef}
|
||||
members={roomMembers}
|
||||
query={mentionQuery}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={() => (showMentions = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Emoji autocomplete -->
|
||||
{#if showEmojiAutocomplete}
|
||||
<EmojiAutocomplete
|
||||
bind:this={emojiAutocompleteRef}
|
||||
query={emojiQuery}
|
||||
onSelect={handleEmojiSelect}
|
||||
onClose={() => (showEmojiAutocomplete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Input wrapper with emoji button inside -->
|
||||
<div class="relative flex items-end">
|
||||
<!-- Emoji preview overlay - shows rendered Twemoji -->
|
||||
{#if message && hasEmoji(message)}
|
||||
<div
|
||||
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
|
||||
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html renderEmojiPreview(message)}
|
||||
</div>
|
||||
{/if}
|
||||
<textarea
|
||||
bind:this={inputRef}
|
||||
bind:value={message}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
{placeholder}
|
||||
disabled={disabled || isSending}
|
||||
rows="1"
|
||||
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
|
||||
placeholder:text-light/40 resize-none overflow-hidden
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors {message && hasEmoji(message)
|
||||
? 'text-transparent caret-light'
|
||||
: 'text-light'}"
|
||||
style="min-height: 48px; max-height: 200px;"
|
||||
></textarea>
|
||||
|
||||
<!-- Emoji button inside input -->
|
||||
<button
|
||||
bind:this={emojiButtonRef}
|
||||
type="button"
|
||||
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
|
||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||
title="Add emoji"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Picker -->
|
||||
{#if showEmojiPicker}
|
||||
<div class="absolute bottom-full right-0 mb-2">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
message += emoji;
|
||||
inputRef?.focus();
|
||||
}}
|
||||
onClose={() => (showEmojiPicker = false)}
|
||||
position={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
|
||||
{message.trim()
|
||||
? 'bg-primary text-white hover:brightness-110'
|
||||
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
|
||||
onclick={handleSend}
|
||||
disabled={!message.trim() || isSending || disabled}
|
||||
title="Send message"
|
||||
>
|
||||
{#if isSending}
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Character count (optional, show when > 1000) -->
|
||||
{#if message.length > 1000}
|
||||
<div
|
||||
class="text-right text-xs mt-1 {message.length > 4000
|
||||
? 'text-red-400'
|
||||
: 'text-light/40'}"
|
||||
>
|
||||
{message.length} / 4000
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
478
src/lib/components/matrix/MessageList.svelte
Normal file
478
src/lib/components/matrix/MessageList.svelte
Normal file
@@ -0,0 +1,478 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick, untrack } from "svelte";
|
||||
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
|
||||
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
|
||||
import { MessageContainer } from "$lib/components/message";
|
||||
import type { Message as MessageType } from "$lib/matrix/types";
|
||||
import { auth } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
messages: MessageType[];
|
||||
onReact?: (messageId: string, emoji: string) => void;
|
||||
onToggleReaction?: (
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
reactionEventId: string | null,
|
||||
) => void;
|
||||
onEdit?: (message: MessageType) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (message: MessageType) => void;
|
||||
onLoadMore?: () => void;
|
||||
isLoading?: boolean;
|
||||
enableVirtualization?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
onReact,
|
||||
onToggleReaction,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onLoadMore,
|
||||
isLoading = false,
|
||||
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement | undefined = $state();
|
||||
let shouldAutoScroll = $state(true);
|
||||
let previousMessageCount = $state(0);
|
||||
|
||||
// Filter out deleted/redacted messages (hide them like Discord)
|
||||
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
|
||||
|
||||
// Virtualizer state - managed via subscription
|
||||
let virtualizer = $state<SvelteVirtualizer<HTMLDivElement, Element> | null>(
|
||||
null,
|
||||
);
|
||||
let virtualizerCleanup: (() => void) | null = null;
|
||||
|
||||
// Estimate size based on message type
|
||||
function estimateSize(index: number): number {
|
||||
const msg = allVisibleMessages[index];
|
||||
if (!msg) return 80;
|
||||
if (msg.type === "image") return 300;
|
||||
if (msg.type === "video") return 350;
|
||||
if (msg.type === "file" || msg.type === "audio") return 100;
|
||||
const lines = Math.ceil((msg.content?.length || 0) / 60);
|
||||
return Math.max(60, Math.min(lines * 24 + 40, 400));
|
||||
}
|
||||
|
||||
// Create/update virtualizer when container or messages change
|
||||
$effect(() => {
|
||||
if (
|
||||
!containerRef ||
|
||||
!enableVirtualization ||
|
||||
allVisibleMessages.length === 0
|
||||
) {
|
||||
virtualizer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up previous subscription
|
||||
if (virtualizerCleanup) {
|
||||
virtualizerCleanup();
|
||||
virtualizerCleanup = null;
|
||||
}
|
||||
|
||||
// Create new virtualizer store
|
||||
const store = createVirtualizer({
|
||||
count: allVisibleMessages.length,
|
||||
getScrollElement: () => containerRef!,
|
||||
estimateSize,
|
||||
overscan: 5,
|
||||
getItemKey: (index) => allVisibleMessages[index]?.eventId ?? index,
|
||||
scrollToFn: elementScroll,
|
||||
});
|
||||
|
||||
// Subscribe to store updates
|
||||
virtualizerCleanup = store.subscribe((v) => {
|
||||
virtualizer = v;
|
||||
});
|
||||
|
||||
// Cleanup on effect re-run or component destroy
|
||||
return () => {
|
||||
if (virtualizerCleanup) {
|
||||
virtualizerCleanup();
|
||||
virtualizerCleanup = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Get virtual items for rendering (reactive to virtualizer changes)
|
||||
const virtualItems = $derived(virtualizer?.getVirtualItems() ?? []);
|
||||
const totalSize = $derived(virtualizer?.getTotalSize() ?? 0);
|
||||
|
||||
/**
|
||||
* Svelte action for dynamic height measurement
|
||||
* Re-measures when images/media finish loading
|
||||
*/
|
||||
function measureRow(node: HTMLElement, index: number) {
|
||||
function measure() {
|
||||
if (virtualizer) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial measurement
|
||||
measure();
|
||||
|
||||
// Re-measure when images load
|
||||
const images = node.querySelectorAll("img");
|
||||
const imageHandlers: Array<() => void> = [];
|
||||
images.forEach((img) => {
|
||||
if (!img.complete) {
|
||||
const handler = () => measure();
|
||||
img.addEventListener("load", handler, { once: true });
|
||||
img.addEventListener("error", handler, { once: true });
|
||||
imageHandlers.push(() => {
|
||||
img.removeEventListener("load", handler);
|
||||
img.removeEventListener("error", handler);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Re-measure when videos load metadata
|
||||
const videos = node.querySelectorAll("video");
|
||||
const videoHandlers: Array<() => void> = [];
|
||||
videos.forEach((video) => {
|
||||
if (video.readyState < 1) {
|
||||
const handler = () => measure();
|
||||
video.addEventListener("loadedmetadata", handler, { once: true });
|
||||
videoHandlers.push(() =>
|
||||
video.removeEventListener("loadedmetadata", handler),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
update(newIndex: number) {
|
||||
// Re-measure on update
|
||||
measure();
|
||||
},
|
||||
destroy() {
|
||||
// Cleanup listeners
|
||||
imageHandlers.forEach((cleanup) => cleanup());
|
||||
videoHandlers.forEach((cleanup) => cleanup());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Track if we're currently loading to prevent scroll jumps
|
||||
let isLoadingMore = $state(false);
|
||||
let scrollTopBeforeLoad = $state(0);
|
||||
let scrollHeightBeforeLoad = $state(0);
|
||||
|
||||
// Check if we should auto-scroll and load more
|
||||
function handleScroll() {
|
||||
if (!containerRef) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
||||
|
||||
// Check if at bottom for auto-scroll
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
shouldAutoScroll = distanceToBottom < 100;
|
||||
|
||||
// Check if at top to load more messages (with debounce via isLoadingMore)
|
||||
if (scrollTop < 100 && onLoadMore && !isLoading && !isLoadingMore) {
|
||||
// Save scroll position before loading
|
||||
isLoadingMore = true;
|
||||
scrollTopBeforeLoad = scrollTop;
|
||||
scrollHeightBeforeLoad = scrollHeight;
|
||||
onLoadMore();
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position after loading older messages
|
||||
$effect(() => {
|
||||
if (!isLoading && isLoadingMore && containerRef) {
|
||||
// Loading finished - restore scroll position
|
||||
tick().then(() => {
|
||||
if (containerRef) {
|
||||
const newScrollHeight = containerRef.scrollHeight;
|
||||
const addedHeight = newScrollHeight - scrollHeightBeforeLoad;
|
||||
// Adjust scroll to maintain visual position
|
||||
containerRef.scrollTop = scrollTopBeforeLoad + addedHeight;
|
||||
}
|
||||
isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
async function scrollToBottom(force = false) {
|
||||
if (!containerRef) return;
|
||||
if (force || shouldAutoScroll) {
|
||||
await tick();
|
||||
containerRef.scrollTop = containerRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when new messages arrive (only if at bottom)
|
||||
$effect(() => {
|
||||
const count = allVisibleMessages.length;
|
||||
|
||||
if (count > previousMessageCount) {
|
||||
if (shouldAutoScroll || previousMessageCount === 0) {
|
||||
// User is at bottom or first load - scroll to new messages
|
||||
scrollToBottom(true);
|
||||
}
|
||||
// If user is scrolled up, scroll anchoring handles it
|
||||
}
|
||||
previousMessageCount = count;
|
||||
});
|
||||
|
||||
// Initial scroll to bottom
|
||||
onMount(() => {
|
||||
tick().then(() => {
|
||||
scrollToBottom(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Check if message should be grouped with previous
|
||||
function shouldGroup(
|
||||
current: MessageType,
|
||||
previous: MessageType | null,
|
||||
): boolean {
|
||||
if (!previous) return false;
|
||||
if (current.sender !== previous.sender) return false;
|
||||
|
||||
// Group if within 5 minutes
|
||||
const timeDiff = current.timestamp - previous.timestamp;
|
||||
return timeDiff < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Check if we need a date separator
|
||||
function needsDateSeparator(
|
||||
current: MessageType,
|
||||
previous: MessageType | null,
|
||||
): boolean {
|
||||
if (!previous) return true;
|
||||
|
||||
const currentDate = new Date(current.timestamp).toDateString();
|
||||
const previousDate = new Date(previous.timestamp).toDateString();
|
||||
|
||||
return currentDate !== previousDate;
|
||||
}
|
||||
|
||||
function formatDateSeparator(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return "Today";
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year:
|
||||
date.getFullYear() !== today.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get reply preview for a message
|
||||
function getReplyPreview(replyToId: string): {
|
||||
senderName: string;
|
||||
content: string;
|
||||
senderAvatar: string | null;
|
||||
hasAttachment: boolean;
|
||||
} | null {
|
||||
const replyMessage = messages.find((m) => m.eventId === replyToId);
|
||||
if (!replyMessage) return null;
|
||||
|
||||
const hasAttachment = ["image", "video", "audio", "file"].includes(
|
||||
replyMessage.type,
|
||||
);
|
||||
let content = replyMessage.content;
|
||||
|
||||
if (hasAttachment && !content) {
|
||||
content =
|
||||
replyMessage.type === "image"
|
||||
? "Click to see attachment"
|
||||
: replyMessage.type === "video"
|
||||
? "Video"
|
||||
: replyMessage.type === "audio"
|
||||
? "Audio"
|
||||
: "File";
|
||||
}
|
||||
|
||||
return {
|
||||
senderName: replyMessage.senderName,
|
||||
senderAvatar: replyMessage.senderAvatar,
|
||||
content: content.slice(0, 50) + (content.length > 50 ? "..." : ""),
|
||||
hasAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
// Scroll to a specific message
|
||||
function scrollToMessage(eventId: string) {
|
||||
const element = document.getElementById(`message-${eventId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Highlight briefly
|
||||
element.classList.add("bg-primary/20");
|
||||
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="h-full overflow-y-auto bg-night"
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
<!-- Load more button -->
|
||||
{#if onLoadMore}
|
||||
<div class="flex justify-center py-4">
|
||||
<button
|
||||
class="text-sm text-primary hover:underline disabled:opacity-50"
|
||||
onclick={() => onLoadMore?.()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Loading..." : "Load older messages"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Messages -->
|
||||
{#if allVisibleMessages.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-light/40"
|
||||
>
|
||||
<svg
|
||||
class="w-16 h-16 mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg">No messages yet</p>
|
||||
<p class="text-sm">Be the first to send a message!</p>
|
||||
</div>
|
||||
{:else if virtualizer && enableVirtualization}
|
||||
<!-- TanStack Virtual: True DOM recycling -->
|
||||
<div class="relative w-full" style="height: {totalSize}px;">
|
||||
{#each virtualItems as virtualRow (virtualRow.key)}
|
||||
{@const message = allVisibleMessages[virtualRow.index]}
|
||||
{@const previousMessage =
|
||||
virtualRow.index > 0
|
||||
? allVisibleMessages[virtualRow.index - 1]
|
||||
: null}
|
||||
{@const isGrouped = shouldGroup(message, previousMessage)}
|
||||
{@const showDateSeparator = needsDateSeparator(
|
||||
message,
|
||||
previousMessage,
|
||||
)}
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style="transform: translateY({virtualRow.start}px);"
|
||||
data-index={virtualRow.index}
|
||||
use:measureRow={virtualRow.index}
|
||||
>
|
||||
<!-- Date separator -->
|
||||
{#if showDateSeparator}
|
||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<span class="text-xs text-light/40 font-medium">
|
||||
{formatDateSeparator(message.timestamp)}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<MessageContainer
|
||||
{message}
|
||||
{isGrouped}
|
||||
isOwnMessage={message.sender === $auth.userId}
|
||||
currentUserId={$auth.userId || ""}
|
||||
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
|
||||
onToggleReaction={(
|
||||
emoji: string,
|
||||
reactionEventId: string | null,
|
||||
) => onToggleReaction?.(message.eventId, emoji, reactionEventId)}
|
||||
onEdit={() => onEdit?.(message)}
|
||||
onDelete={() => onDelete?.(message.eventId)}
|
||||
onReply={() => onReply?.(message)}
|
||||
onScrollToMessage={scrollToMessage}
|
||||
replyPreview={message.replyTo
|
||||
? getReplyPreview(message.replyTo)
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Fallback: Non-virtualized rendering for small lists -->
|
||||
<div class="py-4">
|
||||
{#each allVisibleMessages as message, i (message.eventId)}
|
||||
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
|
||||
{@const isGrouped = shouldGroup(message, previousMessage)}
|
||||
{@const showDateSeparator = needsDateSeparator(
|
||||
message,
|
||||
previousMessage,
|
||||
)}
|
||||
|
||||
<!-- Date separator -->
|
||||
{#if showDateSeparator}
|
||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<span class="text-xs text-light/40 font-medium">
|
||||
{formatDateSeparator(message.timestamp)}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<MessageContainer
|
||||
{message}
|
||||
{isGrouped}
|
||||
isOwnMessage={message.sender === $auth.userId}
|
||||
currentUserId={$auth.userId || ""}
|
||||
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
|
||||
onToggleReaction={(emoji: string, reactionEventId: string | null) =>
|
||||
onToggleReaction?.(message.eventId, emoji, reactionEventId)}
|
||||
onEdit={() => onEdit?.(message)}
|
||||
onDelete={() => onDelete?.(message.eventId)}
|
||||
onReply={() => onReply?.(message)}
|
||||
onScrollToMessage={scrollToMessage}
|
||||
replyPreview={message.replyTo
|
||||
? getReplyPreview(message.replyTo)
|
||||
: null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scroll to bottom button -->
|
||||
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
|
||||
<button
|
||||
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
|
||||
hover:bg-primary/90 transition-all transform hover:scale-105
|
||||
animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
onclick={() => scrollToBottom(true)}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6,9 12,15 18,9" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import RoomSettingsModal from "./RoomSettingsModal.svelte";
|
||||
import {
|
||||
getRoomNotificationLevel,
|
||||
setRoomNotificationLevel,
|
||||
} from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import type { RoomSummary, RoomMember } from "$lib/matrix/types";
|
||||
|
||||
interface Props {
|
||||
room: RoomSummary;
|
||||
members: RoomMember[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { room, members, onClose }: Props = $props();
|
||||
|
||||
let showSettings = $state(false);
|
||||
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
|
||||
let isTogglingMute = $state(false);
|
||||
|
||||
// Group members by role
|
||||
const admins = $derived(members.filter((m) => m.powerLevel >= 100));
|
||||
const moderators = $derived(
|
||||
members.filter((m) => m.powerLevel >= 50 && m.powerLevel < 100),
|
||||
);
|
||||
const regularMembers = $derived(members.filter((m) => m.powerLevel < 50));
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
isTogglingMute = true;
|
||||
try {
|
||||
const newLevel = isMuted ? "all" : "mute";
|
||||
await setRoomNotificationLevel(room.roomId, newLevel);
|
||||
isMuted = !isMuted;
|
||||
toasts.success(isMuted ? "Room muted" : "Room unmuted");
|
||||
} catch (e) {
|
||||
toasts.error("Failed to change notification settings");
|
||||
} finally {
|
||||
isTogglingMute = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col bg-dark/50">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-light/10 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-light">Room Info</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<!-- Room Avatar & Name -->
|
||||
<div class="text-center">
|
||||
<div class="flex justify-center mb-3">
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-light">{room.name}</h3>
|
||||
{#if room.topic}
|
||||
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
|
||||
{/if}
|
||||
<button
|
||||
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showSettings = true)}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
Edit Settings
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
|
||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
: 'text-light/60 hover:text-light hover:bg-light/10'}"
|
||||
onclick={toggleMute}
|
||||
disabled={isTogglingMute}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if isMuted}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
Muted
|
||||
{:else}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path
|
||||
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
|
||||
/>
|
||||
</svg>
|
||||
Notifications On
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Stats -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-night rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
|
||||
<p class="text-xs text-light/50">Members</p>
|
||||
</div>
|
||||
<div class="bg-night rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-light">
|
||||
{room.isEncrypted ? "🔒" : "🔓"}
|
||||
</p>
|
||||
<p class="text-xs text-light/50">
|
||||
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Details -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
||||
Details
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Room ID</span>
|
||||
<span
|
||||
class="text-light font-mono text-xs truncate max-w-[150px]"
|
||||
title={room.roomId}
|
||||
>
|
||||
{room.roomId}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Type</span>
|
||||
<span class="text-light"
|
||||
>{room.isDirect ? "Direct Message" : "Room"}</span
|
||||
>
|
||||
</div>
|
||||
{#if room.lastActivity}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Last Activity</span>
|
||||
<span class="text-light">{formatDate(room.lastActivity)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members by Role -->
|
||||
{#if admins.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Admins ({admins.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each admins as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
<span class="ml-auto text-xs text-yellow-400">👑</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if moderators.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Moderators ({moderators.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each moderators as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
<span class="ml-auto text-xs text-blue-400">🛡️</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
||||
Members ({regularMembers.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each regularMembers.slice(0, 20) as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if regularMembers.length > 20}
|
||||
<li class="text-xs text-light/40 text-center py-2">
|
||||
+{regularMembers.length - 20} more members
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSettings}
|
||||
<RoomSettingsModal {room} onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent } from "$lib/stores/matrix";
|
||||
import type { RoomSummary } from "$lib/matrix/types";
|
||||
|
||||
interface Props {
|
||||
room: RoomSummary;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { room, onClose }: Props = $props();
|
||||
|
||||
let name = $state(room.name);
|
||||
let topic = $state(room.topic || "");
|
||||
let isSaving = $state(false);
|
||||
let avatarFile = $state<File | null>(null);
|
||||
let avatarPreview = $state<string | null>(null);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
avatarFile = file;
|
||||
avatarPreview = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (name !== room.name) {
|
||||
promises.push(setRoomName(room.roomId, name));
|
||||
}
|
||||
|
||||
if (topic !== (room.topic || "")) {
|
||||
promises.push(setRoomTopic(room.roomId, topic));
|
||||
}
|
||||
|
||||
if (avatarFile) {
|
||||
promises.push(setRoomAvatar(room.roomId, avatarFile));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
syncRoomsFromEvent("update", room.roomId);
|
||||
toasts.success("Room settings updated");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error("Failed to update room settings:", e);
|
||||
toasts.error("Failed to update room settings");
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = $derived(
|
||||
name !== room.name || topic !== (room.topic || "") || avatarFile !== null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
role="document"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="settings-title" class="text-xl font-bold text-light">
|
||||
Room Settings
|
||||
</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div class="relative group">
|
||||
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
|
||||
<label
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-light/40 mt-2">Click to change avatar</p>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="room-name"
|
||||
class="block text-sm font-medium text-light/60 mb-1"
|
||||
>
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
id="room-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
placeholder="Enter room name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Topic -->
|
||||
<div class="mb-6">
|
||||
<label
|
||||
for="room-topic"
|
||||
class="block text-sm font-medium text-light/60 mb-1"
|
||||
>
|
||||
Topic
|
||||
</label>
|
||||
<textarea
|
||||
id="room-topic"
|
||||
bind:value={topic}
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary resize-none"
|
||||
placeholder="What's this room about?"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleSave}
|
||||
disabled={isSaving || !hasChanges}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from '$lib/components/ui';
|
||||
import { searchUsers, createDirectMessage } from '$lib/matrix';
|
||||
import { toasts } from '$lib/stores/ui';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onDMCreated: (roomId: string) => void;
|
||||
}
|
||||
|
||||
let { onClose, onDMCreated }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
|
||||
let isSearching = $state(false);
|
||||
let isCreating = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
try {
|
||||
searchResults = await searchUsers(searchQuery);
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleStartDM(userId: string) {
|
||||
isCreating = true;
|
||||
try {
|
||||
const roomId = await createDirectMessage(userId);
|
||||
toasts.success('Direct message started!');
|
||||
onDMCreated(roomId);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create DM:', e);
|
||||
toasts.error(e.message || 'Failed to start direct message');
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-light mb-4">Start a Direct Message</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
placeholder="Search users by name or @user:server"
|
||||
class="w-full pl-9 pr-4 py-3 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if isSearching}
|
||||
<div class="text-center py-8 text-light/40">
|
||||
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Searching...
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each searchResults as user}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors text-left disabled:opacity-50"
|
||||
onclick={() => handleStartDM(user.userId)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light font-medium truncate">{user.displayName}</p>
|
||||
<p class="text-xs text-light/40 truncate">{user.userId}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="text-center py-8 text-light/40">No users found</p>
|
||||
{:else}
|
||||
<p class="text-center py-8 text-light/40">
|
||||
Search for a user to start a conversation
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 text-light/60 hover:text-light transition-colors"
|
||||
onclick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { syncState, syncError, clearState } from "$lib/stores/matrix";
|
||||
import { clearAllCache } from "$lib/cache";
|
||||
|
||||
interface Props {
|
||||
onHardRefresh?: () => void;
|
||||
}
|
||||
|
||||
let { onHardRefresh }: Props = $props();
|
||||
|
||||
let isRefreshing = $state(false);
|
||||
let dismissed = $state(false);
|
||||
let consecutiveErrors = $state(0);
|
||||
|
||||
// Track consecutive sync errors
|
||||
$effect(() => {
|
||||
if ($syncState === "ERROR") {
|
||||
consecutiveErrors++;
|
||||
} else if ($syncState === "SYNCING" || $syncState === "PREPARED") {
|
||||
consecutiveErrors = 0;
|
||||
dismissed = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Show banner after 3+ consecutive errors
|
||||
const shouldShow = $derived(
|
||||
!dismissed && consecutiveErrors >= 3 && $syncState === "ERROR",
|
||||
);
|
||||
|
||||
async function handleHardRefresh() {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// Clear local cache
|
||||
await clearAllCache();
|
||||
|
||||
// Clear in-memory state
|
||||
clearState();
|
||||
|
||||
// Trigger callback for full re-sync
|
||||
onHardRefresh?.();
|
||||
|
||||
// Reload the page for clean state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("[SyncRecovery] Hard refresh failed:", error);
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
dismissed = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<div
|
||||
class="fixed top-4 left-1/2 -translate-x-1/2 z-50 max-w-md w-full mx-4
|
||||
bg-red-900/90 backdrop-blur-sm border border-red-500/50
|
||||
rounded-lg shadow-xl p-4 animate-in slide-in-from-top duration-300"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-red-400 flex-shrink-0 mt-0.5"
|
||||
style="font-size: 20px;">warning</span
|
||||
>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-red-100">Sync Connection Lost</h3>
|
||||
<p class="text-sm text-red-200/80 mt-1">
|
||||
{$syncError ||
|
||||
"Unable to sync with the server. Your messages may be outdated."}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
|
||||
text-white text-sm font-medium rounded-md transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleHardRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {isRefreshing
|
||||
? 'animate-spin'
|
||||
: ''}"
|
||||
style="font-size: 16px;">refresh</span
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Hard Refresh"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 text-red-200 hover:text-white text-sm transition-colors"
|
||||
onclick={handleDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="text-red-400 hover:text-red-200 transition-colors"
|
||||
onclick={handleDismiss}
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;"
|
||||
>close</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
userNames: string[];
|
||||
}
|
||||
|
||||
let { userNames }: Props = $props();
|
||||
|
||||
function formatTypingText(names: string[]): string {
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return `${names[0]} is typing`;
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
|
||||
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if userNames.length > 0}
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
|
||||
<!-- Animated dots -->
|
||||
<div class="flex gap-1">
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
|
||||
</div>
|
||||
<span>{formatTypingText(userNames)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
127
src/lib/components/matrix/UserProfileModal.svelte
Normal file
127
src/lib/components/matrix/UserProfileModal.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from '$lib/components/ui';
|
||||
import { createDirectMessage } from '$lib/matrix';
|
||||
import { userPresence } from '$lib/stores/matrix';
|
||||
import { toasts } from '$lib/stores/ui';
|
||||
import type { RoomMember } from '$lib/matrix/types';
|
||||
|
||||
interface Props {
|
||||
member: RoomMember;
|
||||
onClose: () => void;
|
||||
onStartDM?: (roomId: string) => void;
|
||||
}
|
||||
|
||||
let { member, onClose, onStartDM }: Props = $props();
|
||||
|
||||
let isStartingDM = $state(false);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
const presence = $derived($userPresence.get(member.userId) || 'offline');
|
||||
|
||||
const presenceLabel = $derived({
|
||||
online: { text: 'Online', color: 'text-green-400' },
|
||||
offline: { text: 'Offline', color: 'text-gray-400' },
|
||||
unavailable: { text: 'Away', color: 'text-yellow-400' },
|
||||
}[presence]);
|
||||
|
||||
async function handleStartDM() {
|
||||
isStartingDM = true;
|
||||
try {
|
||||
const roomId = await createDirectMessage(member.userId);
|
||||
toasts.success(`Started DM with ${member.name}`);
|
||||
onStartDM?.(roomId);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to start DM:', e);
|
||||
toasts.error('Failed to start direct message');
|
||||
} finally {
|
||||
isStartingDM = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
|
||||
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
|
||||
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleBadge = $derived(getRoleBadge(member.powerLevel));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="profile-title"
|
||||
tabindex="-1"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
|
||||
role="document"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header with gradient -->
|
||||
<div class="h-24 bg-gradient-to-br from-primary/50 to-primary/20 relative">
|
||||
<button
|
||||
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors"
|
||||
onclick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex justify-center -mt-12 relative z-10">
|
||||
<div class="ring-4 ring-dark rounded-full">
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 pt-3 text-center">
|
||||
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
|
||||
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-center gap-2 mt-3">
|
||||
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
|
||||
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
|
||||
</div>
|
||||
|
||||
<!-- Role badge -->
|
||||
{#if roleBadge}
|
||||
<div class="mt-3">
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
|
||||
{roleBadge.icon} {roleBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 space-y-2">
|
||||
<button
|
||||
class="w-full px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
onclick={handleStartDM}
|
||||
disabled={isStartingDM}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{isStartingDM ? 'Starting...' : 'Send Message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
src/lib/components/matrix/index.ts
Normal file
12
src/lib/components/matrix/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as MessageList } from './MessageList.svelte';
|
||||
export { default as MessageInput } from './MessageInput.svelte';
|
||||
export { default as TypingIndicator } from './TypingIndicator.svelte';
|
||||
export { default as CreateRoomModal } from './CreateRoomModal.svelte';
|
||||
export { default as CreateSpaceModal } from './CreateSpaceModal.svelte';
|
||||
export { default as MemberList } from './MemberList.svelte';
|
||||
export { default as StartDMModal } from './StartDMModal.svelte';
|
||||
export { default as RoomInfoPanel } from './RoomInfoPanel.svelte';
|
||||
export { default as RoomSettingsModal } from './RoomSettingsModal.svelte';
|
||||
export { default as UserProfileModal } from './UserProfileModal.svelte';
|
||||
export { default as MatrixProvider } from './MatrixProvider.svelte';
|
||||
export { default as SyncRecoveryBanner } from './SyncRecoveryBanner.svelte';
|
||||
202
src/lib/components/message/MessageContainer.svelte
Normal file
202
src/lib/components/message/MessageContainer.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import { getReadReceiptsForEvent } from "$lib/matrix";
|
||||
import type { Message } from "$lib/matrix/types";
|
||||
import { formatTime } from "./utils";
|
||||
import {
|
||||
MessageContent,
|
||||
MessageMedia,
|
||||
MessageReactions,
|
||||
MessageActions,
|
||||
MessageReadReceipts,
|
||||
} from "./parts";
|
||||
|
||||
interface ReplyPreview {
|
||||
senderName: string;
|
||||
content: string;
|
||||
senderAvatar: string | null;
|
||||
hasAttachment: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
isGrouped?: boolean;
|
||||
isOwnMessage?: boolean;
|
||||
isPinned?: boolean;
|
||||
currentUserId?: string;
|
||||
replyPreview?: ReplyPreview | null;
|
||||
onReact?: (emoji: string) => void;
|
||||
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onReply?: () => void;
|
||||
onPin?: () => void;
|
||||
onScrollToMessage?: (eventId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
message,
|
||||
isGrouped = false,
|
||||
isOwnMessage = false,
|
||||
isPinned = false,
|
||||
currentUserId = "",
|
||||
replyPreview = null,
|
||||
onReact,
|
||||
onToggleReaction,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onPin,
|
||||
onScrollToMessage,
|
||||
}: Props = $props();
|
||||
|
||||
let showActions = $state(false);
|
||||
|
||||
// Get read receipts for own messages
|
||||
const readReceipts = $derived(
|
||||
isOwnMessage
|
||||
? getReadReceiptsForEvent(message.roomId, message.eventId)
|
||||
: [],
|
||||
);
|
||||
|
||||
// Check if message has media
|
||||
const hasMedia = $derived(
|
||||
["image", "video", "audio", "file"].includes(message.type) && message.media,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
onmouseenter={() => (showActions = true)}
|
||||
onmouseleave={() => (showActions = false)}
|
||||
role="article"
|
||||
id="message-{message.eventId}"
|
||||
>
|
||||
<!-- Reply preview -->
|
||||
{#if replyPreview && message.replyTo}
|
||||
<button
|
||||
class="flex items-center gap-1.5 ml-14 mt-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={() => onScrollToMessage?.(message.replyTo!)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex shrink-0">
|
||||
<Avatar
|
||||
src={replyPreview.senderAvatar}
|
||||
name={replyPreview.senderName}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
|
||||
</div>
|
||||
<span class="text-light/50 truncate max-w-xs">
|
||||
{#if replyPreview.hasAttachment}
|
||||
<svg
|
||||
class="w-3 h-3 inline mr-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21,15 16,10 5,21" />
|
||||
</svg>
|
||||
{/if}
|
||||
{replyPreview.content}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isGrouped}
|
||||
<!-- Grouped message (same sender, close in time) -->
|
||||
<div class="flex gap-4">
|
||||
<div class="w-10 shrink-0 flex items-center justify-center">
|
||||
<span
|
||||
class="text-[10px] text-light/30 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if hasMedia && message.media}
|
||||
<MessageMedia
|
||||
type={message.type as "image" | "video" | "audio" | "file"}
|
||||
media={message.media}
|
||||
altText={message.content}
|
||||
/>
|
||||
{:else}
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
isEdited={message.isEdited}
|
||||
isRedacted={message.isRedacted}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Full message with avatar - mt-4 creates gap between message groups -->
|
||||
<div class="flex gap-4 mt-4 first:mt-0">
|
||||
<div class="w-10 shrink-0">
|
||||
<Avatar
|
||||
src={message.senderAvatar}
|
||||
name={message.senderName}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 mb-0.5">
|
||||
<span class="font-semibold text-light hover:underline cursor-pointer">
|
||||
{message.senderName}
|
||||
</span>
|
||||
<span class="text-xs text-light/40">
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if hasMedia && message.media}
|
||||
<MessageMedia
|
||||
type={message.type as "image" | "video" | "audio" | "file"}
|
||||
media={message.media}
|
||||
altText={message.content}
|
||||
/>
|
||||
{:else}
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
isEdited={message.isEdited}
|
||||
isRedacted={message.isRedacted}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reactions -->
|
||||
<MessageReactions
|
||||
reactions={message.reactions}
|
||||
{currentUserId}
|
||||
isRedacted={message.isRedacted}
|
||||
{onReact}
|
||||
{onToggleReaction}
|
||||
/>
|
||||
|
||||
<!-- Read receipts (own messages only) -->
|
||||
{#if isOwnMessage}
|
||||
<MessageReadReceipts receipts={readReceipts} />
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons (show on hover) -->
|
||||
{#if showActions && !message.isRedacted}
|
||||
<MessageActions
|
||||
{isOwnMessage}
|
||||
{isPinned}
|
||||
messageContent={message.content}
|
||||
messageEventId={message.eventId}
|
||||
{onReact}
|
||||
{onReply}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onPin}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
10
src/lib/components/message/index.ts
Normal file
10
src/lib/components/message/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Message module barrel export
|
||||
*
|
||||
* This module provides modular message components following
|
||||
* single responsibility principle.
|
||||
*/
|
||||
|
||||
export { default as MessageContainer } from './MessageContainer.svelte';
|
||||
export * from './parts';
|
||||
export * from './utils';
|
||||
199
src/lib/components/message/parts/MessageActions.svelte
Normal file
199
src/lib/components/message/parts/MessageActions.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import Twemoji from '$lib/components/ui/Twemoji.svelte';
|
||||
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
isOwnMessage?: boolean;
|
||||
isPinned?: boolean;
|
||||
messageContent: string;
|
||||
messageEventId: string;
|
||||
onReact?: (emoji: string) => void;
|
||||
onReply?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onPin?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isOwnMessage = false,
|
||||
isPinned = false,
|
||||
messageContent,
|
||||
messageEventId,
|
||||
onReact,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPin,
|
||||
}: Props = $props();
|
||||
|
||||
const quickReactions = ['👍', '❤️', '😂'];
|
||||
|
||||
let showEmojiPicker = $state(false);
|
||||
let showContextMenu = $state(false);
|
||||
let menuPosition = $state({ x: 0, y: 0 });
|
||||
|
||||
function openContextMenu(e: MouseEvent) {
|
||||
const button = e.currentTarget as HTMLElement;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const menuHeight = 200;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let y = rect.bottom + 4;
|
||||
if (y + menuHeight > viewportHeight) {
|
||||
y = rect.top - menuHeight - 4;
|
||||
}
|
||||
|
||||
menuPosition = { x: rect.right - 180, y: Math.max(8, y) };
|
||||
showContextMenu = !showContextMenu;
|
||||
showEmojiPicker = false;
|
||||
}
|
||||
|
||||
function openEmojiPicker(e: MouseEvent) {
|
||||
const button = e.currentTarget as HTMLElement;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const menuHeight = 150;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let y = rect.bottom + 4;
|
||||
if (y + menuHeight > viewportHeight) {
|
||||
y = rect.top - menuHeight - 4;
|
||||
}
|
||||
|
||||
menuPosition = { x: rect.right - 220, y: Math.max(8, y) };
|
||||
showEmojiPicker = !showEmojiPicker;
|
||||
showContextMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="absolute right-4 -top-3 flex items-center gap-0.5 bg-dark border border-light/20 rounded-lg shadow-lg p-0.5">
|
||||
<!-- Quick reactions -->
|
||||
{#each quickReactions as emoji}
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => onReact?.(emoji)}
|
||||
title="React with {emoji}"
|
||||
>
|
||||
<Twemoji {emoji} size={18} />
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Emoji picker -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
||||
onclick={openEmojiPicker}
|
||||
title="Add reaction"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showEmojiPicker}
|
||||
<EmojiPicker
|
||||
position={menuPosition}
|
||||
onSelect={(emoji) => onReact?.(emoji)}
|
||||
onClose={() => (showEmojiPicker = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-px h-6 bg-light/20 mx-0.5"></div>
|
||||
|
||||
<!-- Reply button -->
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
||||
onclick={() => onReply?.()}
|
||||
title="Reply"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9,17 4,12 9,7" />
|
||||
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Edit button (own messages only) -->
|
||||
{#if isOwnMessage}
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
||||
onclick={() => onEdit?.()}
|
||||
title="Edit"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
||||
onclick={openContextMenu}
|
||||
title="More options"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showContextMenu}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
|
||||
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
||||
onclick={() => { onPin?.(); showContextMenu = false; }}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
|
||||
</svg>
|
||||
{isPinned ? 'Unpin' : 'Pin'} message
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
||||
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy text
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
||||
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
Copy message ID
|
||||
</button>
|
||||
{#if isOwnMessage}
|
||||
<div class="h-px bg-light/10 my-1"></div>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
|
||||
onclick={() => { onDelete?.(); showContextMenu = false; }}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6" />
|
||||
<path d="M19,6 v14 a2,2 0 0,1 -2,2 H7 a2,2 0 0,1 -2,-2 V6 m3,0 V4 a2,2 0 0,1 2,-2 h4 a2,2 0 0,1 2,2 v2" />
|
||||
</svg>
|
||||
Delete message
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
27
src/lib/components/message/parts/MessageContent.svelte
Normal file
27
src/lib/components/message/parts/MessageContent.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { renderMarkdown, isEmojiOnly } from '../utils';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
isEdited?: boolean;
|
||||
isRedacted?: boolean;
|
||||
}
|
||||
|
||||
let { content, isEdited = false, isRedacted = false }: Props = $props();
|
||||
|
||||
const emojiOnly = $derived(isEmojiOnly(content));
|
||||
const renderedContent = $derived(renderMarkdown(content));
|
||||
</script>
|
||||
|
||||
{#if isRedacted}
|
||||
<p class="text-light break-words italic text-light/40">
|
||||
This message was deleted
|
||||
</p>
|
||||
{:else}
|
||||
<span class="text-light break-words {emojiOnly ? 'emoji-only' : 'prose'}">
|
||||
{@html renderedContent}
|
||||
</span>
|
||||
{#if isEdited}
|
||||
<span class="text-xs text-light/40 ml-1 whitespace-nowrap">(edited)</span>
|
||||
{/if}
|
||||
{/if}
|
||||
103
src/lib/components/message/parts/MessageMedia.svelte
Normal file
103
src/lib/components/message/parts/MessageMedia.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import ImagePreviewModal from '$lib/components/ui/ImagePreviewModal.svelte';
|
||||
import { getAuthenticatedMediaUrl } from '$lib/matrix';
|
||||
import { formatFileSize } from '../utils';
|
||||
import type { MediaInfo } from '$lib/matrix/types';
|
||||
|
||||
interface Props {
|
||||
type: 'image' | 'video' | 'audio' | 'file';
|
||||
media: MediaInfo;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
let { type, media, altText = '' }: Props = $props();
|
||||
|
||||
let mediaUrl = $state<string | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let showPreview = $state(false);
|
||||
|
||||
// Load authenticated media URL
|
||||
$effect(() => {
|
||||
if (media?.url) {
|
||||
isLoading = true;
|
||||
getAuthenticatedMediaUrl(media.url)
|
||||
.then((url) => {
|
||||
mediaUrl = url;
|
||||
isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
mediaUrl = media?.httpUrl || null;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup blob URLs
|
||||
onDestroy(() => {
|
||||
if (mediaUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(mediaUrl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if type === 'image'}
|
||||
{#if isLoading}
|
||||
<div class="w-48 h-32 bg-dark/50 rounded-lg animate-pulse flex items-center justify-center">
|
||||
<span class="text-light/30 text-sm">Loading...</span>
|
||||
</div>
|
||||
{:else if mediaUrl}
|
||||
<button
|
||||
class="block max-w-md cursor-pointer"
|
||||
onclick={() => (showPreview = true)}
|
||||
>
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={altText}
|
||||
class="rounded-lg max-h-80 object-contain bg-dark/50 hover:opacity-90 transition-opacity"
|
||||
style="max-width: 100%;"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
{:else if type === 'video' && mediaUrl}
|
||||
<video
|
||||
src={mediaUrl}
|
||||
controls
|
||||
class="rounded-lg max-w-md max-h-80 bg-dark/50"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if type === 'audio' && mediaUrl}
|
||||
<audio src={mediaUrl} controls class="w-full max-w-md">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
{:else if type === 'file'}
|
||||
<a
|
||||
href={mediaUrl || '#'}
|
||||
download={media.filename}
|
||||
class="flex items-center gap-3 px-4 py-3 bg-dark/50 rounded-lg hover:bg-dark/70 transition-colors max-w-sm"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-primary shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-light truncate">{media.filename || altText}</p>
|
||||
<p class="text-xs text-light/50">{formatFileSize(media.size)}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if showPreview && mediaUrl}
|
||||
<ImagePreviewModal
|
||||
src={mediaUrl}
|
||||
alt={altText}
|
||||
onClose={() => (showPreview = false)}
|
||||
/>
|
||||
{/if}
|
||||
117
src/lib/components/message/parts/MessageReactions.svelte
Normal file
117
src/lib/components/message/parts/MessageReactions.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Twemoji from "$lib/components/ui/Twemoji.svelte";
|
||||
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
|
||||
|
||||
interface Props {
|
||||
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
|
||||
currentUserId: string;
|
||||
isRedacted?: boolean;
|
||||
onReact?: (emoji: string) => void;
|
||||
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
reactions,
|
||||
currentUserId,
|
||||
isRedacted = false,
|
||||
onReact,
|
||||
onToggleReaction,
|
||||
}: Props = $props();
|
||||
|
||||
let showPicker = $state(false);
|
||||
|
||||
// Track recently changed reactions for animation
|
||||
let animatingReactions = $state<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Get the reaction event ID if current user has reacted with this emoji
|
||||
* O(1) access using nested Map structure
|
||||
*/
|
||||
function getUserReactionEventId(emoji: string): string | null {
|
||||
const userMap = reactions.get(emoji);
|
||||
if (!userMap) return null;
|
||||
return userMap.get(currentUserId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a reaction event ID indicates a pending (optimistic) reaction
|
||||
*/
|
||||
function isPendingReaction(eventId: string | null): boolean {
|
||||
return eventId?.startsWith("~pending-") ?? false;
|
||||
}
|
||||
|
||||
function handleClick(emoji: string) {
|
||||
const reactionEventId = getUserReactionEventId(emoji);
|
||||
|
||||
// Trigger animation
|
||||
animatingReactions.add(emoji);
|
||||
setTimeout(() => {
|
||||
animatingReactions = new Set(
|
||||
[...animatingReactions].filter((e) => e !== emoji),
|
||||
);
|
||||
}, 300);
|
||||
|
||||
onToggleReaction?.(emoji, reactionEventId);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if reactions.size > 0}
|
||||
<div class="flex flex-wrap items-center gap-1 mt-1 ml-14">
|
||||
{#each [...reactions.entries()] as [emoji, userMap]}
|
||||
{@const hasReacted = userMap.has(currentUserId)}
|
||||
{@const reactionEventId = getUserReactionEventId(emoji)}
|
||||
{@const isPending = isPendingReaction(reactionEventId)}
|
||||
{@const isAnimating = animatingReactions.has(emoji)}
|
||||
<button
|
||||
class="reaction-badge flex items-center gap-1 px-2 py-0.5 rounded-full text-sm transition-all duration-200
|
||||
{hasReacted
|
||||
? 'bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30'
|
||||
: 'bg-light/10 hover:bg-light/20 text-light/60'}
|
||||
{isPending ? 'opacity-70 animate-pulse' : ''}
|
||||
{isAnimating ? 'scale-125' : 'scale-100'}"
|
||||
onclick={() => handleClick(emoji)}
|
||||
title={hasReacted ? "Remove reaction" : "Add reaction"}
|
||||
>
|
||||
<Twemoji {emoji} size={16} />
|
||||
<span>{userMap.size}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Add reaction button -->
|
||||
{#if !isRedacted}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded-full bg-light/5 hover:bg-light/10 text-light/40 hover:text-light/60 transition-colors"
|
||||
onclick={() => (showPicker = !showPicker)}
|
||||
title="Add reaction"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showPicker}
|
||||
<div class="absolute bottom-full left-0 mb-2 z-50">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
onReact?.(emoji);
|
||||
showPicker = false;
|
||||
}}
|
||||
onClose={() => (showPicker = false)}
|
||||
position={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal file
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
interface ReadReceipt {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
receipts: ReadReceipt[];
|
||||
}
|
||||
|
||||
let { receipts }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if receipts.length > 0}
|
||||
<div
|
||||
class="flex items-center gap-1 mt-1 ml-14"
|
||||
title="Read by {receipts.map((r) => r.name).join(', ')}"
|
||||
>
|
||||
<span class="text-xs text-light/40 mr-1">Read by</span>
|
||||
<div class="flex -space-x-1">
|
||||
{#each receipts.slice(0, 5) as reader}
|
||||
<div
|
||||
class="w-4 h-4 rounded-full bg-dark border border-night overflow-hidden"
|
||||
title={reader.name}
|
||||
>
|
||||
{#if reader.avatarUrl}
|
||||
<img
|
||||
src={reader.avatarUrl}
|
||||
alt={reader.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full bg-primary/50 flex items-center justify-center text-[8px] text-white"
|
||||
>
|
||||
{reader.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if receipts.length > 5}
|
||||
<div
|
||||
class="w-4 h-4 rounded-full bg-light/20 border border-night flex items-center justify-center text-[8px] text-light"
|
||||
>
|
||||
+{receipts.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
9
src/lib/components/message/parts/index.ts
Normal file
9
src/lib/components/message/parts/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Message parts barrel export
|
||||
*/
|
||||
|
||||
export { default as MessageContent } from './MessageContent.svelte';
|
||||
export { default as MessageMedia } from './MessageMedia.svelte';
|
||||
export { default as MessageReactions } from './MessageReactions.svelte';
|
||||
export { default as MessageActions } from './MessageActions.svelte';
|
||||
export { default as MessageReadReceipts } from './MessageReadReceipts.svelte';
|
||||
13
src/lib/components/message/utils/index.ts
Normal file
13
src/lib/components/message/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Message utilities barrel export
|
||||
*/
|
||||
|
||||
export {
|
||||
renderMarkdown,
|
||||
renderEmojisAsTwemoji,
|
||||
renderMentions,
|
||||
isEmojiOnly,
|
||||
formatTime,
|
||||
formatFullTime,
|
||||
formatFileSize,
|
||||
} from './markdown';
|
||||
111
src/lib/components/message/utils/markdown.test.ts
Normal file
111
src/lib/components/message/utils/markdown.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderMentions, isEmojiOnly, formatTime, formatFileSize } from './markdown';
|
||||
|
||||
describe('markdown utils', () => {
|
||||
describe('renderMentions', () => {
|
||||
it('renders @user:server.com as a mention button', () => {
|
||||
const result = renderMentions('Hello @alice:matrix.org');
|
||||
expect(result).toContain('class="mention-ping"');
|
||||
expect(result).toContain('data-user-id="@alice:matrix.org"');
|
||||
expect(result).toContain('@alice</button>');
|
||||
});
|
||||
|
||||
it('renders @everyone as a special mention', () => {
|
||||
const result = renderMentions('Hey @everyone');
|
||||
expect(result).toContain('mention-everyone');
|
||||
expect(result).toContain('@everyone');
|
||||
});
|
||||
|
||||
it('renders @here as a special mention', () => {
|
||||
const result = renderMentions('Attention @here');
|
||||
expect(result).toContain('mention-everyone');
|
||||
expect(result).toContain('@here');
|
||||
});
|
||||
|
||||
it('renders @room as a special mention', () => {
|
||||
const result = renderMentions('FYI @room');
|
||||
expect(result).toContain('mention-everyone');
|
||||
expect(result).toContain('@room');
|
||||
});
|
||||
|
||||
it('leaves plain text unchanged', () => {
|
||||
const result = renderMentions('Hello world');
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('handles multiple mentions', () => {
|
||||
const result = renderMentions('@alice:matrix.org and @bob:example.com');
|
||||
expect(result).toContain('data-user-id="@alice:matrix.org"');
|
||||
expect(result).toContain('data-user-id="@bob:example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmojiOnly', () => {
|
||||
it('returns true for single emoji', () => {
|
||||
expect(isEmojiOnly('😀')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for multiple emojis', () => {
|
||||
expect(isEmojiOnly('😀🎉🔥')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for emojis with spaces', () => {
|
||||
expect(isEmojiOnly('😀 🎉')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for text with emoji', () => {
|
||||
expect(isEmojiOnly('hello 😀')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain text', () => {
|
||||
expect(isEmojiOnly('hello world')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isEmojiOnly('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace only', () => {
|
||||
expect(isEmojiOnly(' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats timestamp to HH:MM', () => {
|
||||
// Create a date at 14:30
|
||||
const date = new Date(2024, 0, 15, 14, 30, 0);
|
||||
const result = formatTime(date.getTime());
|
||||
expect(result).toMatch(/14:30/);
|
||||
});
|
||||
|
||||
it('formats midnight correctly', () => {
|
||||
const date = new Date(2024, 0, 15, 0, 0, 0);
|
||||
const result = formatTime(date.getTime());
|
||||
expect(result).toMatch(/00:00/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(formatFileSize(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for 0', () => {
|
||||
expect(formatFileSize(0)).toBe('');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatFileSize(500)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/lib/components/message/utils/markdown.ts
Normal file
168
src/lib/components/message/utils/markdown.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Markdown rendering utilities for messages
|
||||
* Extracted from Message.svelte for reusability and testability
|
||||
*/
|
||||
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import { getTwemojiUrl } from '$lib/utils/twemoji';
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer for code blocks with syntax highlighting
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
const highlighted = hljs.highlight(text, { language }).value;
|
||||
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
||||
};
|
||||
|
||||
// LRU Cache for memoization (prevents memory leaks)
|
||||
class LRUCache<K, V> {
|
||||
private cache = new Map<K, V>();
|
||||
constructor(private maxSize: number) { }
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Delete oldest entry
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) this.cache.delete(firstKey);
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const markdownCache = new LRUCache<string, string>(200);
|
||||
|
||||
/**
|
||||
* Convert emoji characters to Twemoji images
|
||||
*/
|
||||
export function renderEmojisAsTwemoji(text: string): string {
|
||||
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
|
||||
|
||||
return text.replace(emojiRegex, (emoji) => {
|
||||
const url = getTwemojiUrl(emoji);
|
||||
return `<img class="twemoji-inline" src="${url}" alt="${emoji}" draggable="false" />`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render @mentions as styled buttons
|
||||
*/
|
||||
export function renderMentions(text: string): string {
|
||||
// Replace @userId mentions with styled spans
|
||||
let result = text.replace(
|
||||
/@([a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
|
||||
(match, userId) => {
|
||||
const displayName = userId.split(':')[0];
|
||||
return `<button class="mention-ping" data-user-id="@${userId}" onclick="window.dispatchEvent(new CustomEvent('show-user-profile', { detail: '@${userId}' }))">@${displayName}</button>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Handle @everyone and @here mentions
|
||||
result = result.replace(
|
||||
/@(everyone|here|room)\b/gi,
|
||||
'<span class="mention-ping mention-everyone">@$1</span>'
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown content with memoization
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
// Check cache first
|
||||
const cached = markdownCache.get(text);
|
||||
if (cached) return cached;
|
||||
|
||||
// First handle mentions
|
||||
let processed = renderMentions(text);
|
||||
|
||||
// Don't render markdown if it looks like plain text
|
||||
const hasMarkdown = /[*_`#\[\]!|]/.test(text);
|
||||
if (!hasMarkdown) {
|
||||
processed = renderEmojisAsTwemoji(processed);
|
||||
markdownCache.set(text, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
try {
|
||||
let result = marked.parse(processed, { async: false, renderer }) as string;
|
||||
result = renderEmojisAsTwemoji(result);
|
||||
markdownCache.set(text, result);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = renderEmojisAsTwemoji(processed);
|
||||
markdownCache.set(text, fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message is emoji-only
|
||||
*/
|
||||
export function isEmojiOnly(text: string): boolean {
|
||||
const emojiRegex = /^[\s\p{Emoji_Presentation}\p{Emoji}\uFE0F\u200D]*$/u;
|
||||
const hasEmoji = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u.test(text);
|
||||
return emojiRegex.test(text) && hasEmoji && text.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
export function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for full display
|
||||
*/
|
||||
export function formatFullTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
export function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -2,21 +2,39 @@
|
||||
interface Props {
|
||||
name: string;
|
||||
src?: string | null;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
status?: "online" | "offline" | "away" | "dnd" | null;
|
||||
}
|
||||
|
||||
let { name, src = null, size = "md" }: Props = $props();
|
||||
let { name, src = null, size = "md", status = null }: Props = $props();
|
||||
|
||||
const initial = $derived(name ? name[0].toUpperCase() : "?");
|
||||
|
||||
const sizes = {
|
||||
xs: { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-[12px]" },
|
||||
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
|
||||
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
|
||||
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
|
||||
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
|
||||
};
|
||||
|
||||
const statusSizes: Record<string, string> = {
|
||||
xs: "w-2 h-2",
|
||||
sm: "w-2.5 h-2.5",
|
||||
md: "w-3 h-3",
|
||||
lg: "w-3.5 h-3.5",
|
||||
xl: "w-4 h-4",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
online: "bg-success",
|
||||
offline: "bg-light/30",
|
||||
away: "bg-warning",
|
||||
dnd: "bg-error",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block shrink-0">
|
||||
{#if src}
|
||||
<img
|
||||
{src}
|
||||
@@ -28,8 +46,18 @@
|
||||
class="{sizes[size].box} {sizes[size]
|
||||
.radius} bg-primary flex items-center justify-center shrink-0"
|
||||
>
|
||||
<span class="font-heading {sizes[size].text} text-night leading-none">
|
||||
<span
|
||||
class="font-heading {sizes[size].text} text-night leading-none"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status}
|
||||
<div
|
||||
class="absolute bottom-0 right-0 rounded-full border-2 border-night {statusSizes[
|
||||
size
|
||||
]} {statusColors[status]}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
168
src/lib/components/ui/EmojiPicker.svelte
Normal file
168
src/lib/components/ui/EmojiPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import Twemoji from "./Twemoji.svelte";
|
||||
import {
|
||||
emojiData,
|
||||
searchEmojis,
|
||||
getEmojisByCategory,
|
||||
} from "$lib/utils/emojiData";
|
||||
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
let { onSelect, onClose, position = { x: 0, y: 0 } }: Props = $props();
|
||||
|
||||
let searchQuery = $state("");
|
||||
let activeCategory = $state("frequent");
|
||||
let pickerRef: HTMLDivElement | null = $state(null);
|
||||
let adjustedPosition = $state({ x: 0, y: 0 });
|
||||
|
||||
// Initialize position on first render
|
||||
$effect(() => {
|
||||
adjustedPosition = { x: position.x, y: position.y };
|
||||
});
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
$effect(() => {
|
||||
if (pickerRef) {
|
||||
const rect = pickerRef.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let newX = position.x;
|
||||
let newY = position.y;
|
||||
|
||||
// Adjust horizontal position
|
||||
if (newX + rect.width > viewportWidth - 10) {
|
||||
newX = viewportWidth - rect.width - 10;
|
||||
}
|
||||
if (newX < 10) newX = 10;
|
||||
|
||||
// Adjust vertical position
|
||||
if (newY + rect.height > viewportHeight - 10) {
|
||||
newY = position.y - rect.height - 40; // Position above the button
|
||||
}
|
||||
if (newY < 10) newY = 10;
|
||||
|
||||
adjustedPosition = { x: newX, y: newY };
|
||||
}
|
||||
});
|
||||
|
||||
// Emoji categories
|
||||
const categories = [
|
||||
{ id: "frequent", icon: "🕐", name: "Frequently Used" },
|
||||
{ id: "smileys", icon: "😀", name: "Smileys & Emotion" },
|
||||
{ id: "people", icon: "👋", name: "People & Body" },
|
||||
{ id: "nature", icon: "🐻", name: "Animals & Nature" },
|
||||
{ id: "food", icon: "🍕", name: "Food & Drink" },
|
||||
{ id: "activities", icon: "⚽", name: "Activities" },
|
||||
{ id: "travel", icon: "🚗", name: "Travel & Places" },
|
||||
{ id: "objects", icon: "💡", name: "Objects" },
|
||||
{ id: "symbols", icon: "❤️", name: "Symbols" },
|
||||
];
|
||||
|
||||
// Frequently used emojis
|
||||
const frequentEmojis = [
|
||||
"👍",
|
||||
"❤️",
|
||||
"😂",
|
||||
"🔥",
|
||||
"👀",
|
||||
"🙌",
|
||||
"💯",
|
||||
"✅",
|
||||
"❌",
|
||||
"🎉",
|
||||
"😮",
|
||||
"😢",
|
||||
];
|
||||
|
||||
const filteredEmojis = $derived(() => {
|
||||
if (searchQuery) {
|
||||
// Search using emoji names
|
||||
return searchEmojis(searchQuery).map((e) => e.emoji);
|
||||
}
|
||||
if (activeCategory === "frequent") {
|
||||
return frequentEmojis;
|
||||
}
|
||||
// Get emojis from the data file by category
|
||||
return getEmojisByCategory(activeCategory).map((e) => e.emoji);
|
||||
});
|
||||
|
||||
function handleSelect(emoji: string) {
|
||||
onSelect(emoji);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={pickerRef}
|
||||
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
|
||||
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Emoji picker"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Search bar -->
|
||||
<div class="p-2 border-b border-light/10">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Find the perfect emoji"
|
||||
class="w-full bg-night/50 border border-light/10 rounded-lg pl-10 pr-4 py-2 text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category tabs -->
|
||||
<div class="flex border-b border-light/10 px-1">
|
||||
{#each categories as category}
|
||||
<button
|
||||
class="p-2 hover:bg-light/5 rounded transition-colors {activeCategory ===
|
||||
category.id
|
||||
? 'bg-light/10'
|
||||
: ''}"
|
||||
onclick={() => {
|
||||
activeCategory = category.id;
|
||||
searchQuery = "";
|
||||
}}
|
||||
title={category.name}
|
||||
>
|
||||
<Twemoji emoji={category.icon} size={18} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Emoji grid -->
|
||||
<div class="h-[200px] overflow-y-auto p-2">
|
||||
<div class="text-xs text-light/50 font-medium mb-2 px-1">
|
||||
{categories.find((c) => c.id === activeCategory)?.name || "Emojis"}
|
||||
</div>
|
||||
<div class="grid grid-cols-8 gap-0.5">
|
||||
{#each filteredEmojis() as emoji}
|
||||
<button
|
||||
class="w-9 h-9 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => handleSelect(emoji)}
|
||||
>
|
||||
<Twemoji {emoji} size={22} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal file
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { src, alt = "", onClose }: Props = $props();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light"
|
||||
onclick={onClose}
|
||||
>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Image container -->
|
||||
<div class="max-w-[90vw] max-h-[90vh] flex items-center justify-center">
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Open in new tab button -->
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="absolute bottom-4 right-4 px-4 py-2 rounded-lg bg-light/10 hover:bg-light/20 transition-colors text-light text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
Open Original
|
||||
</a>
|
||||
</div>
|
||||
21
src/lib/components/ui/Twemoji.svelte
Normal file
21
src/lib/components/ui/Twemoji.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { getTwemojiUrl } from '$lib/utils/twemoji';
|
||||
|
||||
interface Props {
|
||||
emoji: string;
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { emoji, size = 20, class: className = '' }: Props = $props();
|
||||
|
||||
const url = $derived(getTwemojiUrl(emoji));
|
||||
</script>
|
||||
|
||||
<img
|
||||
src={url}
|
||||
alt={emoji}
|
||||
class="inline-block align-text-bottom {className}"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
draggable="false"
|
||||
/>
|
||||
114
src/lib/components/ui/VirtualList.svelte
Normal file
114
src/lib/components/ui/VirtualList.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
itemHeight: number;
|
||||
overscan?: number;
|
||||
containerClass?: string;
|
||||
getKey: (item: T, index: number) => string;
|
||||
children: import("svelte").Snippet<[T, number]>;
|
||||
onScrollTop?: () => void;
|
||||
onScrollBottom?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
itemHeight,
|
||||
overscan = 5,
|
||||
containerClass = "",
|
||||
getKey,
|
||||
children,
|
||||
onScrollTop,
|
||||
onScrollBottom,
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement | null = $state(null);
|
||||
let scrollTop = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
// Calculate visible range
|
||||
const visibleRange = $derived(() => {
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
|
||||
const endIndex = Math.min(items.length, startIndex + visibleCount);
|
||||
return { startIndex, endIndex };
|
||||
});
|
||||
|
||||
// Get visible items with their indices
|
||||
const visibleItems = $derived(() => {
|
||||
const { startIndex, endIndex } = visibleRange();
|
||||
return items.slice(startIndex, endIndex).map((item, i) => ({
|
||||
item,
|
||||
index: startIndex + i,
|
||||
}));
|
||||
});
|
||||
|
||||
// Total height of the list
|
||||
const totalHeight = $derived(items.length * itemHeight);
|
||||
|
||||
// Offset for visible items
|
||||
const offsetY = $derived(visibleRange().startIndex * itemHeight);
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
scrollTop = target.scrollTop;
|
||||
|
||||
// Check for scroll to top (load more)
|
||||
if (target.scrollTop < 100 && onScrollTop) {
|
||||
onScrollTop();
|
||||
}
|
||||
|
||||
// Check for scroll to bottom
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (distanceToBottom < 100 && onScrollBottom) {
|
||||
onScrollBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function updateContainerHeight() {
|
||||
if (containerRef) {
|
||||
containerHeight = containerRef.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateContainerHeight();
|
||||
const resizeObserver = new ResizeObserver(updateContainerHeight);
|
||||
if (containerRef) {
|
||||
resizeObserver.observe(containerRef);
|
||||
}
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
export async function scrollToBottom() {
|
||||
await tick();
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = containerRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to specific index
|
||||
export function scrollToIndex(index: number) {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = index * itemHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="overflow-y-auto {containerClass}"
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
<div style="height: {totalHeight}px; position: relative;">
|
||||
<div style="transform: translateY({offsetY}px);">
|
||||
{#each visibleItems() as { item, index } (getKey(item, index))}
|
||||
<div style="height: {itemHeight}px;">
|
||||
{@render children(item, index)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,3 +26,7 @@ export { default as Icon } from './Icon.svelte';
|
||||
export { default as AssigneePicker } from './AssigneePicker.svelte';
|
||||
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||
export { default as PageSkeleton } from './PageSkeleton.svelte';
|
||||
export { default as ImagePreviewModal } from './ImagePreviewModal.svelte';
|
||||
export { default as Twemoji } from './Twemoji.svelte';
|
||||
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
||||
export { default as VirtualList } from './VirtualList.svelte';
|
||||
|
||||
1107
src/lib/matrix/client.ts
Normal file
1107
src/lib/matrix/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
src/lib/matrix/context.ts
Normal file
101
src/lib/matrix/context.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Matrix Client Context
|
||||
*
|
||||
* Provides a Svelte Context-based approach for MatrixClient lifecycle management.
|
||||
* Replaces the module singleton pattern for better testability and explicit dependencies.
|
||||
*
|
||||
* Usage:
|
||||
* // In root layout or provider component:
|
||||
* setMatrixContext(client);
|
||||
*
|
||||
* // In any child component:
|
||||
* const client = getMatrixContext();
|
||||
*/
|
||||
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
// Unique symbol key for context (prevents collisions)
|
||||
const MATRIX_CLIENT_KEY = Symbol('matrix-client');
|
||||
|
||||
// ============================================================================
|
||||
// Context Types
|
||||
// ============================================================================
|
||||
|
||||
export interface MatrixClientContext {
|
||||
client: MatrixClient;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context Setters
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set the MatrixClient in Svelte context.
|
||||
* Must be called during component initialization (not in event handlers).
|
||||
*/
|
||||
export function setMatrixContext(client: MatrixClient): void {
|
||||
setContext<MatrixClientContext>(MATRIX_CLIENT_KEY, {
|
||||
client,
|
||||
isReady: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an uninitialized context (for loading states)
|
||||
*/
|
||||
export function setMatrixContextPending(): void {
|
||||
setContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY, null);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context Getters
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the MatrixClient from Svelte context.
|
||||
* Throws if context is not set or client is not ready.
|
||||
*
|
||||
* @throws Error if called outside of a component that has MatrixProvider as ancestor
|
||||
*/
|
||||
export function getMatrixContext(): MatrixClient {
|
||||
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'Matrix client not available. Ensure this component is wrapped in MatrixProvider.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx.isReady) {
|
||||
throw new Error('Matrix client is not ready yet.');
|
||||
}
|
||||
|
||||
return ctx.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MatrixClient context, returning null if not available.
|
||||
* Safe version that doesn't throw.
|
||||
*/
|
||||
export function getMatrixContextSafe(): MatrixClient | null {
|
||||
try {
|
||||
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
|
||||
return ctx?.isReady ? ctx.client : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Matrix context is available and ready
|
||||
*/
|
||||
export function hasMatrixContext(): boolean {
|
||||
try {
|
||||
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
|
||||
return ctx?.isReady ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
90
src/lib/matrix/index.ts
Normal file
90
src/lib/matrix/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Matrix Module Index
|
||||
*
|
||||
* Re-exports all Matrix-related functionality for convenient imports.
|
||||
*/
|
||||
|
||||
// Client
|
||||
export {
|
||||
initMatrixClient,
|
||||
loginWithPassword,
|
||||
getClient,
|
||||
isClientInitialized,
|
||||
stopClient,
|
||||
logout,
|
||||
getRooms,
|
||||
getRoom,
|
||||
sendMessage,
|
||||
sendReaction,
|
||||
removeReaction,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
createRoom,
|
||||
createSpace,
|
||||
addRoomToSpace,
|
||||
removeRoomFromSpace,
|
||||
getSpaceChildren,
|
||||
getSpaces,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
setTyping,
|
||||
markRoomAsRead,
|
||||
loadMoreMessages,
|
||||
isRoomEncrypted,
|
||||
getCryptoStatus,
|
||||
uploadFile,
|
||||
sendFileMessage,
|
||||
getMediaUrl,
|
||||
getAuthenticatedMediaUrl,
|
||||
getAuthenticatedThumbnailUrl,
|
||||
getRoomMembers,
|
||||
getRoomReadReceipts,
|
||||
getReadReceiptsForEvent,
|
||||
searchMessagesLocal,
|
||||
createDirectMessage,
|
||||
findExistingDM,
|
||||
searchUsers,
|
||||
getUserPresence,
|
||||
setPresence,
|
||||
getRoomMembersPresence,
|
||||
setRoomName,
|
||||
setRoomTopic,
|
||||
setRoomAvatar,
|
||||
getRoomNotificationLevel,
|
||||
setRoomNotificationLevel,
|
||||
getPinnedMessages,
|
||||
pinMessage,
|
||||
unpinMessage,
|
||||
type NotificationLevel,
|
||||
type LoginCredentials,
|
||||
type LoginWithPasswordParams,
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type MatrixEvent,
|
||||
} from './client';
|
||||
|
||||
// Sync
|
||||
export {
|
||||
setupSyncHandlers,
|
||||
removeSyncHandlers,
|
||||
} from './sync';
|
||||
|
||||
// Context (for dependency injection)
|
||||
export {
|
||||
setMatrixContext,
|
||||
getMatrixContext,
|
||||
getMatrixContextSafe,
|
||||
hasMatrixContext,
|
||||
type MatrixClientContext,
|
||||
} from './context';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SyncState,
|
||||
RoomMember,
|
||||
Message,
|
||||
RoomSummary,
|
||||
TypingInfo,
|
||||
ReadReceipt,
|
||||
Space,
|
||||
} from './types';
|
||||
79
src/lib/matrix/matrix-sdk-augment.d.ts
vendored
Normal file
79
src/lib/matrix/matrix-sdk-augment.d.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Matrix SDK Type Augmentations
|
||||
*
|
||||
* Provides extended client start options that include pendingEventOrdering.
|
||||
* Also augments TimelineEvents and AccountDataEvents with Matrix event types.
|
||||
*/
|
||||
|
||||
import 'matrix-js-sdk';
|
||||
|
||||
declare module 'matrix-js-sdk' {
|
||||
/**
|
||||
* Extended start client options that include pendingEventOrdering
|
||||
* which is supported by the SDK but missing from official types
|
||||
*/
|
||||
export interface IStartClientOpts {
|
||||
initialSyncLimit?: number;
|
||||
lazyLoadMembers?: boolean;
|
||||
pendingEventOrdering?: 'chronological' | 'detached';
|
||||
includeArchivedRooms?: boolean;
|
||||
filter?: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Augment TimelineEvents to include Matrix event types
|
||||
*/
|
||||
export interface TimelineEvents {
|
||||
'm.room.message': {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
'm.relates_to'?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
'm.in_reply_to'?: { event_id: string };
|
||||
};
|
||||
'm.new_content'?: {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
};
|
||||
url?: string;
|
||||
info?: Record<string, unknown>;
|
||||
};
|
||||
'm.reaction': {
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.annotation';
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
'm.room.redaction': {
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Augment AccountDataEvents to include Matrix account data types
|
||||
*/
|
||||
export interface AccountDataEvents {
|
||||
'm.direct': Record<string, string[]>;
|
||||
'm.push_rules': unknown;
|
||||
'm.ignored_user_list': { ignored_users: Record<string, object> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Augment StateEvents to include Space-related state events
|
||||
*/
|
||||
export interface StateEvents {
|
||||
'm.space.child': {
|
||||
via?: string[];
|
||||
suggested?: boolean;
|
||||
order?: string;
|
||||
};
|
||||
'm.space.parent': {
|
||||
via?: string[];
|
||||
canonical?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
103
src/lib/matrix/messageUtils.spec.ts
Normal file
103
src/lib/matrix/messageUtils.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getMessageType, stripReplyFallback, formatFileSize } from './messageUtils';
|
||||
|
||||
describe('messageUtils', () => {
|
||||
describe('getMessageType', () => {
|
||||
it('returns "image" for m.image msgtype', () => {
|
||||
expect(getMessageType('m.image')).toBe('image');
|
||||
});
|
||||
|
||||
it('returns "video" for m.video msgtype', () => {
|
||||
expect(getMessageType('m.video')).toBe('video');
|
||||
});
|
||||
|
||||
it('returns "audio" for m.audio msgtype', () => {
|
||||
expect(getMessageType('m.audio')).toBe('audio');
|
||||
});
|
||||
|
||||
it('returns "file" for m.file msgtype', () => {
|
||||
expect(getMessageType('m.file')).toBe('file');
|
||||
});
|
||||
|
||||
it('returns "notice" for m.notice msgtype', () => {
|
||||
expect(getMessageType('m.notice')).toBe('notice');
|
||||
});
|
||||
|
||||
it('returns "emote" for m.emote msgtype', () => {
|
||||
expect(getMessageType('m.emote')).toBe('emote');
|
||||
});
|
||||
|
||||
it('returns "text" for m.text msgtype', () => {
|
||||
expect(getMessageType('m.text')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns "text" for unknown msgtype', () => {
|
||||
expect(getMessageType('m.unknown')).toBe('text');
|
||||
expect(getMessageType('')).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripReplyFallback', () => {
|
||||
it('returns original content when hasReply is false', () => {
|
||||
const content = '> quoted text\n\nactual message';
|
||||
expect(stripReplyFallback(content, false)).toBe(content);
|
||||
});
|
||||
|
||||
it('strips single-line reply fallback', () => {
|
||||
const content = '> <@user:matrix.org> Hello\n\nMy reply';
|
||||
expect(stripReplyFallback(content, true)).toBe('My reply');
|
||||
});
|
||||
|
||||
it('strips multi-line reply fallback', () => {
|
||||
const content = '> <@user:matrix.org> Hello\n> This is a longer message\n\nMy reply';
|
||||
expect(stripReplyFallback(content, true)).toBe('My reply');
|
||||
});
|
||||
|
||||
it('handles empty content', () => {
|
||||
expect(stripReplyFallback('', true)).toBe('');
|
||||
expect(stripReplyFallback('', false)).toBe('');
|
||||
});
|
||||
|
||||
it('handles content with only reply fallback', () => {
|
||||
const content = '> <@user:matrix.org> Hello\n\n';
|
||||
expect(stripReplyFallback(content, true)).toBe('');
|
||||
});
|
||||
|
||||
it('preserves content after reply fallback', () => {
|
||||
const content = '> <@user:matrix.org> Hello\n\nFirst line\nSecond line';
|
||||
expect(stripReplyFallback(content, true)).toBe('First line\nSecond line');
|
||||
});
|
||||
|
||||
it('handles bare > lines', () => {
|
||||
const content = '>\n\nMy message';
|
||||
expect(stripReplyFallback(content, true)).toBe('My message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(formatFileSize(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for 0', () => {
|
||||
expect(formatFileSize(0)).toBe('');
|
||||
});
|
||||
|
||||
it('formats bytes correctly', () => {
|
||||
expect(formatFileSize(500)).toBe('500 B');
|
||||
expect(formatFileSize(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes correctly', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatFileSize(10240)).toBe('10.0 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes correctly', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||
expect(formatFileSize(10 * 1024 * 1024)).toBe('10.0 MB');
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/lib/matrix/messageUtils.ts
Normal file
63
src/lib/matrix/messageUtils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Message Utilities
|
||||
*
|
||||
* Shared utility functions for processing Matrix messages.
|
||||
*/
|
||||
|
||||
import type { Message } from './types';
|
||||
|
||||
/**
|
||||
* Determine message type from Matrix msgtype
|
||||
*/
|
||||
export function getMessageType(msgtype: string): Message['type'] {
|
||||
switch (msgtype) {
|
||||
case 'm.image':
|
||||
return 'image';
|
||||
case 'm.video':
|
||||
return 'video';
|
||||
case 'm.audio':
|
||||
return 'audio';
|
||||
case 'm.file':
|
||||
return 'file';
|
||||
case 'm.notice':
|
||||
return 'notice';
|
||||
case 'm.emote':
|
||||
return 'emote';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Matrix reply fallback from message content
|
||||
* Format: "> <@user> text\n\n actual message"
|
||||
*/
|
||||
export function stripReplyFallback(content: string, hasReply: boolean): string {
|
||||
if (!hasReply) return content;
|
||||
|
||||
const lines = content.split('\n');
|
||||
let startIndex = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('> ') || lines[i] === '>') {
|
||||
startIndex = i + 1;
|
||||
} else if (lines[i] === '') {
|
||||
startIndex = i + 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(startIndex).join('\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
export function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
75
src/lib/matrix/sdk-types.spec.ts
Normal file
75
src/lib/matrix/sdk-types.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
onClientEvent,
|
||||
removeClientEventListeners,
|
||||
type ClientEventName,
|
||||
type SyncStateValue,
|
||||
} from './sdk-types';
|
||||
|
||||
describe('sdk-types', () => {
|
||||
describe('onClientEvent', () => {
|
||||
it('calls client.on with the correct event name', () => {
|
||||
const mockClient = {
|
||||
on: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const handler = vi.fn();
|
||||
onClientEvent(mockClient, 'sync', handler);
|
||||
|
||||
expect(mockClient.on).toHaveBeenCalledWith('sync', handler);
|
||||
});
|
||||
|
||||
it('works with different event types', () => {
|
||||
const mockClient = {
|
||||
on: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const events: ClientEventName[] = [
|
||||
'sync',
|
||||
'Room',
|
||||
'Room.timeline',
|
||||
'Room.redaction',
|
||||
'RoomMember.typing',
|
||||
'RoomMember.membership',
|
||||
'RoomState.events',
|
||||
'User.presence',
|
||||
];
|
||||
|
||||
events.forEach((event) => {
|
||||
const handler = vi.fn();
|
||||
// Use type assertion since we're testing all event types in a loop
|
||||
(onClientEvent as (client: MatrixClient, event: string, handler: (...args: unknown[]) => void) => void)(mockClient, event, handler);
|
||||
expect(mockClient.on).toHaveBeenCalledWith(event, handler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeClientEventListeners', () => {
|
||||
it('calls client.removeAllListeners with the correct event name', () => {
|
||||
const mockClient = {
|
||||
removeAllListeners: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
removeClientEventListeners(mockClient, 'sync');
|
||||
|
||||
expect(mockClient.removeAllListeners).toHaveBeenCalledWith('sync');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncStateValue type', () => {
|
||||
it('accepts valid sync state values', () => {
|
||||
const states: SyncStateValue[] = [
|
||||
'STOPPED',
|
||||
'SYNCING',
|
||||
'PREPARED',
|
||||
'CATCHUP',
|
||||
'RECONNECTING',
|
||||
'ERROR',
|
||||
];
|
||||
|
||||
// This is a compile-time type check - if this compiles, the types are correct
|
||||
expect(states).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
249
src/lib/matrix/sdk-types.ts
Normal file
249
src/lib/matrix/sdk-types.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Matrix SDK Type Extensions
|
||||
*
|
||||
* Type declarations to extend matrix-js-sdk types and provide
|
||||
* better type safety for Matrix events and state.
|
||||
*/
|
||||
|
||||
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
|
||||
|
||||
// ============================================================================
|
||||
// Event Types
|
||||
// ============================================================================
|
||||
|
||||
/** Matrix event type strings */
|
||||
export type MatrixEventType =
|
||||
| 'm.room.message'
|
||||
| 'm.room.name'
|
||||
| 'm.room.topic'
|
||||
| 'm.room.avatar'
|
||||
| 'm.room.member'
|
||||
| 'm.room.pinned_events'
|
||||
| 'm.room.encryption'
|
||||
| 'm.reaction'
|
||||
| 'm.room.redaction';
|
||||
|
||||
/** Matrix state event type strings */
|
||||
export type MatrixStateEventType =
|
||||
| 'm.room.name'
|
||||
| 'm.room.topic'
|
||||
| 'm.room.avatar'
|
||||
| 'm.room.pinned_events'
|
||||
| 'm.room.encryption'
|
||||
| 'm.room.member';
|
||||
|
||||
/** Push rule scope */
|
||||
export type PushRuleScope = 'global' | 'device';
|
||||
|
||||
/** Push rule kind */
|
||||
export type PushRuleKind = 'override' | 'underride' | 'sender' | 'room' | 'content';
|
||||
|
||||
// ============================================================================
|
||||
// Client Event Names
|
||||
// ============================================================================
|
||||
|
||||
/** Client event names for event listeners */
|
||||
export type ClientEventName =
|
||||
| 'sync'
|
||||
| 'Room'
|
||||
| 'Room.timeline'
|
||||
| 'Room.redaction'
|
||||
| 'RoomMember.typing'
|
||||
| 'RoomMember.membership'
|
||||
| 'RoomState.events'
|
||||
| 'User.presence';
|
||||
|
||||
// ============================================================================
|
||||
// Sync State
|
||||
// ============================================================================
|
||||
|
||||
/** Sync state values */
|
||||
export type SyncStateValue =
|
||||
| 'STOPPED'
|
||||
| 'SYNCING'
|
||||
| 'PREPARED'
|
||||
| 'CATCHUP'
|
||||
| 'RECONNECTING'
|
||||
| 'ERROR';
|
||||
|
||||
// ============================================================================
|
||||
// Event Handlers
|
||||
// ============================================================================
|
||||
|
||||
/** Sync event handler */
|
||||
export type SyncEventHandler = (
|
||||
state: SyncStateValue,
|
||||
prevState: SyncStateValue | null,
|
||||
data?: { error?: Error }
|
||||
) => void;
|
||||
|
||||
/** Room event handler */
|
||||
export type RoomEventHandler = (room: Room) => void;
|
||||
|
||||
/** Room timeline event handler */
|
||||
export type RoomTimelineEventHandler = (
|
||||
event: MatrixEvent,
|
||||
room: Room | undefined,
|
||||
toStartOfTimeline: boolean
|
||||
) => void;
|
||||
|
||||
/** Room redaction event handler */
|
||||
export type RoomRedactionEventHandler = (
|
||||
event: MatrixEvent,
|
||||
room: Room
|
||||
) => void;
|
||||
|
||||
/** Room member typing event handler */
|
||||
export type RoomMemberTypingEventHandler = (
|
||||
event: MatrixEvent,
|
||||
member: RoomMember
|
||||
) => void;
|
||||
|
||||
/** Room member membership event handler */
|
||||
export type RoomMemberMembershipEventHandler = (
|
||||
event: MatrixEvent,
|
||||
member: RoomMember
|
||||
) => void;
|
||||
|
||||
/** Room state events handler */
|
||||
export type RoomStateEventsEventHandler = (event: MatrixEvent) => void;
|
||||
|
||||
/** User presence event handler */
|
||||
export type UserPresenceEventHandler = (
|
||||
event: MatrixEvent,
|
||||
user: { userId: string; presence: 'online' | 'offline' | 'unavailable' }
|
||||
) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Pinned Events Content
|
||||
// ============================================================================
|
||||
|
||||
/** Content for m.room.pinned_events state event */
|
||||
export interface PinnedEventsContent {
|
||||
pinned: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Content for m.room.avatar state event */
|
||||
export interface RoomAvatarContent {
|
||||
url: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type-safe Client Extensions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for client.on() with proper event typing
|
||||
*/
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'sync',
|
||||
handler: SyncEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'Room',
|
||||
handler: RoomEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'Room.timeline',
|
||||
handler: RoomTimelineEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'Room.redaction',
|
||||
handler: RoomRedactionEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'RoomMember.typing',
|
||||
handler: RoomMemberTypingEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'RoomMember.membership',
|
||||
handler: RoomMemberMembershipEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'RoomState.events',
|
||||
handler: RoomStateEventsEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: 'User.presence',
|
||||
handler: UserPresenceEventHandler
|
||||
): void;
|
||||
export function onClientEvent(
|
||||
client: MatrixClient,
|
||||
event: ClientEventName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (...args: any[]) => void
|
||||
): void {
|
||||
// The SDK's type definitions are incomplete, so we use type assertion here
|
||||
// but expose a type-safe API to consumers
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(client as any).on(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for client.removeAllListeners()
|
||||
*/
|
||||
export function removeClientEventListeners(
|
||||
client: MatrixClient,
|
||||
event: ClientEventName
|
||||
): void {
|
||||
(client as any).removeAllListeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for sendStateEvent
|
||||
*/
|
||||
export async function sendTypedStateEvent<T extends Record<string, unknown>>(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventType: MatrixStateEventType,
|
||||
content: T,
|
||||
stateKey = ''
|
||||
): Promise<{ event_id: string }> {
|
||||
return (client as any).sendStateEvent(roomId, eventType, content, stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for getStateEvents
|
||||
*/
|
||||
export function getTypedStateEvent(
|
||||
room: Room,
|
||||
eventType: MatrixStateEventType,
|
||||
stateKey = ''
|
||||
): MatrixEvent | null {
|
||||
return room.currentState.getStateEvents(eventType as any, stateKey) as MatrixEvent | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for push rules
|
||||
*/
|
||||
export async function addTypedPushRule(
|
||||
client: MatrixClient,
|
||||
scope: PushRuleScope,
|
||||
kind: PushRuleKind,
|
||||
ruleId: string,
|
||||
body: { actions: string[]; conditions?: unknown[] }
|
||||
): Promise<void> {
|
||||
await (client as any).addPushRule(scope, kind, ruleId, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for deleting push rules
|
||||
*/
|
||||
export async function deleteTypedPushRule(
|
||||
client: MatrixClient,
|
||||
scope: PushRuleScope,
|
||||
kind: PushRuleKind,
|
||||
ruleId: string
|
||||
): Promise<void> {
|
||||
await (client as any).deletePushRule(scope, kind, ruleId);
|
||||
}
|
||||
217
src/lib/matrix/sync.ts
Normal file
217
src/lib/matrix/sync.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Matrix Sync Handler
|
||||
*
|
||||
* Manages the Matrix sync loop and updates Svelte stores accordingly.
|
||||
*/
|
||||
|
||||
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
syncState,
|
||||
syncError,
|
||||
typingByRoom,
|
||||
refreshRooms,
|
||||
syncRoomsFromEvent,
|
||||
upsertRoom,
|
||||
addMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
loadRoomMessages,
|
||||
updatePresence,
|
||||
selectedRoomId
|
||||
} from '$lib/stores/matrix';
|
||||
import type { Message } from '$lib/matrix/types';
|
||||
import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils';
|
||||
import { onClientEvent, removeClientEventListeners, type SyncStateValue } from '$lib/matrix/sdk-types';
|
||||
|
||||
/**
|
||||
* Set up event listeners on the Matrix client to sync with Svelte stores
|
||||
*/
|
||||
export function setupSyncHandlers(client: MatrixClient): void {
|
||||
// Sync state changes
|
||||
onClientEvent(client, 'sync', (state, prevState, data) => {
|
||||
syncState.set(state as SyncStateValue);
|
||||
|
||||
if (state === 'ERROR') {
|
||||
syncError.set(data?.error?.message || 'Sync error');
|
||||
} else {
|
||||
syncError.set(null);
|
||||
}
|
||||
|
||||
// When sync is prepared, load rooms and refresh selected room messages
|
||||
if (state === 'PREPARED' || state === 'SYNCING') {
|
||||
refreshRooms();
|
||||
|
||||
// On initial sync completion, reload messages for the selected room
|
||||
// This ensures we have the canonical state and removes any duplicates
|
||||
// that may have been added during the sync process
|
||||
if (state === 'PREPARED') {
|
||||
const currentRoomId = get(selectedRoomId);
|
||||
if (currentRoomId) {
|
||||
loadRoomMessages(currentRoomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// New room events - use targeted update
|
||||
onClientEvent(client, 'Room', (room: Room) => {
|
||||
if (room?.roomId) {
|
||||
syncRoomsFromEvent('join', room.roomId);
|
||||
}
|
||||
});
|
||||
|
||||
// Consolidated Room.timeline event dispatcher
|
||||
// Handles messages, edits, and reactions in a single pass
|
||||
onClientEvent(client, 'Room.timeline', (event, room, toStartOfTimeline) => {
|
||||
if (!room || toStartOfTimeline) return;
|
||||
|
||||
const eventType = event.getType();
|
||||
const content = event.getContent();
|
||||
const sender = event.getSender();
|
||||
|
||||
// Dispatch based on event type
|
||||
switch (eventType) {
|
||||
case 'm.room.message': {
|
||||
if (!sender) return;
|
||||
|
||||
// Skip edit events - handled by reloading messages
|
||||
if (content['m.relates_to']?.rel_type === 'm.replace') {
|
||||
loadRoomMessages(room.roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get sender info
|
||||
const member = room.getMember(sender);
|
||||
const senderName = member?.name || sender;
|
||||
const senderAvatar = member?.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
|
||||
|
||||
// Determine message type
|
||||
const type = getMessageType(content.msgtype || 'm.text');
|
||||
|
||||
// Strip reply fallback from content
|
||||
const hasReply = !!content['m.relates_to']?.['m.in_reply_to'];
|
||||
const messageContent = stripReplyFallback(content.body || '', hasReply);
|
||||
|
||||
const message: Message = {
|
||||
eventId: event.getId() || '',
|
||||
roomId: room.roomId,
|
||||
sender,
|
||||
senderName,
|
||||
senderAvatar,
|
||||
content: messageContent,
|
||||
timestamp: event.getTs(),
|
||||
type,
|
||||
isEdited: false,
|
||||
isRedacted: false,
|
||||
replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id,
|
||||
reactions: new Map(),
|
||||
};
|
||||
|
||||
addMessage(room.roomId, message);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'm.reaction': {
|
||||
const relatesTo = content['m.relates_to'];
|
||||
if (relatesTo?.rel_type === 'm.annotation') {
|
||||
const targetEventId = relatesTo.event_id;
|
||||
const emoji = relatesTo.key;
|
||||
const reactionEventId = event.getId();
|
||||
|
||||
if (targetEventId && emoji && sender && reactionEventId) {
|
||||
addReaction(room.roomId, targetEventId, emoji, sender, reactionEventId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Consolidated Room.redaction handler
|
||||
onClientEvent(client, 'Room.redaction', (event, room) => {
|
||||
if (!room) return;
|
||||
// Reload messages to reflect redactions (both messages and reactions)
|
||||
loadRoomMessages(room.roomId);
|
||||
});
|
||||
|
||||
// Typing indicators
|
||||
onClientEvent(client, 'RoomMember.typing', (event) => {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) return;
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
// Get list of typing users (excluding self)
|
||||
const typingMembers = room.currentState.getStateEvents('m.room.member')
|
||||
.filter((e: MatrixEvent) => {
|
||||
const userId = e.getStateKey();
|
||||
return userId !== client.getUserId();
|
||||
})
|
||||
.map((e: MatrixEvent) => e.getStateKey() || '')
|
||||
.filter((userId: string) => {
|
||||
// Check if user is actually typing
|
||||
const memberEvent = room.getMember(userId);
|
||||
return memberEvent?.typing;
|
||||
});
|
||||
|
||||
typingByRoom.update(map => {
|
||||
map.set(roomId, typingMembers);
|
||||
return new Map(map);
|
||||
});
|
||||
});
|
||||
|
||||
// Room membership changes - targeted update for specific room
|
||||
onClientEvent(client, 'RoomMember.membership', (event: MatrixEvent, member: RoomMember) => {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) return;
|
||||
|
||||
const membership = member?.membership;
|
||||
if (membership === 'join') {
|
||||
syncRoomsFromEvent('join', roomId);
|
||||
} else if (membership === 'leave' || membership === 'ban') {
|
||||
// Check if it's the current user leaving
|
||||
const userId = member?.userId;
|
||||
if (userId === client.getUserId()) {
|
||||
syncRoomsFromEvent('leave', roomId);
|
||||
} else {
|
||||
// Another user left/was banned - just update the room
|
||||
syncRoomsFromEvent('update', roomId);
|
||||
}
|
||||
} else {
|
||||
syncRoomsFromEvent('update', roomId);
|
||||
}
|
||||
});
|
||||
|
||||
// Room state changes (name, avatar) - targeted update
|
||||
onClientEvent(client, 'RoomState.events', (event: MatrixEvent) => {
|
||||
const eventType = event.getType();
|
||||
if (eventType === 'm.room.name' || eventType === 'm.room.avatar') {
|
||||
const roomId = event.getRoomId();
|
||||
if (roomId) {
|
||||
syncRoomsFromEvent('update', roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// User presence events
|
||||
onClientEvent(client, 'User.presence', (event, user) => {
|
||||
if (!user?.userId) return;
|
||||
updatePresence(user.userId, user.presence || 'offline');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listeners (call on logout/cleanup)
|
||||
*/
|
||||
export function removeSyncHandlers(client: MatrixClient): void {
|
||||
removeClientEventListeners(client, 'sync');
|
||||
removeClientEventListeners(client, 'Room');
|
||||
removeClientEventListeners(client, 'Room.timeline');
|
||||
removeClientEventListeners(client, 'RoomMember.typing');
|
||||
removeClientEventListeners(client, 'RoomMember.membership');
|
||||
removeClientEventListeners(client, 'RoomState.events');
|
||||
removeClientEventListeners(client, 'Room.redaction');
|
||||
removeClientEventListeners(client, 'User.presence');
|
||||
}
|
||||
88
src/lib/matrix/types.ts
Normal file
88
src/lib/matrix/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Matrix Types
|
||||
*
|
||||
* Type definitions for Matrix events and data structures
|
||||
*/
|
||||
|
||||
export type SyncState = 'STOPPED' | 'SYNCING' | 'PREPARED' | 'CATCHUP' | 'RECONNECTING' | 'ERROR';
|
||||
|
||||
export type PresenceState = 'online' | 'offline' | 'unavailable';
|
||||
|
||||
export interface UserPresence {
|
||||
userId: string;
|
||||
presence: PresenceState;
|
||||
lastActiveAgo?: number;
|
||||
statusMsg?: string;
|
||||
currentlyActive?: boolean;
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
membership: 'join' | 'invite' | 'leave' | 'ban';
|
||||
powerLevel: number;
|
||||
presence?: PresenceState;
|
||||
}
|
||||
|
||||
export interface MediaInfo {
|
||||
url: string; // mxc:// URL
|
||||
httpUrl?: string; // HTTP URL for display
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
filename?: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
senderAvatar: string | null;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'notice' | 'emote';
|
||||
isEdited: boolean;
|
||||
isRedacted: boolean;
|
||||
isPending?: boolean; // True while message is being sent
|
||||
replyTo?: string;
|
||||
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
|
||||
media?: MediaInfo; // For image/video/audio/file messages
|
||||
}
|
||||
|
||||
export interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
topic: string | null;
|
||||
isDirect: boolean;
|
||||
isEncrypted: boolean;
|
||||
isSpace: boolean; // True if this is a space (organization)
|
||||
parentSpaceId: string | null; // The space this room belongs to, null for orphan rooms
|
||||
memberCount: number;
|
||||
unreadCount: number;
|
||||
lastMessage: Message | null;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
export interface TypingInfo {
|
||||
roomId: string;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
export interface ReadReceipt {
|
||||
eventId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
roomId: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
childRooms: string[];
|
||||
childSpaces: string[];
|
||||
}
|
||||
23
src/lib/services/index.ts
Normal file
23
src/lib/services/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Services Barrel Export
|
||||
*
|
||||
* Centralized exports for all service modules.
|
||||
*/
|
||||
|
||||
export {
|
||||
reactionService,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
toggleReaction,
|
||||
clearPendingOperations,
|
||||
isOperationPending,
|
||||
hasPendingOperations,
|
||||
pendingReactionsList,
|
||||
categorizeReactionError,
|
||||
isTransientError,
|
||||
ReactionErrorType,
|
||||
type ReactionOperation,
|
||||
type ReactionOperationType,
|
||||
type ReactionOperationStatus,
|
||||
type CategorizedError,
|
||||
} from './reactions';
|
||||
354
src/lib/services/reactions.ts
Normal file
354
src/lib/services/reactions.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Reaction Service
|
||||
*
|
||||
* Handles all reaction operations with optimistic updates, idempotency checks,
|
||||
* and proper error categorization. Extracted from +page.svelte to achieve
|
||||
* separation of concerns.
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import {
|
||||
sendReaction as matrixSendReaction,
|
||||
removeReaction as matrixRemoveReaction,
|
||||
} from '$lib/matrix/client';
|
||||
import {
|
||||
addReaction as storeAddReaction,
|
||||
removeReaction as storeRemoveReaction,
|
||||
} from '$lib/stores/matrix';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type ReactionOperationType = 'add' | 'remove';
|
||||
export type ReactionOperationStatus = 'pending' | 'success' | 'error';
|
||||
|
||||
export interface ReactionOperation {
|
||||
roomId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
type: ReactionOperationType;
|
||||
status: ReactionOperationStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error categories for reaction operations.
|
||||
* Using typed discrimination instead of string matching.
|
||||
*/
|
||||
export enum ReactionErrorType {
|
||||
/** User already has this reaction - idempotent, safe to ignore */
|
||||
AlreadyReacted = 'ALREADY_REACTED',
|
||||
/** Duplicate request in flight */
|
||||
DuplicateRequest = 'DUPLICATE_REQUEST',
|
||||
/** SDK internal state issue */
|
||||
SdkStateError = 'SDK_STATE_ERROR',
|
||||
/** Network connectivity issue */
|
||||
NetworkError = 'NETWORK_ERROR',
|
||||
/** Unknown/unexpected error */
|
||||
Unknown = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export interface CategorizedError {
|
||||
type: ReactionErrorType;
|
||||
message: string;
|
||||
original: unknown;
|
||||
isTransient: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Categorization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Categorize an error into a typed discrimination.
|
||||
* Replaces the string-matching `isIgnorableReactionError` function.
|
||||
*/
|
||||
export function categorizeReactionError(error: unknown): CategorizedError {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Already reacted - idempotent operation
|
||||
if (lowerMessage.includes('already') || lowerMessage.includes('duplicate')) {
|
||||
return {
|
||||
type: ReactionErrorType.AlreadyReacted,
|
||||
message: 'Reaction already exists',
|
||||
original: error,
|
||||
isTransient: true,
|
||||
};
|
||||
}
|
||||
|
||||
// SDK state errors (chronological ordering, pending events)
|
||||
if (lowerMessage.includes('chronological') || lowerMessage.includes('pending')) {
|
||||
return {
|
||||
type: ReactionErrorType.SdkStateError,
|
||||
message: 'SDK state synchronization issue',
|
||||
original: error,
|
||||
isTransient: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
lowerMessage.includes('networkerror') ||
|
||||
lowerMessage.includes('fetch failed') ||
|
||||
lowerMessage.includes('network')
|
||||
) {
|
||||
return {
|
||||
type: ReactionErrorType.NetworkError,
|
||||
message: 'Network connectivity issue',
|
||||
original: error,
|
||||
isTransient: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown error - not transient, should be reported
|
||||
return {
|
||||
type: ReactionErrorType.Unknown,
|
||||
message,
|
||||
original: error,
|
||||
isTransient: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should be silently ignored (transient errors)
|
||||
*/
|
||||
export function isTransientError(error: unknown): boolean {
|
||||
return categorizeReactionError(error).isTransient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reaction Service Store
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Internal store for tracking pending operations.
|
||||
* Key format: `${roomId}:${messageId}:${emoji}`
|
||||
*/
|
||||
const pendingOperations = writable<Map<string, ReactionOperation>>(new Map());
|
||||
|
||||
/**
|
||||
* Derived store: Check if any operations are pending
|
||||
*/
|
||||
export const hasPendingOperations = derived(
|
||||
pendingOperations,
|
||||
($ops) => $ops.size > 0
|
||||
);
|
||||
|
||||
/**
|
||||
* Derived store: Get all pending operations as array
|
||||
*/
|
||||
export const pendingReactionsList = derived(
|
||||
pendingOperations,
|
||||
($ops) => Array.from($ops.values())
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Service Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique key for a reaction operation
|
||||
*/
|
||||
function getOperationKey(roomId: string, messageId: string, emoji: string): string {
|
||||
return `${roomId}:${messageId}:${emoji}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation is currently pending
|
||||
*/
|
||||
export function isOperationPending(roomId: string, messageId: string, emoji: string): boolean {
|
||||
const key = getOperationKey(roomId, messageId, emoji);
|
||||
return get(pendingOperations).has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary event ID for optimistic updates
|
||||
*/
|
||||
function generateTempEventId(): string {
|
||||
return `~pending-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reaction to a message.
|
||||
* Applies optimistic update immediately, then confirms with server.
|
||||
* On failure, rolls back the optimistic update without full reload.
|
||||
*
|
||||
* @returns Promise that resolves on success, rejects with CategorizedError on failure
|
||||
*/
|
||||
export async function addReaction(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const key = getOperationKey(roomId, messageId, emoji);
|
||||
|
||||
// Idempotency check - prevent duplicate in-flight requests
|
||||
if (get(pendingOperations).has(key)) {
|
||||
return; // Silently ignore duplicate request
|
||||
}
|
||||
|
||||
// Generate temporary ID for optimistic update
|
||||
const tempEventId = generateTempEventId();
|
||||
|
||||
// Track the operation with temp ID for potential rollback
|
||||
pendingOperations.update((ops) => {
|
||||
ops.set(key, {
|
||||
roomId,
|
||||
messageId,
|
||||
emoji,
|
||||
type: 'add',
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return new Map(ops);
|
||||
});
|
||||
|
||||
// OPTIMISTIC UPDATE: Add reaction to store immediately
|
||||
storeAddReaction(roomId, messageId, emoji, userId, tempEventId);
|
||||
|
||||
try {
|
||||
// Send to Matrix server
|
||||
await matrixSendReaction(roomId, messageId, emoji);
|
||||
|
||||
// Success - SDK sync will replace temp ID with real event ID
|
||||
// Clean up pending state
|
||||
pendingOperations.update((ops) => {
|
||||
ops.delete(key);
|
||||
return new Map(ops);
|
||||
});
|
||||
} catch (error) {
|
||||
// ROLLBACK: Remove the optimistic reaction
|
||||
storeRemoveReaction(roomId, messageId, emoji, userId);
|
||||
|
||||
// Clean up pending state
|
||||
pendingOperations.update((ops) => {
|
||||
ops.delete(key);
|
||||
return new Map(ops);
|
||||
});
|
||||
|
||||
const categorized = categorizeReactionError(error);
|
||||
|
||||
// Only throw for non-transient errors
|
||||
if (!categorized.isTransient) {
|
||||
throw categorized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message.
|
||||
* Applies optimistic update immediately, then confirms with server.
|
||||
* On failure, rolls back by re-adding the reaction without full reload.
|
||||
*
|
||||
* @returns Promise that resolves on success, rejects with CategorizedError on failure
|
||||
*/
|
||||
export async function removeReaction(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
userId: string,
|
||||
reactionEventId: string
|
||||
): Promise<void> {
|
||||
const key = getOperationKey(roomId, messageId, emoji);
|
||||
|
||||
// Idempotency check
|
||||
if (get(pendingOperations).has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track the operation
|
||||
pendingOperations.update((ops) => {
|
||||
ops.set(key, {
|
||||
roomId,
|
||||
messageId,
|
||||
emoji,
|
||||
type: 'remove',
|
||||
status: 'pending',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return new Map(ops);
|
||||
});
|
||||
|
||||
// OPTIMISTIC UPDATE: Remove from store immediately
|
||||
storeRemoveReaction(roomId, messageId, emoji, userId);
|
||||
|
||||
try {
|
||||
// Send redaction to Matrix server
|
||||
await matrixRemoveReaction(roomId, reactionEventId);
|
||||
|
||||
// Clean up pending state
|
||||
pendingOperations.update((ops) => {
|
||||
ops.delete(key);
|
||||
return new Map(ops);
|
||||
});
|
||||
} catch (error) {
|
||||
// ROLLBACK: Re-add the reaction we just removed
|
||||
storeAddReaction(roomId, messageId, emoji, userId, reactionEventId);
|
||||
|
||||
// Clean up pending state
|
||||
pendingOperations.update((ops) => {
|
||||
ops.delete(key);
|
||||
return new Map(ops);
|
||||
});
|
||||
|
||||
const categorized = categorizeReactionError(error);
|
||||
|
||||
// Only throw for non-transient errors
|
||||
if (!categorized.isTransient) {
|
||||
throw categorized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a reaction on a message.
|
||||
* If user has reacted, removes; otherwise adds.
|
||||
*
|
||||
* @param reactionEventId - The event ID of existing reaction (null if not reacted)
|
||||
*/
|
||||
export async function toggleReaction(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
userId: string,
|
||||
reactionEventId: string | null
|
||||
): Promise<void> {
|
||||
if (reactionEventId) {
|
||||
// User has already reacted - remove it
|
||||
await removeReaction(roomId, messageId, emoji, userId, reactionEventId);
|
||||
} else {
|
||||
// User hasn't reacted - add it
|
||||
await addReaction(roomId, messageId, emoji, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending operations (e.g., on logout or room switch)
|
||||
*/
|
||||
export function clearPendingOperations(): void {
|
||||
pendingOperations.set(new Map());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exports
|
||||
// ============================================================================
|
||||
|
||||
export const reactionService = {
|
||||
// Actions
|
||||
add: addReaction,
|
||||
remove: removeReaction,
|
||||
toggle: toggleReaction,
|
||||
clear: clearPendingOperations,
|
||||
|
||||
// Queries
|
||||
isPending: isOperationPending,
|
||||
hasPending: hasPendingOperations,
|
||||
pendingList: pendingReactionsList,
|
||||
|
||||
// Error handling
|
||||
categorizeError: categorizeReactionError,
|
||||
isTransient: isTransientError,
|
||||
};
|
||||
841
src/lib/stores/matrix.ts
Normal file
841
src/lib/stores/matrix.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
/**
|
||||
* Matrix Stores
|
||||
*
|
||||
* Reactive Svelte stores that sync with Matrix client state.
|
||||
* These stores are the single source of truth for Matrix data in the UI.
|
||||
*/
|
||||
|
||||
import { writable, derived, get, readable } from 'svelte/store';
|
||||
import type { Room, MatrixEvent, MatrixClient } from 'matrix-js-sdk';
|
||||
import type { SyncState, RoomSummary, Message, TypingInfo, MediaInfo } from '$lib/matrix/types';
|
||||
import { getClient, isClientInitialized, isRoomEncrypted } from '$lib/matrix/client';
|
||||
import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils';
|
||||
import {
|
||||
initCache,
|
||||
cacheMessages,
|
||||
getCachedMessages,
|
||||
cacheRooms,
|
||||
getCachedRooms,
|
||||
isCacheAvailable,
|
||||
} from '$lib/cache';
|
||||
|
||||
// ============================================================================
|
||||
// Auth State
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
userId: string | null;
|
||||
homeserverUrl: string | null;
|
||||
accessToken: string | null;
|
||||
deviceId: string | null;
|
||||
}
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
homeserverUrl: null,
|
||||
accessToken: null,
|
||||
deviceId: null,
|
||||
};
|
||||
|
||||
export const auth = writable<AuthState>(initialAuthState);
|
||||
|
||||
// ============================================================================
|
||||
// Sync State
|
||||
// ============================================================================
|
||||
|
||||
export const syncState = writable<SyncState>('STOPPED');
|
||||
export const syncError = writable<string | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Rooms (Normalized Store Architecture)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* PRIMARY STORE: Normalized Map<roomId, Room>
|
||||
* All room operations are O(1) - no secondary index needed
|
||||
*/
|
||||
const _roomsById = writable<Map<string, Room>>(new Map());
|
||||
|
||||
/**
|
||||
* DERIVED: Array view for iteration (computed from Map)
|
||||
* Used by components that need to iterate over rooms
|
||||
*/
|
||||
export const rooms = derived(_roomsById, ($map) => [...$map.values()]);
|
||||
|
||||
/**
|
||||
* O(1) room lookup by ID - direct Map access
|
||||
*/
|
||||
export function getRoom(roomId: string): Room | undefined {
|
||||
return get(_roomsById).get(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) upsert - no index maintenance required
|
||||
*/
|
||||
export function upsertRoom(room: Room): void {
|
||||
_roomsById.update(map => {
|
||||
const newMap = new Map(map);
|
||||
newMap.set(room.roomId, room);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) remove - no rebuild required
|
||||
*/
|
||||
export function removeRoom(roomId: string): void {
|
||||
_roomsById.update(map => {
|
||||
if (!map.has(roomId)) return map;
|
||||
const newMap = new Map(map);
|
||||
newMap.delete(roomId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk set rooms from SDK - used on initial sync
|
||||
*/
|
||||
function setRoomsFromSDK(): void {
|
||||
if (!isClientInitialized()) return;
|
||||
const client = getClient();
|
||||
const joinedRooms = client.getRooms().filter(r => r.getMyMembership() === 'join');
|
||||
|
||||
const roomMap = new Map<string, Room>();
|
||||
joinedRooms.forEach(room => roomMap.set(room.roomId, room));
|
||||
_roomsById.set(roomMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use targeted update functions instead
|
||||
* Kept for backward compatibility during migration
|
||||
*/
|
||||
export function refreshRooms(): void {
|
||||
setRoomsFromSDK();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync rooms from SDK for a specific event type
|
||||
* All operations are O(1)
|
||||
*/
|
||||
export function syncRoomsFromEvent(eventType: 'join' | 'leave' | 'update', roomId?: string): void {
|
||||
if (!isClientInitialized()) return;
|
||||
const client = getClient();
|
||||
|
||||
switch (eventType) {
|
||||
case 'join': {
|
||||
if (roomId) {
|
||||
const room = client.getRoom(roomId);
|
||||
if (room && room.getMyMembership() === 'join') {
|
||||
upsertRoom(room);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'leave': {
|
||||
if (roomId) {
|
||||
removeRoom(roomId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
if (roomId) {
|
||||
const room = client.getRoom(roomId);
|
||||
if (room && room.getMyMembership() === 'join') {
|
||||
upsertRoom(room);
|
||||
}
|
||||
} else {
|
||||
setRoomsFromSDK();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const selectedRoomId = writable<string | null>(null);
|
||||
|
||||
/**
|
||||
* O(1) selected room lookup - uses Map directly
|
||||
*/
|
||||
export const selectedRoom = derived(
|
||||
[_roomsById, selectedRoomId],
|
||||
([$roomsById, $selectedRoomId]) => {
|
||||
if (!$selectedRoomId) return null;
|
||||
return $roomsById.get($selectedRoomId) ?? null;
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Room Summaries (Memoized Derived Store)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Memoization cache for room summaries
|
||||
* Only recomputes when room IDs change or activity timestamps change
|
||||
*/
|
||||
interface RoomSummaryCache {
|
||||
roomIds: Set<string>;
|
||||
lastActivityMap: Map<string, number>;
|
||||
summaries: RoomSummary[];
|
||||
}
|
||||
|
||||
let _summaryCache: RoomSummaryCache | null = null;
|
||||
|
||||
/**
|
||||
* Check if cache is valid (room set unchanged and no activity changes)
|
||||
*/
|
||||
function isSummaryCacheValid(currentRooms: Room[]): boolean {
|
||||
if (!_summaryCache) return false;
|
||||
|
||||
// Check if room count changed
|
||||
if (currentRooms.length !== _summaryCache.roomIds.size) return false;
|
||||
|
||||
// Check if any room was added/removed or activity changed
|
||||
for (const room of currentRooms) {
|
||||
if (!_summaryCache.roomIds.has(room.roomId)) return false;
|
||||
|
||||
const lastEvent = room.timeline[room.timeline.length - 1];
|
||||
const lastActivity = lastEvent?.getTs() || 0;
|
||||
const cachedActivity = _summaryCache.lastActivityMap.get(room.roomId) || 0;
|
||||
|
||||
if (lastActivity !== cachedActivity) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a Room to RoomSummary
|
||||
*/
|
||||
function roomToSummary(room: Room, spaceChildMap: Map<string, string>): RoomSummary {
|
||||
const lastEvent = room.timeline[room.timeline.length - 1];
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null;
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
const isSpace = roomType === 'm.space';
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
name: room.name || 'Unnamed Room',
|
||||
avatarUrl: room.getAvatarUrl(getClient()?.baseUrl || '', 40, 40, 'crop') || null,
|
||||
topic: (room.currentState.getStateEvents('m.room.topic', '') as MatrixEvent | null)?.getContent()?.topic || null,
|
||||
isDirect: room.getDMInviter() !== undefined,
|
||||
isEncrypted: room.hasEncryptionStateEvent(),
|
||||
isSpace,
|
||||
parentSpaceId: spaceChildMap.get(room.roomId) || null,
|
||||
memberCount: room.getJoinedMemberCount(),
|
||||
unreadCount: room.getUnreadNotificationCount() || 0,
|
||||
lastMessage: lastEvent ? eventToMessage(lastEvent, room) : null,
|
||||
lastActivity: lastEvent?.getTs() || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build space-child mapping for parent space detection
|
||||
*/
|
||||
function buildSpaceChildMap(rooms: Room[]): Map<string, string> {
|
||||
const spaceChildMap = new Map<string, string>();
|
||||
|
||||
for (const room of rooms) {
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null;
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
|
||||
if (roomType === 'm.space') {
|
||||
const childEvents = room.currentState.getStateEvents('m.space.child');
|
||||
if (Array.isArray(childEvents)) {
|
||||
for (const event of childEvents) {
|
||||
const childId = (event as MatrixEvent).getStateKey();
|
||||
if (childId && (event as MatrixEvent).getContent()?.via) {
|
||||
spaceChildMap.set(childId, room.roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spaceChildMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* MEMOIZED room summaries - only recomputes on actual changes
|
||||
* Avoids O(n log n) sort on every sync event
|
||||
*/
|
||||
export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => {
|
||||
// Fast path: return cached if valid
|
||||
if (isSummaryCacheValid($rooms)) {
|
||||
return _summaryCache!.summaries;
|
||||
}
|
||||
|
||||
// Slow path: recompute summaries
|
||||
const spaceChildMap = buildSpaceChildMap($rooms);
|
||||
|
||||
const summaries = $rooms
|
||||
.map(room => roomToSummary(room, spaceChildMap))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
|
||||
// Update cache
|
||||
const roomIds = new Set<string>();
|
||||
const lastActivityMap = new Map<string, number>();
|
||||
|
||||
for (const room of $rooms) {
|
||||
roomIds.add(room.roomId);
|
||||
const lastEvent = room.timeline[room.timeline.length - 1];
|
||||
lastActivityMap.set(room.roomId, lastEvent?.getTs() || 0);
|
||||
}
|
||||
|
||||
_summaryCache = { roomIds, lastActivityMap, summaries };
|
||||
|
||||
return summaries;
|
||||
});
|
||||
|
||||
/**
|
||||
* Total unread count across all rooms (for nav badge)
|
||||
*/
|
||||
export const totalUnreadCount = derived(roomSummaries, ($summaries): number => {
|
||||
return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Messages
|
||||
// ============================================================================
|
||||
|
||||
// Map of roomId -> messages array
|
||||
export const messagesByRoom = writable<Map<string, Message[]>>(new Map());
|
||||
|
||||
// Secondary index: roomId -> eventId -> array index (for O(1) lookup)
|
||||
const messageIndexByRoom = new Map<string, Map<string, number>>();
|
||||
|
||||
/**
|
||||
* Rebuild the message index for a room
|
||||
*/
|
||||
function rebuildMessageIndex(roomId: string, messages: Message[]): void {
|
||||
const indexMap = new Map<string, number>();
|
||||
messages.forEach((msg, idx) => indexMap.set(msg.eventId, idx));
|
||||
messageIndexByRoom.set(roomId, indexMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message index by eventId (O(1) lookup)
|
||||
*/
|
||||
function getMessageIndex(roomId: string, eventId: string): number {
|
||||
return messageIndexByRoom.get(roomId)?.get(eventId) ?? -1;
|
||||
}
|
||||
|
||||
// Derived: messages for selected room
|
||||
export const currentMessages = derived(
|
||||
[messagesByRoom, selectedRoomId],
|
||||
([$messagesByRoom, $selectedRoomId]) => {
|
||||
if (!$selectedRoomId) return [];
|
||||
return $messagesByRoom.get($selectedRoomId) || [];
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Typing Indicators
|
||||
// ============================================================================
|
||||
|
||||
export const typingByRoom = writable<Map<string, string[]>>(new Map());
|
||||
|
||||
export const currentTyping = derived(
|
||||
[typingByRoom, selectedRoomId],
|
||||
([$typingByRoom, $selectedRoomId]) => {
|
||||
if (!$selectedRoomId) return [];
|
||||
return $typingByRoom.get($selectedRoomId) || [];
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* LRU Cache for memoizing message transformations
|
||||
* Prevents O(n) re-processing of timeline events
|
||||
*/
|
||||
class MessageCache {
|
||||
private cache = new Map<string, { message: Message; timestamp: number }>();
|
||||
private maxSize: number;
|
||||
private maxAge: number; // in milliseconds
|
||||
|
||||
constructor(maxSize = 500, maxAgeMs = 5 * 60 * 1000) {
|
||||
this.maxSize = maxSize;
|
||||
this.maxAge = maxAgeMs;
|
||||
}
|
||||
|
||||
get(eventId: string): Message | null {
|
||||
const entry = this.cache.get(eventId);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check if entry is stale
|
||||
if (Date.now() - entry.timestamp > this.maxAge) {
|
||||
this.cache.delete(eventId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(eventId);
|
||||
this.cache.set(eventId, entry);
|
||||
return entry.message;
|
||||
}
|
||||
|
||||
set(eventId: string, message: Message): void {
|
||||
// Delete if exists to update position
|
||||
if (this.cache.has(eventId)) {
|
||||
this.cache.delete(eventId);
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Delete oldest entry
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) this.cache.delete(firstKey);
|
||||
}
|
||||
this.cache.set(eventId, { message, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
invalidate(eventId: string): void {
|
||||
this.cache.delete(eventId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const messageCache = new MessageCache();
|
||||
|
||||
/**
|
||||
* Convert a MatrixEvent to our Message type
|
||||
* Uses memoization to prevent redundant transformations
|
||||
*/
|
||||
function eventToMessage(event: MatrixEvent, room?: Room | null, skipCache = false): Message | null {
|
||||
if (event.getType() !== 'm.room.message') return null;
|
||||
|
||||
const eventId = event.getId();
|
||||
if (!eventId) return null;
|
||||
|
||||
// Check cache first (unless skipCache is set for edited messages)
|
||||
if (!skipCache) {
|
||||
const cached = messageCache.get(eventId);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// Check if this is an edit (m.replace relation) - skip it as standalone message
|
||||
const relatesTo = event.getContent()['m.relates_to'];
|
||||
if (relatesTo?.rel_type === 'm.replace') return null;
|
||||
|
||||
// Get the actual content (use replacement if edited)
|
||||
const replacingEvent = event.replacingEvent();
|
||||
const content = replacingEvent
|
||||
? replacingEvent.getContent()['m.new_content'] || event.getContent()
|
||||
: event.getContent();
|
||||
const sender = event.getSender();
|
||||
|
||||
if (!sender) return null;
|
||||
|
||||
// Get sender info
|
||||
let senderName = sender;
|
||||
let senderAvatar: string | null = null;
|
||||
|
||||
if (isClientInitialized()) {
|
||||
const client = getClient();
|
||||
const roomObj = room || client.getRoom(event.getRoomId() || '');
|
||||
if (roomObj) {
|
||||
const member = roomObj.getMember(sender);
|
||||
if (member) {
|
||||
senderName = member.name || sender;
|
||||
senderAvatar = member.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine message type
|
||||
const type = getMessageType(content.msgtype);
|
||||
|
||||
// Extract media info for image/video/audio/file messages
|
||||
let media: MediaInfo | undefined;
|
||||
if (['image', 'video', 'audio', 'file'].includes(type) && content.url) {
|
||||
const client = isClientInitialized() ? getClient() : null;
|
||||
const info = content.info || {};
|
||||
|
||||
media = {
|
||||
url: content.url,
|
||||
httpUrl: client ? (client.mxcUrlToHttp(content.url) || undefined) : undefined,
|
||||
mimetype: info.mimetype,
|
||||
size: info.size,
|
||||
width: info.w,
|
||||
height: info.h,
|
||||
filename: content.filename || content.body,
|
||||
thumbnailUrl: info.thumbnail_url && client
|
||||
? (client.mxcUrlToHttp(info.thumbnail_url) || undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate reactions from related events
|
||||
// Using nested Map: emoji -> userId -> reactionEventId
|
||||
const reactions = new Map<string, Map<string, string>>();
|
||||
|
||||
// Strip Matrix reply fallback from content
|
||||
const hasReply = !!content['m.relates_to']?.['m.in_reply_to'];
|
||||
const messageContent = stripReplyFallback(content.body || '', hasReply);
|
||||
|
||||
const message: Message = {
|
||||
eventId,
|
||||
roomId: event.getRoomId() || '',
|
||||
sender,
|
||||
senderName,
|
||||
senderAvatar,
|
||||
content: messageContent,
|
||||
timestamp: event.getTs(),
|
||||
type,
|
||||
isEdited: !!replacingEvent,
|
||||
isRedacted: event.isRedacted(),
|
||||
replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id,
|
||||
reactions,
|
||||
media,
|
||||
};
|
||||
|
||||
// Cache the transformed message
|
||||
messageCache.set(eventId, message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached message (call when message is edited)
|
||||
*/
|
||||
export function invalidateMessageCache(eventId: string): void {
|
||||
messageCache.invalidate(eventId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load messages for a room
|
||||
*/
|
||||
export function loadRoomMessages(roomId: string): void {
|
||||
if (!isClientInitialized()) return;
|
||||
const client = getClient();
|
||||
const room = client.getRoom(roomId);
|
||||
|
||||
if (!room) return;
|
||||
|
||||
const timeline = room.getLiveTimeline();
|
||||
const events = timeline.getEvents();
|
||||
|
||||
// First, collect all reaction events
|
||||
// Using nested Map: messageEventId -> emoji -> userId -> reactionEventId
|
||||
const reactionsByEventId = new Map<string, Map<string, Map<string, string>>>();
|
||||
for (const event of events) {
|
||||
if (event.getType() === 'm.reaction' && !event.isRedacted()) {
|
||||
const content = event.getContent();
|
||||
const relatesTo = content['m.relates_to'];
|
||||
if (relatesTo?.rel_type === 'm.annotation') {
|
||||
const targetEventId = relatesTo.event_id;
|
||||
const emoji = relatesTo.key;
|
||||
const sender = event.getSender();
|
||||
const reactionEventId = event.getId();
|
||||
if (targetEventId && emoji && sender && reactionEventId) {
|
||||
if (!reactionsByEventId.has(targetEventId)) {
|
||||
reactionsByEventId.set(targetEventId, new Map());
|
||||
}
|
||||
const emojiMap = reactionsByEventId.get(targetEventId)!;
|
||||
const userMap = emojiMap.get(emoji) ?? new Map<string, string>();
|
||||
// O(1) check and set
|
||||
if (!userMap.has(sender)) {
|
||||
userMap.set(sender, reactionEventId);
|
||||
emojiMap.set(emoji, userMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messages = events
|
||||
.filter(e => e.getType() === 'm.room.message')
|
||||
.map(e => eventToMessage(e, room))
|
||||
.filter((m): m is Message => m !== null)
|
||||
.map(m => {
|
||||
// Attach reactions to messages
|
||||
const reactions = reactionsByEventId.get(m.eventId);
|
||||
if (reactions) {
|
||||
m.reactions = reactions;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
messagesByRoom.update(map => {
|
||||
map.set(roomId, messages);
|
||||
return new Map(map);
|
||||
});
|
||||
|
||||
// Rebuild O(1) lookup index
|
||||
rebuildMessageIndex(roomId, messages);
|
||||
|
||||
// Cache messages in background
|
||||
if (isCacheAvailable()) {
|
||||
cacheMessages(roomId, messages).catch(() => {
|
||||
// Silently ignore cache errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single message to a room
|
||||
* Uses O(1) index lookup for deduplication - no linear scan fallback
|
||||
* Index integrity is maintained by all mutation functions
|
||||
*/
|
||||
export function addMessage(roomId: string, message: Message): void {
|
||||
messagesByRoom.update(map => {
|
||||
const existing = map.get(roomId) || [];
|
||||
const roomIndex = messageIndexByRoom.get(roomId) ?? new Map<string, number>();
|
||||
|
||||
// O(1) deduplication check - index is authoritative
|
||||
if (roomIndex.has(message.eventId)) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Check for pending message match using index scan of pending messages
|
||||
// This is O(p) where p = pending messages, typically 0-2
|
||||
let pendingMatchIndex = -1;
|
||||
for (let i = existing.length - 1; i >= 0 && i >= existing.length - 10; i--) {
|
||||
const m = existing[i];
|
||||
if (
|
||||
m.isPending &&
|
||||
m.sender === message.sender &&
|
||||
m.content === message.content &&
|
||||
Math.abs(m.timestamp - message.timestamp) < 30000
|
||||
) {
|
||||
pendingMatchIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMatchIndex !== -1) {
|
||||
// Replace pending message with confirmed one
|
||||
const pendingMessage = existing[pendingMatchIndex];
|
||||
const updatedMessages = [...existing];
|
||||
updatedMessages[pendingMatchIndex] = { ...message, isPending: false };
|
||||
map.set(roomId, updatedMessages);
|
||||
|
||||
// Update index: remove pending eventId, add real eventId
|
||||
roomIndex.delete(pendingMessage.eventId);
|
||||
roomIndex.set(message.eventId, pendingMatchIndex);
|
||||
messageIndexByRoom.set(roomId, roomIndex);
|
||||
|
||||
return new Map(map);
|
||||
}
|
||||
|
||||
// Append new message
|
||||
const newMessages = [...existing, message];
|
||||
map.set(roomId, newMessages);
|
||||
|
||||
// Update index
|
||||
roomIndex.set(message.eventId, newMessages.length - 1);
|
||||
messageIndexByRoom.set(roomId, roomIndex);
|
||||
|
||||
// Cache in background
|
||||
if (isCacheAvailable()) {
|
||||
cacheMessages(roomId, [message]).catch(() => { });
|
||||
}
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a pending message (optimistic update before send completes)
|
||||
* Maintains index integrity for O(1) lookups
|
||||
*/
|
||||
export function addPendingMessage(roomId: string, message: Message): void {
|
||||
messagesByRoom.update(map => {
|
||||
const existing = map.get(roomId) || [];
|
||||
const pendingMessage = { ...message, isPending: true };
|
||||
const newMessages = [...existing, pendingMessage];
|
||||
map.set(roomId, newMessages);
|
||||
|
||||
// Add to index
|
||||
const roomIndex = messageIndexByRoom.get(roomId) ?? new Map<string, number>();
|
||||
roomIndex.set(pendingMessage.eventId, newMessages.length - 1);
|
||||
messageIndexByRoom.set(roomId, roomIndex);
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a pending message with real event ID after send completes
|
||||
* Maintains index integrity
|
||||
*/
|
||||
export function confirmPendingMessage(roomId: string, tempEventId: string, realEventId: string): void {
|
||||
messagesByRoom.update(map => {
|
||||
const messages = map.get(roomId);
|
||||
if (!messages) return map;
|
||||
|
||||
const roomIndex = messageIndexByRoom.get(roomId);
|
||||
const messageIdx = roomIndex?.get(tempEventId) ?? -1;
|
||||
if (messageIdx === -1) return map;
|
||||
|
||||
const updatedMessages = [...messages];
|
||||
updatedMessages[messageIdx] = {
|
||||
...updatedMessages[messageIdx],
|
||||
eventId: realEventId,
|
||||
isPending: false,
|
||||
};
|
||||
map.set(roomId, updatedMessages);
|
||||
|
||||
// Update index: remove temp, add real
|
||||
if (roomIndex) {
|
||||
roomIndex.delete(tempEventId);
|
||||
roomIndex.set(realEventId, messageIdx);
|
||||
}
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending message (if send fails)
|
||||
* Rebuilds index after removal to maintain integrity
|
||||
*/
|
||||
export function removePendingMessage(roomId: string, tempEventId: string): void {
|
||||
messagesByRoom.update(map => {
|
||||
const messages = map.get(roomId);
|
||||
if (!messages) return map;
|
||||
|
||||
const filteredMessages = messages.filter(m => m.eventId !== tempEventId);
|
||||
map.set(roomId, filteredMessages);
|
||||
|
||||
// Rebuild index for this room
|
||||
rebuildMessageIndex(roomId, filteredMessages);
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reaction to a message
|
||||
* Uses nested Map structure: emoji -> userId -> reactionEventId for O(1) access
|
||||
* Uses O(1) index lookup for message finding
|
||||
*/
|
||||
export function addReaction(roomId: string, eventId: string, emoji: string, userId: string, reactionEventId: string): void {
|
||||
messagesByRoom.update(map => {
|
||||
const messages = map.get(roomId);
|
||||
if (!messages) return map;
|
||||
|
||||
// O(1) lookup using index
|
||||
const messageIndex = getMessageIndex(roomId, eventId);
|
||||
if (messageIndex === -1) return map;
|
||||
|
||||
const message = messages[messageIndex];
|
||||
const reactions = new Map(message.reactions);
|
||||
|
||||
// Get or create the user map for this emoji
|
||||
const userMap = reactions.get(emoji) ?? new Map<string, string>();
|
||||
|
||||
// O(1) check and set
|
||||
if (!userMap.has(userId)) {
|
||||
userMap.set(userId, reactionEventId);
|
||||
reactions.set(emoji, userMap);
|
||||
}
|
||||
|
||||
const updatedMessages = [...messages];
|
||||
updatedMessages[messageIndex] = { ...message, reactions };
|
||||
map.set(roomId, updatedMessages);
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message
|
||||
* Uses nested Map structure for O(1) access
|
||||
* Uses O(1) index lookup for message finding
|
||||
*/
|
||||
export function removeReaction(roomId: string, eventId: string, emoji: string, userId: string): void {
|
||||
messagesByRoom.update(map => {
|
||||
const messages = map.get(roomId);
|
||||
if (!messages) return map;
|
||||
|
||||
// O(1) lookup using index
|
||||
const messageIndex = getMessageIndex(roomId, eventId);
|
||||
if (messageIndex === -1) return map;
|
||||
|
||||
const message = messages[messageIndex];
|
||||
const reactions = new Map(message.reactions);
|
||||
const userMap = reactions.get(emoji);
|
||||
|
||||
if (userMap) {
|
||||
// O(1) delete
|
||||
userMap.delete(userId);
|
||||
|
||||
if (userMap.size === 0) {
|
||||
reactions.delete(emoji);
|
||||
} else {
|
||||
reactions.set(emoji, userMap);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessages = [...messages];
|
||||
updatedMessages[messageIndex] = { ...message, reactions };
|
||||
map.set(roomId, updatedMessages);
|
||||
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a room and load its messages
|
||||
* Loads from cache first for instant display, then fetches fresh data
|
||||
*/
|
||||
export async function selectRoom(roomId: string | null): Promise<void> {
|
||||
selectedRoomId.set(roomId);
|
||||
if (roomId) {
|
||||
// Load cached messages first for instant display
|
||||
if (isCacheAvailable()) {
|
||||
const cached = await getCachedMessages(roomId);
|
||||
if (cached.length > 0) {
|
||||
messagesByRoom.update(map => {
|
||||
map.set(roomId, cached);
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Then load fresh messages (will update/replace cached)
|
||||
loadRoomMessages(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Presence
|
||||
// ============================================================================
|
||||
|
||||
export type PresenceState = 'online' | 'offline' | 'unavailable';
|
||||
|
||||
// Map of userId -> presence state
|
||||
export const userPresence = writable<Map<string, PresenceState>>(new Map());
|
||||
|
||||
/**
|
||||
* Update a user's presence
|
||||
*/
|
||||
export function updatePresence(userId: string, presence: PresenceState): void {
|
||||
userPresence.update(map => {
|
||||
map.set(userId, presence);
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state (on logout)
|
||||
*/
|
||||
export function clearState(): void {
|
||||
auth.set(initialAuthState);
|
||||
syncState.set('STOPPED');
|
||||
syncError.set(null);
|
||||
_roomsById.set(new Map());
|
||||
selectedRoomId.set(null);
|
||||
messagesByRoom.set(new Map());
|
||||
typingByRoom.set(new Map());
|
||||
userPresence.set(new Map());
|
||||
messageCache.clear();
|
||||
}
|
||||
196
src/lib/stores/theme.ts
Normal file
196
src/lib/stores/theme.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Theme Store - Manages app theme (dark/light mode and accent colors)
|
||||
*/
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type ThemeMode = 'dark' | 'light';
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const PRESET_COLORS: ThemeColors[] = [
|
||||
{ name: 'Cyan', primary: '#00A3E0' },
|
||||
{ name: 'Purple', primary: '#8B5CF6' },
|
||||
{ name: 'Pink', primary: '#EC4899' },
|
||||
{ name: 'Green', primary: '#10B981' },
|
||||
{ name: 'Orange', primary: '#F97316' },
|
||||
{ name: 'Red', primary: '#EF4444' },
|
||||
];
|
||||
|
||||
const THEME_STORAGE_KEY = 'app_theme';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
const defaultTheme: ThemeState = {
|
||||
mode: 'dark',
|
||||
primaryColor: '#00A3E0',
|
||||
};
|
||||
|
||||
// Convert hex to HSL
|
||||
function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return { h: 0, s: 0, l: 0 };
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
// Convert HSL to hex
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
// Generate derived colors from primary
|
||||
function generateDerivedColors(primary: string, mode: ThemeMode) {
|
||||
const { h, s } = hexToHSL(primary);
|
||||
|
||||
if (mode === 'dark') {
|
||||
return {
|
||||
night: hslToHex(h, Math.min(s, 40), 6), // 6% lightness - panels
|
||||
dark: hslToHex(h, Math.min(s, 35), 10), // 10% lightness - elevated panels
|
||||
background: hslToHex(h, Math.min(s, 30), 3), // 3% lightness - page background
|
||||
light: '#e5e6f0', // Light color for text/icons
|
||||
text: '#ffffff', // White text
|
||||
textMuted: 'rgba(229, 230, 240, 0.5)',
|
||||
};
|
||||
} else {
|
||||
// Light mode: use lower saturation to avoid too colorful backgrounds
|
||||
const lightSat = Math.min(s, 30);
|
||||
return {
|
||||
night: hslToHex(h, lightSat, 92),
|
||||
dark: hslToHex(h, lightSat, 85),
|
||||
background: hslToHex(h, lightSat, 98),
|
||||
light: '#1a1a2e',
|
||||
text: '#0a121f',
|
||||
textMuted: 'rgba(10, 18, 31, 0.6)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function loadTheme(): ThemeState {
|
||||
if (!browser) return defaultTheme;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultTheme, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load theme:', e);
|
||||
}
|
||||
return defaultTheme;
|
||||
}
|
||||
|
||||
function saveTheme(theme: ThemeState): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
|
||||
}
|
||||
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<ThemeState>(loadTheme());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setMode: (mode: ThemeMode) => {
|
||||
update(state => {
|
||||
const newState = { ...state, mode };
|
||||
saveTheme(newState);
|
||||
applyTheme(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
setPrimaryColor: (color: string) => {
|
||||
update(state => {
|
||||
const newState = { ...state, primaryColor: color };
|
||||
saveTheme(newState);
|
||||
applyTheme(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
toggleMode: () => {
|
||||
update(state => {
|
||||
const newMode: ThemeMode = state.mode === 'dark' ? 'light' : 'dark';
|
||||
const newState: ThemeState = { ...state, mode: newMode };
|
||||
saveTheme(newState);
|
||||
applyTheme(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
set(defaultTheme);
|
||||
saveTheme(defaultTheme);
|
||||
applyTheme(defaultTheme);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const isDarkMode = derived(theme, $t => $t.mode === 'dark');
|
||||
export const primaryColor = derived(theme, $t => $t.primaryColor);
|
||||
|
||||
// Apply theme to document
|
||||
export function applyTheme(state: ThemeState): void {
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Set mode class
|
||||
root.classList.remove('dark', 'light');
|
||||
root.classList.add(state.mode);
|
||||
|
||||
// Set CSS custom property for primary color
|
||||
root.style.setProperty('--color-primary', state.primaryColor);
|
||||
|
||||
// Calculate hover variant
|
||||
const { h, s, l } = hexToHSL(state.primaryColor);
|
||||
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10)));
|
||||
|
||||
// Generate and apply derived colors
|
||||
const derived = generateDerivedColors(state.primaryColor, state.mode);
|
||||
root.style.setProperty('--color-night', derived.night);
|
||||
root.style.setProperty('--color-dark', derived.dark);
|
||||
root.style.setProperty('--color-background', derived.background);
|
||||
root.style.setProperty('--color-light', derived.light);
|
||||
root.style.setProperty('--color-text', derived.text);
|
||||
root.style.setProperty('--color-text-muted', derived.textMuted);
|
||||
}
|
||||
|
||||
// Initialize theme on load
|
||||
if (browser) {
|
||||
applyTheme(loadTheme());
|
||||
}
|
||||
55
src/lib/stores/ui.ts
Normal file
55
src/lib/stores/ui.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* UI Stores
|
||||
*
|
||||
* Re-exports toasts from the main toast store for backward compatibility
|
||||
* with Matrix components, plus Matrix-specific UI state.
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Re-export toasts so Matrix components can import from '$lib/stores/ui'
|
||||
export { toasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
// ============================================================================
|
||||
// Chat Layout State
|
||||
// ============================================================================
|
||||
|
||||
export const sidebarOpen = writable(true);
|
||||
export const membersPanelOpen = writable(false);
|
||||
|
||||
// ============================================================================
|
||||
// Chat Modals
|
||||
// ============================================================================
|
||||
|
||||
export type ModalType =
|
||||
| 'none'
|
||||
| 'createRoom'
|
||||
| 'roomSettings'
|
||||
| 'roomMembers'
|
||||
| 'userProfile'
|
||||
| 'settings';
|
||||
|
||||
export const activeModal = writable<ModalType>('none');
|
||||
export const modalData = writable<any>(null);
|
||||
|
||||
export function openModal(type: ModalType, data?: any): void {
|
||||
activeModal.set(type);
|
||||
modalData.set(data ?? null);
|
||||
}
|
||||
|
||||
export function closeModal(): void {
|
||||
activeModal.set('none');
|
||||
modalData.set(null);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Loading States
|
||||
// ============================================================================
|
||||
|
||||
export const isLoading = writable(false);
|
||||
export const loadingMessage = writable<string | null>(null);
|
||||
|
||||
export function setLoading(loading: boolean, message?: string): void {
|
||||
isLoading.set(loading);
|
||||
loadingMessage.set(message ?? null);
|
||||
}
|
||||
@@ -613,6 +613,50 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
matrix_credentials: {
|
||||
Row: {
|
||||
access_token: string
|
||||
created_at: string
|
||||
device_id: string | null
|
||||
homeserver_url: string
|
||||
id: string
|
||||
matrix_user_id: string
|
||||
org_id: string
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
access_token: string
|
||||
created_at?: string
|
||||
device_id?: string | null
|
||||
homeserver_url: string
|
||||
id?: string
|
||||
matrix_user_id: string
|
||||
org_id: string
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
access_token?: string
|
||||
created_at?: string
|
||||
device_id?: string | null
|
||||
homeserver_url?: string
|
||||
id?: string
|
||||
matrix_user_id?: string
|
||||
org_id?: string
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "matrix_credentials_org_id_fkey"
|
||||
columns: ["org_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "organizations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
org_google_calendars: {
|
||||
Row: {
|
||||
calendar_id: string
|
||||
@@ -748,6 +792,13 @@ export type Database = {
|
||||
referencedRelation: "org_roles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "org_members_user_id_profiles_fk"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
org_roles: {
|
||||
@@ -803,6 +854,7 @@ export type Database = {
|
||||
created_at: string | null
|
||||
icon_url: string | null
|
||||
id: string
|
||||
matrix_space_id: string | null
|
||||
name: string
|
||||
slug: string
|
||||
theme_color: string | null
|
||||
@@ -813,6 +865,7 @@ export type Database = {
|
||||
created_at?: string | null
|
||||
icon_url?: string | null
|
||||
id?: string
|
||||
matrix_space_id?: string | null
|
||||
name: string
|
||||
slug: string
|
||||
theme_color?: string | null
|
||||
@@ -823,6 +876,7 @@ export type Database = {
|
||||
created_at?: string | null
|
||||
icon_url?: string | null
|
||||
id?: string
|
||||
matrix_space_id?: string | null
|
||||
name?: string
|
||||
slug?: string
|
||||
theme_color?: string | null
|
||||
@@ -1148,7 +1202,7 @@ export const Constants = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── Convenience type aliases ──────────────────────────
|
||||
// ── Convenience type aliases ─────────────────────────────────────────
|
||||
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
type PublicTables = Database['public']['Tables']
|
||||
|
||||
@@ -1171,3 +1225,4 @@ export type Team = PublicTables['teams']['Row']
|
||||
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
|
||||
export type ActivityLog = PublicTables['activity_log']['Row']
|
||||
export type UserPreferences = PublicTables['user_preferences']['Row']
|
||||
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
|
||||
|
||||
596
src/lib/utils/emojiData.ts
Normal file
596
src/lib/utils/emojiData.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
// Emoji data with names for autocomplete
|
||||
// Only includes emojis that have Twemoji support
|
||||
|
||||
export interface EmojiItem {
|
||||
emoji: string;
|
||||
names: string[];
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const emojiData: EmojiItem[] = [
|
||||
// Smileys & Emotion
|
||||
{ emoji: "😀", names: ["grinning", "smile", "happy"], category: "smileys" },
|
||||
{ emoji: "😃", names: ["smiley", "happy", "joy"], category: "smileys" },
|
||||
{ emoji: "😄", names: ["smile", "happy", "joy"], category: "smileys" },
|
||||
{ emoji: "😁", names: ["grin", "happy"], category: "smileys" },
|
||||
{ emoji: "😆", names: ["laughing", "satisfied", "lol"], category: "smileys" },
|
||||
{ emoji: "😅", names: ["sweat_smile", "nervous"], category: "smileys" },
|
||||
{ emoji: "🤣", names: ["rofl", "rolling", "lmao"], category: "smileys" },
|
||||
{ emoji: "😂", names: ["joy", "laugh", "lol", "crying_laughing"], category: "smileys" },
|
||||
{ emoji: "🙂", names: ["slightly_smiling", "ok"], category: "smileys" },
|
||||
{ emoji: "😊", names: ["blush", "happy", "smile"], category: "smileys" },
|
||||
{ emoji: "😇", names: ["innocent", "angel", "halo"], category: "smileys" },
|
||||
{ emoji: "🥰", names: ["smiling_hearts", "love", "adore"], category: "smileys" },
|
||||
{ emoji: "😍", names: ["heart_eyes", "love", "crush"], category: "smileys" },
|
||||
{ emoji: "🤩", names: ["star_struck", "excited", "wow"], category: "smileys" },
|
||||
{ emoji: "😘", names: ["kissing_heart", "kiss", "love"], category: "smileys" },
|
||||
{ emoji: "😗", names: ["kissing", "kiss"], category: "smileys" },
|
||||
{ emoji: "😚", names: ["kissing_closed_eyes", "kiss"], category: "smileys" },
|
||||
{ emoji: "😙", names: ["kissing_smiling_eyes", "kiss"], category: "smileys" },
|
||||
{ emoji: "🥲", names: ["smiling_tear", "sad_happy"], category: "smileys" },
|
||||
{ emoji: "😋", names: ["yum", "delicious", "tongue"], category: "smileys" },
|
||||
{ emoji: "😛", names: ["stuck_out_tongue", "playful"], category: "smileys" },
|
||||
{ emoji: "😜", names: ["wink_tongue", "crazy", "playful"], category: "smileys" },
|
||||
{ emoji: "🤪", names: ["zany", "crazy", "wild"], category: "smileys" },
|
||||
{ emoji: "😝", names: ["squinting_tongue", "playful"], category: "smileys" },
|
||||
{ emoji: "🤑", names: ["money_mouth", "rich", "money"], category: "smileys" },
|
||||
{ emoji: "🤗", names: ["hugging", "hug", "warm"], category: "smileys" },
|
||||
{ emoji: "🤭", names: ["hand_over_mouth", "oops", "giggle"], category: "smileys" },
|
||||
{ emoji: "🤫", names: ["shushing", "quiet", "secret"], category: "smileys" },
|
||||
{ emoji: "🤔", names: ["thinking", "hmm", "consider"], category: "smileys" },
|
||||
{ emoji: "🤐", names: ["zipper_mouth", "quiet", "secret"], category: "smileys" },
|
||||
{ emoji: "🤨", names: ["raised_eyebrow", "skeptical", "sus"], category: "smileys" },
|
||||
{ emoji: "😐", names: ["neutral", "meh", "blank"], category: "smileys" },
|
||||
{ emoji: "😑", names: ["expressionless", "blank"], category: "smileys" },
|
||||
{ emoji: "😶", names: ["no_mouth", "silent", "speechless"], category: "smileys" },
|
||||
{ emoji: "😏", names: ["smirk", "smug"], category: "smileys" },
|
||||
{ emoji: "😒", names: ["unamused", "meh", "bored"], category: "smileys" },
|
||||
{ emoji: "🙄", names: ["eye_roll", "whatever", "annoyed"], category: "smileys" },
|
||||
{ emoji: "😬", names: ["grimacing", "awkward", "cringe"], category: "smileys" },
|
||||
{ emoji: "😌", names: ["relieved", "peaceful", "calm"], category: "smileys" },
|
||||
{ emoji: "😔", names: ["pensive", "sad", "disappointed"], category: "smileys" },
|
||||
{ emoji: "😪", names: ["sleepy", "tired"], category: "smileys" },
|
||||
{ emoji: "🤤", names: ["drooling", "hungry", "want"], category: "smileys" },
|
||||
{ emoji: "😴", names: ["sleeping", "zzz", "tired"], category: "smileys" },
|
||||
{ emoji: "😷", names: ["mask", "sick", "ill"], category: "smileys" },
|
||||
{ emoji: "🤒", names: ["thermometer", "sick", "fever"], category: "smileys" },
|
||||
{ emoji: "🤕", names: ["bandage", "hurt", "injured"], category: "smileys" },
|
||||
{ emoji: "🤢", names: ["nauseated", "sick", "green"], category: "smileys" },
|
||||
{ emoji: "🤮", names: ["vomiting", "sick", "throw_up"], category: "smileys" },
|
||||
{ emoji: "🤧", names: ["sneezing", "sick", "achoo"], category: "smileys" },
|
||||
{ emoji: "🥵", names: ["hot", "heat", "sweating"], category: "smileys" },
|
||||
{ emoji: "🥶", names: ["cold", "freezing", "frozen"], category: "smileys" },
|
||||
{ emoji: "🥴", names: ["woozy", "drunk", "tipsy"], category: "smileys" },
|
||||
{ emoji: "😵", names: ["dizzy", "dead", "knocked_out"], category: "smileys" },
|
||||
{ emoji: "🤯", names: ["exploding_head", "mind_blown", "shocked"], category: "smileys" },
|
||||
{ emoji: "🤠", names: ["cowboy", "yeehaw"], category: "smileys" },
|
||||
{ emoji: "🥳", names: ["partying", "party", "celebrate"], category: "smileys" },
|
||||
{ emoji: "🥸", names: ["disguised", "incognito"], category: "smileys" },
|
||||
{ emoji: "😎", names: ["sunglasses", "cool", "awesome"], category: "smileys" },
|
||||
{ emoji: "🤓", names: ["nerd", "geek", "smart"], category: "smileys" },
|
||||
{ emoji: "🧐", names: ["monocle", "fancy", "hmm"], category: "smileys" },
|
||||
{ emoji: "😕", names: ["confused", "puzzled"], category: "smileys" },
|
||||
{ emoji: "😟", names: ["worried", "concerned"], category: "smileys" },
|
||||
{ emoji: "🙁", names: ["slightly_frowning", "sad"], category: "smileys" },
|
||||
{ emoji: "😮", names: ["open_mouth", "surprised", "wow"], category: "smileys" },
|
||||
{ emoji: "😯", names: ["hushed", "surprised"], category: "smileys" },
|
||||
{ emoji: "😲", names: ["astonished", "shocked", "wow"], category: "smileys" },
|
||||
{ emoji: "😳", names: ["flushed", "embarrassed", "shy"], category: "smileys" },
|
||||
{ emoji: "🥺", names: ["pleading", "puppy_eyes", "please"], category: "smileys" },
|
||||
{ emoji: "😦", names: ["frowning", "sad"], category: "smileys" },
|
||||
{ emoji: "😧", names: ["anguished", "worried"], category: "smileys" },
|
||||
{ emoji: "😨", names: ["fearful", "scared", "afraid"], category: "smileys" },
|
||||
{ emoji: "😰", names: ["anxious", "worried", "sweat"], category: "smileys" },
|
||||
{ emoji: "😥", names: ["disappointed_relieved", "sad"], category: "smileys" },
|
||||
{ emoji: "😢", names: ["cry", "sad", "tear"], category: "smileys" },
|
||||
{ emoji: "😭", names: ["sob", "crying", "sad", "bawling"], category: "smileys" },
|
||||
{ emoji: "😱", names: ["scream", "horror", "shocked"], category: "smileys" },
|
||||
{ emoji: "😖", names: ["confounded", "frustrated"], category: "smileys" },
|
||||
{ emoji: "😣", names: ["persevere", "struggling"], category: "smileys" },
|
||||
{ emoji: "😞", names: ["disappointed", "sad"], category: "smileys" },
|
||||
{ emoji: "😓", names: ["downcast_sweat", "tired"], category: "smileys" },
|
||||
{ emoji: "😩", names: ["weary", "tired", "exhausted"], category: "smileys" },
|
||||
{ emoji: "😫", names: ["tired_face", "exhausted"], category: "smileys" },
|
||||
{ emoji: "🥱", names: ["yawning", "tired", "bored"], category: "smileys" },
|
||||
{ emoji: "😤", names: ["triumph", "angry", "frustrated"], category: "smileys" },
|
||||
{ emoji: "😡", names: ["rage", "angry", "mad"], category: "smileys" },
|
||||
{ emoji: "😠", names: ["angry", "mad", "grumpy"], category: "smileys" },
|
||||
{ emoji: "🤬", names: ["cursing", "swearing", "angry"], category: "smileys" },
|
||||
{ emoji: "😈", names: ["smiling_imp", "devil", "evil"], category: "smileys" },
|
||||
{ emoji: "👿", names: ["imp", "devil", "angry"], category: "smileys" },
|
||||
{ emoji: "💀", names: ["skull", "dead", "death"], category: "smileys" },
|
||||
{ emoji: "☠️", names: ["skull_crossbones", "death", "danger"], category: "smileys" },
|
||||
{ emoji: "💩", names: ["poop", "shit", "crap"], category: "smileys" },
|
||||
{ emoji: "🤡", names: ["clown", "joker"], category: "smileys" },
|
||||
{ emoji: "👹", names: ["ogre", "monster", "japanese"], category: "smileys" },
|
||||
{ emoji: "👺", names: ["goblin", "tengu", "japanese"], category: "smileys" },
|
||||
{ emoji: "👻", names: ["ghost", "boo", "spooky"], category: "smileys" },
|
||||
{ emoji: "👽", names: ["alien", "ufo", "space"], category: "smileys" },
|
||||
{ emoji: "👾", names: ["space_invader", "alien", "game"], category: "smileys" },
|
||||
{ emoji: "🤖", names: ["robot", "bot"], category: "smileys" },
|
||||
{ emoji: "😺", names: ["smiley_cat", "happy_cat"], category: "smileys" },
|
||||
{ emoji: "😸", names: ["smile_cat", "happy_cat"], category: "smileys" },
|
||||
{ emoji: "😹", names: ["joy_cat", "laughing_cat"], category: "smileys" },
|
||||
{ emoji: "😻", names: ["heart_eyes_cat", "love_cat"], category: "smileys" },
|
||||
{ emoji: "😼", names: ["smirk_cat"], category: "smileys" },
|
||||
{ emoji: "😽", names: ["kissing_cat"], category: "smileys" },
|
||||
{ emoji: "🙀", names: ["scream_cat", "shocked_cat"], category: "smileys" },
|
||||
{ emoji: "😿", names: ["crying_cat", "sad_cat"], category: "smileys" },
|
||||
{ emoji: "😾", names: ["pouting_cat", "angry_cat"], category: "smileys" },
|
||||
|
||||
// Gestures & People
|
||||
{ emoji: "👍", names: ["thumbsup", "like", "ok", "+1", "yes"], category: "people" },
|
||||
{ emoji: "👎", names: ["thumbsdown", "dislike", "-1", "no"], category: "people" },
|
||||
{ emoji: "👋", names: ["wave", "hello", "hi", "bye"], category: "people" },
|
||||
{ emoji: "🤚", names: ["raised_back_hand", "stop"], category: "people" },
|
||||
{ emoji: "🖐️", names: ["hand_splayed", "high_five"], category: "people" },
|
||||
{ emoji: "✋", names: ["hand", "stop", "high_five"], category: "people" },
|
||||
{ emoji: "🖖", names: ["vulcan", "spock", "star_trek"], category: "people" },
|
||||
{ emoji: "👌", names: ["ok_hand", "perfect", "nice"], category: "people" },
|
||||
{ emoji: "🤌", names: ["pinched_fingers", "italian"], category: "people" },
|
||||
{ emoji: "🤏", names: ["pinching_hand", "small", "tiny"], category: "people" },
|
||||
{ emoji: "✌️", names: ["v", "peace", "victory"], category: "people" },
|
||||
{ emoji: "🤞", names: ["crossed_fingers", "luck", "hope"], category: "people" },
|
||||
{ emoji: "🤟", names: ["love_you", "ily", "rock"], category: "people" },
|
||||
{ emoji: "🤘", names: ["metal", "rock", "horns"], category: "people" },
|
||||
{ emoji: "🤙", names: ["call_me", "shaka", "hang_loose"], category: "people" },
|
||||
{ emoji: "👈", names: ["point_left", "left"], category: "people" },
|
||||
{ emoji: "👉", names: ["point_right", "right"], category: "people" },
|
||||
{ emoji: "👆", names: ["point_up", "up"], category: "people" },
|
||||
{ emoji: "🖕", names: ["middle_finger", "fu"], category: "people" },
|
||||
{ emoji: "👇", names: ["point_down", "down"], category: "people" },
|
||||
{ emoji: "☝️", names: ["point_up_2", "one"], category: "people" },
|
||||
{ emoji: "✊", names: ["fist", "punch", "power"], category: "people" },
|
||||
{ emoji: "👊", names: ["punch", "fist_bump"], category: "people" },
|
||||
{ emoji: "🤛", names: ["left_fist", "fist_bump"], category: "people" },
|
||||
{ emoji: "🤜", names: ["right_fist", "fist_bump"], category: "people" },
|
||||
{ emoji: "👏", names: ["clap", "applause", "bravo"], category: "people" },
|
||||
{ emoji: "🙌", names: ["raised_hands", "hooray", "yay"], category: "people" },
|
||||
{ emoji: "👐", names: ["open_hands", "hug"], category: "people" },
|
||||
{ emoji: "🤲", names: ["palms_up", "prayer"], category: "people" },
|
||||
{ emoji: "🤝", names: ["handshake", "deal", "agreement"], category: "people" },
|
||||
{ emoji: "🙏", names: ["pray", "please", "thanks", "namaste"], category: "people" },
|
||||
{ emoji: "✍️", names: ["writing", "write"], category: "people" },
|
||||
{ emoji: "💪", names: ["muscle", "flex", "strong", "bicep"], category: "people" },
|
||||
|
||||
// Hearts & Love
|
||||
{ emoji: "❤️", names: ["heart", "love", "red_heart"], category: "symbols" },
|
||||
{ emoji: "🧡", names: ["orange_heart"], category: "symbols" },
|
||||
{ emoji: "💛", names: ["yellow_heart"], category: "symbols" },
|
||||
{ emoji: "💚", names: ["green_heart"], category: "symbols" },
|
||||
{ emoji: "💙", names: ["blue_heart"], category: "symbols" },
|
||||
{ emoji: "💜", names: ["purple_heart"], category: "symbols" },
|
||||
{ emoji: "🖤", names: ["black_heart"], category: "symbols" },
|
||||
{ emoji: "🤍", names: ["white_heart"], category: "symbols" },
|
||||
{ emoji: "🤎", names: ["brown_heart"], category: "symbols" },
|
||||
{ emoji: "💔", names: ["broken_heart", "heartbreak"], category: "symbols" },
|
||||
{ emoji: "❤️🔥", names: ["heart_on_fire", "burning_heart"], category: "symbols" },
|
||||
{ emoji: "❤️🩹", names: ["mending_heart", "healing"], category: "symbols" },
|
||||
{ emoji: "💕", names: ["two_hearts", "love"], category: "symbols" },
|
||||
{ emoji: "💞", names: ["revolving_hearts", "love"], category: "symbols" },
|
||||
{ emoji: "💓", names: ["heartbeat", "love"], category: "symbols" },
|
||||
{ emoji: "💗", names: ["heartpulse", "love", "growing_heart"], category: "symbols" },
|
||||
{ emoji: "💖", names: ["sparkling_heart", "love"], category: "symbols" },
|
||||
{ emoji: "💘", names: ["cupid", "arrow_heart", "love"], category: "symbols" },
|
||||
{ emoji: "💝", names: ["gift_heart", "love"], category: "symbols" },
|
||||
|
||||
// Common Objects & Symbols
|
||||
{ emoji: "🔥", names: ["fire", "hot", "lit", "flame"], category: "symbols" },
|
||||
{ emoji: "✨", names: ["sparkles", "magic", "shine", "stars"], category: "symbols" },
|
||||
{ emoji: "⭐", names: ["star", "favorite"], category: "symbols" },
|
||||
{ emoji: "🌟", names: ["glowing_star", "awesome"], category: "symbols" },
|
||||
{ emoji: "💫", names: ["dizzy", "star", "magic"], category: "symbols" },
|
||||
{ emoji: "💥", names: ["boom", "collision", "explosion"], category: "symbols" },
|
||||
{ emoji: "💢", names: ["anger", "angry", "mad"], category: "symbols" },
|
||||
{ emoji: "💦", names: ["sweat_drops", "water", "wet"], category: "symbols" },
|
||||
{ emoji: "💨", names: ["dash", "wind", "fast"], category: "symbols" },
|
||||
{ emoji: "🎉", names: ["tada", "party", "celebrate", "hooray"], category: "symbols" },
|
||||
{ emoji: "🎊", names: ["confetti", "party", "celebrate"], category: "symbols" },
|
||||
{ emoji: "🎈", names: ["balloon", "party"], category: "symbols" },
|
||||
{ emoji: "🎁", names: ["gift", "present"], category: "symbols" },
|
||||
{ emoji: "🏆", names: ["trophy", "win", "award", "champion"], category: "symbols" },
|
||||
{ emoji: "🥇", names: ["first_place", "gold", "winner"], category: "symbols" },
|
||||
{ emoji: "🥈", names: ["second_place", "silver"], category: "symbols" },
|
||||
{ emoji: "🥉", names: ["third_place", "bronze"], category: "symbols" },
|
||||
{ emoji: "⚡", names: ["zap", "lightning", "electric", "thunder"], category: "symbols" },
|
||||
{ emoji: "💡", names: ["bulb", "idea", "light"], category: "symbols" },
|
||||
{ emoji: "💯", names: ["100", "perfect", "score", "hundred"], category: "symbols" },
|
||||
{ emoji: "✅", names: ["white_check_mark", "check", "done", "yes"], category: "symbols" },
|
||||
{ emoji: "❌", names: ["x", "cross", "no", "wrong"], category: "symbols" },
|
||||
{ emoji: "❓", names: ["question", "what"], category: "symbols" },
|
||||
{ emoji: "❗", names: ["exclamation", "important", "bang"], category: "symbols" },
|
||||
{ emoji: "⚠️", names: ["warning", "alert", "caution"], category: "symbols" },
|
||||
{ emoji: "🚫", names: ["no_entry", "forbidden", "prohibited"], category: "symbols" },
|
||||
{ emoji: "⛔", names: ["no_entry_sign", "stop"], category: "symbols" },
|
||||
{ emoji: "🔴", names: ["red_circle"], category: "symbols" },
|
||||
{ emoji: "🟢", names: ["green_circle"], category: "symbols" },
|
||||
{ emoji: "🔵", names: ["blue_circle"], category: "symbols" },
|
||||
{ emoji: "⚪", names: ["white_circle"], category: "symbols" },
|
||||
{ emoji: "⚫", names: ["black_circle"], category: "symbols" },
|
||||
{ emoji: "🔶", names: ["large_orange_diamond"], category: "symbols" },
|
||||
{ emoji: "🔷", names: ["large_blue_diamond"], category: "symbols" },
|
||||
{ emoji: "▶️", names: ["play", "arrow_forward"], category: "symbols" },
|
||||
{ emoji: "⏸️", names: ["pause"], category: "symbols" },
|
||||
{ emoji: "⏹️", names: ["stop"], category: "symbols" },
|
||||
{ emoji: "🔁", names: ["repeat", "loop"], category: "symbols" },
|
||||
{ emoji: "🔀", names: ["shuffle", "random"], category: "symbols" },
|
||||
{ emoji: "🔊", names: ["loud_sound", "volume"], category: "symbols" },
|
||||
{ emoji: "🔇", names: ["mute", "silent"], category: "symbols" },
|
||||
{ emoji: "🔔", names: ["bell", "notification"], category: "symbols" },
|
||||
{ emoji: "🔕", names: ["no_bell", "mute"], category: "symbols" },
|
||||
{ emoji: "📢", names: ["loudspeaker", "announcement"], category: "symbols" },
|
||||
{ emoji: "📣", names: ["mega", "megaphone"], category: "symbols" },
|
||||
{ emoji: "💬", names: ["speech_balloon", "chat", "comment"], category: "symbols" },
|
||||
{ emoji: "💭", names: ["thought_balloon", "thinking"], category: "symbols" },
|
||||
{ emoji: "🗨️", names: ["left_speech_bubble", "chat"], category: "symbols" },
|
||||
{ emoji: "👀", names: ["eyes", "look", "see", "watching"], category: "people" },
|
||||
{ emoji: "👁️", names: ["eye", "see"], category: "people" },
|
||||
{ emoji: "👂", names: ["ear", "listen", "hear"], category: "people" },
|
||||
{ emoji: "👃", names: ["nose", "smell"], category: "people" },
|
||||
{ emoji: "👅", names: ["tongue", "taste"], category: "people" },
|
||||
{ emoji: "👄", names: ["lips", "kiss", "mouth"], category: "people" },
|
||||
{ emoji: "🧠", names: ["brain", "smart", "think"], category: "people" },
|
||||
|
||||
// Nature & Animals
|
||||
{ emoji: "🐶", names: ["dog", "puppy", "pet"], category: "nature" },
|
||||
{ emoji: "🐱", names: ["cat", "kitty", "pet"], category: "nature" },
|
||||
{ emoji: "🐭", names: ["mouse"], category: "nature" },
|
||||
{ emoji: "🐹", names: ["hamster"], category: "nature" },
|
||||
{ emoji: "🐰", names: ["rabbit", "bunny"], category: "nature" },
|
||||
{ emoji: "🦊", names: ["fox"], category: "nature" },
|
||||
{ emoji: "🐻", names: ["bear"], category: "nature" },
|
||||
{ emoji: "🐼", names: ["panda"], category: "nature" },
|
||||
{ emoji: "🐨", names: ["koala"], category: "nature" },
|
||||
{ emoji: "🐯", names: ["tiger"], category: "nature" },
|
||||
{ emoji: "🦁", names: ["lion"], category: "nature" },
|
||||
{ emoji: "🐮", names: ["cow"], category: "nature" },
|
||||
{ emoji: "🐷", names: ["pig"], category: "nature" },
|
||||
{ emoji: "🐸", names: ["frog"], category: "nature" },
|
||||
{ emoji: "🐵", names: ["monkey"], category: "nature" },
|
||||
{ emoji: "🙈", names: ["see_no_evil", "monkey"], category: "nature" },
|
||||
{ emoji: "🙉", names: ["hear_no_evil", "monkey"], category: "nature" },
|
||||
{ emoji: "🙊", names: ["speak_no_evil", "monkey"], category: "nature" },
|
||||
{ emoji: "🐔", names: ["chicken"], category: "nature" },
|
||||
{ emoji: "🐧", names: ["penguin"], category: "nature" },
|
||||
{ emoji: "🐦", names: ["bird"], category: "nature" },
|
||||
{ emoji: "🦆", names: ["duck"], category: "nature" },
|
||||
{ emoji: "🦅", names: ["eagle"], category: "nature" },
|
||||
{ emoji: "🦉", names: ["owl"], category: "nature" },
|
||||
{ emoji: "🦇", names: ["bat"], category: "nature" },
|
||||
{ emoji: "🐺", names: ["wolf"], category: "nature" },
|
||||
{ emoji: "🐴", names: ["horse"], category: "nature" },
|
||||
{ emoji: "🦄", names: ["unicorn"], category: "nature" },
|
||||
{ emoji: "🐝", names: ["bee", "honeybee"], category: "nature" },
|
||||
{ emoji: "🐛", names: ["bug", "caterpillar"], category: "nature" },
|
||||
{ emoji: "🦋", names: ["butterfly"], category: "nature" },
|
||||
{ emoji: "🐌", names: ["snail", "slow"], category: "nature" },
|
||||
{ emoji: "🐞", names: ["ladybug", "beetle"], category: "nature" },
|
||||
{ emoji: "🐍", names: ["snake"], category: "nature" },
|
||||
{ emoji: "🐢", names: ["turtle"], category: "nature" },
|
||||
{ emoji: "🐙", names: ["octopus"], category: "nature" },
|
||||
{ emoji: "🦀", names: ["crab"], category: "nature" },
|
||||
{ emoji: "🦐", names: ["shrimp"], category: "nature" },
|
||||
{ emoji: "🦑", names: ["squid"], category: "nature" },
|
||||
{ emoji: "🐠", names: ["fish", "tropical_fish"], category: "nature" },
|
||||
{ emoji: "🐟", names: ["fish"], category: "nature" },
|
||||
{ emoji: "🐬", names: ["dolphin"], category: "nature" },
|
||||
{ emoji: "🐳", names: ["whale"], category: "nature" },
|
||||
{ emoji: "🦈", names: ["shark"], category: "nature" },
|
||||
{ emoji: "🐊", names: ["crocodile", "alligator"], category: "nature" },
|
||||
{ emoji: "🐘", names: ["elephant"], category: "nature" },
|
||||
{ emoji: "🦒", names: ["giraffe"], category: "nature" },
|
||||
{ emoji: "🦓", names: ["zebra"], category: "nature" },
|
||||
{ emoji: "🦍", names: ["gorilla"], category: "nature" },
|
||||
{ emoji: "🐒", names: ["monkey"], category: "nature" },
|
||||
|
||||
// Plants & Flowers
|
||||
{ emoji: "🌸", names: ["cherry_blossom", "sakura", "flower"], category: "nature" },
|
||||
{ emoji: "🌹", names: ["rose", "flower"], category: "nature" },
|
||||
{ emoji: "🌺", names: ["hibiscus", "flower"], category: "nature" },
|
||||
{ emoji: "🌻", names: ["sunflower", "flower"], category: "nature" },
|
||||
{ emoji: "🌼", names: ["blossom", "flower"], category: "nature" },
|
||||
{ emoji: "🌷", names: ["tulip", "flower"], category: "nature" },
|
||||
{ emoji: "🌱", names: ["seedling", "plant", "sprout"], category: "nature" },
|
||||
{ emoji: "🌲", names: ["evergreen_tree", "tree"], category: "nature" },
|
||||
{ emoji: "🌳", names: ["deciduous_tree", "tree"], category: "nature" },
|
||||
{ emoji: "🌴", names: ["palm_tree", "tropical"], category: "nature" },
|
||||
{ emoji: "🌵", names: ["cactus", "desert"], category: "nature" },
|
||||
{ emoji: "🍀", names: ["four_leaf_clover", "lucky", "luck"], category: "nature" },
|
||||
{ emoji: "🍁", names: ["maple_leaf", "fall", "autumn"], category: "nature" },
|
||||
{ emoji: "🍂", names: ["fallen_leaf", "fall", "autumn"], category: "nature" },
|
||||
{ emoji: "🍃", names: ["leaves", "wind"], category: "nature" },
|
||||
|
||||
// Weather & Sky
|
||||
{ emoji: "☀️", names: ["sunny", "sun"], category: "nature" },
|
||||
{ emoji: "🌤️", names: ["sun_behind_cloud", "partly_sunny"], category: "nature" },
|
||||
{ emoji: "⛅", names: ["partly_sunny", "cloudy"], category: "nature" },
|
||||
{ emoji: "🌥️", names: ["sun_behind_large_cloud"], category: "nature" },
|
||||
{ emoji: "☁️", names: ["cloud", "cloudy"], category: "nature" },
|
||||
{ emoji: "🌦️", names: ["sun_behind_rain_cloud"], category: "nature" },
|
||||
{ emoji: "🌧️", names: ["cloud_with_rain", "rain", "rainy"], category: "nature" },
|
||||
{ emoji: "⛈️", names: ["thunder_cloud_rain", "storm"], category: "nature" },
|
||||
{ emoji: "🌩️", names: ["cloud_with_lightning", "thunder"], category: "nature" },
|
||||
{ emoji: "🌨️", names: ["cloud_with_snow", "snow"], category: "nature" },
|
||||
{ emoji: "❄️", names: ["snowflake", "cold", "winter"], category: "nature" },
|
||||
{ emoji: "☃️", names: ["snowman", "winter"], category: "nature" },
|
||||
{ emoji: "⛄", names: ["snowman_without_snow"], category: "nature" },
|
||||
{ emoji: "🌪️", names: ["tornado"], category: "nature" },
|
||||
{ emoji: "🌈", names: ["rainbow"], category: "nature" },
|
||||
{ emoji: "🌊", names: ["ocean", "wave", "water"], category: "nature" },
|
||||
{ emoji: "🌙", names: ["crescent_moon", "moon", "night"], category: "nature" },
|
||||
{ emoji: "🌕", names: ["full_moon"], category: "nature" },
|
||||
{ emoji: "🌑", names: ["new_moon", "dark"], category: "nature" },
|
||||
{ emoji: "⚡", names: ["lightning", "zap", "electric"], category: "nature" },
|
||||
|
||||
// Food & Drink
|
||||
{ emoji: "🍎", names: ["apple", "red_apple"], category: "food" },
|
||||
{ emoji: "🍐", names: ["pear"], category: "food" },
|
||||
{ emoji: "🍊", names: ["orange", "tangerine"], category: "food" },
|
||||
{ emoji: "🍋", names: ["lemon"], category: "food" },
|
||||
{ emoji: "🍌", names: ["banana"], category: "food" },
|
||||
{ emoji: "🍉", names: ["watermelon"], category: "food" },
|
||||
{ emoji: "🍇", names: ["grapes"], category: "food" },
|
||||
{ emoji: "🍓", names: ["strawberry"], category: "food" },
|
||||
{ emoji: "🍒", names: ["cherries"], category: "food" },
|
||||
{ emoji: "🍑", names: ["peach"], category: "food" },
|
||||
{ emoji: "🥭", names: ["mango"], category: "food" },
|
||||
{ emoji: "🍍", names: ["pineapple"], category: "food" },
|
||||
{ emoji: "🥥", names: ["coconut"], category: "food" },
|
||||
{ emoji: "🥝", names: ["kiwi"], category: "food" },
|
||||
{ emoji: "🍅", names: ["tomato"], category: "food" },
|
||||
{ emoji: "🥑", names: ["avocado"], category: "food" },
|
||||
{ emoji: "🥦", names: ["broccoli"], category: "food" },
|
||||
{ emoji: "🌶️", names: ["hot_pepper", "chili", "spicy"], category: "food" },
|
||||
{ emoji: "🌽", names: ["corn"], category: "food" },
|
||||
{ emoji: "🥕", names: ["carrot"], category: "food" },
|
||||
{ emoji: "🥔", names: ["potato"], category: "food" },
|
||||
{ emoji: "🍞", names: ["bread"], category: "food" },
|
||||
{ emoji: "🥐", names: ["croissant"], category: "food" },
|
||||
{ emoji: "🧀", names: ["cheese"], category: "food" },
|
||||
{ emoji: "🍳", names: ["fried_egg", "egg", "breakfast"], category: "food" },
|
||||
{ emoji: "🥓", names: ["bacon"], category: "food" },
|
||||
{ emoji: "🥩", names: ["steak", "meat"], category: "food" },
|
||||
{ emoji: "🍗", names: ["chicken_leg", "poultry"], category: "food" },
|
||||
{ emoji: "🍖", names: ["meat_on_bone"], category: "food" },
|
||||
{ emoji: "🌭", names: ["hot_dog", "hotdog"], category: "food" },
|
||||
{ emoji: "🍔", names: ["hamburger", "burger"], category: "food" },
|
||||
{ emoji: "🍟", names: ["fries", "french_fries"], category: "food" },
|
||||
{ emoji: "🍕", names: ["pizza"], category: "food" },
|
||||
{ emoji: "🌮", names: ["taco"], category: "food" },
|
||||
{ emoji: "🌯", names: ["burrito"], category: "food" },
|
||||
{ emoji: "🥗", names: ["salad", "green_salad"], category: "food" },
|
||||
{ emoji: "🍜", names: ["ramen", "noodles"], category: "food" },
|
||||
{ emoji: "🍝", names: ["spaghetti", "pasta"], category: "food" },
|
||||
{ emoji: "🍣", names: ["sushi"], category: "food" },
|
||||
{ emoji: "🍱", names: ["bento", "lunch_box"], category: "food" },
|
||||
{ emoji: "🍩", names: ["doughnut", "donut"], category: "food" },
|
||||
{ emoji: "🍪", names: ["cookie"], category: "food" },
|
||||
{ emoji: "🎂", names: ["birthday", "cake"], category: "food" },
|
||||
{ emoji: "🍰", names: ["cake", "shortcake"], category: "food" },
|
||||
{ emoji: "🧁", names: ["cupcake"], category: "food" },
|
||||
{ emoji: "🍫", names: ["chocolate"], category: "food" },
|
||||
{ emoji: "🍬", names: ["candy"], category: "food" },
|
||||
{ emoji: "🍭", names: ["lollipop"], category: "food" },
|
||||
{ emoji: "🍿", names: ["popcorn"], category: "food" },
|
||||
{ emoji: "🍦", names: ["ice_cream", "icecream"], category: "food" },
|
||||
{ emoji: "☕", names: ["coffee", "cafe"], category: "food" },
|
||||
{ emoji: "🍵", names: ["tea"], category: "food" },
|
||||
{ emoji: "🥤", names: ["cup_with_straw", "soda", "drink"], category: "food" },
|
||||
{ emoji: "🍺", names: ["beer"], category: "food" },
|
||||
{ emoji: "🍻", names: ["beers", "cheers"], category: "food" },
|
||||
{ emoji: "🥂", names: ["champagne", "cheers", "toast"], category: "food" },
|
||||
{ emoji: "🍷", names: ["wine", "wine_glass"], category: "food" },
|
||||
{ emoji: "🥃", names: ["whisky", "tumbler_glass"], category: "food" },
|
||||
{ emoji: "🍸", names: ["cocktail", "martini"], category: "food" },
|
||||
|
||||
// Activities & Sports
|
||||
{ emoji: "⚽", names: ["soccer", "football"], category: "activities" },
|
||||
{ emoji: "🏀", names: ["basketball"], category: "activities" },
|
||||
{ emoji: "🏈", names: ["football", "american_football"], category: "activities" },
|
||||
{ emoji: "⚾", names: ["baseball"], category: "activities" },
|
||||
{ emoji: "🎾", names: ["tennis"], category: "activities" },
|
||||
{ emoji: "🏐", names: ["volleyball"], category: "activities" },
|
||||
{ emoji: "🏓", names: ["ping_pong", "table_tennis"], category: "activities" },
|
||||
{ emoji: "🎱", names: ["pool", "billiards", "8ball"], category: "activities" },
|
||||
{ emoji: "🎮", names: ["video_game", "gaming", "controller"], category: "activities" },
|
||||
{ emoji: "🕹️", names: ["joystick", "gaming"], category: "activities" },
|
||||
{ emoji: "🎲", names: ["dice", "game"], category: "activities" },
|
||||
{ emoji: "🧩", names: ["puzzle", "piece"], category: "activities" },
|
||||
{ emoji: "♟️", names: ["chess", "pawn"], category: "activities" },
|
||||
{ emoji: "🎯", names: ["dart", "target", "bullseye"], category: "activities" },
|
||||
{ emoji: "🎳", names: ["bowling"], category: "activities" },
|
||||
{ emoji: "🎸", names: ["guitar"], category: "activities" },
|
||||
{ emoji: "🎹", names: ["piano", "keyboard"], category: "activities" },
|
||||
{ emoji: "🥁", names: ["drum"], category: "activities" },
|
||||
{ emoji: "🎤", names: ["microphone", "mic", "karaoke"], category: "activities" },
|
||||
{ emoji: "🎧", names: ["headphones", "music"], category: "activities" },
|
||||
{ emoji: "🎬", names: ["clapper", "movie", "film"], category: "activities" },
|
||||
{ emoji: "🎨", names: ["art", "palette", "paint"], category: "activities" },
|
||||
{ emoji: "🎭", names: ["theater", "drama", "masks"], category: "activities" },
|
||||
|
||||
// Objects & Tech
|
||||
{ emoji: "💻", names: ["laptop", "computer"], category: "objects" },
|
||||
{ emoji: "🖥️", names: ["desktop", "computer"], category: "objects" },
|
||||
{ emoji: "📱", names: ["phone", "iphone", "mobile"], category: "objects" },
|
||||
{ emoji: "📷", names: ["camera"], category: "objects" },
|
||||
{ emoji: "📸", names: ["camera_flash"], category: "objects" },
|
||||
{ emoji: "📹", names: ["video_camera"], category: "objects" },
|
||||
{ emoji: "🎥", names: ["movie_camera", "film"], category: "objects" },
|
||||
{ emoji: "📺", names: ["tv", "television"], category: "objects" },
|
||||
{ emoji: "📻", names: ["radio"], category: "objects" },
|
||||
{ emoji: "🎙️", names: ["studio_microphone"], category: "objects" },
|
||||
{ emoji: "⌨️", names: ["keyboard"], category: "objects" },
|
||||
{ emoji: "🖱️", names: ["mouse", "computer_mouse"], category: "objects" },
|
||||
{ emoji: "💾", names: ["floppy_disk", "save"], category: "objects" },
|
||||
{ emoji: "💿", names: ["cd", "disc"], category: "objects" },
|
||||
{ emoji: "📀", names: ["dvd", "disc"], category: "objects" },
|
||||
{ emoji: "🔋", names: ["battery"], category: "objects" },
|
||||
{ emoji: "🔌", names: ["plug", "electric"], category: "objects" },
|
||||
{ emoji: "📧", names: ["email", "e-mail"], category: "objects" },
|
||||
{ emoji: "📨", names: ["incoming_envelope", "email"], category: "objects" },
|
||||
{ emoji: "📩", names: ["envelope_with_arrow", "email"], category: "objects" },
|
||||
{ emoji: "📝", names: ["memo", "note", "pencil"], category: "objects" },
|
||||
{ emoji: "📁", names: ["folder", "file_folder"], category: "objects" },
|
||||
{ emoji: "📂", names: ["open_folder"], category: "objects" },
|
||||
{ emoji: "📎", names: ["paperclip", "attachment"], category: "objects" },
|
||||
{ emoji: "🔗", names: ["link", "chain"], category: "objects" },
|
||||
{ emoji: "📌", names: ["pushpin", "pin"], category: "objects" },
|
||||
{ emoji: "📍", names: ["round_pushpin", "location"], category: "objects" },
|
||||
{ emoji: "✏️", names: ["pencil", "edit"], category: "objects" },
|
||||
{ emoji: "🖊️", names: ["pen"], category: "objects" },
|
||||
{ emoji: "🔍", names: ["mag", "search", "magnifying_glass"], category: "objects" },
|
||||
{ emoji: "🔎", names: ["mag_right", "search"], category: "objects" },
|
||||
{ emoji: "🔐", names: ["locked_key", "secure"], category: "objects" },
|
||||
{ emoji: "🔒", names: ["lock", "locked", "secure"], category: "objects" },
|
||||
{ emoji: "🔓", names: ["unlock", "unlocked"], category: "objects" },
|
||||
{ emoji: "🔑", names: ["key"], category: "objects" },
|
||||
{ emoji: "🗝️", names: ["old_key"], category: "objects" },
|
||||
{ emoji: "🔧", names: ["wrench", "tool"], category: "objects" },
|
||||
{ emoji: "🔨", names: ["hammer", "tool"], category: "objects" },
|
||||
{ emoji: "⚙️", names: ["gear", "settings", "cog"], category: "objects" },
|
||||
{ emoji: "🛠️", names: ["tools", "hammer_and_wrench"], category: "objects" },
|
||||
{ emoji: "⚗️", names: ["alembic", "science"], category: "objects" },
|
||||
{ emoji: "🧪", names: ["test_tube", "science"], category: "objects" },
|
||||
{ emoji: "🧬", names: ["dna", "genetics"], category: "objects" },
|
||||
{ emoji: "🔬", names: ["microscope", "science"], category: "objects" },
|
||||
{ emoji: "🔭", names: ["telescope", "astronomy"], category: "objects" },
|
||||
{ emoji: "📡", names: ["satellite", "antenna"], category: "objects" },
|
||||
{ emoji: "💉", names: ["syringe", "needle", "vaccine"], category: "objects" },
|
||||
{ emoji: "💊", names: ["pill", "medicine"], category: "objects" },
|
||||
{ emoji: "🩹", names: ["bandage", "adhesive"], category: "objects" },
|
||||
{ emoji: "🩺", names: ["stethoscope", "doctor"], category: "objects" },
|
||||
{ emoji: "🚀", names: ["rocket", "launch", "ship"], category: "objects" },
|
||||
{ emoji: "🛸", names: ["ufo", "flying_saucer"], category: "objects" },
|
||||
{ emoji: "🛰️", names: ["satellite"], category: "objects" },
|
||||
{ emoji: "💰", names: ["money_bag", "money", "cash"], category: "objects" },
|
||||
{ emoji: "💵", names: ["dollar", "money", "cash"], category: "objects" },
|
||||
{ emoji: "💳", names: ["credit_card", "card"], category: "objects" },
|
||||
{ emoji: "💎", names: ["gem", "diamond", "jewel"], category: "objects" },
|
||||
{ emoji: "⏰", names: ["alarm_clock", "clock"], category: "objects" },
|
||||
{ emoji: "⏳", names: ["hourglass", "time"], category: "objects" },
|
||||
{ emoji: "⌛", names: ["hourglass_done", "time"], category: "objects" },
|
||||
{ emoji: "📅", names: ["calendar", "date"], category: "objects" },
|
||||
{ emoji: "📆", names: ["tear_off_calendar", "calendar"], category: "objects" },
|
||||
{ emoji: "🗓️", names: ["spiral_calendar"], category: "objects" },
|
||||
{ emoji: "📊", names: ["chart", "bar_chart", "graph"], category: "objects" },
|
||||
{ emoji: "📈", names: ["chart_up", "chart_with_upwards_trend"], category: "objects" },
|
||||
{ emoji: "📉", names: ["chart_down", "chart_with_downwards_trend"], category: "objects" },
|
||||
{ emoji: "📋", names: ["clipboard"], category: "objects" },
|
||||
{ emoji: "📄", names: ["page", "document"], category: "objects" },
|
||||
{ emoji: "📃", names: ["page_curl", "document"], category: "objects" },
|
||||
{ emoji: "📑", names: ["bookmark_tabs"], category: "objects" },
|
||||
{ emoji: "🔖", names: ["bookmark"], category: "objects" },
|
||||
{ emoji: "🏷️", names: ["label", "tag"], category: "objects" },
|
||||
{ emoji: "📚", names: ["books"], category: "objects" },
|
||||
{ emoji: "📖", names: ["book", "open_book"], category: "objects" },
|
||||
{ emoji: "📰", names: ["newspaper", "news"], category: "objects" },
|
||||
{ emoji: "🗞️", names: ["rolled_newspaper", "news"], category: "objects" },
|
||||
|
||||
// Travel & Places
|
||||
{ emoji: "🚗", names: ["car", "automobile"], category: "travel" },
|
||||
{ emoji: "🚕", names: ["taxi", "cab"], category: "travel" },
|
||||
{ emoji: "🚌", names: ["bus"], category: "travel" },
|
||||
{ emoji: "🚎", names: ["trolleybus"], category: "travel" },
|
||||
{ emoji: "🏎️", names: ["racing_car"], category: "travel" },
|
||||
{ emoji: "🚓", names: ["police_car"], category: "travel" },
|
||||
{ emoji: "🚑", names: ["ambulance"], category: "travel" },
|
||||
{ emoji: "🚒", names: ["fire_engine", "fire_truck"], category: "travel" },
|
||||
{ emoji: "🚚", names: ["truck"], category: "travel" },
|
||||
{ emoji: "🚲", names: ["bike", "bicycle"], category: "travel" },
|
||||
{ emoji: "🛵", names: ["scooter", "motor_scooter"], category: "travel" },
|
||||
{ emoji: "🏍️", names: ["motorcycle"], category: "travel" },
|
||||
{ emoji: "✈️", names: ["airplane", "plane"], category: "travel" },
|
||||
{ emoji: "🚀", names: ["rocket"], category: "travel" },
|
||||
{ emoji: "🚁", names: ["helicopter"], category: "travel" },
|
||||
{ emoji: "🚢", names: ["ship", "boat"], category: "travel" },
|
||||
{ emoji: "⛵", names: ["sailboat", "boat"], category: "travel" },
|
||||
{ emoji: "🚤", names: ["speedboat", "boat"], category: "travel" },
|
||||
{ emoji: "🚂", names: ["train", "steam_locomotive"], category: "travel" },
|
||||
{ emoji: "🚃", names: ["railway_car", "train"], category: "travel" },
|
||||
{ emoji: "🚄", names: ["bullet_train", "high_speed_train"], category: "travel" },
|
||||
{ emoji: "🏠", names: ["house", "home"], category: "travel" },
|
||||
{ emoji: "🏡", names: ["house_with_garden", "home"], category: "travel" },
|
||||
{ emoji: "🏢", names: ["office", "building"], category: "travel" },
|
||||
{ emoji: "🏣", names: ["post_office"], category: "travel" },
|
||||
{ emoji: "🏥", names: ["hospital"], category: "travel" },
|
||||
{ emoji: "🏦", names: ["bank"], category: "travel" },
|
||||
{ emoji: "🏨", names: ["hotel"], category: "travel" },
|
||||
{ emoji: "🏩", names: ["love_hotel"], category: "travel" },
|
||||
{ emoji: "🏪", names: ["convenience_store", "store"], category: "travel" },
|
||||
{ emoji: "🏫", names: ["school"], category: "travel" },
|
||||
{ emoji: "🏬", names: ["department_store"], category: "travel" },
|
||||
{ emoji: "🏭", names: ["factory"], category: "travel" },
|
||||
{ emoji: "🏯", names: ["japanese_castle", "castle"], category: "travel" },
|
||||
{ emoji: "🏰", names: ["castle", "european_castle"], category: "travel" },
|
||||
{ emoji: "🗽", names: ["statue_of_liberty"], category: "travel" },
|
||||
{ emoji: "🗼", names: ["tokyo_tower", "tower"], category: "travel" },
|
||||
{ emoji: "🗻", names: ["mount_fuji", "mountain"], category: "travel" },
|
||||
{ emoji: "🌋", names: ["volcano"], category: "travel" },
|
||||
{ emoji: "🏔️", names: ["mountain", "snow_capped_mountain"], category: "travel" },
|
||||
{ emoji: "⛰️", names: ["mountain"], category: "travel" },
|
||||
{ emoji: "🏕️", names: ["camping", "tent"], category: "travel" },
|
||||
{ emoji: "🏖️", names: ["beach", "beach_umbrella"], category: "travel" },
|
||||
{ emoji: "🏜️", names: ["desert"], category: "travel" },
|
||||
{ emoji: "🏝️", names: ["island", "desert_island"], category: "travel" },
|
||||
{ emoji: "🌍", names: ["earth_africa", "globe", "world"], category: "travel" },
|
||||
{ emoji: "🌎", names: ["earth_americas", "globe", "world"], category: "travel" },
|
||||
{ emoji: "🌏", names: ["earth_asia", "globe", "world"], category: "travel" },
|
||||
{ emoji: "🗺️", names: ["world_map", "map"], category: "travel" },
|
||||
];
|
||||
|
||||
// Search emojis by name with relevance scoring
|
||||
// Prioritizes exact matches and prefix matches over substring matches
|
||||
export function searchEmojis(query: string): EmojiItem[] {
|
||||
if (!query) return [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Score each emoji based on match quality
|
||||
const scored = emojiData
|
||||
.map(item => {
|
||||
let bestScore = 0;
|
||||
for (const name of item.names) {
|
||||
if (name === lowerQuery) {
|
||||
// Exact match - highest priority
|
||||
bestScore = Math.max(bestScore, 100);
|
||||
} else if (name.startsWith(lowerQuery)) {
|
||||
// Prefix match - high priority, shorter names score higher
|
||||
bestScore = Math.max(bestScore, 50 + (20 - name.length));
|
||||
} else if (name.includes(lowerQuery)) {
|
||||
// Substring match - lower priority
|
||||
bestScore = Math.max(bestScore, 10);
|
||||
}
|
||||
}
|
||||
return { item, score: bestScore };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10)
|
||||
.map(({ item }) => item);
|
||||
|
||||
return scored;
|
||||
}
|
||||
|
||||
// Get emoji by exact name
|
||||
export function getEmojiByName(name: string): string | null {
|
||||
const lowerName = name.toLowerCase();
|
||||
const item = emojiData.find(e => e.names.includes(lowerName));
|
||||
return item?.emoji || null;
|
||||
}
|
||||
|
||||
// Get all emojis by category
|
||||
export function getEmojisByCategory(category: string): EmojiItem[] {
|
||||
return emojiData.filter(item => item.category === category);
|
||||
}
|
||||
|
||||
// Get all unique categories
|
||||
export function getCategories(): string[] {
|
||||
return [...new Set(emojiData.map(e => e.category))];
|
||||
}
|
||||
|
||||
// Convert emoji shortcodes like :heart: to actual emojis
|
||||
export function convertEmojiShortcodes(text: string): string {
|
||||
return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match, name) => {
|
||||
const emoji = getEmojiByName(name);
|
||||
return emoji || match; // Keep original if no match
|
||||
});
|
||||
}
|
||||
30
src/lib/utils/twemoji.ts
Normal file
30
src/lib/utils/twemoji.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Twemoji utility for rendering emojis as Twitter-style images
|
||||
*/
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
/**
|
||||
* Parse text and replace emojis with Twemoji images
|
||||
*/
|
||||
export function parseTwemoji(text: string): string {
|
||||
return twemoji.parse(text, {
|
||||
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/',
|
||||
folder: 'svg',
|
||||
ext: '.svg',
|
||||
className: 'twemoji',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Twemoji image URL for a single emoji
|
||||
*/
|
||||
export function getTwemojiUrl(emoji: string): string {
|
||||
// Remove variation selector (FE0F) as Twemoji uses base codepoints
|
||||
const codePoint = [...emoji]
|
||||
.filter((char) => char.codePointAt(0) !== 0xfe0f)
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
.toLowerCase();
|
||||
return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg`;
|
||||
}
|
||||
176
src/lib/utils/twemojiGlobal.ts
Normal file
176
src/lib/utils/twemojiGlobal.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Global Twemoji replacement utility
|
||||
* Automatically converts all emoji characters to Twemoji images throughout the DOM
|
||||
*/
|
||||
|
||||
// Regex to match emojis including those with and without variation selectors
|
||||
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
|
||||
|
||||
// Elements to skip when processing
|
||||
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'CODE', 'PRE', 'NOSCRIPT']);
|
||||
|
||||
/**
|
||||
* Convert an emoji to a Twemoji image element
|
||||
*/
|
||||
function emojiToTwemoji(emoji: string): string {
|
||||
// Remove variation selectors (FE0F) for Twemoji URL compatibility
|
||||
// Twemoji uses base codepoints without variation selectors
|
||||
const codePoint = [...emoji]
|
||||
.filter((char) => char.codePointAt(0) !== 0xfe0f)
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
.toLowerCase();
|
||||
|
||||
return `<img class="twemoji-inline" src="https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg" alt="${emoji}" draggable="false" />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains any emojis
|
||||
*/
|
||||
function containsEmoji(text: string): boolean {
|
||||
return emojiRegex.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a text node and replace emojis with Twemoji images
|
||||
*/
|
||||
function processTextNode(textNode: Text): void {
|
||||
const text = textNode.textContent || '';
|
||||
|
||||
// Reset regex lastIndex
|
||||
emojiRegex.lastIndex = 0;
|
||||
|
||||
if (!containsEmoji(text)) return;
|
||||
|
||||
// Reset regex lastIndex again after the check
|
||||
emojiRegex.lastIndex = 0;
|
||||
|
||||
// Create a temporary container
|
||||
const temp = document.createElement('span');
|
||||
temp.innerHTML = text.replace(emojiRegex, (emoji) => emojiToTwemoji(emoji));
|
||||
|
||||
// Replace the text node with the processed content
|
||||
const parent = textNode.parentNode;
|
||||
if (parent) {
|
||||
while (temp.firstChild) {
|
||||
parent.insertBefore(temp.firstChild, textNode);
|
||||
}
|
||||
parent.removeChild(textNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all text nodes within an element
|
||||
*/
|
||||
function processElement(element: Element): void {
|
||||
// Skip certain elements
|
||||
if (SKIP_TAGS.has(element.tagName)) return;
|
||||
|
||||
// Skip elements that already contain twemoji
|
||||
if (element.classList?.contains('twemoji-inline')) return;
|
||||
|
||||
// Skip elements with data-no-twemoji attribute
|
||||
if (element.hasAttribute?.('data-no-twemoji')) return;
|
||||
|
||||
// Get all text nodes using TreeWalker
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (parent && SKIP_TAGS.has(parent.tagName)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
// Skip if parent is already a twemoji image
|
||||
if (parent?.classList?.contains('twemoji-inline')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node as Text);
|
||||
}
|
||||
|
||||
// Process each text node
|
||||
textNodes.forEach(processTextNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize global Twemoji replacement with MutationObserver
|
||||
*/
|
||||
export function initGlobalTwemoji(): () => void {
|
||||
// Process existing content
|
||||
processElement(document.body);
|
||||
|
||||
// Set up MutationObserver to watch for new content
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
// Process added nodes
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
processElement(node as Element);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const parent = node.parentElement;
|
||||
if (parent && !SKIP_TAGS.has(parent.tagName)) {
|
||||
processTextNode(node as Text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Process character data changes (text content updates)
|
||||
if (mutation.type === 'characterData' && mutation.target.nodeType === Node.TEXT_NODE) {
|
||||
const parent = mutation.target.parentElement;
|
||||
if (parent && !SKIP_TAGS.has(parent.tagName)) {
|
||||
processTextNode(mutation.target as Text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action to apply Twemoji to an element and its descendants
|
||||
*/
|
||||
export function twemoji(node: HTMLElement): { destroy: () => void } {
|
||||
processElement(node);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((addedNode) => {
|
||||
if (addedNode.nodeType === Node.ELEMENT_NODE) {
|
||||
processElement(addedNode as Element);
|
||||
} else if (addedNode.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(addedNode as Text);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(node, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
||||
import { setContext } from "svelte";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import { totalUnreadCount } from "$lib/stores/matrix";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -122,6 +123,12 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `/${data.org.slug}/chat`,
|
||||
label: "Chat",
|
||||
icon: "chat",
|
||||
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
|
||||
},
|
||||
// Settings requires settings.view or admin role
|
||||
...(canAccess("settings.view")
|
||||
? [
|
||||
@@ -218,6 +225,11 @@
|
||||
? 'opacity-0 max-w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||
>
|
||||
{#if item.badge}
|
||||
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
|
||||
{item.badge}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
806
src/routes/[orgSlug]/chat/+page.svelte
Normal file
806
src/routes/[orgSlug]/chat/+page.svelte
Normal file
@@ -0,0 +1,806 @@
|
||||
<script lang="ts">
|
||||
import { onMount, getContext } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { page } from "$app/state";
|
||||
import { Avatar, Button, Input, Modal } from "$lib/components/ui";
|
||||
import {
|
||||
MessageList,
|
||||
MessageInput,
|
||||
TypingIndicator,
|
||||
CreateRoomModal,
|
||||
MemberList,
|
||||
StartDMModal,
|
||||
RoomInfoPanel,
|
||||
MatrixProvider,
|
||||
} from "$lib/components/matrix";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
initMatrixClient,
|
||||
setupSyncHandlers,
|
||||
logout as matrixLogout,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
loadMoreMessages,
|
||||
getRoomMembers,
|
||||
searchMessagesLocal,
|
||||
uploadFile,
|
||||
sendFileMessage,
|
||||
type LoginCredentials,
|
||||
} from "$lib/matrix";
|
||||
import {
|
||||
auth,
|
||||
syncState,
|
||||
roomSummaries,
|
||||
selectedRoomId,
|
||||
selectRoom,
|
||||
clearState,
|
||||
currentMessages,
|
||||
currentTyping,
|
||||
loadRoomMessages,
|
||||
} from "$lib/stores/matrix";
|
||||
import { reactionService } from "$lib/services";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import { initCache, cleanupCache } from "$lib/cache";
|
||||
import { clearBlobUrlCache } from "$lib/cache/mediaCache";
|
||||
import type { Message } from "$lib/matrix/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = getContext<SupabaseClient>("supabase");
|
||||
let data = $derived(page.data);
|
||||
|
||||
// Matrix state
|
||||
let matrixClient = $state<MatrixClient | null>(null);
|
||||
let isInitializing = $state(true);
|
||||
let showMatrixLogin = $state(false);
|
||||
|
||||
// Matrix login form
|
||||
let matrixHomeserver = $state("https://matrix.org");
|
||||
let matrixUsername = $state("");
|
||||
let matrixPassword = $state("");
|
||||
let isLoggingIn = $state(false);
|
||||
|
||||
// Chat UI state
|
||||
let showCreateRoomModal = $state(false);
|
||||
let showStartDMModal = $state(false);
|
||||
let replyToMessage = $state<Message | null>(null);
|
||||
let editingMsg = $state<Message | null>(null);
|
||||
let isLoadingMore = $state(false);
|
||||
let showMemberList = $state(false);
|
||||
let showRoomInfo = $state(false);
|
||||
let roomSearchQuery = $state("");
|
||||
let showMessageSearch = $state(false);
|
||||
let messageSearchQuery = $state("");
|
||||
let isDraggingFile = $state(false);
|
||||
let isUploadingDrop = $state(false);
|
||||
|
||||
const messageSearchResults = $derived(
|
||||
messageSearchQuery.trim() && $selectedRoomId
|
||||
? searchMessagesLocal($selectedRoomId, messageSearchQuery)
|
||||
: [],
|
||||
);
|
||||
|
||||
// All non-space rooms (exclude Space entries themselves from the list)
|
||||
const allRooms = $derived(
|
||||
$roomSummaries.filter((r) => !r.isSpace),
|
||||
);
|
||||
|
||||
// Org rooms: rooms that belong to any Space
|
||||
const orgRooms = $derived(
|
||||
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
|
||||
);
|
||||
|
||||
// DMs: direct messages (not tied to org)
|
||||
const dmRooms = $derived(
|
||||
allRooms.filter((r) => r.isDirect),
|
||||
);
|
||||
|
||||
// Other rooms: not in a space and not a DM
|
||||
const otherRooms = $derived(
|
||||
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
|
||||
);
|
||||
|
||||
// Apply search filter across all sections
|
||||
const filterBySearch = (rooms: typeof allRooms) =>
|
||||
roomSearchQuery.trim()
|
||||
? rooms.filter(
|
||||
(room) =>
|
||||
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
|
||||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
|
||||
)
|
||||
: rooms;
|
||||
|
||||
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
|
||||
const filteredDmRooms = $derived(filterBySearch(dmRooms));
|
||||
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
|
||||
|
||||
const currentMembers = $derived(
|
||||
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
await initCache();
|
||||
await cleanupCache(7 * 24 * 60 * 60 * 1000);
|
||||
} catch (e) {
|
||||
console.warn("Cache initialization failed:", e);
|
||||
}
|
||||
|
||||
// Try to load credentials from Supabase
|
||||
try {
|
||||
const res = await fetch(`/api/matrix-credentials?org_id=${data.org.id}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.credentials) {
|
||||
await initFromCredentials({
|
||||
homeserverUrl: result.credentials.homeserver_url,
|
||||
userId: result.credentials.matrix_user_id,
|
||||
accessToken: result.credentials.access_token,
|
||||
deviceId: result.credentials.device_id,
|
||||
});
|
||||
} else {
|
||||
// No stored credentials — show login form
|
||||
showMatrixLogin = true;
|
||||
isInitializing = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load Matrix credentials:", e);
|
||||
showMatrixLogin = true;
|
||||
isInitializing = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function initFromCredentials(credentials: LoginCredentials) {
|
||||
try {
|
||||
const client = await initMatrixClient(credentials);
|
||||
matrixClient = client;
|
||||
setupSyncHandlers(client);
|
||||
|
||||
auth.set({
|
||||
isLoggedIn: true,
|
||||
userId: credentials.userId,
|
||||
homeserverUrl: credentials.homeserverUrl,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId || null,
|
||||
});
|
||||
|
||||
// Check if org has a Matrix Space, auto-create if not
|
||||
await ensureOrgSpace(credentials);
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to init Matrix client:", e);
|
||||
toasts.error("Failed to connect to chat. Please re-login.");
|
||||
showMatrixLogin = true;
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOrgSpace(credentials: LoginCredentials) {
|
||||
try {
|
||||
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
|
||||
const spaceResult = await spaceRes.json();
|
||||
|
||||
if (!spaceResult.spaceId) {
|
||||
// No Space yet — create one using the user's credentials
|
||||
const createRes = await fetch("/api/matrix-space", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
org_id: data.org.id,
|
||||
action: "create",
|
||||
homeserver_url: credentials.homeserverUrl,
|
||||
access_token: credentials.accessToken,
|
||||
org_name: data.org.name,
|
||||
}),
|
||||
});
|
||||
const createResult = await createRes.json();
|
||||
if (createResult.spaceId) {
|
||||
toasts.success(`Organization space created`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to ensure org space:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatrixLogin() {
|
||||
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
||||
toasts.error("Please enter username and password");
|
||||
return;
|
||||
}
|
||||
|
||||
isLoggingIn = true;
|
||||
try {
|
||||
const { loginWithPassword } = await import("$lib/matrix");
|
||||
const credentials = await loginWithPassword({
|
||||
homeserverUrl: matrixHomeserver,
|
||||
username: matrixUsername.trim(),
|
||||
password: matrixPassword,
|
||||
});
|
||||
|
||||
// Save to Supabase
|
||||
await fetch("/api/matrix-credentials", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
org_id: data.org.id,
|
||||
homeserver_url: credentials.homeserverUrl,
|
||||
matrix_user_id: credentials.userId,
|
||||
access_token: credentials.accessToken,
|
||||
device_id: credentials.deviceId,
|
||||
}),
|
||||
});
|
||||
|
||||
showMatrixLogin = false;
|
||||
await initFromCredentials(credentials);
|
||||
toasts.success("Connected to chat!");
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Login failed");
|
||||
} finally {
|
||||
isLoggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await matrixLogout();
|
||||
} catch {}
|
||||
clearState();
|
||||
clearBlobUrlCache();
|
||||
|
||||
// Remove from Supabase
|
||||
await fetch(`/api/matrix-credentials?org_id=${data.org.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
matrixClient = null;
|
||||
showMatrixLogin = true;
|
||||
auth.set({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
homeserverUrl: null,
|
||||
accessToken: null,
|
||||
deviceId: null,
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoomSelect(roomId: string) {
|
||||
selectRoom(roomId);
|
||||
}
|
||||
|
||||
async function handleReact(messageId: string, emoji: string) {
|
||||
if (!$selectedRoomId || !$auth.userId) return;
|
||||
try {
|
||||
await reactionService.add($selectedRoomId, messageId, emoji, $auth.userId);
|
||||
} catch (e) {
|
||||
const error = e as { message?: string };
|
||||
toasts.error(error.message || "Failed to add reaction");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleReaction(
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
reactionEventId: string | null,
|
||||
) {
|
||||
if (!$selectedRoomId || !$auth.userId) return;
|
||||
try {
|
||||
await reactionService.toggle(
|
||||
$selectedRoomId,
|
||||
messageId,
|
||||
emoji,
|
||||
$auth.userId,
|
||||
reactionEventId,
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as { message?: string };
|
||||
toasts.error(error.message || "Failed to toggle reaction");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditMessage(message: Message) {
|
||||
editingMsg = message;
|
||||
}
|
||||
|
||||
async function handleSaveEdit(newContent: string) {
|
||||
if (!$selectedRoomId || !editingMsg) return;
|
||||
try {
|
||||
await editMessage($selectedRoomId, editingMsg.eventId, newContent);
|
||||
editingMsg = null;
|
||||
toasts.success("Message edited");
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to edit message");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingMsg = null;
|
||||
}
|
||||
|
||||
async function handleDeleteMessage(messageId: string) {
|
||||
if (!$selectedRoomId) return;
|
||||
if (!confirm("Delete this message?")) return;
|
||||
try {
|
||||
await deleteMessage($selectedRoomId, messageId);
|
||||
toasts.success("Message deleted");
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to delete message");
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(message: Message) {
|
||||
replyToMessage = message;
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyToMessage = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer?.types.includes("Files")) isDraggingFile = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDraggingFile = false;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDraggingFile = false;
|
||||
if (!$selectedRoomId || isUploadingDrop) return;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toasts.error("File too large. Maximum size is 50MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
isUploadingDrop = true;
|
||||
try {
|
||||
toasts.info(`Uploading ${file.name}...`);
|
||||
const contentUri = await uploadFile(file);
|
||||
await sendFileMessage($selectedRoomId, file, contentUri);
|
||||
toasts.success("File sent!");
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to upload file");
|
||||
} finally {
|
||||
isUploadingDrop = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (!$selectedRoomId || isLoadingMore) return;
|
||||
isLoadingMore = true;
|
||||
try {
|
||||
const result = await loadMoreMessages($selectedRoomId);
|
||||
loadRoomMessages($selectedRoomId);
|
||||
if (!result.hasMore) toasts.info("No more messages to load");
|
||||
} catch (e: any) {
|
||||
console.error("Failed to load more messages:", e);
|
||||
} finally {
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Matrix Login Modal -->
|
||||
{#if showMatrixLogin}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="bg-night rounded-[32px] p-8 w-full max-w-md">
|
||||
<h2 class="font-heading text-h3 text-white mb-2">Connect to Chat</h2>
|
||||
<p class="text-light/50 text-body mb-6">
|
||||
Enter your Matrix credentials to enable messaging.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Homeserver URL"
|
||||
bind:value={matrixHomeserver}
|
||||
placeholder="https://matrix.org"
|
||||
/>
|
||||
<Input
|
||||
label="Username"
|
||||
bind:value={matrixUsername}
|
||||
placeholder="@user:matrix.org"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-body-sm font-body text-light mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={matrixPassword}
|
||||
placeholder="Password"
|
||||
class="w-full bg-dark border border-light/10 rounded-2xl px-4 py-3 text-white font-body text-body placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") handleMatrixLogin();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onclick={handleMatrixLogin}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{isLoggingIn ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{:else if isInitializing || ($syncState !== "PREPARED" && $syncState !== "SYNCING")}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="animate-spin w-12 h-12 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-light/50">
|
||||
{#if isInitializing}
|
||||
Connecting to Matrix...
|
||||
{:else if $syncState === "CATCHUP"}
|
||||
Catching up on messages...
|
||||
{:else if $syncState === "RECONNECTING"}
|
||||
Reconnecting...
|
||||
{:else if $syncState === "ERROR"}
|
||||
Connection error, retrying...
|
||||
{:else}
|
||||
Syncing...
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chat UI -->
|
||||
{:else if matrixClient}
|
||||
<MatrixProvider client={matrixClient}>
|
||||
{#snippet children()}
|
||||
<div class="h-full flex gap-2 min-h-0">
|
||||
<!-- Chat Sidebar -->
|
||||
<aside class="w-56 bg-night rounded-[32px] flex flex-col overflow-hidden shrink-0">
|
||||
<header class="px-3 py-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-rounded text-light" style="font-size: 20px;">chat</span>
|
||||
<span class="flex-1 font-heading text-light text-base">Messages</span>
|
||||
<button
|
||||
class="text-light hover:text-primary transition-colors"
|
||||
onclick={() => (showStartDMModal = true)}
|
||||
title="New message"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Room search -->
|
||||
<div class="px-3 pb-2">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40"
|
||||
style="font-size: 16px;"
|
||||
>search</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={roomSearchQuery}
|
||||
placeholder="Search rooms..."
|
||||
class="w-full pl-9 pr-3 py-2 bg-dark text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room list (sectioned) -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{#if allRooms.length === 0}
|
||||
<p class="text-light/40 text-sm text-center py-8">
|
||||
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Org / Space Rooms -->
|
||||
{#if filteredOrgRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">workspaces</span>
|
||||
Organization
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredOrgRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direct Messages -->
|
||||
{#if filteredDmRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">chat_bubble</span>
|
||||
Direct Messages
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showStartDMModal = true)}
|
||||
title="New DM"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredDmRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Other Rooms (not in a space, not DMs) -->
|
||||
{#if filteredOtherRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">tag</span>
|
||||
Rooms
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredOtherRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- User footer -->
|
||||
<footer class="p-3 border-t border-light/10">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar name={$auth.userId || "User"} size="xs" status="online" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-light truncate">{$auth.userId}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-light/50 hover:text-light p-1 rounded-lg hover:bg-light/10 transition-colors"
|
||||
onclick={handleLogout}
|
||||
title="Disconnect chat"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-h-0 overflow-hidden bg-night rounded-[32px]">
|
||||
{#if $selectedRoomId}
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Room Header -->
|
||||
<header class="h-14 px-5 flex items-center border-b border-light/10">
|
||||
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as room}
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="font-heading text-h5 text-light truncate">{room.name}</h2>
|
||||
<p class="text-xs text-light/50">
|
||||
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showMessageSearch = !showMessageSearch)}
|
||||
title="Search messages"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">search</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showRoomInfo = !showRoomInfo)}
|
||||
title="Room info"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">info</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showMemberList = !showMemberList)}
|
||||
title="Members"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">group</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</header>
|
||||
|
||||
<!-- Message search panel -->
|
||||
{#if showMessageSearch}
|
||||
<div class="border-b border-light/10 p-3 bg-dark/50">
|
||||
<div class="relative">
|
||||
<span class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40" style="font-size: 16px;">search</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={messageSearchQuery}
|
||||
placeholder="Search messages in this room..."
|
||||
class="w-full pl-9 pr-8 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
|
||||
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if messageSearchQuery && messageSearchResults.length > 0}
|
||||
<div class="mt-2 max-h-48 overflow-y-auto">
|
||||
<p class="text-xs text-light/40 mb-2">
|
||||
{messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{#each messageSearchResults.slice(0, 20) as result}
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 hover:bg-light/5 rounded transition-colors"
|
||||
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
|
||||
>
|
||||
<p class="text-xs text-primary">{result.senderName}</p>
|
||||
<p class="text-sm text-light truncate">{result.content}</p>
|
||||
<p class="text-xs text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if messageSearchQuery}
|
||||
<p class="text-sm text-light/40 mt-2">No results found</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Messages area with drag-drop -->
|
||||
<div
|
||||
class="flex-1 flex min-h-0 overflow-hidden relative"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
role="region"
|
||||
>
|
||||
{#if isDraggingFile}
|
||||
<div class="absolute inset-0 z-50 bg-primary/20 border-2 border-dashed border-primary rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<div class="text-center">
|
||||
<span class="material-symbols-rounded text-primary mb-4 block" style="font-size: 64px;">upload_file</span>
|
||||
<p class="text-xl font-semibold text-primary">Drop to upload</p>
|
||||
<p class="text-sm text-light/60 mt-1">Release to send file</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Messages column -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<MessageList
|
||||
messages={$currentMessages}
|
||||
onReact={handleReact}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
onEdit={handleEditMessage}
|
||||
onDelete={handleDeleteMessage}
|
||||
onReply={handleReply}
|
||||
onLoadMore={handleLoadMore}
|
||||
isLoading={isLoadingMore}
|
||||
/>
|
||||
<TypingIndicator userNames={$currentTyping} />
|
||||
<MessageInput
|
||||
roomId={$selectedRoomId}
|
||||
replyTo={replyToMessage}
|
||||
onCancelReply={cancelReply}
|
||||
editingMessage={editingMsg}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Side panels -->
|
||||
{#if showRoomInfo}
|
||||
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom}
|
||||
<aside class="w-72 border-l border-light/10 bg-dark/30">
|
||||
<RoomInfoPanel
|
||||
room={currentRoom}
|
||||
members={currentMembers}
|
||||
onClose={() => (showRoomInfo = false)}
|
||||
/>
|
||||
</aside>
|
||||
{/each}
|
||||
{:else if showMemberList}
|
||||
<aside class="w-64 border-l border-light/10 bg-dark/30">
|
||||
<MemberList members={currentMembers} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- No room selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-light/40">
|
||||
<span class="material-symbols-rounded mb-4 block" style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300;">chat</span>
|
||||
<h2 class="font-heading text-h4 text-light/50 mb-2">Select a room</h2>
|
||||
<p class="text-body text-light/30">Choose a conversation to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
{/snippet}
|
||||
</MatrixProvider>
|
||||
{/if}
|
||||
|
||||
<!-- Modals -->
|
||||
<CreateRoomModal isOpen={showCreateRoomModal} onClose={() => (showCreateRoomModal = false)} />
|
||||
|
||||
{#if showStartDMModal}
|
||||
<StartDMModal
|
||||
onClose={() => (showStartDMModal = false)}
|
||||
onDMCreated={(roomId) => handleRoomSelect(roomId)}
|
||||
/>
|
||||
{/if}
|
||||
85
src/routes/api/matrix-credentials/+server.ts
Normal file
85
src/routes/api/matrix-credentials/+server.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('org_id', orgId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ credentials: data ?? null });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, homeserver_url, matrix_user_id, access_token, device_id } = body;
|
||||
|
||||
if (!org_id || !homeserver_url || !matrix_user_id || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.upsert(
|
||||
{
|
||||
user_id: session.user.id,
|
||||
org_id,
|
||||
homeserver_url,
|
||||
matrix_user_id,
|
||||
access_token,
|
||||
device_id: device_id ?? null,
|
||||
},
|
||||
{ onConflict: 'user_id,org_id' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.delete()
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('org_id', orgId);
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
168
src/routes/api/matrix-space/+server.ts
Normal file
168
src/routes/api/matrix-space/+server.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET: Retrieve the Matrix Space ID for an org
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', orgId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId: data?.matrix_space_id ?? null });
|
||||
};
|
||||
|
||||
/**
|
||||
* POST: Create a Matrix Space for an org, or link an existing one.
|
||||
*
|
||||
* Body options:
|
||||
* - { org_id, action: "create", homeserver_url, access_token, org_name }
|
||||
* Creates a new Space on the homeserver and stores the ID.
|
||||
* - { org_id, action: "link", space_id }
|
||||
* Links an existing Matrix Space ID to the org.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, action } = body;
|
||||
|
||||
if (!org_id || !action) {
|
||||
return json({ error: 'org_id and action are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const { homeserver_url, access_token, org_name } = body;
|
||||
if (!homeserver_url || !access_token || !org_name) {
|
||||
return json({ error: 'homeserver_url, access_token, and org_name are required for create' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a Matrix Space via the Client-Server API
|
||||
const createRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: org_name,
|
||||
topic: `Organization space for ${org_name}`,
|
||||
visibility: 'private',
|
||||
creation_content: {
|
||||
type: 'm.space',
|
||||
},
|
||||
initial_state: [
|
||||
{
|
||||
type: 'm.room.guest_access',
|
||||
state_key: '',
|
||||
content: { guest_access: 'can_join' },
|
||||
},
|
||||
],
|
||||
power_level_content_override: {
|
||||
invite: 50,
|
||||
kick: 50,
|
||||
ban: 50,
|
||||
events_default: 0,
|
||||
state_default: 50,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const err = await createRes.json().catch(() => ({}));
|
||||
return json({ error: err.error || 'Failed to create Matrix Space' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { room_id: spaceId } = await createRes.json();
|
||||
|
||||
// Also create default #general room inside the space
|
||||
const generalRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'General',
|
||||
topic: 'General discussion',
|
||||
visibility: 'private',
|
||||
preset: 'private_chat',
|
||||
}),
|
||||
});
|
||||
|
||||
if (generalRes.ok) {
|
||||
const { room_id: generalRoomId } = await generalRes.json();
|
||||
|
||||
// Add #general as a child of the space
|
||||
await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(generalRoomId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
via: [new URL(homeserver_url).hostname],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Store space ID in org record
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.update({ matrix_space_id: spaceId })
|
||||
.eq('id', org_id);
|
||||
|
||||
if (updateError) {
|
||||
return json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId, created: true });
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create Matrix Space:', e);
|
||||
return json({ error: e.message || 'Failed to create Matrix Space' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'link') {
|
||||
const { space_id } = body;
|
||||
if (!space_id) {
|
||||
return json({ error: 'space_id is required for link action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.update({ matrix_space_id: space_id })
|
||||
.eq('id', org_id);
|
||||
|
||||
if (updateError) {
|
||||
return json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId: space_id, linked: true });
|
||||
}
|
||||
|
||||
return json({ error: 'Invalid action. Use "create" or "link".' }, { status: 400 });
|
||||
};
|
||||
184
src/routes/api/matrix-space/members/+server.ts
Normal file
184
src/routes/api/matrix-space/members/+server.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST: Invite a user to the org's Matrix Space (and its child rooms).
|
||||
*
|
||||
* Body: { org_id, matrix_user_id, homeserver_url, access_token }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, matrix_user_id, homeserver_url, access_token } = body;
|
||||
|
||||
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get org's Matrix Space ID
|
||||
const { data: org } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', org_id)
|
||||
.single();
|
||||
|
||||
if (!org?.matrix_space_id) {
|
||||
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||
}
|
||||
|
||||
const spaceId = org.matrix_space_id;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Invite to the Space itself
|
||||
const inviteRes = await matrixInvite(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||
if (!inviteRes.ok) {
|
||||
const err = await inviteRes.json().catch(() => ({}));
|
||||
// M_FORBIDDEN means already joined, which is fine
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Space invite: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also invite to all child rooms of the space
|
||||
try {
|
||||
const stateRes = await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (stateRes.ok) {
|
||||
const stateEvents = await stateRes.json();
|
||||
const childRoomIds = stateEvents
|
||||
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||
.map((e: any) => e.state_key);
|
||||
|
||||
for (const childRoomId of childRoomIds) {
|
||||
const childInvite = await matrixInvite(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||
if (!childInvite.ok) {
|
||||
const err = await childInvite.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Failed to fetch space children');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: errors.length === 0,
|
||||
invited: matrix_user_id,
|
||||
spaceId,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE: Kick a user from the org's Matrix Space (and its child rooms).
|
||||
*
|
||||
* Query: ?org_id=...&matrix_user_id=...&homeserver_url=...&access_token=...
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const org_id = url.searchParams.get('org_id');
|
||||
const matrix_user_id = url.searchParams.get('matrix_user_id');
|
||||
const homeserver_url = url.searchParams.get('homeserver_url');
|
||||
const access_token = url.searchParams.get('access_token');
|
||||
|
||||
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: org } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', org_id)
|
||||
.single();
|
||||
|
||||
if (!org?.matrix_space_id) {
|
||||
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||
}
|
||||
|
||||
const spaceId = org.matrix_space_id;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Kick from child rooms first, then from the space
|
||||
try {
|
||||
const stateRes = await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (stateRes.ok) {
|
||||
const stateEvents = await stateRes.json();
|
||||
const childRoomIds = stateEvents
|
||||
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||
.map((e: any) => e.state_key);
|
||||
|
||||
for (const childRoomId of childRoomIds) {
|
||||
const kickRes = await matrixKick(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||
if (!kickRes.ok) {
|
||||
const err = await kickRes.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Failed to fetch space children');
|
||||
}
|
||||
|
||||
// Kick from the space itself
|
||||
const kickRes = await matrixKick(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||
if (!kickRes.ok) {
|
||||
const err = await kickRes.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Space kick: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: errors.length === 0,
|
||||
kicked: matrix_user_id,
|
||||
spaceId,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper: invite a user to a room
|
||||
async function matrixInvite(homeserver: string, token: string, roomId: string, userId: string) {
|
||||
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: kick a user from a room
|
||||
async function matrixKick(homeserver: string, token: string, roomId: string, userId: string) {
|
||||
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId, reason: 'Removed from organization' }),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@@ -102,4 +103,68 @@
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
||||
.prose a { @apply text-primary underline; }
|
||||
.prose hr { @apply border-t border-dark my-4; }
|
||||
.prose img { @apply max-w-full rounded-sm; }
|
||||
.prose table { @apply w-full border-collapse my-2; }
|
||||
.prose th, .prose td { @apply border border-dark p-2 text-left; }
|
||||
.prose th { @apply bg-night font-semibold; }
|
||||
}
|
||||
|
||||
/* Chat: Inline Twemoji sizing */
|
||||
.twemoji-inline {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: -0.2em;
|
||||
margin: 0 0.05em;
|
||||
}
|
||||
|
||||
/* Chat: Emoji-only messages show larger emojis */
|
||||
.emoji-only .twemoji-inline {
|
||||
width: 2.8em;
|
||||
height: 2.8em;
|
||||
vertical-align: -0.3em;
|
||||
margin: 0 0.075em;
|
||||
}
|
||||
|
||||
.twemoji {
|
||||
display: inline-block;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
/* Chat: Mention styles */
|
||||
.mention-ping {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
color: var(--color-primary);
|
||||
padding: 0 0.25em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.mention-ping:hover {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mention-everyone {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mention-everyone:hover {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Chat: Message highlight animation for reply scroll */
|
||||
@keyframes message-highlight {
|
||||
0%, 100% { background-color: transparent; }
|
||||
50% { background-color: rgba(0, 163, 224, 0.2); }
|
||||
}
|
||||
|
||||
.message-highlight {
|
||||
animation: message-highlight 1s ease-in-out 2;
|
||||
}
|
||||
|
||||
52
supabase/migrations/020_matrix_credentials.sql
Normal file
52
supabase/migrations/020_matrix_credentials.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Matrix credentials storage for chat integration
|
||||
-- Stores Matrix access tokens per user per org, so users auto-connect to chat after Supabase login
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matrix_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
homeserver_url TEXT NOT NULL,
|
||||
matrix_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id, org_id)
|
||||
);
|
||||
|
||||
-- RLS policies
|
||||
ALTER TABLE matrix_credentials ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can only read their own credentials
|
||||
CREATE POLICY "Users can read own matrix credentials"
|
||||
ON matrix_credentials FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can insert their own credentials
|
||||
CREATE POLICY "Users can insert own matrix credentials"
|
||||
ON matrix_credentials FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Users can update their own credentials
|
||||
CREATE POLICY "Users can update own matrix credentials"
|
||||
ON matrix_credentials FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can delete their own credentials
|
||||
CREATE POLICY "Users can delete own matrix credentials"
|
||||
ON matrix_credentials FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Auto-update updated_at
|
||||
CREATE OR REPLACE FUNCTION update_matrix_credentials_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER matrix_credentials_updated_at
|
||||
BEFORE UPDATE ON matrix_credentials
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_matrix_credentials_updated_at();
|
||||
2
supabase/migrations/021_org_matrix_space.sql
Normal file
2
supabase/migrations/021_org_matrix_space.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add Matrix Space ID to organizations for org <-> space mapping
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS matrix_space_id TEXT;
|
||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
||||
sveltekit(),
|
||||
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
|
||||
],
|
||||
optimizeDeps: {
|
||||
exclude: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [],
|
||||
external: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Reduce file-watcher overhead on Windows — ignore heavy dirs
|
||||
|
||||
Reference in New Issue
Block a user