mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-07 23:18:21 +00:00
feat(widgets): add localhost:5001 to auto-approved widget domains
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js >= 22.18
|
- Node.js >= 22.18
|
||||||
@@ -10,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Setup
|
### 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)
|
- Copy `config.sample.json` to `config.json` and configure (see docs/config.md)
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
@@ -32,12 +36,12 @@ sudo sysctl fs.inotify.max_user_instances=512
|
|||||||
- `yarn coverage` - Run tests with coverage reporting
|
- `yarn coverage` - Run tests with coverage reporting
|
||||||
|
|
||||||
To run a single test file:
|
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`
|
- `yarn test:playwright path/to/e2e-test.ts`
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
- `yarn lint` - Run all linters (TypeScript, ESLint, Stylelint)
|
- `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:types` - TypeScript type checking only
|
||||||
- `yarn lint:style` - Check CSS/PostCSS styles
|
- `yarn lint:style` - Check CSS/PostCSS styles
|
||||||
|
|
||||||
@@ -48,8 +52,6 @@ To run a single test file:
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is Element Web, a Matrix client built with React and TypeScript, customized as "HealthChat". Key architectural patterns:
|
|
||||||
|
|
||||||
### Component Structure
|
### Component Structure
|
||||||
The application follows a hierarchical component structure:
|
The application follows a hierarchical component structure:
|
||||||
- `MatrixChat` - Root component managing the entire application state
|
- `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
|
- `MainSplit` - Central room view with messages and input
|
||||||
- `RightPanel` - Member list, room info, and contextual panels
|
- `RightPanel` - Member list, room info, and contextual panels
|
||||||
|
|
||||||
Components are organized in `/src/components/`:
|
Components are organized in `src/components/`:
|
||||||
- `/structures/` - Major layout components
|
- `structures/` - Major layout components (use class components)
|
||||||
- `/views/` - Reusable UI components grouped by function (auth, dialogs, messages, rooms, settings)
|
- `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
|
### State Management
|
||||||
Uses Flux architecture with:
|
Uses Flux architecture with:
|
||||||
- Centralized dispatcher in `/src/dispatcher/`
|
- Centralized dispatcher in `src/dispatcher/`
|
||||||
- Stores in `/src/stores/` as singletons (access via `FooStore.instance`)
|
- 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
|
- Dispatch actions: `dis.dispatch({ action: Action.FOO, ...payload })` or `dis.fire(Action.FOO)` for action-only
|
||||||
|
|
||||||
### Styling
|
### 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`
|
- BEM-like naming: `mx_ComponentName_element`
|
||||||
- Component styles co-located with components (e.g., `_MessageTile.pcss`)
|
|
||||||
- Theming via CSS custom properties
|
- Theming via CSS custom properties
|
||||||
|
- Use `$font` variables, not manual font sizes
|
||||||
|
- Document z-index values, pixel adjustments with comments explaining why
|
||||||
|
|
||||||
### Matrix Integration
|
### Matrix Integration
|
||||||
- Uses `matrix-js-sdk` for all Matrix protocol operations
|
- Uses `matrix-js-sdk` for all Matrix protocol operations
|
||||||
@@ -79,29 +86,36 @@ Uses Flux architecture with:
|
|||||||
- Room and event handling through SDK abstractions
|
- Room and event handling through SDK abstractions
|
||||||
|
|
||||||
### Module System
|
### Module System
|
||||||
Plugin architecture in `/src/modules/`:
|
Plugin architecture in `src/modules/` and `module_system/`:
|
||||||
- Allows runtime extension of functionality
|
- Allows runtime extension of functionality
|
||||||
- Module API for registering custom components and handlers
|
- Module API for registering custom components and handlers
|
||||||
|
- Build step: `yarn build:module_system`
|
||||||
|
|
||||||
### Testing Patterns
|
### Testing Patterns
|
||||||
- Unit tests in `/test/unit-tests/` using Jest
|
- Unit tests in `test/unit-tests/` mirroring `src/` structure, using Jest
|
||||||
- E2E tests in `/playwright/e2e/` using Playwright
|
- **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
|
- 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
|
### 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)
|
- Functional components with hooks preferred over class components (except "structures" which use classes)
|
||||||
- Translations use `_t()` function with hierarchical keys
|
- 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
|
- Component files: UpperCamelCase, utilities: kebab-case
|
||||||
|
- Use named exports, one component per file
|
||||||
- Use `Optional<T>` type instead of truly optional parameters when callers should explicitly acknowledge missing values
|
- Use `Optional<T>` 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`)
|
- 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`)
|
- 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
|
- No `React.forwardRef` - use ref props directly instead
|
||||||
- Use UIStore for window dimensions instead of `window.innerHeight`/`window.innerWidth`
|
- Use UIStore for window dimensions instead of `window.innerHeight`/`window.innerWidth`
|
||||||
- Use Media helper for MXC URL handling instead of direct `mxcUrlToHttp` calls
|
- 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
|
### Linked Development with matrix-js-sdk
|
||||||
For developing against local SDK changes:
|
For developing against local SDK changes:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch users, optionally filtered by search query. */
|
||||||
|
async getUsers(search?: string): Promise<MatrixUserSearchResult> {
|
||||||
|
const params = search ? `?search=${encodeURIComponent(search)}` : "";
|
||||||
|
return this.request<MatrixUserSearchResult>(`/api/usermatrix/users${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or get an existing direct chat room with a target user. */
|
||||||
|
async createDirectChat(targetUserId: string): Promise<DirectChatResponse> {
|
||||||
|
return this.request<DirectChatResponse>("/api/usermatrix/direct-chat", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ targetUserId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the current user's profile is complete. */
|
||||||
|
async getProfileCompleteness(): Promise<ProfileCompletenessResponse> {
|
||||||
|
return this.request<ProfileCompletenessResponse>("/api/usermatrix/profile/completeness");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an invitation email to a new user. */
|
||||||
|
async sendInvite(email: string, personalNote: string): Promise<InviteResponse> {
|
||||||
|
return this.request<InviteResponse>("/api/usermatrix/invite", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, personalNote }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ searchQuery }: EmptyStateProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_state mx_UserDirectory_empty">
|
||||||
|
{searchQuery ? (
|
||||||
|
<p>Keine Benutzer gefunden für «{searchQuery}».</p>
|
||||||
|
) : (
|
||||||
|
<p>Keine Benutzer im Verzeichnis vorhanden.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message, onRetry }: ErrorStateProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_state mx_UserDirectory_error">
|
||||||
|
<p className="mx_UserDirectory_error_message">Fehler: {message}</p>
|
||||||
|
<button className="mx_UserDirectory_button mx_UserDirectory_button_retry" onClick={onRetry}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<void> {
|
||||||
|
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 (
|
||||||
|
<div className="mx_UserDirectory_invite">
|
||||||
|
<h3 className="mx_UserDirectory_invite_title">Benutzer einladen</h3>
|
||||||
|
<p className="mx_UserDirectory_invite_description">
|
||||||
|
Kennen Sie jemanden, der noch nicht bei HealthChat registriert ist?
|
||||||
|
Senden Sie eine Einladung per E-Mail.
|
||||||
|
</p>
|
||||||
|
<form className="mx_UserDirectory_invite_form" onSubmit={handleSubmit}>
|
||||||
|
<div className="mx_UserDirectory_invite_field">
|
||||||
|
<label htmlFor="invite-email">E-Mail-Adresse</label>
|
||||||
|
<input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
{email && !emailValid && (
|
||||||
|
<span className="mx_UserDirectory_invite_validation">
|
||||||
|
Bitte geben Sie eine gültige E-Mail-Adresse ein.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mx_UserDirectory_invite_field">
|
||||||
|
<label htmlFor="invite-note">Persönliche Nachricht (mind. {MIN_NOTE_LENGTH} Zeichen)</label>
|
||||||
|
<textarea
|
||||||
|
id="invite-note"
|
||||||
|
value={personalNote}
|
||||||
|
onChange={(e) => setPersonalNote(e.target.value)}
|
||||||
|
placeholder="Schreiben Sie eine kurze persönliche Nachricht..."
|
||||||
|
rows={3}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
{personalNote && !noteValid && (
|
||||||
|
<span className="mx_UserDirectory_invite_validation">
|
||||||
|
Die Nachricht muss mindestens {MIN_NOTE_LENGTH} Zeichen lang sein.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mx_UserDirectory_button mx_UserDirectory_button_invite"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{sending ? "Wird gesendet..." : "Einladung senden"}
|
||||||
|
</button>
|
||||||
|
{success && (
|
||||||
|
<p className="mx_UserDirectory_invite_success">
|
||||||
|
Einladung wurde erfolgreich gesendet!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mx_UserDirectory_invite_error">{error}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function LoadingState(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_state mx_UserDirectory_loading">
|
||||||
|
<div className="mx_UserDirectory_spinner" />
|
||||||
|
<p>Benutzerverzeichnis wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { ProfileCompletenessResponse } from "../types.ts";
|
||||||
|
|
||||||
|
interface ProfileIncompleteWarningProps {
|
||||||
|
completeness: ProfileCompletenessResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
DisplayName: "Anzeigename",
|
||||||
|
Specialty: "Fachgebiet",
|
||||||
|
PracticeName: "Praxis / Unternehmen",
|
||||||
|
BusinessCity: "Ort",
|
||||||
|
BusinessEmail: "E-Mail (geschäftlich)",
|
||||||
|
BusinessTel: "Telefon (geschäftlich)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileIncompleteWarning({ completeness }: ProfileIncompleteWarningProps): React.JSX.Element {
|
||||||
|
const missingLabels = completeness.missingFields.map((f) => FIELD_LABELS[f] ?? f);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_profileWarning">
|
||||||
|
<div className="mx_UserDirectory_profileWarning_icon">!</div>
|
||||||
|
<div className="mx_UserDirectory_profileWarning_content">
|
||||||
|
<p className="mx_UserDirectory_profileWarning_title">Profil unvollständig</p>
|
||||||
|
<p className="mx_UserDirectory_profileWarning_text">
|
||||||
|
Bitte vervollständigen Sie Ihr Profil, damit andere Benutzer Sie finden können.
|
||||||
|
Fehlende Angaben: {missingLabels.join(", ")}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import type { MatrixClientPeg, MatrixUser } from "../types.ts";
|
||||||
|
|
||||||
|
declare const window: Window & {
|
||||||
|
mxMatrixClientPeg: MatrixClientPeg;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
user: MatrixUser;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(user: MatrixUser): string {
|
||||||
|
if (user.firstName && user.lastName) {
|
||||||
|
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.displayName) {
|
||||||
|
const parts = user.displayName.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return parts[0][0].toUpperCase();
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color hash for consistent avatar background colors. */
|
||||||
|
function hashColor(str: string): string {
|
||||||
|
const colors = [
|
||||||
|
"#e74c3c", "#e67e22", "#f1c40f", "#2ecc71",
|
||||||
|
"#1abc9c", "#3498db", "#9b59b6", "#e84393",
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({ user, size = 48 }: UserAvatarProps): React.JSX.Element {
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
const avatarUrl = user.avatarMxcUrl
|
||||||
|
? window.mxMatrixClientPeg.safeGet().mxcUrlToHttp(user.avatarMxcUrl, size, size, "crop")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (avatarUrl && !imgError) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="mx_UserDirectory_avatar"
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = getInitials(user);
|
||||||
|
const bgColor = hashColor(user.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx_UserDirectory_avatar mx_UserDirectory_avatar_initials"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
fontSize: size * 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import type { NavigationApi } from "@element-hq/element-web-module-api";
|
||||||
|
import type { ApiClient } from "../api/client.ts";
|
||||||
|
import type { MatrixUser } from "../types.ts";
|
||||||
|
import { UserAvatar } from "./UserAvatar.tsx";
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: MatrixUser;
|
||||||
|
apiClient: ApiClient;
|
||||||
|
navigation: NavigationApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCard({ user, apiClient, navigation }: UserCardProps): React.JSX.Element {
|
||||||
|
const [startingChat, setStartingChat] = useState(false);
|
||||||
|
const [chatError, setChatError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const displayName = user.displayName || `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.userId;
|
||||||
|
|
||||||
|
async function handleStartChat(): Promise<void> {
|
||||||
|
setStartingChat(true);
|
||||||
|
setChatError(null);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.createDirectChat(user.userId);
|
||||||
|
navigation.openRoom(result.roomId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setChatError(err instanceof Error ? err.message : "Chat konnte nicht gestartet werden");
|
||||||
|
} finally {
|
||||||
|
setStartingChat(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_card">
|
||||||
|
<div className="mx_UserDirectory_card_header">
|
||||||
|
<UserAvatar user={user} size={56} />
|
||||||
|
<div className="mx_UserDirectory_card_name">
|
||||||
|
<span className="mx_UserDirectory_card_displayName">{displayName}</span>
|
||||||
|
{user.specialty && (
|
||||||
|
<span className="mx_UserDirectory_card_specialty">{user.specialty}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx_UserDirectory_card_details">
|
||||||
|
{user.practiceName && (
|
||||||
|
<div className="mx_UserDirectory_card_detail">
|
||||||
|
<span className="mx_UserDirectory_card_label">Praxis</span>
|
||||||
|
<span className="mx_UserDirectory_card_value">{user.practiceName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.businessCity && (
|
||||||
|
<div className="mx_UserDirectory_card_detail">
|
||||||
|
<span className="mx_UserDirectory_card_label">Ort</span>
|
||||||
|
<span className="mx_UserDirectory_card_value">{user.businessCity}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.businessEmail && (
|
||||||
|
<div className="mx_UserDirectory_card_detail">
|
||||||
|
<span className="mx_UserDirectory_card_label">E-Mail</span>
|
||||||
|
<span className="mx_UserDirectory_card_value">{user.businessEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.businessTel && (
|
||||||
|
<div className="mx_UserDirectory_card_detail">
|
||||||
|
<span className="mx_UserDirectory_card_label">Telefon</span>
|
||||||
|
<span className="mx_UserDirectory_card_value">{user.businessTel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mx_UserDirectory_card_actions">
|
||||||
|
<button
|
||||||
|
className="mx_UserDirectory_button mx_UserDirectory_button_chat"
|
||||||
|
onClick={handleStartChat}
|
||||||
|
disabled={startingChat}
|
||||||
|
>
|
||||||
|
{startingChat ? "Wird gestartet..." : "Chat starten"}
|
||||||
|
</button>
|
||||||
|
{chatError && <p className="mx_UserDirectory_card_error">{chatError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { NavigationApi } from "@element-hq/element-web-module-api";
|
||||||
|
import type { ApiClient } from "../api/client.ts";
|
||||||
|
import type { MatrixUser } from "../types.ts";
|
||||||
|
import { UserCard } from "./UserCard.tsx";
|
||||||
|
|
||||||
|
interface UserCardGridProps {
|
||||||
|
users: MatrixUser[];
|
||||||
|
apiClient: ApiClient;
|
||||||
|
navigation: NavigationApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCardGrid({ users, apiClient, navigation }: UserCardGridProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_grid">
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserCard
|
||||||
|
key={user.userId}
|
||||||
|
user={user}
|
||||||
|
apiClient={apiClient}
|
||||||
|
navigation={navigation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface UserDirectoryHeaderProps {
|
||||||
|
totalCount: number;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserDirectoryHeader({
|
||||||
|
totalCount,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: UserDirectoryHeaderProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_header">
|
||||||
|
<div className="mx_UserDirectory_header_title">
|
||||||
|
<h2>Mitglieder</h2>
|
||||||
|
<span className="mx_UserDirectory_header_count">{totalCount} Benutzer</span>
|
||||||
|
<button
|
||||||
|
className="mx_UserDirectory_button mx_UserDirectory_button_refresh"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
title="Aktualisieren"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mx_UserDirectory_header_search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="mx_UserDirectory_searchInput"
|
||||||
|
placeholder="Benutzer suchen..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import type { Api } from "@element-hq/element-web-module-api";
|
||||||
|
import { ApiClient } from "../api/client.ts";
|
||||||
|
import { useUsers } from "../hooks/useUsers.ts";
|
||||||
|
import { useProfileCompleteness } from "../hooks/useProfileCompleteness.ts";
|
||||||
|
import { UserDirectoryHeader } from "./UserDirectoryHeader.tsx";
|
||||||
|
import { UserCardGrid } from "./UserCardGrid.tsx";
|
||||||
|
import { InviteForm } from "./InviteForm.tsx";
|
||||||
|
import { ProfileIncompleteWarning } from "./ProfileIncompleteWarning.tsx";
|
||||||
|
import { LoadingState } from "./LoadingState.tsx";
|
||||||
|
import { ErrorState } from "./ErrorState.tsx";
|
||||||
|
import { EmptyState } from "./EmptyState.tsx";
|
||||||
|
|
||||||
|
interface UserDirectoryPageProps {
|
||||||
|
api: Api;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserDirectoryPage({ api, apiBaseUrl }: UserDirectoryPageProps): React.JSX.Element {
|
||||||
|
const apiClient = useMemo(() => new ApiClient(apiBaseUrl), [apiBaseUrl]);
|
||||||
|
|
||||||
|
const { users, totalCount, loading, error, searchQuery, setSearchQuery, refresh } = useUsers(apiClient);
|
||||||
|
const { completeness } = useProfileCompleteness(apiClient);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_UserDirectory_page">
|
||||||
|
{completeness && !completeness.isComplete && (
|
||||||
|
<ProfileIncompleteWarning completeness={completeness} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UserDirectoryHeader
|
||||||
|
totalCount={totalCount}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onRefresh={refresh}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx_UserDirectory_content">
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{error && !loading && <ErrorState message={error} onRetry={refresh} />}
|
||||||
|
{!loading && !error && users.length === 0 && <EmptyState searchQuery={searchQuery} />}
|
||||||
|
{!loading && !error && users.length > 0 && (
|
||||||
|
<UserCardGrid users={users} apiClient={apiClient} navigation={api.navigation} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InviteForm apiClient={apiClient} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce a value by the given delay in milliseconds.
|
||||||
|
* Returns the debounced value that only updates after the delay.
|
||||||
|
*/
|
||||||
|
export function useDebounce<T>(value: T, delayMs: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getOpenIdToken } from "../api/auth.ts";
|
||||||
|
|
||||||
|
interface UseOpenIdTokenResult {
|
||||||
|
token: string | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that acquires an OpenID token on mount.
|
||||||
|
* The token is cached internally by the auth module.
|
||||||
|
*/
|
||||||
|
export function useOpenIdToken(): UseOpenIdTokenResult {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
getOpenIdToken()
|
||||||
|
.then((t) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setToken(t);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to acquire token");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { token, error, loading };
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { ApiClient } from "../api/client.ts";
|
||||||
|
import type { ProfileCompletenessResponse } from "../types.ts";
|
||||||
|
|
||||||
|
interface UseProfileCompletenessResult {
|
||||||
|
completeness: ProfileCompletenessResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check profile completeness on mount.
|
||||||
|
* Shows a warning if the user's profile is incomplete.
|
||||||
|
*/
|
||||||
|
export function useProfileCompleteness(apiClient: ApiClient): UseProfileCompletenessResult {
|
||||||
|
const [completeness, setCompleteness] = useState<ProfileCompletenessResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getProfileCompleteness()
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCompleteness(result);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to check profile");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
return { completeness, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { ApiClient } from "../api/client.ts";
|
||||||
|
import type { MatrixUser } from "../types.ts";
|
||||||
|
import { useDebounce } from "./useDebounce.ts";
|
||||||
|
|
||||||
|
interface UseUsersResult {
|
||||||
|
users: MatrixUser[];
|
||||||
|
totalCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and search users from the backend API.
|
||||||
|
* Debounces search input by 300ms.
|
||||||
|
*/
|
||||||
|
export function useUsers(apiClient: ApiClient): UseUsersResult {
|
||||||
|
const [users, setUsers] = useState<MatrixUser[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshCounter((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = debouncedSearch.trim() || undefined;
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getUsers(query)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUsers(result.users);
|
||||||
|
setTotalCount(result.totalCount);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load users");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [apiClient, debouncedSearch, refreshCounter]);
|
||||||
|
|
||||||
|
return { users, totalCount, loading, error, searchQuery, setSearchQuery, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Api, Module, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||||
|
import { UserDirectoryPage } from "./components/UserDirectoryPage.tsx";
|
||||||
|
import "./types.ts";
|
||||||
|
import "./styles/user-directory.css";
|
||||||
|
|
||||||
|
/** SVG icon for the space panel button (people group). */
|
||||||
|
function DirectoryIcon(): React.ReactElement {
|
||||||
|
return React.createElement(
|
||||||
|
"svg",
|
||||||
|
{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "currentColor",
|
||||||
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
},
|
||||||
|
React.createElement("path", {
|
||||||
|
d: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserDirectoryModule implements Module {
|
||||||
|
static readonly moduleApiVersion = "^1.0.0";
|
||||||
|
|
||||||
|
constructor(private readonly api: Api) {}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
const config = this.api.config.get("ch.healthchat.user_directory");
|
||||||
|
const apiBaseUrl = config?.api_base_url;
|
||||||
|
|
||||||
|
if (!apiBaseUrl) {
|
||||||
|
console.warn("[UserDirectoryModule] Missing ch.healthchat.user_directory.api_base_url in config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = this.api;
|
||||||
|
|
||||||
|
this.api.navigation.registerLocationRenderer("user-directory", () => {
|
||||||
|
return React.createElement(UserDirectoryPage, { api, apiBaseUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.api.extras.setSpacePanelItem("user-directory", {
|
||||||
|
label: "Mitglieder",
|
||||||
|
tooltip: "Benutzerverzeichnis",
|
||||||
|
icon: React.createElement(DirectoryIcon),
|
||||||
|
onSelected: () => {
|
||||||
|
window.location.hash = "#/user-directory";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDirectoryModule satisfies ModuleFactory;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* React shim that re-exports from window.React.
|
||||||
|
* Element Web sets window.React in loadPlugins() before loading modules.
|
||||||
|
* This avoids bundling a separate copy of React.
|
||||||
|
*/
|
||||||
|
declare const window: Window & { React: typeof import("react") };
|
||||||
|
|
||||||
|
const React = window.React;
|
||||||
|
export default React;
|
||||||
|
export const {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
createElement,
|
||||||
|
Fragment,
|
||||||
|
} = React;
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/* User Directory Module Styles
|
||||||
|
* Uses Element Web CSS custom properties for theming compatibility.
|
||||||
|
* All classes prefixed with mx_UserDirectory_ per convention.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_UserDirectory_page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--cpd-color-bg-canvas-default, #fff);
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
|
||||||
|
.mx_UserDirectory_header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_header_title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_header_title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_header_count {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--cpd-color-bg-subtle-secondary, #f3f8fd);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_header_search {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_searchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary, #c1c6cd);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--cpd-color-bg-canvas-default, #fff);
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_searchInput:focus {
|
||||||
|
border-color: var(--cpd-color-border-interactive-primary, #0467dd);
|
||||||
|
box-shadow: 0 0 0 2px var(--cpd-color-bg-action-primary-rest, rgba(4, 103, 221, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_searchInput::placeholder {
|
||||||
|
color: var(--cpd-color-text-placeholder, #a9b2bc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.mx_UserDirectory_button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_chat {
|
||||||
|
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
|
||||||
|
color: var(--cpd-color-text-on-solid-primary, #fff);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_chat:hover:not(:disabled) {
|
||||||
|
background: var(--cpd-color-bg-action-primary-hovered, #035fb8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_refresh {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_refresh:hover:not(:disabled) {
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
background: var(--cpd-color-bg-subtle-secondary, #f3f8fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_retry {
|
||||||
|
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
|
||||||
|
color: var(--cpd-color-text-on-solid-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_invite {
|
||||||
|
background: var(--cpd-color-bg-action-primary-rest, #0467dd);
|
||||||
|
color: var(--cpd-color-text-on-solid-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_button_invite:hover:not(:disabled) {
|
||||||
|
background: var(--cpd-color-bg-action-primary-hovered, #035fb8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Grid */
|
||||||
|
|
||||||
|
.mx_UserDirectory_content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mx_UserDirectory_grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.mx_UserDirectory_grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Card */
|
||||||
|
|
||||||
|
.mx_UserDirectory_card {
|
||||||
|
background: var(--cpd-color-bg-canvas-default, #fff);
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_displayName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_specialty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_detail {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_label {
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_value {
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_card_error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cpd-color-text-critical-primary, #ff5b55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
|
||||||
|
.mx_UserDirectory_avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_avatar_initials {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
|
||||||
|
.mx_UserDirectory_state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
|
||||||
|
border-top-color: var(--cpd-color-bg-action-primary-rest, #0467dd);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: mx_UserDirectory_spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mx_UserDirectory_spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_error_message {
|
||||||
|
color: var(--cpd-color-text-critical-primary, #ff5b55);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Warning */
|
||||||
|
|
||||||
|
.mx_UserDirectory_profileWarning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--cpd-color-bg-critical-subtle, #fff5f5);
|
||||||
|
border: 1px solid var(--cpd-color-border-critical-subtle, #ffc9c6);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_profileWarning_icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--cpd-color-bg-critical-primary, #ff5b55);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_profileWarning_title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--cpd-color-text-critical-primary, #ff5b55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_profileWarning_text {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invite Form */
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--cpd-color-border-interactive-secondary, #e3e8f0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_field label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cpd-color-text-secondary, #737d8c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_field input,
|
||||||
|
.mx_UserDirectory_invite_field textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary, #c1c6cd);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--cpd-color-bg-canvas-default, #fff);
|
||||||
|
color: var(--cpd-color-text-primary, #17191c);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_field input:focus,
|
||||||
|
.mx_UserDirectory_invite_field textarea:focus {
|
||||||
|
border-color: var(--cpd-color-border-interactive-primary, #0467dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_validation {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cpd-color-text-critical-primary, #ff5b55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_success {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cpd-color-text-success-primary, #0a8a0a);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserDirectory_invite_error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cpd-color-text-critical-primary, #ff5b55);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** Extend the module API Config with our custom key. */
|
||||||
|
declare module "@element-hq/element-web-module-api" {
|
||||||
|
interface Config {
|
||||||
|
"ch.healthchat.user_directory": UserDirectoryConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDirectoryConfig {
|
||||||
|
api_base_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixUser {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
title: string;
|
||||||
|
businessEmail: string;
|
||||||
|
businessTel: string;
|
||||||
|
businessCity: string;
|
||||||
|
practiceName: string;
|
||||||
|
specialty: string;
|
||||||
|
avatarMxcUrl: string;
|
||||||
|
furtherInfo: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
creationTimestamp: string;
|
||||||
|
lastSeenTimestamp: string;
|
||||||
|
lastSyncedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixUserSearchResult {
|
||||||
|
users: MatrixUser[];
|
||||||
|
totalCount: number;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectChatResponse {
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileCompletenessResponse {
|
||||||
|
isComplete: boolean;
|
||||||
|
missingFields: string[];
|
||||||
|
hasProfilePhoto: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteRequest {
|
||||||
|
email: string;
|
||||||
|
personalNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenID token from the Matrix client.
|
||||||
|
* Used to authenticate with the HealthChat backend.
|
||||||
|
*/
|
||||||
|
export interface OpenIdToken {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
matrix_server_name: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal typing for window.mxMatrixClientPeg */
|
||||||
|
export interface MatrixClientPeg {
|
||||||
|
safeGet(): MatrixClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixClient {
|
||||||
|
getOpenIdToken(): Promise<OpenIdToken>;
|
||||||
|
mxcUrlToHttp(mxcUrl: string, width?: number, height?: number, resizeMethod?: string): string | null;
|
||||||
|
getUserId(): string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin that inlines extracted CSS into the JS entry chunk.
|
||||||
|
* Injects a <style> tag at runtime so the module is a single .js file.
|
||||||
|
*/
|
||||||
|
function inlineCssPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "inline-css",
|
||||||
|
apply: "build",
|
||||||
|
enforce: "post",
|
||||||
|
generateBundle(_, bundle) {
|
||||||
|
let cssContent = "";
|
||||||
|
const cssKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const [key, chunk] of Object.entries(bundle)) {
|
||||||
|
if (key.endsWith(".css") && chunk.type === "asset" && typeof chunk.source === "string") {
|
||||||
|
cssContent += chunk.source;
|
||||||
|
cssKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove CSS files from the bundle
|
||||||
|
for (const key of cssKeys) {
|
||||||
|
delete bundle[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject CSS into the entry JS chunk
|
||||||
|
if (cssContent) {
|
||||||
|
for (const chunk of Object.values(bundle)) {
|
||||||
|
if (chunk.type === "chunk" && chunk.isEntry) {
|
||||||
|
const injection =
|
||||||
|
`(function(){` +
|
||||||
|
`var s=document.createElement("style");` +
|
||||||
|
`s.textContent=${JSON.stringify(cssContent)};` +
|
||||||
|
`document.head.appendChild(s)` +
|
||||||
|
`})();\n`;
|
||||||
|
chunk.code = injection + chunk.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "src/index.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: "user-directory-module",
|
||||||
|
},
|
||||||
|
outDir: "dist",
|
||||||
|
rollupOptions: {
|
||||||
|
/* @element-hq/element-web-module-api is type-only at runtime;
|
||||||
|
the module API object is passed via constructor, not imported. */
|
||||||
|
external: ["@element-hq/element-web-module-api"],
|
||||||
|
},
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
/* React is provided globally by Element Web (window.React).
|
||||||
|
Alias to a shim so the bundle uses the host app's React. */
|
||||||
|
"react": resolve(__dirname, "src/shims/react.ts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [inlineCssPlugin()],
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ const showRevokeButton = (
|
|||||||
userWidget: boolean | undefined,
|
userWidget: boolean | undefined,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// Don't show revoke button for api.healthchat.ch widgets
|
// Don't show revoke button for api.healthchat.ch widgets
|
||||||
if (app.url?.includes("api.healthchat.ch")) return false;
|
if (app.url?.includes("api.healthchat.ch") || app.url?.includes("localhost:5001")) return false;
|
||||||
|
|
||||||
const isAllowedWidget =
|
const isAllowedWidget =
|
||||||
(isAppWidget(app) &&
|
(isAppWidget(app) &&
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||||||
// This is a function to make the impact of calling SettingsStore slightly less
|
// This is a function to make the impact of calling SettingsStore slightly less
|
||||||
private hasPermissionToLoad = (props: IProps): boolean => {
|
private hasPermissionToLoad = (props: IProps): boolean => {
|
||||||
// Auto-approve widgets from api.healthchat.ch domain
|
// Auto-approve widgets from api.healthchat.ch domain
|
||||||
if (props.app.url?.includes("api.healthchat.ch")) return true;
|
if (props.app.url?.includes("api.healthchat.ch") || props.app.url?.includes("localhost:5001")) return true;
|
||||||
|
|
||||||
if (this.usingLocalWidget()) return true;
|
if (this.usingLocalWidget()) return true;
|
||||||
if (!props.room) return true; // user widgets always have permissions
|
if (!props.room) return true; // user widgets always have permissions
|
||||||
@@ -808,7 +808,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Don't show minimize button for api.healthchat.ch widgets
|
// Don't show minimize button for api.healthchat.ch widgets
|
||||||
if (!this.props.app.url?.includes("api.healthchat.ch")) {
|
if (!this.props.app.url?.includes("api.healthchat.ch") && !this.props.app.url?.includes("localhost:5001")) {
|
||||||
layoutButtons.push(
|
layoutButtons.push(
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
key="minimise"
|
key="minimise"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations<Wid
|
|||||||
requestedCapabilities: Set<Capability>,
|
requestedCapabilities: Set<Capability>,
|
||||||
): Promise<Set<Capability>> => {
|
): Promise<Set<Capability>> => {
|
||||||
// Check if the widget URL contains api.healthchat.ch
|
// Check if the widget URL contains api.healthchat.ch
|
||||||
if (widget.templateUrl?.includes("api.healthchat.ch")) {
|
if (widget.templateUrl?.includes("api.healthchat.ch") || widget.templateUrl?.includes("localhost:5001")) {
|
||||||
// Return all requested capabilities as approved
|
// Return all requested capabilities as approved
|
||||||
return new Set(requestedCapabilities);
|
return new Set(requestedCapabilities);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class ElementWidgetDriver extends WidgetDriver {
|
|||||||
this.allowedCapabilities = new Set([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]);
|
this.allowedCapabilities = new Set([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]);
|
||||||
|
|
||||||
// Auto-approve all capabilities for api.healthchat.ch widgets
|
// Auto-approve all capabilities for api.healthchat.ch widgets
|
||||||
if (this.forWidget.templateUrl?.includes("api.healthchat.ch")) {
|
if (this.forWidget.templateUrl?.includes("api.healthchat.ch") || this.forWidget.templateUrl?.includes("localhost:5001")) {
|
||||||
// Add all common capabilities that HealthChat widgets might request
|
// Add all common capabilities that HealthChat widgets might request
|
||||||
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||||
this.allowedCapabilities.add(MatrixCapabilities.StickerSending);
|
this.allowedCapabilities.add(MatrixCapabilities.StickerSending);
|
||||||
@@ -617,7 +617,7 @@ export class ElementWidgetDriver extends WidgetDriver {
|
|||||||
|
|
||||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||||
// Auto-approve OpenID for api.healthchat.ch widgets
|
// Auto-approve OpenID for api.healthchat.ch widgets
|
||||||
if (this.forWidget.templateUrl?.includes("api.healthchat.ch")) {
|
if (this.forWidget.templateUrl?.includes("api.healthchat.ch") || this.forWidget.templateUrl?.includes("localhost:5001")) {
|
||||||
return observer.update({
|
return observer.update({
|
||||||
state: OpenIDRequestState.Allowed,
|
state: OpenIDRequestState.Allowed,
|
||||||
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
|
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
|
||||||
|
|||||||
Reference in New Issue
Block a user