feat: expose more apis

This commit is contained in:
2026-04-29 23:39:09 +02:00
parent fa2cca6fa4
commit 3f92e74350
14 changed files with 297 additions and 23 deletions

View File

@@ -150,4 +150,4 @@ graph LR
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -144,7 +144,7 @@
@for (record of pluginComposerActions(); track record.id) {
<button
type="button"
(click)="runPluginComposerAction(record.contribution.run)"
(click)="runPluginComposerAction(record.contribution)"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"

View File

@@ -23,7 +23,11 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { PluginUiRegistryService } from '../../../../../plugins';
import {
PluginApiActionContribution,
PluginClientApiService,
PluginUiRegistryService
} from '../../../../../plugins';
import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
@@ -83,6 +87,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly pluginApi = inject(PluginClientApiService);
private readonly pluginUi = inject(PluginUiRegistryService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
@@ -222,9 +227,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.klipyGifPickerToggleRequested.emit();
}
runPluginComposerAction(action: () => Promise<void> | void): void {
runPluginComposerAction(action: PluginApiActionContribution): void {
void Promise.resolve()
.then(() => action());
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
}
getKlipyTriggerRect(): DOMRect | null {

View File

@@ -91,7 +91,7 @@
@if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else {
@if (pluginEmbeds().length === 0) {
@if (!pluginEmbedToken()) {
@if (requiresRichMarkdown(msg.content)) {
@defer {
<div class="chat-markdown mt-1 break-words">
@@ -105,6 +105,18 @@
}
}
@if (missingPluginEmbed(); as missingEmbed) {
<article class="mt-2 max-w-lg rounded-md border border-border bg-secondary/30 p-3 text-sm text-muted-foreground">
Required plugin is not installed to view this content, visit the
<button
type="button"
class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)"
>store</button
>.
</article>
}
@if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) {
@if (shouldShowLinkEmbed(meta.url)) {

View File

@@ -12,6 +12,7 @@ import {
signal,
ViewChild
} from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -39,7 +40,7 @@ import {
} from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginUiRegistryService } from '../../../../../plugins';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import {
ChatAudioPlayerComponent,
@@ -88,6 +89,16 @@ interface ChatMessageAttachmentViewModel extends Attachment {
progressPercent: number;
}
interface PluginEmbedToken {
embedType: string;
payloadText: string;
}
interface MissingPluginEmbedFallback {
pluginName: string;
searchTerm: string;
}
@Component({
selector: 'app-chat-message-item',
standalone: true,
@@ -127,8 +138,10 @@ export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>();
@@ -150,7 +163,9 @@ export class ChatMessageItemComponent {
readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {
@@ -196,28 +211,52 @@ export class ChatMessageItemComponent {
});
});
private findPluginEmbeds(content: string) {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
if (!match) {
void this.router.navigate(['/plugin-store'], {
queryParams: {
returnUrl,
search: fallback.searchTerm
}
});
}
private findPluginEmbeds(token: PluginEmbedToken | null) {
if (!token) {
return [];
}
const [
,
embedType,
payloadText
] = match;
const payload = parseEmbedPayload(payloadText);
const payload = parseEmbedPayload(token.payloadText);
return this.pluginUi.embedRecords()
.filter((record) => record.contribution.embedType === embedType)
.filter((record) => record.contribution.embedType === token.embedType)
.map((record) => ({
...record,
render: () => record.contribution.render(payload)
}));
}
private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null {
const token = this.pluginEmbedToken();
if (!token || this.pluginEmbeds().length > 0) {
return null;
}
const missingRequirement = this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType)
?? this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds'))
?? this.pluginRequirements.missingRequiredRequirements()[0];
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
return {
pluginName,
searchTerm: pluginName
};
}
startEdit(): void {
this.editContent = this.message().content;
this.isEditing.set(true);
@@ -535,6 +574,29 @@ export class ChatMessageItemComponent {
}
}
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
if (!match) {
return null;
}
return {
embedType: match[1],
payloadText: match[2]
};
}
function pluginNameFromEmbedType(embedType: string): string {
const parts = embedType.split(/[.:_-]/).filter(Boolean);
const pluginParts = parts.length > 2 ? parts.slice(0, -1) : parts;
const label = pluginParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
.trim();
return label || embedType;
}
function parseEmbedPayload(payloadText: string | undefined): unknown {
if (!payloadText?.trim()) {
return null;

View File

@@ -25,6 +25,7 @@ const MAX_SHOWN = 4;
interface TypingSignalingMessage {
type: string;
displayName: string;
isTyping?: boolean;
oderId: string;
serverId: string;
channelId?: string;
@@ -66,8 +67,14 @@ export class TypingIndicatorComponent {
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim()
: 'general';
const typingKey = `${channelId}:${msg.oderId}`;
this.typingMap.set(`${channelId}:${msg.oderId}`, {
if (msg.isTyping === false) {
this.typingMap.delete(typingKey);
return;
}
this.typingMap.set(typingKey, {
name: msg.displayName,
channelId,
expiresAt: now + TYPING_TTL