From d1ce5d09512e74af1141255ef39626c14f96a818 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 01:44:06 +0200 Subject: [PATCH] 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 --- package-lock.json | 336 ++++- package.json | 9 +- src/lib/cache/index.ts | 663 ++++++++++ src/lib/cache/mediaCache.ts | 195 +++ .../components/chat-layout/ChatArea.svelte | 300 +++++ src/lib/components/chat-layout/Sidebar.svelte | 340 +++++ src/lib/components/chat-layout/index.ts | 2 + .../chat-settings/UserSettingsModal.svelte | 346 ++++++ src/lib/components/chat-settings/index.ts | 1 + .../components/matrix/CreateRoomModal.svelte | 103 ++ .../components/matrix/CreateSpaceModal.svelte | 174 +++ .../matrix/EmojiAutocomplete.svelte | 88 ++ .../components/matrix/MatrixProvider.svelte | 32 + src/lib/components/matrix/MemberList.svelte | 102 ++ .../matrix/MentionAutocomplete.svelte | 91 ++ src/lib/components/matrix/MessageInput.svelte | 761 +++++++++++ src/lib/components/matrix/MessageList.svelte | 478 +++++++ .../components/matrix/RoomInfoPanel.svelte | 261 ++++ .../matrix/RoomSettingsModal.svelte | 187 +++ src/lib/components/matrix/StartDMModal.svelte | 139 +++ .../matrix/SyncRecoveryBanner.svelte | 113 ++ .../components/matrix/TypingIndicator.svelte | 27 + .../components/matrix/UserProfileModal.svelte | 127 ++ src/lib/components/matrix/index.ts | 12 + .../message/MessageContainer.svelte | 202 +++ src/lib/components/message/index.ts | 10 + .../message/parts/MessageActions.svelte | 199 +++ .../message/parts/MessageContent.svelte | 27 + .../message/parts/MessageMedia.svelte | 103 ++ .../message/parts/MessageReactions.svelte | 117 ++ .../message/parts/MessageReadReceipts.svelte | 52 + src/lib/components/message/parts/index.ts | 9 + src/lib/components/message/utils/index.ts | 13 + src/lib/components/message/utils/markdown.ts | 168 +++ src/lib/components/ui/Avatar.svelte | 64 +- src/lib/components/ui/EmojiPicker.svelte | 168 +++ .../components/ui/ImagePreviewModal.svelte | 63 + src/lib/components/ui/Twemoji.svelte | 21 + src/lib/components/ui/VirtualList.svelte | 114 ++ src/lib/components/ui/index.ts | 4 + src/lib/matrix/client.ts | 1107 +++++++++++++++++ src/lib/matrix/context.ts | 101 ++ src/lib/matrix/index.ts | 90 ++ src/lib/matrix/matrix-sdk-augment.d.ts | 79 ++ src/lib/matrix/messageUtils.spec.ts | 103 ++ src/lib/matrix/messageUtils.ts | 63 + src/lib/matrix/sdk-types.spec.ts | 75 ++ src/lib/matrix/sdk-types.ts | 249 ++++ src/lib/matrix/sync.ts | 217 ++++ src/lib/matrix/types.ts | 88 ++ src/lib/services/index.ts | 23 + src/lib/services/reactions.ts | 354 ++++++ src/lib/stores/matrix.ts | 834 +++++++++++++ src/lib/stores/theme.ts | 196 +++ src/lib/stores/ui.ts | 55 + src/lib/utils/emojiData.ts | 596 +++++++++ src/lib/utils/twemoji.ts | 30 + src/lib/utils/twemojiGlobal.ts | 176 +++ src/routes/[orgSlug]/+layout.svelte | 5 + src/routes/[orgSlug]/chat/+page.svelte | 669 ++++++++++ src/routes/api/matrix-credentials/+server.ts | 90 ++ .../migrations/020_matrix_credentials.sql | 52 + 62 files changed, 11432 insertions(+), 41 deletions(-) create mode 100644 src/lib/cache/index.ts create mode 100644 src/lib/cache/mediaCache.ts create mode 100644 src/lib/components/chat-layout/ChatArea.svelte create mode 100644 src/lib/components/chat-layout/Sidebar.svelte create mode 100644 src/lib/components/chat-layout/index.ts create mode 100644 src/lib/components/chat-settings/UserSettingsModal.svelte create mode 100644 src/lib/components/chat-settings/index.ts create mode 100644 src/lib/components/matrix/CreateRoomModal.svelte create mode 100644 src/lib/components/matrix/CreateSpaceModal.svelte create mode 100644 src/lib/components/matrix/EmojiAutocomplete.svelte create mode 100644 src/lib/components/matrix/MatrixProvider.svelte create mode 100644 src/lib/components/matrix/MemberList.svelte create mode 100644 src/lib/components/matrix/MentionAutocomplete.svelte create mode 100644 src/lib/components/matrix/MessageInput.svelte create mode 100644 src/lib/components/matrix/MessageList.svelte create mode 100644 src/lib/components/matrix/RoomInfoPanel.svelte create mode 100644 src/lib/components/matrix/RoomSettingsModal.svelte create mode 100644 src/lib/components/matrix/StartDMModal.svelte create mode 100644 src/lib/components/matrix/SyncRecoveryBanner.svelte create mode 100644 src/lib/components/matrix/TypingIndicator.svelte create mode 100644 src/lib/components/matrix/UserProfileModal.svelte create mode 100644 src/lib/components/matrix/index.ts create mode 100644 src/lib/components/message/MessageContainer.svelte create mode 100644 src/lib/components/message/index.ts create mode 100644 src/lib/components/message/parts/MessageActions.svelte create mode 100644 src/lib/components/message/parts/MessageContent.svelte create mode 100644 src/lib/components/message/parts/MessageMedia.svelte create mode 100644 src/lib/components/message/parts/MessageReactions.svelte create mode 100644 src/lib/components/message/parts/MessageReadReceipts.svelte create mode 100644 src/lib/components/message/parts/index.ts create mode 100644 src/lib/components/message/utils/index.ts create mode 100644 src/lib/components/message/utils/markdown.ts create mode 100644 src/lib/components/ui/EmojiPicker.svelte create mode 100644 src/lib/components/ui/ImagePreviewModal.svelte create mode 100644 src/lib/components/ui/Twemoji.svelte create mode 100644 src/lib/components/ui/VirtualList.svelte create mode 100644 src/lib/matrix/client.ts create mode 100644 src/lib/matrix/context.ts create mode 100644 src/lib/matrix/index.ts create mode 100644 src/lib/matrix/matrix-sdk-augment.d.ts create mode 100644 src/lib/matrix/messageUtils.spec.ts create mode 100644 src/lib/matrix/messageUtils.ts create mode 100644 src/lib/matrix/sdk-types.spec.ts create mode 100644 src/lib/matrix/sdk-types.ts create mode 100644 src/lib/matrix/sync.ts create mode 100644 src/lib/matrix/types.ts create mode 100644 src/lib/services/index.ts create mode 100644 src/lib/services/reactions.ts create mode 100644 src/lib/stores/matrix.ts create mode 100644 src/lib/stores/theme.ts create mode 100644 src/lib/stores/ui.ts create mode 100644 src/lib/utils/emojiData.ts create mode 100644 src/lib/utils/twemoji.ts create mode 100644 src/lib/utils/twemojiGlobal.ts create mode 100644 src/routes/[orgSlug]/chat/+page.svelte create mode 100644 src/routes/api/matrix-credentials/+server.ts create mode 100644 supabase/migrations/020_matrix_credentials.sql diff --git a/package-lock.json b/package-lock.json index b75ab85..c9700c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } diff --git a/package.json b/package.json index 64d70ed..34328aa 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/cache/index.ts b/src/lib/cache/index.ts new file mode 100644 index 0000000..39c519d --- /dev/null +++ b/src/lib/cache/index.ts @@ -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 { + 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: { userId: eventId } } + */ +function serializeReactions(reactions: Map>): Record> { + const result: Record> = {}; + 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> | undefined): Map> { + const result = new Map>(); + if (!obj || typeof obj !== 'object') return result; + + for (const [emoji, userObj] of Object.entries(obj)) { + const userMap = new Map(); + 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 { + 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 { + 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>), + })); + resolve(messages); + }; + request.onerror = () => reject(request.error); + }); +} + +/** + * Get the latest cached message timestamp for a room + */ +export async function getLatestMessageTimestamp(roomId: string): Promise { + 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 { + 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(); + +/** + * 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 { + 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 { + if (!db || rooms.length === 0) return 0; + + const now = Date.now(); + const changedRooms: RoomSummary[] = []; + const newHashes = new Map(); + + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (!db) return 0; + + let totalSize = 0; + + // Media size + const mediaTx = getTransaction(STORES.MEDIA, 'readonly'); + const mediaStore = mediaTx.objectStore(STORES.MEDIA); + + await new Promise((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 { + 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((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((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 { + 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 { + 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; +} diff --git a/src/lib/cache/mediaCache.ts b/src/lib/cache/mediaCache.ts new file mode 100644 index 0000000..c66930e --- /dev/null +++ b/src/lib/cache/mediaCache.ts @@ -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(); + +/** + * 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 { + // 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 { + 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 { + 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()], + }; +} diff --git a/src/lib/components/chat-layout/ChatArea.svelte b/src/lib/components/chat-layout/ChatArea.svelte new file mode 100644 index 0000000..bef8966 --- /dev/null +++ b/src/lib/components/chat-layout/ChatArea.svelte @@ -0,0 +1,300 @@ + + +
+ + {#if room} +
+
+ +
+
+

{room.name}

+ {#if room.isEncrypted} + + + + + + {/if} +
+

+ {room.memberCount} + {room.memberCount === 1 ? "member" : "members"}{room.isEncrypted + ? " โ€ข Encrypted" + : ""} +

+
+ + + + + + + + + +
+
+ {/if} + + + {#if showMessageSearch} +
+
+ + + + + + +
+ {#if messageSearchQuery && messageSearchResults.length > 0} +
+

+ {messageSearchResults.length} result{messageSearchResults.length !== + 1 + ? "s" + : ""} +

+ {#each messageSearchResults.slice(0, 20) as result} + + {/each} +
+ {:else if messageSearchQuery} +

No results found

+ {/if} +
+ {/if} + + +
+ + {#if isDraggingFile} +
+
+ + + + + +

Drop to upload

+

Release to send file

+
+
+ {/if} + + +
+ onReact(msgId, emoji)} + {onEdit} + {onDelete} + {onReply} + {onLoadMore} + isLoading={isLoadingMore} + /> + + +
+ + + {#if showRoomInfo && room} + + {:else if showMemberList} + + {/if} +
+
diff --git a/src/lib/components/chat-layout/Sidebar.svelte b/src/lib/components/chat-layout/Sidebar.svelte new file mode 100644 index 0000000..03bf41d --- /dev/null +++ b/src/lib/components/chat-layout/Sidebar.svelte @@ -0,0 +1,340 @@ + + + diff --git a/src/lib/components/chat-layout/index.ts b/src/lib/components/chat-layout/index.ts new file mode 100644 index 0000000..8b566e5 --- /dev/null +++ b/src/lib/components/chat-layout/index.ts @@ -0,0 +1,2 @@ +export { default as Sidebar } from './Sidebar.svelte'; +export { default as ChatArea } from './ChatArea.svelte'; diff --git a/src/lib/components/chat-settings/UserSettingsModal.svelte b/src/lib/components/chat-settings/UserSettingsModal.svelte new file mode 100644 index 0000000..ebb50a3 --- /dev/null +++ b/src/lib/components/chat-settings/UserSettingsModal.svelte @@ -0,0 +1,346 @@ + + +{#if open} + +{/if} diff --git a/src/lib/components/chat-settings/index.ts b/src/lib/components/chat-settings/index.ts new file mode 100644 index 0000000..0b5b5c5 --- /dev/null +++ b/src/lib/components/chat-settings/index.ts @@ -0,0 +1 @@ +export { default as UserSettingsModal } from './UserSettingsModal.svelte'; diff --git a/src/lib/components/matrix/CreateRoomModal.svelte b/src/lib/components/matrix/CreateRoomModal.svelte new file mode 100644 index 0000000..dd87062 --- /dev/null +++ b/src/lib/components/matrix/CreateRoomModal.svelte @@ -0,0 +1,103 @@ + + +{#if isOpen} + +{/if} diff --git a/src/lib/components/matrix/CreateSpaceModal.svelte b/src/lib/components/matrix/CreateSpaceModal.svelte new file mode 100644 index 0000000..feccaf6 --- /dev/null +++ b/src/lib/components/matrix/CreateSpaceModal.svelte @@ -0,0 +1,174 @@ + + +{#if isOpen} + +{/if} diff --git a/src/lib/components/matrix/EmojiAutocomplete.svelte b/src/lib/components/matrix/EmojiAutocomplete.svelte new file mode 100644 index 0000000..587c578 --- /dev/null +++ b/src/lib/components/matrix/EmojiAutocomplete.svelte @@ -0,0 +1,88 @@ + + +{#if filteredEmojis.length > 0} +
+
+ Emojis matching :{query} +
+ {#each filteredEmojis as emoji, i} + + {/each} +
+{/if} diff --git a/src/lib/components/matrix/MatrixProvider.svelte b/src/lib/components/matrix/MatrixProvider.svelte new file mode 100644 index 0000000..19ae170 --- /dev/null +++ b/src/lib/components/matrix/MatrixProvider.svelte @@ -0,0 +1,32 @@ + + +{@render children()} diff --git a/src/lib/components/matrix/MemberList.svelte b/src/lib/components/matrix/MemberList.svelte new file mode 100644 index 0000000..a4dbe63 --- /dev/null +++ b/src/lib/components/matrix/MemberList.svelte @@ -0,0 +1,102 @@ + + +
+
+

Members ({members.length})

+
+ +
+ {#each sortedMembers as member} + + {/each} + + {#if members.length === 0} +
+

No members

+
+ {/if} +
+
+ +{#if selectedMember} + (selectedMember = null)} + {onStartDM} + /> +{/if} diff --git a/src/lib/components/matrix/MentionAutocomplete.svelte b/src/lib/components/matrix/MentionAutocomplete.svelte new file mode 100644 index 0000000..5da4de5 --- /dev/null +++ b/src/lib/components/matrix/MentionAutocomplete.svelte @@ -0,0 +1,91 @@ + + +{#if filteredMembers.length > 0} +
+
+ Members matching @{query} +
+ {#each filteredMembers as member, i} + + {/each} +
+{/if} diff --git a/src/lib/components/matrix/MessageInput.svelte b/src/lib/components/matrix/MessageInput.svelte new file mode 100644 index 0000000..753803c --- /dev/null +++ b/src/lib/components/matrix/MessageInput.svelte @@ -0,0 +1,761 @@ + + +
+ + {#if editingMessage} +
+
+
+

Editing message

+

{editingMessage.content}

+
+ +
+
+ {/if} + + + {#if replyTo && !editingMessage} +
+
+
+

+ Replying to {replyTo.senderName} +

+

{replyTo.content}

+
+ +
+
+ {/if} + +
+ + + + + + + +
+ + {#if showMentions} + (showMentions = false)} + /> + {/if} + + + {#if showEmojiAutocomplete} + (showEmojiAutocomplete = false)} + /> + {/if} + + +
+ + {#if message && hasEmoji(message)} + + {/if} + + + + +
+ + + {#if showEmojiPicker} +
+ { + message += emoji; + inputRef?.focus(); + }} + onClose={() => (showEmojiPicker = false)} + position={{ x: 0, y: 0 }} + /> +
+ {/if} +
+ + + +
+ + + {#if message.length > 1000} +
+ {message.length} / 4000 +
+ {/if} +
diff --git a/src/lib/components/matrix/MessageList.svelte b/src/lib/components/matrix/MessageList.svelte new file mode 100644 index 0000000..734be0f --- /dev/null +++ b/src/lib/components/matrix/MessageList.svelte @@ -0,0 +1,478 @@ + + +
+
+ + {#if onLoadMore} +
+ +
+ {/if} + + + {#if allVisibleMessages.length === 0} +
+ + + +

No messages yet

+

Be the first to send a message!

+
+ {:else if virtualizer && enableVirtualization} + +
+ {#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, + )} + +
+ + {#if showDateSeparator} +
+
+ + {formatDateSeparator(message.timestamp)} + +
+
+ {/if} + + 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} +
+ {:else} + +
+ {#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, + )} + + + {#if showDateSeparator} +
+
+ + {formatDateSeparator(message.timestamp)} + +
+
+ {/if} + + 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} +
+ {/if} +
+ + + {#if !shouldAutoScroll && allVisibleMessages.length > 0} + + {/if} +
diff --git a/src/lib/components/matrix/RoomInfoPanel.svelte b/src/lib/components/matrix/RoomInfoPanel.svelte new file mode 100644 index 0000000..ce96b7d --- /dev/null +++ b/src/lib/components/matrix/RoomInfoPanel.svelte @@ -0,0 +1,261 @@ + + +
+ +
+

Room Info

+ +
+ + +
+ +
+
+ +
+

{room.name}

+ {#if room.topic} +

{room.topic}

+ {/if} + + +
+ + +
+
+

{room.memberCount}

+

Members

+
+
+

+ {room.isEncrypted ? "๐Ÿ”’" : "๐Ÿ”“"} +

+

+ {room.isEncrypted ? "Encrypted" : "Not Encrypted"} +

+
+
+ + +
+

+ Details +

+ +
+
+ Room ID + + {room.roomId} + +
+
+ Type + {room.isDirect ? "Direct Message" : "Room"} +
+ {#if room.lastActivity} +
+ Last Activity + {formatDate(room.lastActivity)} +
+ {/if} +
+
+ + + {#if admins.length > 0} +
+

+ Admins ({admins.length}) +

+
    + {#each admins as member} +
  • + + {member.name} + ๐Ÿ‘‘ +
  • + {/each} +
+
+ {/if} + + {#if moderators.length > 0} +
+

+ Moderators ({moderators.length}) +

+
    + {#each moderators as member} +
  • + + {member.name} + ๐Ÿ›ก๏ธ +
  • + {/each} +
+
+ {/if} + +
+

+ Members ({regularMembers.length}) +

+
    + {#each regularMembers.slice(0, 20) as member} +
  • + + {member.name} +
  • + {/each} + {#if regularMembers.length > 20} +
  • + +{regularMembers.length - 20} more members +
  • + {/if} +
+
+
+
+ +{#if showSettings} + (showSettings = false)} /> +{/if} diff --git a/src/lib/components/matrix/RoomSettingsModal.svelte b/src/lib/components/matrix/RoomSettingsModal.svelte new file mode 100644 index 0000000..b96d813 --- /dev/null +++ b/src/lib/components/matrix/RoomSettingsModal.svelte @@ -0,0 +1,187 @@ + + + + + diff --git a/src/lib/components/matrix/StartDMModal.svelte b/src/lib/components/matrix/StartDMModal.svelte new file mode 100644 index 0000000..890f147 --- /dev/null +++ b/src/lib/components/matrix/StartDMModal.svelte @@ -0,0 +1,139 @@ + + + + + diff --git a/src/lib/components/matrix/SyncRecoveryBanner.svelte b/src/lib/components/matrix/SyncRecoveryBanner.svelte new file mode 100644 index 0000000..eb83b06 --- /dev/null +++ b/src/lib/components/matrix/SyncRecoveryBanner.svelte @@ -0,0 +1,113 @@ + + +{#if shouldShow} + +{/if} diff --git a/src/lib/components/matrix/TypingIndicator.svelte b/src/lib/components/matrix/TypingIndicator.svelte new file mode 100644 index 0000000..4a85e9e --- /dev/null +++ b/src/lib/components/matrix/TypingIndicator.svelte @@ -0,0 +1,27 @@ + + +{#if userNames.length > 0} +
+ +
+ + + +
+ {formatTypingText(userNames)} +
+{/if} diff --git a/src/lib/components/matrix/UserProfileModal.svelte b/src/lib/components/matrix/UserProfileModal.svelte new file mode 100644 index 0000000..9a722b0 --- /dev/null +++ b/src/lib/components/matrix/UserProfileModal.svelte @@ -0,0 +1,127 @@ + + + + + diff --git a/src/lib/components/matrix/index.ts b/src/lib/components/matrix/index.ts new file mode 100644 index 0000000..9f052da --- /dev/null +++ b/src/lib/components/matrix/index.ts @@ -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'; diff --git a/src/lib/components/message/MessageContainer.svelte b/src/lib/components/message/MessageContainer.svelte new file mode 100644 index 0000000..1f90186 --- /dev/null +++ b/src/lib/components/message/MessageContainer.svelte @@ -0,0 +1,202 @@ + + +
(showActions = true)} + onmouseleave={() => (showActions = false)} + role="article" + id="message-{message.eventId}" +> + + {#if replyPreview && message.replyTo} + + {/if} + + {#if isGrouped} + +
+
+ + {formatTime(message.timestamp)} + +
+
+ {#if hasMedia && message.media} + + {:else} + + {/if} +
+
+ {:else} + +
+
+ +
+
+
+ + {message.senderName} + + + {formatTime(message.timestamp)} + +
+ {#if hasMedia && message.media} + + {:else} + + {/if} +
+
+ {/if} + + + + + + {#if isOwnMessage} + + {/if} + + + {#if showActions && !message.isRedacted} + + {/if} +
diff --git a/src/lib/components/message/index.ts b/src/lib/components/message/index.ts new file mode 100644 index 0000000..15feebe --- /dev/null +++ b/src/lib/components/message/index.ts @@ -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'; diff --git a/src/lib/components/message/parts/MessageActions.svelte b/src/lib/components/message/parts/MessageActions.svelte new file mode 100644 index 0000000..77433a4 --- /dev/null +++ b/src/lib/components/message/parts/MessageActions.svelte @@ -0,0 +1,199 @@ + + +
+ + {#each quickReactions as emoji} + + {/each} + + +
+ + + {#if showEmojiPicker} + onReact?.(emoji)} + onClose={() => (showEmojiPicker = false)} + /> + {/if} +
+ +
+ + + + + + {#if isOwnMessage} + + {/if} + + +
+ + + {#if showContextMenu} + +
e.stopPropagation()} + > + + + + {#if isOwnMessage} +
+ + {/if} +
+ {/if} +
+
diff --git a/src/lib/components/message/parts/MessageContent.svelte b/src/lib/components/message/parts/MessageContent.svelte new file mode 100644 index 0000000..23ae728 --- /dev/null +++ b/src/lib/components/message/parts/MessageContent.svelte @@ -0,0 +1,27 @@ + + +{#if isRedacted} +

+ This message was deleted +

+{:else} + + {@html renderedContent} + + {#if isEdited} + (edited) + {/if} +{/if} diff --git a/src/lib/components/message/parts/MessageMedia.svelte b/src/lib/components/message/parts/MessageMedia.svelte new file mode 100644 index 0000000..cc38e14 --- /dev/null +++ b/src/lib/components/message/parts/MessageMedia.svelte @@ -0,0 +1,103 @@ + + +{#if type === 'image'} + {#if isLoading} +
+ Loading... +
+ {:else if mediaUrl} + + {/if} +{:else if type === 'video' && mediaUrl} + +{:else if type === 'audio' && mediaUrl} + +{:else if type === 'file'} + + + + + +
+

{media.filename || altText}

+

{formatFileSize(media.size)}

+
+
+{/if} + +{#if showPreview && mediaUrl} + (showPreview = false)} + /> +{/if} diff --git a/src/lib/components/message/parts/MessageReactions.svelte b/src/lib/components/message/parts/MessageReactions.svelte new file mode 100644 index 0000000..bc6fce7 --- /dev/null +++ b/src/lib/components/message/parts/MessageReactions.svelte @@ -0,0 +1,117 @@ + + +{#if reactions.size > 0} +
+ {#each [...reactions.entries()] as [emoji, userMap]} + {@const hasReacted = userMap.has(currentUserId)} + {@const reactionEventId = getUserReactionEventId(emoji)} + {@const isPending = isPendingReaction(reactionEventId)} + {@const isAnimating = animatingReactions.has(emoji)} + + {/each} + + + {#if !isRedacted} +
+ + + {#if showPicker} +
+ { + onReact?.(emoji); + showPicker = false; + }} + onClose={() => (showPicker = false)} + position={{ x: 0, y: 0 }} + /> +
+ {/if} +
+ {/if} +
+{/if} diff --git a/src/lib/components/message/parts/MessageReadReceipts.svelte b/src/lib/components/message/parts/MessageReadReceipts.svelte new file mode 100644 index 0000000..e418aef --- /dev/null +++ b/src/lib/components/message/parts/MessageReadReceipts.svelte @@ -0,0 +1,52 @@ + + +{#if receipts.length > 0} +
+ Read by +
+ {#each receipts.slice(0, 5) as reader} +
+ {#if reader.avatarUrl} + {reader.name} + {:else} +
+ {reader.name[0]?.toUpperCase()} +
+ {/if} +
+ {/each} + {#if receipts.length > 5} +
+ +{receipts.length - 5} +
+ {/if} +
+
+{/if} diff --git a/src/lib/components/message/parts/index.ts b/src/lib/components/message/parts/index.ts new file mode 100644 index 0000000..01315af --- /dev/null +++ b/src/lib/components/message/parts/index.ts @@ -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'; diff --git a/src/lib/components/message/utils/index.ts b/src/lib/components/message/utils/index.ts new file mode 100644 index 0000000..7613349 --- /dev/null +++ b/src/lib/components/message/utils/index.ts @@ -0,0 +1,13 @@ +/** + * Message utilities barrel export + */ + +export { + renderMarkdown, + renderEmojisAsTwemoji, + renderMentions, + isEmojiOnly, + formatTime, + formatFullTime, + formatFileSize, +} from './markdown'; diff --git a/src/lib/components/message/utils/markdown.ts b/src/lib/components/message/utils/markdown.ts new file mode 100644 index 0000000..59b14c4 --- /dev/null +++ b/src/lib/components/message/utils/markdown.ts @@ -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 `
${highlighted}
`; +}; + +// LRU Cache for memoization (prevents memory leaks) +class LRUCache { + private cache = new Map(); + 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(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 `${emoji}`; + }); +} + +/** + * 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 ``; + } + ); + + // Handle @everyone and @here mentions + result = result.replace( + /@(everyone|here|room)\b/gi, + '@$1' + ); + + 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`; +} diff --git a/src/lib/components/ui/Avatar.svelte b/src/lib/components/ui/Avatar.svelte index e3dd16b..bd74309 100644 --- a/src/lib/components/ui/Avatar.svelte +++ b/src/lib/components/ui/Avatar.svelte @@ -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 = { + 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 = { + online: "bg-success", + offline: "bg-light/30", + away: "bg-warning", + dnd: "bg-error", + }; -{#if src} - {name} -{:else} -
- - {initial} - -
-{/if} +
+ {#if src} + {name} + {:else} +
+ + {initial} + +
+ {/if} + {#if status} +
+ {/if} +
diff --git a/src/lib/components/ui/EmojiPicker.svelte b/src/lib/components/ui/EmojiPicker.svelte new file mode 100644 index 0000000..94cd392 --- /dev/null +++ b/src/lib/components/ui/EmojiPicker.svelte @@ -0,0 +1,168 @@ + + + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-label="Emoji picker" + tabindex="-1" +> + +
+
+ + + + + +
+
+ + +
+ {#each categories as category} + + {/each} +
+ + +
+
+ {categories.find((c) => c.id === activeCategory)?.name || "Emojis"} +
+
+ {#each filteredEmojis() as emoji} + + {/each} +
+
+
diff --git a/src/lib/components/ui/ImagePreviewModal.svelte b/src/lib/components/ui/ImagePreviewModal.svelte new file mode 100644 index 0000000..f1a847c --- /dev/null +++ b/src/lib/components/ui/ImagePreviewModal.svelte @@ -0,0 +1,63 @@ + + + + + +
+ + + + +
+ +
+ + + + + + + + + Open Original + +
diff --git a/src/lib/components/ui/Twemoji.svelte b/src/lib/components/ui/Twemoji.svelte new file mode 100644 index 0000000..a3f4db2 --- /dev/null +++ b/src/lib/components/ui/Twemoji.svelte @@ -0,0 +1,21 @@ + + +{emoji} diff --git a/src/lib/components/ui/VirtualList.svelte b/src/lib/components/ui/VirtualList.svelte new file mode 100644 index 0000000..da5460f --- /dev/null +++ b/src/lib/components/ui/VirtualList.svelte @@ -0,0 +1,114 @@ + + +
+
+
+ {#each visibleItems() as { item, index } (getKey(item, index))} +
+ {@render children(item, index)} +
+ {/each} +
+
+
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index 5bcf2a7..eb32b1c 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -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'; diff --git a/src/lib/matrix/client.ts b/src/lib/matrix/client.ts new file mode 100644 index 0000000..77c4699 --- /dev/null +++ b/src/lib/matrix/client.ts @@ -0,0 +1,1107 @@ +/** + * Matrix Client Wrapper + * + * This module provides a singleton MatrixClient instance and utilities + * for interacting with the Matrix protocol via matrix-js-sdk. + */ + +import * as sdk from 'matrix-js-sdk'; +import { Preset, Visibility } from 'matrix-js-sdk'; +import type { MatrixClient, Room, MatrixEvent, ICreateClientOpts, IContent } from 'matrix-js-sdk'; +import { + sendTypedStateEvent, + getTypedStateEvent, + addTypedPushRule, + deleteTypedPushRule, + type PinnedEventsContent, + type RoomAvatarContent, +} from './sdk-types'; + +// Matrix message content types +interface MessageContent extends IContent { + msgtype: string; + body: string; + format?: string; + formatted_body?: string; + 'm.relates_to'?: { + 'm.in_reply_to'?: { event_id: string }; + rel_type?: string; + event_id?: string; + key?: string; + }; + 'm.new_content'?: { + msgtype: string; + body: string; + }; +} + +// Push rule types for notification settings +interface PushRuleCondition { + kind: string; + key?: string; + pattern?: string; +} + +interface PushRule { + rule_id: string; + actions: string[]; + conditions?: PushRuleCondition[]; +} + +// File message content type +interface FileMessageContent extends IContent { + msgtype: string; + body: string; + url: string; + info?: { + mimetype?: string; + size?: number; + w?: number; + h?: number; + }; +} + +let client: MatrixClient | null = null; + +export interface LoginCredentials { + homeserverUrl: string; + userId: string; + accessToken: string; + deviceId?: string; +} + +export interface LoginWithPasswordParams { + homeserverUrl: string; + username: string; + password: string; +} + +/** + * Initialize the Matrix client with existing credentials + */ +export async function initMatrixClient(credentials: LoginCredentials): Promise { + if (client) { + console.warn('Matrix client already initialized, stopping existing client'); + await stopClient(); + } + + const opts: ICreateClientOpts = { + baseUrl: credentials.homeserverUrl, + accessToken: credentials.accessToken, + userId: credentials.userId, + deviceId: credentials.deviceId, + timelineSupport: true, + cryptoCallbacks: {}, + }; + + // Create client + client = sdk.createClient(opts); + + // Initialize crypto (E2EE) - optional, app works without it + // Note: Rust crypto requires WASM which may not load in all environments + try { + // Check if crypto module is available before trying to init + if (typeof client.initRustCrypto === 'function') { + await client.initRustCrypto(); + console.log('E2EE crypto initialized successfully'); + } + } catch (e) { + // This is expected in dev mode - WASM loading can be problematic + console.info('Crypto not available - encrypted rooms will show encrypted messages'); + } + + // Start the client (begins sync loop) + // Note: pendingEventOrdering is supported but not in SDK types + await client.startClient({ + initialSyncLimit: 50, + lazyLoadMembers: true, + pendingEventOrdering: 'detached', + } as Parameters[0]); + + return client; +} + +/** + * Check if a room is encrypted + */ +export function isRoomEncrypted(roomId: string): boolean { + if (!client) return false; + const room = client.getRoom(roomId); + return room?.hasEncryptionStateEvent() ?? false; +} + +/** + * Get encryption status for display + */ +export function getCryptoStatus(): { initialized: boolean; deviceId: string | null } { + if (!client) return { initialized: false, deviceId: null }; + const crypto = client.getCrypto(); + return { + initialized: !!crypto, + deviceId: client.getDeviceId() || null, + }; +} + +/** + * Login with username and password, returns credentials + */ +export async function loginWithPassword(params: LoginWithPasswordParams): Promise { + const tempClient = sdk.createClient({ + baseUrl: params.homeserverUrl, + }); + + const response = await tempClient.login('m.login.password', { + user: params.username, + password: params.password, + initial_device_display_name: 'Root v2 Web', + }); + + const credentials: LoginCredentials = { + homeserverUrl: params.homeserverUrl, + userId: response.user_id, + accessToken: response.access_token, + deviceId: response.device_id, + }; + + // Stop temp client + tempClient.stopClient(); + + return credentials; +} + +/** + * Get the current Matrix client instance + */ +export function getClient(): MatrixClient { + if (!client) { + throw new Error('Matrix client not initialized. Call initMatrixClient first.'); + } + return client; +} + +/** + * Check if client is initialized + */ +export function isClientInitialized(): boolean { + return client !== null; +} + +/** + * Stop the Matrix client and clean up + */ +export async function stopClient(): Promise { + if (client) { + client.stopClient(); + client = null; + } +} + +/** + * Logout and clear credentials + */ +export async function logout(): Promise { + if (client) { + try { + await client.logout(); + } catch (e) { + console.error('Error during logout:', e); + } + await stopClient(); + } +} + +/** + * Get all joined rooms + */ +export function getRooms(): Room[] { + if (!client) return []; + return client.getRooms().filter(room => room.getMyMembership() === 'join'); +} + +/** + * Get a specific room by ID + */ +export function getRoom(roomId: string): Room | null { + if (!client) return null; + return client.getRoom(roomId); +} + +/** + * Send a text message to a room + */ +export async function sendMessage(roomId: string, body: string, replyToEventId?: string): Promise<{ event_id: string }> { + const c = getClient(); + + const content: MessageContent = { + msgtype: 'm.text', + body, + }; + + // Add reply relation if replying to a message + if (replyToEventId) { + const replyEvent = c.getRoom(roomId)?.findEventById(replyToEventId); + const replyBody = replyEvent?.getContent()?.body || ''; + const replySender = replyEvent?.getSender() || ''; + + content.body = `> <${replySender}> ${replyBody}\n\n${body}`; + content.format = 'org.matrix.custom.html'; + content.formatted_body = `
In reply to ${replySender}
${replyBody}
${body}`; + content['m.relates_to'] = { + 'm.in_reply_to': { + event_id: replyToEventId, + }, + }; + } + + const response = await c.sendEvent(roomId, 'm.room.message', content); + return response; +} + +/** + * Send a reaction to a message + */ +export async function sendReaction(roomId: string, eventId: string, emoji: string): Promise { + const c = getClient(); + await c.sendEvent(roomId, 'm.reaction', { + 'm.relates_to': { + rel_type: 'm.annotation', + event_id: eventId, + key: emoji, + }, + }); +} + +/** + * Remove a reaction from a message by redacting the reaction event + */ +export async function removeReaction(roomId: string, reactionEventId: string): Promise { + const c = getClient(); + await c.redactEvent(roomId, reactionEventId); +} + +/** + * Edit a message + */ +export async function editMessage(roomId: string, eventId: string, newBody: string): Promise { + const c = getClient(); + await c.sendEvent(roomId, 'm.room.message', { + msgtype: 'm.text', + body: `* ${newBody}`, + 'm.new_content': { + msgtype: 'm.text', + body: newBody, + }, + 'm.relates_to': { + rel_type: 'm.replace', + event_id: eventId, + }, + }); +} + +/** + * Delete (redact) a message + */ +export async function deleteMessage(roomId: string, eventId: string, reason?: string): Promise { + const c = getClient(); + await c.redactEvent(roomId, eventId, undefined, { reason }); +} + +/** + * Create a new room + */ +export async function createRoom(name: string, isDirect = false): Promise<{ room_id: string }> { + const c = getClient(); + return await c.createRoom({ + name, + visibility: Visibility.Private, + preset: isDirect ? Preset.TrustedPrivateChat : Preset.PrivateChat, + is_direct: isDirect, + }); +} + +/** + * Create a new Space (organization container for rooms) + */ +export async function createSpace( + name: string, + options: { + topic?: string; + isPublic?: boolean; + parentSpaceId?: string; + } = {} +): Promise<{ room_id: string }> { + const c = getClient(); + + const initialState: Array<{ type: string; state_key: string; content: Record }> = []; + + // Add topic if provided + if (options.topic) { + initialState.push({ + type: 'm.room.topic', + state_key: '', + content: { topic: options.topic }, + }); + } + + const result = await c.createRoom({ + name, + visibility: options.isPublic ? Visibility.Public : Visibility.Private, + preset: options.isPublic ? Preset.PublicChat : Preset.PrivateChat, + creation_content: { + type: 'm.space', + }, + initial_state: initialState, + power_level_content_override: { + events: { + 'm.space.child': 50, // Moderators can add/remove rooms + }, + }, + }); + + // If parent space provided, add this as a child of that space + if (options.parentSpaceId) { + await addRoomToSpace(options.parentSpaceId, result.room_id); + } + + return result; +} + +/** + * Add a room or space as a child of a space + */ +export async function addRoomToSpace( + spaceId: string, + childRoomId: string, + options: { + suggested?: boolean; + order?: string; + } = {} +): Promise { + const c = getClient(); + + // Get the homeserver from the room ID for the 'via' field + const homeserver = childRoomId.split(':')[1]; + + await c.sendStateEvent(spaceId, 'm.space.child', { + via: [homeserver], + suggested: options.suggested ?? false, + ...(options.order ? { order: options.order } : {}), + }, childRoomId); +} + +/** + * Remove a room from a space + */ +export async function removeRoomFromSpace(spaceId: string, childRoomId: string): Promise { + const c = getClient(); + + // Setting empty content removes the child + await c.sendStateEvent(spaceId, 'm.space.child', {}, childRoomId); +} + +/** + * Get all child rooms/spaces of a space + */ +export function getSpaceChildren(spaceId: string): Array<{ roomId: string; suggested: boolean; order?: string }> { + const c = getClient(); + const room = c.getRoom(spaceId); + if (!room) return []; + + const childEvents = room.currentState.getStateEvents('m.space.child'); + const children: Array<{ roomId: string; suggested: boolean; order?: string }> = []; + + for (const event of childEvents) { + const childId = event.getStateKey(); + const content = event.getContent(); + + // Only include if it has 'via' (empty content means removed) + if (childId && content.via && Array.isArray(content.via) && content.via.length > 0) { + children.push({ + roomId: childId, + suggested: content.suggested ?? false, + order: content.order, + }); + } + } + + // Sort by order if present + return children.sort((a, b) => { + if (a.order && b.order) return a.order.localeCompare(b.order); + if (a.order) return -1; + if (b.order) return 1; + return 0; + }); +} + +/** + * Get all spaces the user is a member of + */ +export function getSpaces(): Array<{ roomId: string; name: string; avatarUrl: string | null }> { + const c = getClient(); + const rooms = c.getRooms(); + + return rooms + .filter(room => { + const createEvent = room.currentState.getStateEvents('m.room.create', ''); + return createEvent?.getContent()?.type === 'm.space'; + }) + .map(room => ({ + roomId: room.roomId, + name: room.name || 'Unnamed Space', + avatarUrl: room.getAvatarUrl(c.baseUrl, 96, 96, 'crop') || null, + })); +} + +/** + * Join a room by ID or alias + */ +export async function joinRoom(roomIdOrAlias: string): Promise<{ room_id: string }> { + const c = getClient(); + const room = await c.joinRoom(roomIdOrAlias); + return { room_id: room.roomId }; +} + +/** + * Leave a room + */ +export async function leaveRoom(roomId: string): Promise { + const c = getClient(); + await c.leave(roomId); +} + +/** + * Update room name + */ +export async function setRoomName(roomId: string, name: string): Promise { + const c = getClient(); + await c.setRoomName(roomId, name); +} + +/** + * Update room topic + */ +export async function setRoomTopic(roomId: string, topic: string): Promise { + const c = getClient(); + await c.setRoomTopic(roomId, topic); +} + +/** + * Update room avatar + */ +export async function setRoomAvatar(roomId: string, file: File): Promise { + const c = getClient(); + const uploadResponse = await c.uploadContent(file, { type: file.type }); + await sendTypedStateEvent(c, roomId, 'm.room.avatar', { url: uploadResponse.content_uri }); +} + +/** + * Get room notification level + */ +export type NotificationLevel = 'all' | 'mentions' | 'mute'; + +export function getRoomNotificationLevel(roomId: string): NotificationLevel { + if (!client) return 'all'; + + const room = client.getRoom(roomId); + if (!room) return 'all'; + + // Check push rules for this room + const pushRules = client.pushRules; + if (!pushRules) return 'all'; + + // Check room-specific override rules + const overrideRules = (pushRules.global?.override || []) as PushRule[]; + const roomRule = overrideRules.find((r: PushRule) => + r.conditions?.some((c: PushRuleCondition) => c.kind === 'event_match' && c.key === 'room_id' && c.pattern === roomId) + ); + + if (roomRule?.actions?.includes('dont_notify')) { + return 'mute'; + } + + // Check room rules + const roomRules = (pushRules.global?.room || []) as PushRule[]; + const specificRule = roomRules.find((r: PushRule) => r.rule_id === roomId); + + if (specificRule?.actions?.includes('dont_notify')) { + return 'mute'; + } + + return 'all'; +} + +/** + * Set room notification level + */ +export async function setRoomNotificationLevel(roomId: string, level: NotificationLevel): Promise { + if (!client) return; + + try { + if (level === 'mute') { + // Add a room rule to mute notifications + await addTypedPushRule(client, 'global', 'room', roomId, { + actions: ['dont_notify'], + }); + } else { + // Remove any mute rules + try { + await deleteTypedPushRule(client, 'global', 'room', roomId); + } catch { + // Rule might not exist, ignore + } + } + } catch (e) { + console.error('Failed to set notification level:', e); + throw e; + } +} + +/** + * Set typing indicator + */ +export async function setTyping(roomId: string, isTyping: boolean, timeoutMs = 30000): Promise { + const c = getClient(); + await c.sendTyping(roomId, isTyping, timeoutMs); +} + +/** + * Get pinned message event IDs for a room + */ +export function getPinnedMessages(roomId: string): string[] { + if (!client) return []; + + const room = client.getRoom(roomId); + if (!room) return []; + + const pinnedEvent = getTypedStateEvent(room, 'm.room.pinned_events'); + if (!pinnedEvent) return []; + + const content = pinnedEvent.getContent(); + return content?.pinned || []; +} + +/** + * Pin a message + */ +export async function pinMessage(roomId: string, eventId: string): Promise { + if (!client) return; + + const currentPinned = getPinnedMessages(roomId); + if (currentPinned.includes(eventId)) return; + + const newPinned = [...currentPinned, eventId]; + await sendTypedStateEvent(client, roomId, 'm.room.pinned_events', { pinned: newPinned }); +} + +/** + * Unpin a message + */ +export async function unpinMessage(roomId: string, eventId: string): Promise { + if (!client) return; + + const currentPinned = getPinnedMessages(roomId); + const newPinned = currentPinned.filter(id => id !== eventId); + await sendTypedStateEvent(client, roomId, 'm.room.pinned_events', { pinned: newPinned }); +} + +/** + * Load more messages (pagination) for a room + * Returns: { hasMore: boolean, loaded: number } + */ +export async function loadMoreMessages(roomId: string, limit = 50): Promise<{ hasMore: boolean; loaded: number }> { + const c = getClient(); + const room = c.getRoom(roomId); + if (!room) return { hasMore: false, loaded: 0 }; + + const timeline = room.getLiveTimeline(); + const beforeCount = timeline.getEvents().length; + + try { + // Paginate backwards to load older messages + const hasMore = await c.paginateEventTimeline(timeline, { backwards: true, limit }); + const afterCount = timeline.getEvents().length; + const loaded = afterCount - beforeCount; + + // Refresh the message store after pagination + const { loadRoomMessages } = await import('$lib/stores/matrix'); + loadRoomMessages(roomId); + + return { hasMore, loaded }; + } catch (e) { + console.error('Failed to load more messages:', e); + return { hasMore: false, loaded: 0 }; + } +} + +/** + * Mark room as read + */ +export async function markRoomAsRead(roomId: string): Promise { + const c = getClient(); + const room = c.getRoom(roomId); + if (room) { + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + if (events.length > 0) { + const lastEvent = events[events.length - 1]; + await c.sendReadReceipt(lastEvent); + } + } +} + +/** + * Upload a file to the Matrix content repository + */ +export async function uploadFile(file: File): Promise { + const c = getClient(); + const response = await c.uploadContent(file, { + name: file.name, + type: file.type, + }); + return response.content_uri; +} + +/** + * Send a file/image message to a room + */ +export async function sendFileMessage( + roomId: string, + file: File, + contentUri: string +): Promise<{ event_id: string }> { + const c = getClient(); + + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + const isAudio = file.type.startsWith('audio/'); + + let msgtype = 'm.file'; + if (isImage) msgtype = 'm.image'; + else if (isVideo) msgtype = 'm.video'; + else if (isAudio) msgtype = 'm.audio'; + + const content: any = { + msgtype, + body: file.name, + url: contentUri, + info: { + mimetype: file.type, + size: file.size, + }, + }; + + // For images, try to get dimensions + if (isImage) { + try { + const dimensions = await getImageDimensions(file); + content.info.w = dimensions.width; + content.info.h = dimensions.height; + } catch (e) { + console.warn('Failed to get image dimensions:', e); + } + } + + const response = await c.sendEvent(roomId, 'm.room.message', content); + return response; +} + +/** + * Helper to get image dimensions + */ +function getImageDimensions(file: File): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.width, height: img.height }); + URL.revokeObjectURL(img.src); + }; + img.onerror = reject; + img.src = URL.createObjectURL(file); + }); +} + +/** + * Get the HTTP URL for a Matrix content URI (mxc://) + */ +export function getMediaUrl(mxcUrl: string, width?: number, height?: number): string | null { + if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; + + if (width && height) { + return client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); + } + return client.mxcUrlToHttp(mxcUrl); +} + +// Cache for blob URLs to avoid re-fetching +const mediaBlobCache = new Map(); + +/** + * Fetch media with authentication and return a blob URL + * This is needed because Matrix media requires auth headers + */ +export async function getAuthenticatedMediaUrl(mxcUrl: string): Promise { + if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; + + // Check cache first + if (mediaBlobCache.has(mxcUrl)) { + return mediaBlobCache.get(mxcUrl)!; + } + + try { + // Parse mxc:// URL + const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); + if (!match) return null; + + const [, serverName, mediaId] = match; + const accessToken = client.getAccessToken(); + + // Use the authenticated media endpoint + const url = `${client.baseUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + // Fallback to legacy endpoint without auth (some servers still support it) + const legacyUrl = client.mxcUrlToHttp(mxcUrl); + if (legacyUrl) { + mediaBlobCache.set(mxcUrl, legacyUrl); + return legacyUrl; + } + return null; + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Cache the blob URL + mediaBlobCache.set(mxcUrl, blobUrl); + + return blobUrl; + } catch (e) { + console.error('Failed to fetch authenticated media:', e); + // Fallback to unauthenticated URL + return client.mxcUrlToHttp(mxcUrl); + } +} + +/** + * Get authenticated thumbnail URL + */ +export async function getAuthenticatedThumbnailUrl( + mxcUrl: string, + width: number, + height: number +): Promise { + if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; + + const cacheKey = `${mxcUrl}_${width}x${height}`; + if (mediaBlobCache.has(cacheKey)) { + return mediaBlobCache.get(cacheKey)!; + } + + try { + const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); + if (!match) return null; + + const [, serverName, mediaId] = match; + const accessToken = client.getAccessToken(); + + const url = `${client.baseUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${width}&height=${height}&method=scale`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + // Fallback + const legacyUrl = client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); + if (legacyUrl) { + mediaBlobCache.set(cacheKey, legacyUrl); + return legacyUrl; + } + return null; + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + mediaBlobCache.set(cacheKey, blobUrl); + + return blobUrl; + } catch (e) { + console.error('Failed to fetch authenticated thumbnail:', e); + return client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); + } +} + +/** + * Get members of a room + */ +export function getRoomMembers(roomId: string): Array<{ + userId: string; + name: string; + avatarUrl: string | null; + membership: 'join' | 'invite' | 'leave' | 'ban'; + powerLevel: number; +}> { + if (!client) return []; + + const room = client.getRoom(roomId); + if (!room) return []; + + const members = room.getJoinedMembers(); + const powerLevels = room.currentState.getStateEvents('m.room.power_levels', '')?.getContent(); + const userPowerLevels = powerLevels?.users || {}; + const defaultPowerLevel = powerLevels?.users_default || 0; + + return members.map(member => ({ + userId: member.userId, + name: member.name || member.userId, + avatarUrl: member.getAvatarUrl(client!.baseUrl, 40, 40, 'crop', true, true) || null, + membership: member.membership as 'join' | 'invite' | 'leave' | 'ban', + powerLevel: userPowerLevels[member.userId] ?? defaultPowerLevel, + })); +} + +/** + * Create or get existing DM room with a user + */ +export async function createDirectMessage(userId: string): Promise { + if (!client) throw new Error('Client not initialized'); + + // Check if we already have a DM with this user + const existingDM = findExistingDM(userId); + if (existingDM) return existingDM; + + // Create new DM room + const response = await client.createRoom({ + preset: Preset.TrustedPrivateChat, + is_direct: true, + invite: [userId], + initial_state: [], + }); + + // Mark as DM in account data + const dmMap = client.getAccountData('m.direct')?.getContent() || {}; + if (!dmMap[userId]) { + dmMap[userId] = []; + } + dmMap[userId].push(response.room_id); + await client.setAccountData('m.direct', dmMap); + + return response.room_id; +} + +/** + * Find existing DM room with a user + */ +export function findExistingDM(userId: string): string | null { + if (!client) return null; + + const dmMap = client.getAccountData('m.direct')?.getContent() || {}; + const dmRoomIds = dmMap[userId] || []; + + // Find a room that exists and we're joined to + for (const roomId of dmRoomIds) { + const room = client.getRoom(roomId); + if (room && room.getMyMembership() === 'join') { + return roomId; + } + } + + return null; +} + +/** + * Search for users by name or ID + */ +export async function searchUsers(query: string, limit = 10): Promise> { + if (!client || !query.trim()) return []; + + try { + const response = await client.searchUserDirectory({ term: query, limit }); + + return response.results.map((user: any) => ({ + userId: user.user_id, + displayName: user.display_name || user.user_id, + avatarUrl: user.avatar_url ? client!.mxcUrlToHttp(user.avatar_url, 40, 40, 'crop') : null, + })); + } catch (e) { + console.error('User search failed:', e); + return []; + } +} + +/** + * Search messages in a room (local search through loaded timeline) + */ +export function searchMessagesLocal(roomId: string, query: string): Array<{ + eventId: string; + sender: string; + senderName: string; + content: string; + timestamp: number; +}> { + if (!client || !query.trim()) return []; + + const room = client.getRoom(roomId); + if (!room) return []; + + const lowerQuery = query.toLowerCase(); + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + + const results: Array<{ + eventId: string; + sender: string; + senderName: string; + content: string; + timestamp: number; + }> = []; + + for (const event of events) { + if (event.getType() !== 'm.room.message') continue; + + const content = event.getContent(); + const body = content.body || ''; + + if (body.toLowerCase().includes(lowerQuery)) { + const sender = event.getSender() || ''; + const member = room.getMember(sender); + + results.push({ + eventId: event.getId() || '', + sender, + senderName: member?.name || sender, + content: body, + timestamp: event.getTs(), + }); + } + } + + return results.reverse(); // Most recent first +} + +/** + * Get read receipts for a room - returns map of eventId -> userIds who have read up to that event + */ +export function getRoomReadReceipts(roomId: string): Map { + if (!client) return new Map(); + + const room = client.getRoom(roomId); + if (!room) return new Map(); + + const receipts = new Map(); + const members = room.getJoinedMembers(); + + for (const member of members) { + // Skip own user + if (member.userId === client.getUserId()) continue; + + const receipt = room.getReadReceiptForUserId(member.userId); + if (receipt?.eventId) { + const existing = receipts.get(receipt.eventId) || []; + existing.push(member.userId); + receipts.set(receipt.eventId, existing); + } + } + + return receipts; +} + +/** + * Get users who have read up to a specific event + */ +export function getReadReceiptsForEvent(roomId: string, eventId: string): Array<{ + userId: string; + name: string; + avatarUrl: string | null; +}> { + if (!client) return []; + + const room = client.getRoom(roomId); + if (!room) return []; + + const readers: Array<{ userId: string; name: string; avatarUrl: string | null }> = []; + const members = room.getJoinedMembers(); + + for (const member of members) { + if (member.userId === client.getUserId()) continue; + + const receipt = room.getReadReceiptForUserId(member.userId); + if (receipt?.eventId === eventId) { + readers.push({ + userId: member.userId, + name: member.name || member.userId, + avatarUrl: member.getAvatarUrl(client.baseUrl, 20, 20, 'crop', true, true) || null, + }); + } + } + + return readers; +} + +/** + * Get user presence status + */ +export function getUserPresence(userId: string): { presence: 'online' | 'offline' | 'unavailable'; lastActiveAgo?: number; statusMsg?: string } { + if (!client) return { presence: 'offline' }; + + try { + const user = client.getUser(userId); + if (!user) return { presence: 'offline' }; + + const presence = user.presence as 'online' | 'offline' | 'unavailable' || 'offline'; + return { + presence, + lastActiveAgo: user.lastActiveAgo, + statusMsg: user.presenceStatusMsg, + }; + } catch { + return { presence: 'offline' }; + } +} + +/** + * Set own presence status + */ +export async function setPresence(presence: 'online' | 'offline' | 'unavailable', statusMsg?: string): Promise { + if (!client) return; + + try { + await client.setPresence({ presence, status_msg: statusMsg }); + } catch (e) { + console.error('Failed to set presence:', e); + } +} + +/** + * Get all presence for room members + */ +export function getRoomMembersPresence(roomId: string): Map { + const presenceMap = new Map(); + if (!client) return presenceMap; + + const room = client.getRoom(roomId); + if (!room) return presenceMap; + + const members = room.getJoinedMembers(); + for (const member of members) { + const { presence } = getUserPresence(member.userId); + presenceMap.set(member.userId, presence); + } + + return presenceMap; +} + +// Re-export types for convenience +export type { MatrixClient, Room, MatrixEvent }; diff --git a/src/lib/matrix/context.ts b/src/lib/matrix/context.ts new file mode 100644 index 0000000..0b91440 --- /dev/null +++ b/src/lib/matrix/context.ts @@ -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(MATRIX_CLIENT_KEY, { + client, + isReady: true, + }); +} + +/** + * Set an uninitialized context (for loading states) + */ +export function setMatrixContextPending(): void { + setContext(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(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(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(MATRIX_CLIENT_KEY); + return ctx?.isReady ?? false; + } catch { + return false; + } +} diff --git a/src/lib/matrix/index.ts b/src/lib/matrix/index.ts new file mode 100644 index 0000000..cf3ab97 --- /dev/null +++ b/src/lib/matrix/index.ts @@ -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'; diff --git a/src/lib/matrix/matrix-sdk-augment.d.ts b/src/lib/matrix/matrix-sdk-augment.d.ts new file mode 100644 index 0000000..e6d1fef --- /dev/null +++ b/src/lib/matrix/matrix-sdk-augment.d.ts @@ -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; + }; + '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; + 'm.push_rules': unknown; + 'm.ignored_user_list': { ignored_users: Record }; + } + + /** + * 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; + }; + } +} diff --git a/src/lib/matrix/messageUtils.spec.ts b/src/lib/matrix/messageUtils.spec.ts new file mode 100644 index 0000000..7c29cf1 --- /dev/null +++ b/src/lib/matrix/messageUtils.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/lib/matrix/messageUtils.ts b/src/lib/matrix/messageUtils.ts new file mode 100644 index 0000000..003e272 --- /dev/null +++ b/src/lib/matrix/messageUtils.ts @@ -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`; +} diff --git a/src/lib/matrix/sdk-types.spec.ts b/src/lib/matrix/sdk-types.spec.ts new file mode 100644 index 0000000..da52489 --- /dev/null +++ b/src/lib/matrix/sdk-types.spec.ts @@ -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); + }); + }); +}); diff --git a/src/lib/matrix/sdk-types.ts b/src/lib/matrix/sdk-types.ts new file mode 100644 index 0000000..01581fd --- /dev/null +++ b/src/lib/matrix/sdk-types.ts @@ -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>( + 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 { + 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 { + await (client as any).deletePushRule(scope, kind, ruleId); +} diff --git a/src/lib/matrix/sync.ts b/src/lib/matrix/sync.ts new file mode 100644 index 0000000..e0480e6 --- /dev/null +++ b/src/lib/matrix/sync.ts @@ -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'); +} diff --git a/src/lib/matrix/types.ts b/src/lib/matrix/types.ts new file mode 100644 index 0000000..2fd4277 --- /dev/null +++ b/src/lib/matrix/types.ts @@ -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>; // 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[]; +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts new file mode 100644 index 0000000..d31e8e1 --- /dev/null +++ b/src/lib/services/index.ts @@ -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'; diff --git a/src/lib/services/reactions.ts b/src/lib/services/reactions.ts new file mode 100644 index 0000000..d50cdde --- /dev/null +++ b/src/lib/services/reactions.ts @@ -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>(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 { + 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 { + 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 { + 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, +}; diff --git a/src/lib/stores/matrix.ts b/src/lib/stores/matrix.ts new file mode 100644 index 0000000..17529fb --- /dev/null +++ b/src/lib/stores/matrix.ts @@ -0,0 +1,834 @@ +/** + * 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(initialAuthState); + +// ============================================================================ +// Sync State +// ============================================================================ + +export const syncState = writable('STOPPED'); +export const syncError = writable(null); + +// ============================================================================ +// Rooms (Normalized Store Architecture) +// ============================================================================ + +/** + * PRIMARY STORE: Normalized Map + * All room operations are O(1) - no secondary index needed + */ +const _roomsById = writable>(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(); + 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(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; + lastActivityMap: Map; + 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): 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 { + const spaceChildMap = new Map(); + + 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(); + const lastActivityMap = new Map(); + + 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; +}); + +// ============================================================================ +// Messages +// ============================================================================ + +// Map of roomId -> messages array +export const messagesByRoom = writable>(new Map()); + +// Secondary index: roomId -> eventId -> array index (for O(1) lookup) +const messageIndexByRoom = new Map>(); + +/** + * Rebuild the message index for a room + */ +function rebuildMessageIndex(roomId: string, messages: Message[]): void { + const indexMap = new Map(); + 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>(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(); + 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>(); + + // 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>>(); + 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(); + // 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(); + + // 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(); + 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(); + + // 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 { + 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>(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(); +} diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts new file mode 100644 index 0000000..edb73b9 --- /dev/null +++ b/src/lib/stores/theme.ts @@ -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(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()); +} diff --git a/src/lib/stores/ui.ts b/src/lib/stores/ui.ts new file mode 100644 index 0000000..c39e484 --- /dev/null +++ b/src/lib/stores/ui.ts @@ -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('none'); +export const modalData = writable(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(null); + +export function setLoading(loading: boolean, message?: string): void { + isLoading.set(loading); + loadingMessage.set(message ?? null); +} diff --git a/src/lib/utils/emojiData.ts b/src/lib/utils/emojiData.ts new file mode 100644 index 0000000..be8b017 --- /dev/null +++ b/src/lib/utils/emojiData.ts @@ -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 + }); +} diff --git a/src/lib/utils/twemoji.ts b/src/lib/utils/twemoji.ts new file mode 100644 index 0000000..a9f085a --- /dev/null +++ b/src/lib/utils/twemoji.ts @@ -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`; +} diff --git a/src/lib/utils/twemojiGlobal.ts b/src/lib/utils/twemojiGlobal.ts new file mode 100644 index 0000000..b694074 --- /dev/null +++ b/src/lib/utils/twemojiGlobal.ts @@ -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 `${emoji}`; +} + +/** + * 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(); + } + }; +} diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index a707c54..abfe296 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -122,6 +122,11 @@ }, ] : []), + { + href: `/${data.org.slug}/chat`, + label: "Chat", + icon: "chat", + }, // Settings requires settings.view or admin role ...(canAccess("settings.view") ? [ diff --git a/src/routes/[orgSlug]/chat/+page.svelte b/src/routes/[orgSlug]/chat/+page.svelte new file mode 100644 index 0000000..7c98f59 --- /dev/null +++ b/src/routes/[orgSlug]/chat/+page.svelte @@ -0,0 +1,669 @@ + + + +{#if showMatrixLogin} +
+
+

Connect to Chat

+

+ Enter your Matrix credentials to enable messaging. +

+ +
+ + +
+ + { + if (e.key === "Enter") handleMatrixLogin(); + }} + /> +
+ +
+
+
+ + +{:else if isInitializing || ($syncState !== "PREPARED" && $syncState !== "SYNCING")} +
+
+
+

+ {#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} +

+
+
+ + +{:else if matrixClient} + + {#snippet children()} +
+ + + + +
+ {#if $selectedRoomId} +
+ +
+ {#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as room} +
+ +
+

{room.name}

+

+ {room.memberCount} members{room.isEncrypted ? " ยท Encrypted" : ""} +

+
+ + + +
+ {/each} +
+ + + {#if showMessageSearch} +
+
+ search + + +
+ {#if messageSearchQuery && messageSearchResults.length > 0} +
+

+ {messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""} +

+ {#each messageSearchResults.slice(0, 20) as result} + + {/each} +
+ {:else if messageSearchQuery} +

No results found

+ {/if} +
+ {/if} + + +
+ {#if isDraggingFile} +
+
+ upload_file +

Drop to upload

+

Release to send file

+
+
+ {/if} + + +
+ + + +
+ + + {#if showRoomInfo} + {#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom} + + {/each} + {:else if showMemberList} + + {/if} +
+
+ {:else} + +
+
+ chat +

Select a room

+

Choose a conversation to start chatting

+
+
+ {/if} +
+
+ {/snippet} +
+{/if} + + + (showCreateRoomModal = false)} /> + +{#if showStartDMModal} + (showStartDMModal = false)} + onDMCreated={(roomId) => handleRoomSelect(roomId)} + /> +{/if} diff --git a/src/routes/api/matrix-credentials/+server.ts b/src/routes/api/matrix-credentials/+server.ts new file mode 100644 index 0000000..2be14ce --- /dev/null +++ b/src/routes/api/matrix-credentials/+server.ts @@ -0,0 +1,90 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// Cast supabase to any to bypass typed client โ€” matrix_credentials table +// was added in migration 020 but types haven't been regenerated yet. +// TODO: Remove casts after running `supabase gen types` +const db = (supabase: any) => supabase; + +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 db(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 db(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 db(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 }); +}; diff --git a/supabase/migrations/020_matrix_credentials.sql b/supabase/migrations/020_matrix_credentials.sql new file mode 100644 index 0000000..8d3b0f0 --- /dev/null +++ b/supabase/migrations/020_matrix_credentials.sql @@ -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();