mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-10-31 08:15:07 +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.
|
||||
|
||||
## 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<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`)
|
||||
- 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:
|
||||
|
||||
@@ -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,
|
||||
): boolean => {
|
||||
// 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 =
|
||||
(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
|
||||
private hasPermissionToLoad = (props: IProps): boolean => {
|
||||
// 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 (!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
|
||||
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(
|
||||
<AccessibleButton
|
||||
key="minimise"
|
||||
|
||||
@@ -20,7 +20,7 @@ export const WidgetPermissionCustomisations: WidgetPermissionsCustomisations<Wid
|
||||
requestedCapabilities: Set<Capability>,
|
||||
): Promise<Set<Capability>> => {
|
||||
// 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 new Set(requestedCapabilities);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export class ElementWidgetDriver extends WidgetDriver {
|
||||
this.allowedCapabilities = new Set([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]);
|
||||
|
||||
// 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
|
||||
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.StickerSending);
|
||||
@@ -617,7 +617,7 @@ export class ElementWidgetDriver extends WidgetDriver {
|
||||
|
||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||
// 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({
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
|
||||
|
||||
Reference in New Issue
Block a user