feat: plugins v1
This commit is contained in:
@@ -141,6 +141,20 @@
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||
@for (record of pluginComposerActions(); track record.id) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="runPluginComposerAction(record.contribution.run)"
|
||||
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()"
|
||||
[attr.aria-label]="record.contribution.label"
|
||||
[title]="record.contribution.label"
|
||||
>
|
||||
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (klipyEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { ThemeNodeDirective } from '../../../../../theme';
|
||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
@@ -82,8 +83,10 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
@@ -219,6 +222,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
}
|
||||
|
||||
runPluginComposerAction(action: () => Promise<void> | void): void {
|
||||
void Promise.resolve()
|
||||
.then(() => action());
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
@if (pluginEmbeds().length > 0) {
|
||||
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
|
||||
@for (embed of pluginEmbeds(); track embed.id) {
|
||||
<article class="rounded-md border border-border bg-secondary/30 p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ embed.contribution.embedType }}</span>
|
||||
<span>{{ embed.pluginId }}</span>
|
||||
</div>
|
||||
<app-plugin-render-host [render]="embed.render" />
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (attachmentsList.length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
@for (att of attachmentsList; track att.id) {
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
User
|
||||
} from '../../../../../../shared-kernel';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||
import { PluginUiRegistryService } from '../../../../../plugins';
|
||||
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -98,6 +100,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
ChatMessageMarkdownComponent,
|
||||
ChatLinkEmbedComponent,
|
||||
UserAvatarComponent,
|
||||
PluginRenderHostComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -124,6 +127,7 @@ export class ChatMessageItemComponent {
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
@@ -146,6 +150,7 @@ export class ChatMessageItemComponent {
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
|
||||
readonly isEditing = signal(false);
|
||||
readonly showEmojiPicker = signal(false);
|
||||
readonly senderUser = computed<User>(() => {
|
||||
@@ -191,6 +196,28 @@ export class ChatMessageItemComponent {
|
||||
});
|
||||
});
|
||||
|
||||
private findPluginEmbeds(content: string) {
|
||||
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
|
||||
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
embedType,
|
||||
payloadText
|
||||
] = match;
|
||||
const payload = parseEmbedPayload(payloadText);
|
||||
|
||||
return this.pluginUi.embedRecords()
|
||||
.filter((record) => record.contribution.embedType === embedType)
|
||||
.map((record) => ({
|
||||
...record,
|
||||
render: () => record.contribution.render(payload)
|
||||
}));
|
||||
}
|
||||
|
||||
startEdit(): void {
|
||||
this.editContent = this.message().content;
|
||||
this.isEditing.set(true);
|
||||
@@ -507,3 +534,15 @@ export class ChatMessageItemComponent {
|
||||
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
function parseEmbedPayload(payloadText: string | undefined): unknown {
|
||||
if (!payloadText?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(payloadText) as unknown;
|
||||
} catch {
|
||||
return payloadText;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user