feat: expose more apis
This commit is contained in:
@@ -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".
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user