8 Commits

Author SHA1 Message Date
AlacrisDevs
8140fddc8b test: add 20 unit tests for markdown utils (mentions, emoji-only, formatTime, formatFileSize) 2026-02-07 09:32:57 +02:00
AlacrisDevs
13cdb605ca chore: regenerate Supabase types (includes matrix_credentials + matrix_space_id), remove db() cast workarounds 2026-02-07 09:25:33 +02:00
AlacrisDevs
45ab939b7f feat: Matrix Space membership sync API (invite/kick members from org space + child rooms) 2026-02-07 02:02:11 +02:00
AlacrisDevs
23035b6ab4 feat: auto-provision Matrix Space per org + migration 021 + /api/matrix-space endpoint 2026-02-07 02:01:08 +02:00
AlacrisDevs
3f267e3b13 feat: room scoping (org/DM/other sections), unread badge on nav, highlight.js CSS 2026-02-07 01:59:34 +02:00
AlacrisDevs
be99a02e78 fix: add missing chat CSS (twemoji sizing, emoji-only, mentions, message highlight) 2026-02-07 01:53:06 +02:00
AlacrisDevs
a8d79cf138 fix: configure Vite to handle matrix-js-sdk WASM crypto module 2026-02-07 01:49:42 +02:00
AlacrisDevs
d1ce5d0951 feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps
- Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/
- Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/
- Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList
- Copy utils: emojiData, twemoji, twemojiGlobal
- Replace lucide-svelte with Material Symbols in SyncRecoveryBanner
- Extend Avatar with xs size and status indicator prop
- Fix ui.ts store conflict: re-export toasts from toast.svelte.ts
- Add migration 020_matrix_credentials for storing Matrix tokens per user/org
- Add /api/matrix-credentials endpoint (GET/POST/DELETE)
- Create [orgSlug]/chat page with Matrix login form + full chat UI
- Add Chat to sidebar navigation
2026-02-07 01:44:06 +02:00
69 changed files with 12171 additions and 42 deletions

336
package-lock.json generated
View File

@@ -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"
}
}

View File

@@ -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
View 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
View 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()],
};
}

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './Sidebar.svelte';
export { default as ChatArea } from './ChatArea.svelte';

View 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}

View File

@@ -0,0 +1 @@
export { default as UserSettingsModal } from './UserSettingsModal.svelte';

View 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}

View 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}

View 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}

View 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()}

View 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}

View 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}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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>

View 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>

View 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}

View 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>

View 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>

View 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}

View 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}

View 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>

View 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';

View 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>

View 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';

View 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>

View 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}

View 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}

View 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}

View 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}

View 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';

View File

@@ -0,0 +1,13 @@
/**
* Message utilities barrel export
*/
export {
renderMarkdown,
renderEmojisAsTwemoji,
renderMentions,
isEmojiOnly,
formatTime,
formatFullTime,
formatFileSize,
} from './markdown';

View 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');
});
});
});

View 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`;
}

View File

@@ -2,34 +2,62 @@
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>
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
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">
{initial}
</span>
</div>
{/if}
<div class="relative inline-block shrink-0">
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
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"
>
{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>

View 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>

View 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>

View 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"
/>

View 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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

101
src/lib/matrix/context.ts Normal file
View 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
View 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
View 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;
};
}
}

View 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');
});
});
});

View 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`;
}

View 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
View 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
View 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
View 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
View 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';

View 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
View 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
View 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
View 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);
}

View File

@@ -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
View 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
View 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`;
}

View 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();
}
};
}

View File

@@ -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>

View 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}

View 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 });
};

View 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 });
};

View 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' }),
});
}

View File

@@ -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;
}

View 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();

View 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;

View File

@@ -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