feat(matrix): add widget support to Manalink client

Add the ability to view and interact with Matrix widgets in the room
settings panel. Widgets are displayed in a new "Widgets" tab with
collapsible iframes.

- Add RoomWidget type to types.ts
- Add getRoomWidgets() and buildWidgetUrl() methods to store
- Add Widgets tab to RoomSettingsPanel with iframe display
- Handle Matrix variable substitution ($matrix_user_id, $matrix_room_id)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-16 11:16:27 +01:00
parent 78c7383d54
commit 376ba8279d
3 changed files with 132 additions and 1 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import type { RoomWidget } from '$lib/matrix/types';
import {
X,
Users,
@ -11,6 +12,7 @@
Bell,
BellSlash,
CircleNotch,
SquaresFour,
} from '@manacore/shared-icons';
interface Props {
@ -20,15 +22,25 @@
let { open, onClose }: Props = $props();
let activeTab = $state<'members' | 'settings'>('members');
let activeTab = $state<'members' | 'widgets' | 'settings'>('members');
let inviteQuery = $state('');
let searchResults = $state<{ userId: string; displayName?: string; avatarUrl?: string }[]>([]);
let searching = $state(false);
let inviting = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
let expandedWidget = $state<string | null>(null);
let room = $derived(matrixStore.currentSimpleRoom);
let members = $derived(matrixStore.getRoomMembers());
let widgets = $derived(matrixStore.getRoomWidgets());
function getWidgetUrl(widget: RoomWidget): string {
return matrixStore.buildWidgetUrl(widget);
}
function toggleWidget(widgetId: string) {
expandedWidget = expandedWidget === widgetId ? null : widgetId;
}
function handleSearchInput() {
clearTimeout(searchTimeout);
@ -129,6 +141,17 @@
<Users class="mr-1 h-4 w-4" />
Mitglieder
</button>
<button
class="tab flex-1"
class:tab-active={activeTab === 'widgets'}
onclick={() => (activeTab = 'widgets')}
>
<SquaresFour class="mr-1 h-4 w-4" />
Widgets
{#if widgets.length > 0}
<span class="badge badge-sm badge-primary ml-1">{widgets.length}</span>
{/if}
</button>
<button
class="tab flex-1"
class:tab-active={activeTab === 'settings'}
@ -211,6 +234,43 @@
</li>
{/each}
</ul>
{:else if activeTab === 'widgets'}
<!-- Widgets -->
<div class="p-3">
{#if widgets.length === 0}
<div class="text-center py-8 text-base-content/50">
<SquaresFour class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Keine Widgets in diesem Raum</p>
<p class="text-xs mt-1">Bots können Widgets hinzufügen</p>
</div>
{:else}
<div class="space-y-3">
{#each widgets as widget}
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-sm">{widget.name}</h3>
<button class="btn btn-ghost btn-xs" onclick={() => toggleWidget(widget.id)}>
{expandedWidget === widget.id ? 'Schließen' : 'Öffnen'}
</button>
</div>
{#if expandedWidget === widget.id}
<div class="mt-2 -mx-3 -mb-3">
<iframe
src={getWidgetUrl(widget)}
title={widget.name}
class="w-full border-0 rounded-b-2xl bg-base-300"
style="height: 300px;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Settings -->
<div class="space-y-2 p-3">

View file

@ -26,6 +26,7 @@ import type {
CallState as CallStateType,
CallType,
CallDirection,
RoomWidget,
} from './types';
const STORAGE_KEY = 'matrix_credentials';
@ -968,6 +969,60 @@ class MatrixStore {
}));
}
/**
* Get widgets in a room
*/
getRoomWidgets(roomId?: string): RoomWidget[] {
const id = roomId || this._currentRoomId;
if (!this._client || !id) return [];
const room = this._client.getRoom(id);
if (!room) return [];
const widgets: RoomWidget[] = [];
// Get all widget state events (im.vector.modular.widgets is the standard type)
const widgetEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
for (const event of widgetEvents) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (event as any).getContent?.();
if (!content || !content.url) continue; // Skip removed widgets
widgets.push({
id: content.id || (event as any).getStateKey?.() || '',
type: content.type || 'm.custom',
name: content.name || 'Widget',
url: content.url,
creatorUserId: content.creatorUserId || (event as any).getSender?.() || '',
data: content.data,
});
}
return widgets;
}
/**
* Build widget URL with Matrix variable substitution
*/
buildWidgetUrl(widget: RoomWidget, roomId?: string): string {
const id = roomId || this._currentRoomId;
const userId = this._client?.getUserId() || '';
let url = widget.url;
// Substitute Matrix variables
url = url.replace(/\$matrix_user_id/g, encodeURIComponent(userId));
url = url.replace(/\$matrix_room_id/g, encodeURIComponent(id || ''));
url = url.replace(
/\$matrix_display_name/g,
encodeURIComponent(userId.split(':')[0].substring(1))
);
url = url.replace(/\$matrix_avatar_url/g, '');
return url;
}
// ─────────────────────────────────────────────────────────
// Crypto Actions
// ─────────────────────────────────────────────────────────

View file

@ -306,3 +306,19 @@ export interface CallCallbacks {
onCallStateChange?: (call: SimpleCall) => void;
onCallEnded?: (call: SimpleCall, reason?: string) => void;
}
// ─────────────────────────────────────────────────────────
// Widget Types
// ─────────────────────────────────────────────────────────
/**
* Matrix widget info
*/
export interface RoomWidget {
id: string;
type: string;
name: string;
url: string;
creatorUserId: string;
data?: Record<string, unknown>;
}