diff --git a/CLAUDE.md b/CLAUDE.md index be00c79c28..f997116a3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Project Context + +This is a **HealthChat** fork of Element Web (a Matrix client built with React and TypeScript). HealthChat customizations are primarily configuration-based: custom branding, homeserver (matrix.healthchat.ch), SSO auto-redirect, and many advanced features disabled for a simplified UX. The `release/healthchat` branch is the main working branch; `master` tracks upstream. + ## Prerequisites - Node.js >= 22.18 @@ -10,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ### Setup -- `yarn install` - Install dependencies +- `yarn install` - Install dependencies (also runs `postinstall` which applies patches via patch-package) - Copy `config.sample.json` to `config.json` and configure (see docs/config.md) ### Development @@ -32,12 +36,12 @@ sudo sysctl fs.inotify.max_user_instances=512 - `yarn coverage` - Run tests with coverage reporting To run a single test file: -- `yarn test path/to/test-file.test.ts` +- `yarn test path/to/test-file-test.ts` (note: test files use `-test.ts` suffix, not `.test.ts`) - `yarn test:playwright path/to/e2e-test.ts` ### Code Quality - `yarn lint` - Run all linters (TypeScript, ESLint, Stylelint) -- `yarn lint:js-fix` - Auto-fix ESLint issues +- `yarn lint:js-fix` - Auto-fix ESLint issues (runs Prettier + ESLint) - `yarn lint:types` - TypeScript type checking only - `yarn lint:style` - Check CSS/PostCSS styles @@ -48,8 +52,6 @@ To run a single test file: ## Architecture -This is Element Web, a Matrix client built with React and TypeScript, customized as "HealthChat". Key architectural patterns: - ### Component Structure The application follows a hierarchical component structure: - `MatrixChat` - Root component managing the entire application state @@ -57,21 +59,26 @@ The application follows a hierarchical component structure: - `MainSplit` - Central room view with messages and input - `RightPanel` - Member list, room info, and contextual panels -Components are organized in `/src/components/`: -- `/structures/` - Major layout components -- `/views/` - Reusable UI components grouped by function (auth, dialogs, messages, rooms, settings) +Components are organized in `src/components/`: +- `structures/` - Major layout components (use class components) +- `views/` - Reusable UI components grouped by function (auth, dialogs, messages, rooms, settings) + +### Monorepo Structure +- `packages/shared-components/` - Separate package (`@element-hq/web-shared-components`) with its own build (Vite), tests, and Storybook. Contains shared UI components (audio, avatar, composer, event-tiles, message-body). ### State Management Uses Flux architecture with: -- Centralized dispatcher in `/src/dispatcher/` -- Stores in `/src/stores/` as singletons (access via `FooStore.instance`) +- Centralized dispatcher in `src/dispatcher/` +- Stores in `src/stores/` as singletons (access via `FooStore.instance`) - Dispatch actions: `dis.dispatch({ action: Action.FOO, ...payload })` or `dis.fire(Action.FOO)` for action-only ### Styling -- PostCSS files with `.pcss` extension +- PostCSS files with `.pcss` extension, located in `res/css/` (not co-located with components) +- CSS file must match component name: `res/css/views/rooms/_MessageTile.pcss` for `src/components/views/rooms/MessageTile.tsx` - BEM-like naming: `mx_ComponentName_element` -- Component styles co-located with components (e.g., `_MessageTile.pcss`) - Theming via CSS custom properties +- Use `$font` variables, not manual font sizes +- Document z-index values, pixel adjustments with comments explaining why ### Matrix Integration - Uses `matrix-js-sdk` for all Matrix protocol operations @@ -79,29 +86,36 @@ Uses Flux architecture with: - Room and event handling through SDK abstractions ### Module System -Plugin architecture in `/src/modules/`: +Plugin architecture in `src/modules/` and `module_system/`: - Allows runtime extension of functionality - Module API for registering custom components and handlers +- Build step: `yarn build:module_system` ### Testing Patterns -- Unit tests in `/test/unit-tests/` using Jest -- E2E tests in `/playwright/e2e/` using Playwright +- Unit tests in `test/unit-tests/` mirroring `src/` structure, using Jest +- **Test file naming**: `*-test.[tj]s?(x)` (hyphenated, NOT `.test.ts`) +- E2E tests in `playwright/e2e/` using Playwright with page object models in `playwright/pages/` - Mock Matrix client and SDK extensively in tests -- Component tests via Storybook +- Use `jest-matrix-react` for component rendering (NOT `@testing-library/react`) +- Test convention: `describe("ComponentName", () => { it("should ...", () => { ... }); });` ### Important Conventions -- TypeScript strict mode - always use explicit types +- TypeScript strict mode - always use explicit types, describe types exhaustively - Functional components with hooks preferred over class components (except "structures" which use classes) - Translations use `_t()` function with hierarchical keys -- 4-space indentation, 120 character line limit +- 4-space indentation, 120 character line limit, double quotes, Prettier-formatted - Component files: UpperCamelCase, utilities: kebab-case +- Use named exports, one component per file - Use `Optional` type instead of truly optional parameters when callers should explicitly acknowledge missing values - CSS class names must be prefixed with `mx_` and match component name (e.g., `mx_RoomTile`) - Import matrix-js-sdk via `matrix-js-sdk/src/matrix` only (not `matrix-js-sdk` or `matrix-js-sdk/src`) -- Use `jest-matrix-react` instead of `@testing-library/react` for component tests - No `React.forwardRef` - use ref props directly instead - Use UIStore for window dimensions instead of `window.innerHeight`/`window.innerWidth` - Use Media helper for MXC URL handling instead of direct `mxcUrlToHttp` calls +- `eslint-ignore` / `@ts-ignore` must have a comment explaining why +- Prefer async/await over promise chaining +- Interfaces for object definitions, types for union/parameter-value-only declarations +- Interface properties terminated with semicolons, not commas ### Linked Development with matrix-js-sdk For developing against local SDK changes: diff --git a/packages/user-directory-module/package.json b/packages/user-directory-module/package.json new file mode 100644 index 0000000000..cc4c8ae016 --- /dev/null +++ b/packages/user-directory-module/package.json @@ -0,0 +1,25 @@ +{ + "name": "@healthchat/user-directory-module", + "version": "1.0.0", + "description": "HealthChat user directory module for Element Web", + "type": "module", + "main": "dist/user-directory-module.js", + "types": "dist/user-directory-module.d.ts", + "files": ["dist", "src", "package.json"], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint:types": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^19.0.0", + "@element-hq/element-web-module-api": "^1.8.0" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "^1.8.0", + "@types/react": "^19.2.2", + "react": "^19.1.0", + "typescript": "^5.9.3", + "vite": "^7.1.9" + } +} diff --git a/packages/user-directory-module/src/api/auth.ts b/packages/user-directory-module/src/api/auth.ts new file mode 100644 index 0000000000..962c614bcb --- /dev/null +++ b/packages/user-directory-module/src/api/auth.ts @@ -0,0 +1,46 @@ +import type { MatrixClientPeg, OpenIdToken } from "../types.ts"; + +declare const window: Window & { + mxMatrixClientPeg: MatrixClientPeg; +}; + +interface CachedToken { + token: OpenIdToken; + acquiredAt: number; +} + +let cachedToken: CachedToken | null = null; + +/** Margin in seconds before expiry to trigger refresh. */ +const REFRESH_MARGIN_SECONDS = 60; + +/** + * Get an OpenID token from the Matrix client, with caching. + * Refreshes the token if it's within 60 seconds of expiring. + */ +export async function getOpenIdToken(): Promise { + const now = Date.now(); + + if (cachedToken) { + const expiresAt = cachedToken.acquiredAt + cachedToken.token.expires_in * 1000; + const stillValid = expiresAt - now > REFRESH_MARGIN_SECONDS * 1000; + if (stillValid) { + return cachedToken.token.access_token; + } + } + + const client = window.mxMatrixClientPeg.safeGet(); + const token = await client.getOpenIdToken(); + + cachedToken = { + token, + acquiredAt: now, + }; + + return token.access_token; +} + +/** Clear the cached token (e.g. on logout). */ +export function clearCachedToken(): void { + cachedToken = null; +} diff --git a/packages/user-directory-module/src/api/client.ts b/packages/user-directory-module/src/api/client.ts new file mode 100644 index 0000000000..c411079251 --- /dev/null +++ b/packages/user-directory-module/src/api/client.ts @@ -0,0 +1,59 @@ +import { getOpenIdToken } from "./auth.ts"; +import type { + MatrixUserSearchResult, + DirectChatResponse, + ProfileCompletenessResponse, + InviteResponse, +} from "../types.ts"; + +export class ApiClient { + constructor(private readonly baseUrl: string) {} + + private async request(path: string, options: RequestInit = {}): Promise { + const token = await getOpenIdToken(); + const url = `${this.baseUrl}${path}`; + + const response = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`API request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`); + } + + return response.json() as Promise; + } + + /** Fetch users, optionally filtered by search query. */ + async getUsers(search?: string): Promise { + const params = search ? `?search=${encodeURIComponent(search)}` : ""; + return this.request(`/api/usermatrix/users${params}`); + } + + /** Create or get an existing direct chat room with a target user. */ + async createDirectChat(targetUserId: string): Promise { + return this.request("/api/usermatrix/direct-chat", { + method: "POST", + body: JSON.stringify({ targetUserId }), + }); + } + + /** Check if the current user's profile is complete. */ + async getProfileCompleteness(): Promise { + return this.request("/api/usermatrix/profile/completeness"); + } + + /** Send an invitation email to a new user. */ + async sendInvite(email: string, personalNote: string): Promise { + return this.request("/api/usermatrix/invite", { + method: "POST", + body: JSON.stringify({ email, personalNote }), + }); + } +} diff --git a/packages/user-directory-module/src/components/EmptyState.tsx b/packages/user-directory-module/src/components/EmptyState.tsx new file mode 100644 index 0000000000..d8e130fedc --- /dev/null +++ b/packages/user-directory-module/src/components/EmptyState.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +interface EmptyStateProps { + searchQuery: string; +} + +export function EmptyState({ searchQuery }: EmptyStateProps): React.JSX.Element { + return ( +
+ {searchQuery ? ( +

Keine Benutzer gefunden für «{searchQuery}».

+ ) : ( +

Keine Benutzer im Verzeichnis vorhanden.

+ )} +
+ ); +} diff --git a/packages/user-directory-module/src/components/ErrorState.tsx b/packages/user-directory-module/src/components/ErrorState.tsx new file mode 100644 index 0000000000..b05221b24f --- /dev/null +++ b/packages/user-directory-module/src/components/ErrorState.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +interface ErrorStateProps { + message: string; + onRetry: () => void; +} + +export function ErrorState({ message, onRetry }: ErrorStateProps): React.JSX.Element { + return ( +
+

Fehler: {message}

+ +
+ ); +} diff --git a/packages/user-directory-module/src/components/InviteForm.tsx b/packages/user-directory-module/src/components/InviteForm.tsx new file mode 100644 index 0000000000..3c4c43b98a --- /dev/null +++ b/packages/user-directory-module/src/components/InviteForm.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import type { ApiClient } from "../api/client.ts"; + +interface InviteFormProps { + apiClient: ApiClient; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_NOTE_LENGTH = 10; + +export function InviteForm({ apiClient }: InviteFormProps): React.JSX.Element { + const [email, setEmail] = useState(""); + const [personalNote, setPersonalNote] = useState(""); + const [sending, setSending] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const emailValid = EMAIL_REGEX.test(email); + const noteValid = personalNote.trim().length >= MIN_NOTE_LENGTH; + const canSubmit = emailValid && noteValid && !sending; + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!canSubmit) return; + + setSending(true); + setError(null); + setSuccess(false); + + try { + await apiClient.sendInvite(email.trim(), personalNote.trim()); + setSuccess(true); + setEmail(""); + setPersonalNote(""); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Einladung konnte nicht gesendet werden"); + } finally { + setSending(false); + } + } + + return ( +
+

Benutzer einladen

+

+ Kennen Sie jemanden, der noch nicht bei HealthChat registriert ist? + Senden Sie eine Einladung per E-Mail. +

+
+
+ + setEmail(e.target.value)} + placeholder="name@example.com" + disabled={sending} + /> + {email && !emailValid && ( + + Bitte geben Sie eine gültige E-Mail-Adresse ein. + + )} +
+
+ +