diff --git a/apps/matrix/CLAUDE.md b/apps/matrix/CLAUDE.md new file mode 100644 index 000000000..083809240 --- /dev/null +++ b/apps/matrix/CLAUDE.md @@ -0,0 +1,179 @@ +# Matrix Client + +Self-hosted Matrix chat client built with SvelteKit and matrix-js-sdk. + +## Project Overview + +A minimal, privacy-focused Matrix client that connects to your self-hosted Synapse server (matrix.mana.how). + +### Tech Stack + +| Layer | Technology | +|-------|------------| +| Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 | +| Matrix SDK | matrix-js-sdk | +| State Management | Svelte 5 runes ($state, $derived) | +| Icons | lucide-svelte | +| Date Handling | date-fns | + +## Project Structure + +``` +apps/matrix/ +├── apps/ +│ └── web/ # SvelteKit web client +│ ├── src/ +│ │ ├── routes/ +│ │ │ ├── (auth)/ # Login flow +│ │ │ ├── (app)/ # Protected chat routes +│ │ │ └── health/ # Health check endpoint +│ │ └── lib/ +│ │ ├── matrix/ # Matrix SDK integration +│ │ │ ├── store.svelte.ts # Reactive Matrix store +│ │ │ ├── client.ts # Login/auth functions +│ │ │ ├── types.ts # TypeScript types +│ │ │ └── polyfills.ts # Browser polyfills +│ │ └── components/ +│ │ └── chat/ # Chat UI components +│ └── package.json +└── packages/ + └── shared/ # Shared types +``` + +## Development + +```bash +# Start the Matrix web client +pnpm dev:matrix:web + +# Or from monorepo root +pnpm matrix:dev +``` + +The client runs on **http://localhost:5180** + +## Key Files + +### Matrix Store (`src/lib/matrix/store.svelte.ts`) + +Central reactive store using Svelte 5 runes: + +```typescript +import { matrixStore } from '$lib/matrix'; + +// State +matrixStore.syncState // 'STOPPED' | 'PREPARED' | 'SYNCING' | etc. +matrixStore.isReady // boolean - client ready for use +matrixStore.rooms // SimpleRoom[] - all rooms +matrixStore.messages // SimpleMessage[] - current room messages +matrixStore.currentRoom // Room | null - selected room + +// Actions +await matrixStore.initialize(credentials); +matrixStore.selectRoom(roomId); +await matrixStore.sendMessage('Hello!'); +await matrixStore.sendTyping(true); +matrixStore.logout(); +``` + +### Login Client (`src/lib/matrix/client.ts`) + +```typescript +import { loginWithPassword, checkHomeserver } from '$lib/matrix'; + +const result = await loginWithPassword('matrix.mana.how', 'user', 'password'); +if (result.success) { + await matrixStore.initialize(result.credentials); +} +``` + +## Features + +### Phase 1 (Current) +- [x] Password login +- [x] Room list (DMs and groups) +- [x] Message timeline +- [x] Send text messages +- [x] Typing indicators +- [x] Read receipts +- [x] Unread counts +- [x] Message pagination (load more) + +### Phase 2 (Planned) +- [ ] End-to-end encryption (E2EE) +- [ ] File/image uploads +- [ ] Message editing/deletion +- [ ] Room creation +- [ ] User search/invite + +### Phase 3 (Future) +- [ ] VoIP calls (WebRTC) +- [ ] Video calls +- [ ] Screen sharing + +## Configuration + +### Environment Variables + +No environment variables required for basic usage. The client stores credentials in localStorage. + +### Default Homeserver + +The login page defaults to `matrix.mana.how` but any Matrix homeserver can be used. + +## Matrix SDK Notes + +### Browser Polyfills + +matrix-js-sdk requires polyfills for browser usage. These are automatically loaded in `src/lib/matrix/polyfills.ts`: + +- `Buffer` from buffer package +- `global` mapped to `globalThis` +- `process.env` stub + +### Vite Configuration + +Special Vite config in `vite.config.ts`: + +```typescript +define: { + global: 'globalThis', +}, +optimizeDeps: { + include: ['buffer', 'events'], +} +``` + +### Client-Side Only + +matrix-js-sdk only works client-side. Always guard with: + +```typescript +import { browser } from '$app/environment'; + +if (browser) { + await matrixStore.initialize(); +} +``` + +## Troubleshooting + +### "super.off is not a function" + +This is a known issue with typed-event-emitter. Make sure polyfills are loaded before any matrix-js-sdk imports. + +### Login fails with network error + +1. Check if homeserver is reachable: `curl https://matrix.mana.how/_matrix/client/versions` +2. Verify CORS is configured on Synapse +3. Try without https:// prefix in homeserver field + +### Messages not loading + +The initial sync can take time depending on room history. Check `matrixStore.syncState` for status. + +## Related Documentation + +- [Matrix Client-Server API](https://spec.matrix.org/latest/client-server-api/) +- [matrix-js-sdk docs](https://matrix-org.github.io/matrix-js-sdk/) +- [Synapse Admin API](https://element-hq.github.io/synapse/latest/admin_api/) diff --git a/apps/matrix/apps/web/package.json b/apps/matrix/apps/web/package.json new file mode 100644 index 000000000..1d852c2a3 --- /dev/null +++ b/apps/matrix/apps/web/package.json @@ -0,0 +1,48 @@ +{ + "name": "@matrix/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write .", + "lint": "eslint ." + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^22.15.21", + "prettier": "^3.5.3", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.33.0", + "svelte-check": "^4.2.1", + "tailwindcss": "^4.1.7", + "tslib": "^2.8.1", + "typescript": "^5.8.3", + "vite": "^6.3.5" + }, + "dependencies": { + "matrix-js-sdk": "^37.1.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-i18n": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "lucide-svelte": "^0.509.0", + "date-fns": "^4.1.0", + "svelte-i18n": "^4.0.1" + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } +} diff --git a/apps/matrix/apps/web/src/app.css b/apps/matrix/apps/web/src/app.css new file mode 100644 index 000000000..c4976f67c --- /dev/null +++ b/apps/matrix/apps/web/src/app.css @@ -0,0 +1,39 @@ +@import 'tailwindcss'; +@import '@manacore/shared-tailwind/themes.css'; + +/* Scan shared packages for Tailwind classes */ +@source '../../../packages/shared/src'; +@source '../../../../../packages/shared-ui/src'; +@source '../../../../../packages/shared-icons/src'; + +@layer base { + :root { + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + } + + body { + @apply bg-base-100 text-base-content; + } +} + +/* Custom scrollbar for chat */ +.chat-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.chat-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.chat-scrollbar::-webkit-scrollbar-thumb { + background: oklch(var(--bc) / 0.2); + border-radius: 3px; +} + +.chat-scrollbar::-webkit-scrollbar-thumb:hover { + background: oklch(var(--bc) / 0.3); +} diff --git a/apps/matrix/apps/web/src/app.d.ts b/apps/matrix/apps/web/src/app.d.ts new file mode 100644 index 000000000..b15512ca1 --- /dev/null +++ b/apps/matrix/apps/web/src/app.d.ts @@ -0,0 +1,20 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } + + // Polyfills for matrix-js-sdk + interface Window { + global: typeof globalThis; + Buffer: typeof import('buffer').Buffer; + process: { env: Record }; + } +} + +export {}; diff --git a/apps/matrix/apps/web/src/app.html b/apps/matrix/apps/web/src/app.html new file mode 100644 index 000000000..8997ef202 --- /dev/null +++ b/apps/matrix/apps/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Mana Matrix + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/matrix/apps/web/src/lib/components/chat/Message.svelte b/apps/matrix/apps/web/src/lib/components/chat/Message.svelte new file mode 100644 index 000000000..34570f18f --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/Message.svelte @@ -0,0 +1,89 @@ + + + +{#if showTimestamp} +
+
+ {formattedDate()} +
+
+{/if} + + +
+ +
+ {#if showAvatar && !message.isOwn} +
+
+ {initials} +
+
+ {/if} +
+ + +
+ {#if showAvatar} +
+ + {message.isOwn ? 'Du' : message.senderName} + + {formattedTime} + {#if message.edited} + (bearbeitet) + {/if} +
+ {/if} + + +
+ {#if message.type === 'm.emote'} +

* {message.senderName} {message.body}

+ {:else if message.type === 'm.notice'} +

{message.body}

+ {:else} +

{message.body}

+ {/if} + + + {#if !showAvatar} + + {/if} +
+
+
diff --git a/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte new file mode 100644 index 000000000..dc643bae0 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte @@ -0,0 +1,105 @@ + + +
+
+ + + + +
+ + + + +
+ + + +
+ + +

+ Press Enter to send, Shift+Enter for new line +

+
diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte new file mode 100644 index 000000000..972e3f722 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte @@ -0,0 +1,65 @@ + + +{#if room} +
+ + + + +
+
+ {#if room.avatar} + {room.name} + {:else} + {room.name.charAt(0).toUpperCase()} + {/if} +
+
+ + +
+
+

{room.name}

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

+ {#if room.topic} + {room.topic} + {:else if room.isDirect} + Direct message + {:else} + + {room.memberCount} members + {/if} +

+
+ + +
+ + + +
+
+{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte new file mode 100644 index 000000000..ae204c5ad --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte @@ -0,0 +1,91 @@ + + + diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte new file mode 100644 index 000000000..1e0d34239 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte @@ -0,0 +1,76 @@ + + +
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+ {#each filteredRooms as room (room.id)} + matrixStore.selectRoom(room.id)} + /> + {:else} +
+ {#if search} + +

No rooms match "{search}"

+ {:else} + +

No {showDMs ? 'direct messages' : 'rooms'} yet

+ {/if} +
+ {/each} +
+ + +
+ +
+
diff --git a/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte b/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte new file mode 100644 index 000000000..b24325e21 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte @@ -0,0 +1,113 @@ + + +
+
+ + {#if loadingMore} +
+ +
+ {/if} + + +
+ {#each matrixStore.messages as message, index (message.id)} + {@const prevMessage = matrixStore.messages[index - 1]} + {@const showAvatar = !prevMessage || prevMessage.sender !== message.sender} + {@const showTimestamp = + !prevMessage || message.timestamp - prevMessage.timestamp > 5 * 60 * 1000} + + {:else} +
+

No messages yet

+

Start the conversation!

+
+ {/each} +
+ + + {#if matrixStore.currentRoomTyping.length > 0} + + {/if} +
+ + + {#if showScrollButton} + + {/if} +
diff --git a/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte b/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte new file mode 100644 index 000000000..40cebb2c5 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte @@ -0,0 +1,29 @@ + + +{#if users.length > 0} +
+ + + + + + + {text()} +
+{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/index.ts b/apps/matrix/apps/web/src/lib/components/chat/index.ts new file mode 100644 index 000000000..705dc54f0 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/components/chat/index.ts @@ -0,0 +1,7 @@ +export { default as RoomList } from './RoomList.svelte'; +export { default as RoomItem } from './RoomItem.svelte'; +export { default as RoomHeader } from './RoomHeader.svelte'; +export { default as Timeline } from './Timeline.svelte'; +export { default as Message } from './Message.svelte'; +export { default as MessageInput } from './MessageInput.svelte'; +export { default as TypingIndicator } from './TypingIndicator.svelte'; diff --git a/apps/matrix/apps/web/src/lib/matrix/client.ts b/apps/matrix/apps/web/src/lib/matrix/client.ts new file mode 100644 index 000000000..6daa0b5b0 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/matrix/client.ts @@ -0,0 +1,198 @@ +import type { MatrixCredentials, LoginResult } from './types'; + +/** + * Login with username and password + */ +export async function loginWithPassword( + homeserver: string, + username: string, + password: string +): Promise { + // Load polyfills first + await import('./polyfills'); + const { createClient } = await import('matrix-js-sdk'); + + // Normalize homeserver URL + let baseUrl = homeserver.trim(); + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + baseUrl = `https://${baseUrl}`; + } + // Remove trailing slash + baseUrl = baseUrl.replace(/\/$/, ''); + + const tempClient = createClient({ baseUrl }); + + try { + const response = await tempClient.login('m.login.password', { + user: username, + password: password, + initial_device_display_name: 'Mana Matrix Client', + }); + + return { + success: true, + credentials: { + homeserver: baseUrl, + accessToken: response.access_token, + userId: response.user_id, + deviceId: response.device_id, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Login failed'; + + // Provide more helpful error messages + if (message.includes('M_FORBIDDEN')) { + return { success: false, error: 'Invalid username or password' }; + } + if (message.includes('M_USER_DEACTIVATED')) { + return { success: false, error: 'This account has been deactivated' }; + } + if (message.includes('Failed to fetch') || message.includes('NetworkError')) { + return { success: false, error: 'Could not connect to homeserver' }; + } + + return { success: false, error: message }; + } +} + +/** + * Login with an existing access token (for SSO/OAuth flows) + */ +export async function loginWithToken( + homeserver: string, + accessToken: string, + userId: string, + deviceId?: string +): Promise { + // Normalize homeserver URL + let baseUrl = homeserver.trim(); + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + baseUrl = `https://${baseUrl}`; + } + baseUrl = baseUrl.replace(/\/$/, ''); + + return { + success: true, + credentials: { + homeserver: baseUrl, + accessToken, + userId, + deviceId: deviceId || `MANA_${Date.now()}`, + }, + }; +} + +/** + * Discover homeserver from user ID or domain + * Uses .well-known discovery + */ +export async function discoverHomeserver(userIdOrDomain: string): Promise { + // Extract domain from user ID if provided + let domain = userIdOrDomain; + if (userIdOrDomain.startsWith('@')) { + const parts = userIdOrDomain.split(':'); + if (parts.length < 2) return null; + domain = parts[1]; + } + + // Remove any protocol prefix + domain = domain.replace(/^https?:\/\//, ''); + + try { + // Try .well-known discovery + const wellKnownUrl = `https://${domain}/.well-known/matrix/client`; + const response = await fetch(wellKnownUrl); + + if (response.ok) { + const data = await response.json(); + const baseUrl = data['m.homeserver']?.base_url; + if (baseUrl) { + return baseUrl.replace(/\/$/, ''); + } + } + } catch { + // .well-known not available + } + + // Fallback: assume homeserver is at the domain + return `https://${domain}`; +} + +/** + * Check if a homeserver is reachable + */ +export async function checkHomeserver(homeserver: string): Promise<{ ok: boolean; error?: string }> { + let baseUrl = homeserver.trim(); + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + baseUrl = `https://${baseUrl}`; + } + + try { + const response = await fetch(`${baseUrl}/_matrix/client/versions`, { + method: 'GET', + }); + + if (response.ok) { + return { ok: true }; + } + + return { ok: false, error: `Server returned ${response.status}` }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Could not connect to server', + }; + } +} + +/** + * Register a new account (if registration is open) + */ +export async function register( + homeserver: string, + username: string, + password: string +): Promise { + await import('./polyfills'); + const { createClient } = await import('matrix-js-sdk'); + + let baseUrl = homeserver.trim(); + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + baseUrl = `https://${baseUrl}`; + } + baseUrl = baseUrl.replace(/\/$/, ''); + + const tempClient = createClient({ baseUrl }); + + try { + const response = await tempClient.register(username, password, null, { + initial_device_display_name: 'Mana Matrix Client', + }); + + return { + success: true, + credentials: { + homeserver: baseUrl, + accessToken: response.access_token!, + userId: response.user_id, + deviceId: response.device_id!, + }, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Registration failed'; + + // Check for common errors + if (message.includes('M_USER_IN_USE')) { + return { success: false, error: 'Username is already taken' }; + } + if (message.includes('M_INVALID_USERNAME')) { + return { success: false, error: 'Invalid username format' }; + } + if (message.includes('M_FORBIDDEN')) { + return { success: false, error: 'Registration is disabled on this server' }; + } + + return { success: false, error: message }; + } +} diff --git a/apps/matrix/apps/web/src/lib/matrix/index.ts b/apps/matrix/apps/web/src/lib/matrix/index.ts new file mode 100644 index 000000000..27642a6ac --- /dev/null +++ b/apps/matrix/apps/web/src/lib/matrix/index.ts @@ -0,0 +1,10 @@ +// Matrix client exports +export { matrixStore } from './store.svelte'; +export { + loginWithPassword, + loginWithToken, + discoverHomeserver, + checkHomeserver, + register, +} from './client'; +export * from './types'; diff --git a/apps/matrix/apps/web/src/lib/matrix/polyfills.ts b/apps/matrix/apps/web/src/lib/matrix/polyfills.ts new file mode 100644 index 000000000..5d1648ebc --- /dev/null +++ b/apps/matrix/apps/web/src/lib/matrix/polyfills.ts @@ -0,0 +1,18 @@ +/** + * Polyfills required for matrix-js-sdk to work in browser environment + * Must be imported before any matrix-js-sdk imports + */ +import { Buffer } from 'buffer'; + +if (typeof window !== 'undefined') { + // Global object polyfill + window.global = window.globalThis; + + // Buffer polyfill (used by matrix-js-sdk for binary data) + (window as Window).Buffer = Buffer; + + // Process polyfill (some dependencies check process.env) + (window as Window).process = { env: {} }; +} + +export {}; diff --git a/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts b/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts new file mode 100644 index 000000000..a3f980bba --- /dev/null +++ b/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts @@ -0,0 +1,484 @@ +import { browser } from '$app/environment'; +import type { MatrixClient, Room, MatrixEvent, RoomMember as SDKRoomMember } from 'matrix-js-sdk'; +import type { SyncState, MatrixCredentials, SimpleRoom, SimpleMessage, RoomMember } from './types'; + +const STORAGE_KEY = 'matrix_credentials'; + +/** + * Reactive Matrix store using Svelte 5 runes + */ +class MatrixStore { + // ───────────────────────────────────────────────────────── + // Private State + // ───────────────────────────────────────────────────────── + private _client = $state(null); + private _syncState = $state('STOPPED'); + private _rooms = $state([]); + private _currentRoomId = $state(null); + private _timeline = $state([]); + private _typingUsers = $state>(new Map()); + private _error = $state(null); + private _initialized = $state(false); + + // ───────────────────────────────────────────────────────── + // Public Getters + // ───────────────────────────────────────────────────────── + get client() { + return this._client; + } + get syncState() { + return this._syncState; + } + get error() { + return this._error; + } + get initialized() { + return this._initialized; + } + get currentRoomId() { + return this._currentRoomId; + } + + // ───────────────────────────────────────────────────────── + // Derived State + // ───────────────────────────────────────────────────────── + + /** Is the client ready to use? */ + isReady = $derived(this._syncState === 'PREPARED' || this._syncState === 'SYNCING'); + + /** Is currently syncing? */ + isSyncing = $derived(this._syncState === 'SYNCING' || this._syncState === 'CATCHUP'); + + /** Current user ID */ + userId = $derived(this._client?.getUserId() || null); + + /** Simplified room list sorted by last activity */ + rooms = $derived( + this._rooms + .map((room) => this.roomToSimpleRoom(room)) + .sort((a, b) => (b.lastMessageTime || 0) - (a.lastMessageTime || 0)) + ); + + /** Direct message rooms */ + directRooms = $derived(this.rooms.filter((r) => r.isDirect)); + + /** Group rooms (non-DM) */ + groupRooms = $derived(this.rooms.filter((r) => !r.isDirect)); + + /** Current selected room */ + currentRoom = $derived( + this._currentRoomId ? this._rooms.find((r) => r.roomId === this._currentRoomId) || null : null + ); + + /** Current room as SimpleRoom */ + currentSimpleRoom = $derived(this.currentRoom ? this.roomToSimpleRoom(this.currentRoom) : null); + + /** Messages in current room */ + messages = $derived( + this._timeline + .filter((e) => e.getType() === 'm.room.message') + .map((e) => this.eventToSimpleMessage(e)) + ); + + /** Users currently typing in current room */ + currentRoomTyping = $derived( + this._currentRoomId ? this._typingUsers.get(this._currentRoomId) || [] : [] + ); + + /** Total unread count across all rooms */ + totalUnreadCount = $derived(this.rooms.reduce((sum, r) => sum + r.unreadCount, 0)); + + // ───────────────────────────────────────────────────────── + // Initialization + // ───────────────────────────────────────────────────────── + + /** + * Initialize the Matrix client + * @param credentials Optional credentials, will load from storage if not provided + */ + async initialize(credentials?: MatrixCredentials): Promise { + if (!browser) return false; + if (this._initialized && this._client) return true; + + // Load polyfills first + await import('./polyfills'); + + // Get credentials + const creds = credentials || this.loadCredentials(); + if (!creds) { + this._error = 'No credentials available'; + return false; + } + + try { + const sdk = await import('matrix-js-sdk'); + + this._client = sdk.createClient({ + baseUrl: creds.homeserver, + accessToken: creds.accessToken, + userId: creds.userId, + deviceId: creds.deviceId, + timelineSupport: true, + }); + + this.setupEventHandlers(sdk); + + await this._client.startClient({ + initialSyncLimit: 20, + lazyLoadMembers: true, + }); + + this.saveCredentials(creds); + this._initialized = true; + this._error = null; + + return true; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to initialize Matrix client'; + console.error('Matrix initialization error:', err); + return false; + } + } + + /** + * Setup event handlers for Matrix SDK events + */ + private setupEventHandlers(sdk: typeof import('matrix-js-sdk')) { + if (!this._client) return; + + // Sync state changes + this._client.on(sdk.ClientEvent.Sync, (state, prevState) => { + this._syncState = state as SyncState; + + if (state === 'PREPARED') { + this._rooms = this._client!.getRooms(); + console.log(`Matrix sync prepared, ${this._rooms.length} rooms loaded`); + } + + if (state === 'ERROR') { + this._error = 'Sync error occurred'; + } + }); + + // Room timeline updates (new messages) + this._client.on(sdk.RoomEvent.Timeline, (event, room, toStartOfTimeline) => { + // Skip historical events from pagination + if (toStartOfTimeline) return; + + // Update rooms list + this._rooms = this._client!.getRooms(); + + // Update timeline if we're in this room + if (room?.roomId === this._currentRoomId) { + this._timeline = [...(room.getLiveTimeline().getEvents() || [])]; + } + }); + + // Typing indicators + this._client.on(sdk.RoomMemberEvent.Typing, (event, member) => { + const roomId = event.getRoomId(); + if (!roomId) return; + + const room = this._client!.getRoom(roomId); + const typingMembers = + room + ?.getMembersWithMembership('join') + .filter((m) => m.typing && m.userId !== this._client!.getUserId()) + .map((m) => m.name || m.userId) || []; + + // Trigger reactivity by creating new Map + const newMap = new Map(this._typingUsers); + newMap.set(roomId, typingMembers); + this._typingUsers = newMap; + }); + + // Room membership changes (invites, joins, leaves) + this._client.on(sdk.RoomEvent.MyMembership, (room, membership, prevMembership) => { + console.log(`Membership changed: ${room.roomId} - ${prevMembership} -> ${membership}`); + this._rooms = this._client!.getRooms(); + }); + + // Room name/state changes + this._client.on(sdk.RoomStateEvent.Events, (event, state, prevEvent) => { + // Trigger reactivity for room updates + this._rooms = this._client!.getRooms(); + }); + } + + // ───────────────────────────────────────────────────────── + // Room Actions + // ───────────────────────────────────────────────────────── + + /** + * Select a room to view + */ + selectRoom(roomId: string) { + this._currentRoomId = roomId; + const room = this._client?.getRoom(roomId); + + if (room) { + this._timeline = room.getLiveTimeline().getEvents() || []; + + // Mark as read + const lastEvent = this._timeline[this._timeline.length - 1]; + if (lastEvent) { + this._client?.sendReadReceipt(lastEvent).catch(console.error); + } + } else { + this._timeline = []; + } + } + + /** + * Clear current room selection + */ + clearRoom() { + this._currentRoomId = null; + this._timeline = []; + } + + /** + * Join a room by ID or alias + */ + async joinRoom(roomIdOrAlias: string): Promise { + if (!this._client) return false; + + try { + await this._client.joinRoom(roomIdOrAlias); + this._rooms = this._client.getRooms(); + return true; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to join room'; + return false; + } + } + + /** + * Leave a room + */ + async leaveRoom(roomId: string): Promise { + if (!this._client) return false; + + try { + await this._client.leave(roomId); + + if (this._currentRoomId === roomId) { + this.clearRoom(); + } + + this._rooms = this._client.getRooms(); + return true; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to leave room'; + return false; + } + } + + /** + * Create a new room + */ + async createRoom(options: { + name?: string; + topic?: string; + isDirect?: boolean; + invite?: string[]; + }): Promise { + if (!this._client) return null; + + try { + const result = await this._client.createRoom({ + name: options.name, + topic: options.topic, + is_direct: options.isDirect, + invite: options.invite, + preset: options.isDirect ? 'trusted_private_chat' : 'private_chat', + }); + + this._rooms = this._client.getRooms(); + return result.room_id; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to create room'; + return null; + } + } + + // ───────────────────────────────────────────────────────── + // Message Actions + // ───────────────────────────────────────────────────────── + + /** + * Send a text message to current room + */ + async sendMessage(body: string): Promise { + if (!this._client || !this._currentRoomId) return false; + + try { + await this._client.sendTextMessage(this._currentRoomId, body); + return true; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to send message'; + return false; + } + } + + /** + * Send typing indicator + */ + async sendTyping(typing: boolean): Promise { + if (!this._client || !this._currentRoomId) return; + + try { + await this._client.sendTyping(this._currentRoomId, typing, typing ? 30000 : 0); + } catch (err) { + // Ignore typing errors + } + } + + /** + * Load more messages (pagination) + */ + async loadMoreMessages(limit = 50): Promise { + if (!this._client || !this._currentRoomId) return false; + + const room = this._client.getRoom(this._currentRoomId); + if (!room) return false; + + try { + await this._client.scrollback(room, limit); + this._timeline = room.getLiveTimeline().getEvents() || []; + return true; + } catch (err) { + this._error = err instanceof Error ? err.message : 'Failed to load messages'; + return false; + } + } + + // ───────────────────────────────────────────────────────── + // Cleanup + // ───────────────────────────────────────────────────────── + + /** + * Stop the client and clean up + */ + destroy() { + this._client?.stopClient(); + this._client = null; + this._syncState = 'STOPPED'; + this._rooms = []; + this._timeline = []; + this._currentRoomId = null; + this._typingUsers = new Map(); + this._initialized = false; + } + + /** + * Logout and clear credentials + */ + logout() { + this.destroy(); + if (browser) { + localStorage.removeItem(STORAGE_KEY); + } + } + + // ───────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────── + + /** + * Convert SDK Room to SimpleRoom + */ + private roomToSimpleRoom(room: Room): SimpleRoom { + const lastEvent = room + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === 'm.room.message') + .pop(); + + return { + id: room.roomId, + name: room.name || 'Unnamed Room', + topic: room.currentState.getStateEvents('m.room.topic', '')?.[0]?.getContent()?.topic, + avatar: room.getAvatarUrl(this._client?.baseUrl || '', 48, 48, 'scale') || undefined, + lastMessage: lastEvent?.getContent()?.body, + lastMessageSender: lastEvent ? this.getSenderName(lastEvent) : undefined, + lastMessageTime: room.getLastActiveTimestamp() || undefined, + unreadCount: room.getUnreadNotificationCount('total') || 0, + highlightCount: room.getUnreadNotificationCount('highlight') || 0, + isDirect: this.isDirectRoom(room), + isEncrypted: room.hasEncryptionStateEvent(), + memberCount: room.getJoinedMemberCount(), + }; + } + + /** + * Convert SDK MatrixEvent to SimpleMessage + */ + private eventToSimpleMessage(event: MatrixEvent): SimpleMessage { + const content = event.getContent(); + const relatesTo = content['m.relates_to']; + + return { + id: event.getId() || '', + sender: event.getSender() || '', + senderName: this.getSenderName(event), + body: content.body || '', + formattedBody: content.formatted_body, + timestamp: event.getTs(), + type: content.msgtype || 'm.text', + isOwn: event.getSender() === this._client?.getUserId(), + replyTo: relatesTo?.['m.in_reply_to']?.event_id, + edited: !!event.replacingEvent(), + }; + } + + /** + * Get display name for message sender + */ + private getSenderName(event: MatrixEvent): string { + const room = this._client?.getRoom(event.getRoomId() || ''); + const member = room?.getMember(event.getSender() || ''); + return member?.name || event.getSender()?.split(':')[0].substring(1) || 'Unknown'; + } + + /** + * Check if room is a direct message room + */ + private isDirectRoom(room: Room): boolean { + const dominated = this._client?.getAccountData('m.direct')?.getContent() || {}; + return Object.values(dominated).flat().includes(room.roomId); + } + + /** + * Load credentials from localStorage + */ + private loadCredentials(): MatrixCredentials | null { + if (!browser) return null; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } + } + + /** + * Save credentials to localStorage + */ + private saveCredentials(creds: MatrixCredentials) { + if (browser) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(creds)); + } + } + + /** + * Check if credentials exist in storage + */ + hasStoredCredentials(): boolean { + return this.loadCredentials() !== null; + } +} + +// Export singleton instance +export const matrixStore = new MatrixStore(); diff --git a/apps/matrix/apps/web/src/lib/matrix/types.ts b/apps/matrix/apps/web/src/lib/matrix/types.ts new file mode 100644 index 000000000..65c353180 --- /dev/null +++ b/apps/matrix/apps/web/src/lib/matrix/types.ts @@ -0,0 +1,83 @@ +import type { Room, MatrixEvent, MatrixClient } from 'matrix-js-sdk'; + +/** + * Matrix sync states + */ +export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP'; + +/** + * Credentials for Matrix authentication + */ +export interface MatrixCredentials { + homeserver: string; + accessToken: string; + userId: string; + deviceId: string; +} + +/** + * Simplified message for UI rendering + */ +export interface SimpleMessage { + id: string; + sender: string; + senderName: string; + body: string; + formattedBody?: string; + timestamp: number; + type: MessageType; + isOwn: boolean; + replyTo?: string; + edited?: boolean; +} + +export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice'; + +/** + * Simplified room for UI rendering + */ +export interface SimpleRoom { + id: string; + name: string; + topic?: string; + avatar?: string; + lastMessage?: string; + lastMessageSender?: string; + lastMessageTime?: number; + unreadCount: number; + highlightCount: number; + isDirect: boolean; + isEncrypted: boolean; + memberCount: number; +} + +/** + * Room member info + */ +export interface RoomMember { + userId: string; + displayName: string; + avatarUrl?: string; + membership: 'join' | 'invite' | 'leave' | 'ban' | 'knock'; + powerLevel: number; +} + +/** + * Login result + */ +export interface LoginResult { + success: boolean; + credentials?: MatrixCredentials; + error?: string; +} + +/** + * Matrix store state (for debugging) + */ +export interface MatrixStoreState { + syncState: SyncState; + roomCount: number; + currentRoomId: string | null; + messageCount: number; + error: string | null; +} diff --git a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 000000000..b505020fc --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,105 @@ + + +{#if loading} + +
+ +
+

Connecting to Matrix...

+

+ {#if matrixStore.syncState === 'PREPARED'} + Preparing sync... + {:else if matrixStore.syncState === 'SYNCING'} + Syncing messages... + {:else if matrixStore.syncState === 'CATCHUP'} + Catching up... + {:else} + Initializing... + {/if} +

+
+
+{:else if initError} + +
+
+ +
+
+

Connection Failed

+

{initError}

+
+
+ + +
+
+{:else if matrixStore.isReady} + + {@render children()} +{:else} + +
+

Redirecting...

+
+{/if} diff --git a/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte new file mode 100644 index 000000000..8d8fe32ee --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte @@ -0,0 +1,105 @@ + + +
+ + + + +
+ {#if matrixStore.currentRoom} + + + + + + + + + {:else} + +
+ +
+

Welcome to Mana Matrix

+

Select a conversation from the sidebar to start chatting

+
+ + +
+
+

{matrixStore.rooms.length}

+

Rooms

+
+
+

{matrixStore.totalUnreadCount}

+

Unread

+
+
+
+ {/if} +
+
diff --git a/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte new file mode 100644 index 000000000..54f9b53a8 --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte @@ -0,0 +1,26 @@ + + + +
+

Loading room...

+
diff --git a/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 000000000..c43f404dc --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,123 @@ + + +
+ +
+ + + +

Settings

+
+ + +
+
+ +
+
+

+ + Profile +

+
+
+
+ + {matrixStore.userId?.charAt(1).toUpperCase() || '?'} + +
+
+
+

{matrixStore.userId}

+

Matrix ID

+
+
+
+
+ + +
+
+

+ + Server +

+
+
+ Homeserver + {matrixStore.client?.getHomeserverUrl() || 'Unknown'} +
+
+ Sync Status + + {matrixStore.syncState} + +
+
+ Rooms + {matrixStore.rooms.length} +
+
+
+
+ + +
+
+

+ + Appearance +

+

Theme and display settings coming soon...

+
+
+ + +
+
+

+ + Notifications +

+

Notification settings coming soon...

+
+
+ + +
+
+

+ + Security +

+

+ End-to-end encryption settings coming in Phase 2... +

+
+
+ + +
+
+ +
+
+
+
+
diff --git a/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte b/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..73195633b --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children()} +
diff --git a/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte b/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..375f4c046 --- /dev/null +++ b/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,201 @@ + + +
+
+ +
+

Mana Matrix

+

Sign in to your Matrix account

+
+ + + {#if error} +
+ + {error} +
+ {/if} + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+

+ Don't have an account? + Register +

+
+
+
diff --git a/apps/matrix/apps/web/src/routes/+layout.svelte b/apps/matrix/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..f980c8560 --- /dev/null +++ b/apps/matrix/apps/web/src/routes/+layout.svelte @@ -0,0 +1,17 @@ + + + + Mana Matrix + + + +{@render children()} diff --git a/apps/matrix/apps/web/src/routes/+page.svelte b/apps/matrix/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..729286fbd --- /dev/null +++ b/apps/matrix/apps/web/src/routes/+page.svelte @@ -0,0 +1,33 @@ + + +
+
+ +

Loading...

+
+
diff --git a/apps/matrix/apps/web/src/routes/health/+server.ts b/apps/matrix/apps/web/src/routes/health/+server.ts new file mode 100644 index 000000000..0b1fdd950 --- /dev/null +++ b/apps/matrix/apps/web/src/routes/health/+server.ts @@ -0,0 +1,17 @@ +import type { RequestHandler } from '@sveltejs/kit'; + +export const GET: RequestHandler = async () => { + return new Response( + JSON.stringify({ + status: 'ok', + timestamp: new Date().toISOString(), + service: 'matrix-web', + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ); +}; diff --git a/apps/matrix/apps/web/svelte.config.js b/apps/matrix/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/matrix/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/matrix/apps/web/tsconfig.json b/apps/matrix/apps/web/tsconfig.json new file mode 100644 index 000000000..942bcc11c --- /dev/null +++ b/apps/matrix/apps/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*", "src/**/*.svelte"] +} diff --git a/apps/matrix/apps/web/vite.config.ts b/apps/matrix/apps/web/vite.config.ts new file mode 100644 index 000000000..857f7aa9a --- /dev/null +++ b/apps/matrix/apps/web/vite.config.ts @@ -0,0 +1,25 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5180, + strictPort: true, + }, + define: { + global: 'globalThis', + }, + optimizeDeps: { + include: ['buffer', 'events'], + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + }, + ssr: { + noExternal: ['@manacore/shared-*', '@matrix/shared'], + }, +}); diff --git a/apps/matrix/package.json b/apps/matrix/package.json new file mode 100644 index 000000000..c29fd260a --- /dev/null +++ b/apps/matrix/package.json @@ -0,0 +1,8 @@ +{ + "name": "matrix", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "turbo run dev" + } +} diff --git a/apps/matrix/packages/shared/package.json b/apps/matrix/packages/shared/package.json new file mode 100644 index 000000000..db66a3b90 --- /dev/null +++ b/apps/matrix/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@matrix/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/apps/matrix/packages/shared/src/index.ts b/apps/matrix/packages/shared/src/index.ts new file mode 100644 index 000000000..a5980435f --- /dev/null +++ b/apps/matrix/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +// Re-export all types +export * from './types'; diff --git a/apps/matrix/packages/shared/src/types.ts b/apps/matrix/packages/shared/src/types.ts new file mode 100644 index 000000000..66f9e5942 --- /dev/null +++ b/apps/matrix/packages/shared/src/types.ts @@ -0,0 +1,42 @@ +/** + * Shared types for Matrix client + */ + +export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP'; + +export interface MatrixCredentials { + homeserver: string; + accessToken: string; + userId: string; + deviceId: string; +} + +export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice'; + +export interface SimpleMessage { + id: string; + sender: string; + senderName: string; + body: string; + formattedBody?: string; + timestamp: number; + type: MessageType; + isOwn: boolean; + replyTo?: string; + edited?: boolean; +} + +export interface SimpleRoom { + id: string; + name: string; + topic?: string; + avatar?: string; + lastMessage?: string; + lastMessageSender?: string; + lastMessageTime?: number; + unreadCount: number; + highlightCount: number; + isDirect: boolean; + isEncrypted: boolean; + memberCount: number; +} diff --git a/apps/matrix/packages/shared/tsconfig.json b/apps/matrix/packages/shared/tsconfig.json new file mode 100644 index 000000000..882ac919d --- /dev/null +++ b/apps/matrix/packages/shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ES2022"], + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index 9785afb43..d5f2e4368 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,8 @@ "todo:db:studio": "pnpm --filter @todo/backend db:studio", "todo:db:seed": "pnpm --filter @todo/backend db:seed", "dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"", + "matrix:dev": "turbo run dev --filter=matrix...", + "dev:matrix:web": "pnpm --filter @matrix/web dev", "moodlit:dev": "turbo run dev --filter=moodlit...", "dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev", "dev:moodlit:web": "pnpm --filter @moodlit/web dev",