feat: Add chat embeds v1

Youtube and Website metadata embeds
This commit is contained in:
2026-04-04 04:47:04 +02:00
parent 35352923a5
commit 84fa45985a
25 changed files with 759 additions and 24 deletions

View File

@@ -16,6 +16,7 @@
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">

View File

@@ -29,6 +29,7 @@ import {
ChatMessageComposerSubmitEvent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
@@ -191,6 +192,15 @@ export class ChatMessagesComponent {
this.composerBottomPadding.set(height + 20);
}
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.store.dispatch(
MessagesActions.removeLinkEmbed({
messageId: event.messageId,
url: event.url
})
);
}
toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker();

View File

@@ -0,0 +1,47 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
@if (metadata(); as meta) {
@if (!meta.failed && (meta.title || meta.description)) {
<div class="group/embed relative mt-2 max-w-[480px] overflow-hidden rounded-md border border-border/60 bg-secondary/20">
@if (canRemove()) {
<button
type="button"
(click)="removed.emit()"
class="absolute right-1.5 top-1.5 z-10 grid h-5 w-5 place-items-center rounded bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/embed:opacity-100"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
}
<div class="flex">
@if (meta.imageUrl) {
<img
[src]="meta.imageUrl"
[alt]="meta.title || 'Link preview'"
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
loading="lazy"
referrerpolicy="no-referrer"
/>
}
<div class="flex min-w-0 flex-1 flex-col gap-0.5 p-3">
@if (meta.siteName) {
<span class="truncate text-xs text-muted-foreground">{{ meta.siteName }}</span>
}
@if (meta.title) {
<a
[href]="meta.url"
target="_blank"
rel="noopener noreferrer"
class="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
>{{ meta.title }}</a
>
}
@if (meta.description) {
<span class="line-clamp-2 text-xs text-muted-foreground">{{ meta.description }}</span>
}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,21 @@
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../shared-kernel';
@Component({
selector: 'app-chat-link-embed',
standalone: true,
imports: [NgIcon],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './chat-link-embed.component.html'
})
export class ChatLinkEmbedComponent {
readonly metadata = input.required<LinkMetadata>();
readonly canRemove = input(false);
readonly removed = output();
}

View File

@@ -89,6 +89,16 @@
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
@if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) {
<app-chat-link-embed
[metadata]="meta"
[canRemove]="isOwnMessage() || isAdmin()"
(removed)="removeEmbed(meta.url)"
/>
}
}
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {

View File

@@ -37,9 +37,11 @@ import {
UserAvatarComponent
} from '../../../../../../shared';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
@@ -85,6 +87,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent
],
viewProviders: [
@@ -127,6 +130,7 @@ export class ChatMessageItemComponent {
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
@@ -235,6 +239,13 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message());
}
removeEmbed(url: string): void {
this.embedRemoved.emit({
messageId: this.message().id,
url
});
}
requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId);
}

View File

@@ -62,6 +62,7 @@
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>
}
}

View File

@@ -18,6 +18,7 @@ import { Message } from '../../../../../../shared-kernel';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
@@ -69,6 +70,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
private readonly PAGE_SIZE = 50;
@@ -299,6 +301,10 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.imageContextMenuRequested.emit(event);
}
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.embedRemoved.emit(event);
}
private resetScrollingState(): void {
this.initialScrollPending = true;
this.stopInitialScrollWatch();

View File

@@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent {
export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message;
export interface ChatMessageEmbedRemoveEvent {
messageId: string;
url: string;
}