feat: Add chat embeds v1
Youtube and Website metadata embeds
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
import { LinkMetadata } from '../../../shared-kernel';
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LinkMetadataService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
extractUrls(content: string): string[] {
|
||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
||||
}
|
||||
|
||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||
try {
|
||||
const apiBase = this.serverDirectory.getApiBaseUrl();
|
||||
const result = await firstValueFrom(
|
||||
this.http.get<Omit<LinkMetadata, 'url'>>(
|
||||
`${apiBase}/link-metadata`,
|
||||
{ params: { url } }
|
||||
)
|
||||
);
|
||||
|
||||
return { url, ...result };
|
||||
} catch {
|
||||
return { url, failed: true };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAllMetadata(urls: string[]): Promise<LinkMetadata[]> {
|
||||
const unique = [...new Set(urls)];
|
||||
|
||||
return Promise.all(unique.map((url) => this.fetchMetadata(url)));
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
(downloadRequested)="handleDownloadRequested($event)"
|
||||
(imageOpened)="handleImageOpened($event)"
|
||||
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent {
|
||||
|
||||
export type ChatMessageReplyEvent = Message;
|
||||
export type ChatMessageDeleteEvent = Message;
|
||||
|
||||
export interface ChatMessageEmbedRemoveEvent {
|
||||
messageId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export interface LinkMetadata {
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
@@ -12,6 +21,7 @@ export interface Message {
|
||||
reactions: Reaction[];
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
linkMetadata?: LinkMetadata[];
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
emptyProps,
|
||||
props
|
||||
} from '@ngrx/store';
|
||||
import { Message, Reaction } from '../../shared-kernel';
|
||||
import {
|
||||
Message,
|
||||
Reaction,
|
||||
LinkMetadata
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export const MessagesActions = createActionGroup({
|
||||
source: 'Messages',
|
||||
@@ -49,6 +53,12 @@ export const MessagesActions = createActionGroup({
|
||||
/** Marks the end of a message sync cycle. */
|
||||
'Sync Complete': emptyProps(),
|
||||
|
||||
/** Attaches fetched link metadata to a message. */
|
||||
'Update Link Metadata': props<{ messageId: string; linkMetadata: LinkMetadata[] }>(),
|
||||
|
||||
/** Removes a single link embed from a message by URL. */
|
||||
'Remove Link Embed': props<{ messageId: string; url: string }>(),
|
||||
|
||||
/** Removes all messages from the store (e.g. when leaving a room). */
|
||||
'Clear Messages': emptyProps()
|
||||
}
|
||||
|
||||
@@ -31,11 +31,13 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { MessagesActions } from './messages.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { selectMessagesEntities } from './messages.selectors';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||
import { DebuggingService } from '../../core/services';
|
||||
import { AttachmentFacade } from '../../domains/attachment';
|
||||
import { LinkMetadataService } from '../../domains/chat/application/link-metadata.service';
|
||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
@@ -56,6 +58,7 @@ export class MessagesEffects {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
|
||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||
loadMessages$ = createEffect(() =>
|
||||
@@ -374,6 +377,76 @@ export class MessagesEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches link metadata for newly sent or received messages that
|
||||
* contain URLs but don't already have metadata attached.
|
||||
*/
|
||||
fetchLinkMetadata$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.sendMessageSuccess, MessagesActions.receiveMessage),
|
||||
mergeMap(({ message }) => {
|
||||
if (message.isDeleted || message.linkMetadata?.length)
|
||||
return EMPTY;
|
||||
|
||||
const urls = this.linkMetadata.extractUrls(message.content);
|
||||
|
||||
if (urls.length === 0)
|
||||
return EMPTY;
|
||||
|
||||
return from(this.linkMetadata.fetchAllMetadata(urls)).pipe(
|
||||
mergeMap((metadata) => {
|
||||
const meaningful = metadata.filter((md) => !md.failed);
|
||||
|
||||
if (meaningful.length === 0)
|
||||
return EMPTY;
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.updateMessage(message.id, { linkMetadata: meaningful }),
|
||||
'Failed to persist link metadata',
|
||||
{ messageId: message.id }
|
||||
);
|
||||
|
||||
return of(MessagesActions.updateLinkMetadata({
|
||||
messageId: message.id,
|
||||
linkMetadata: meaningful
|
||||
}));
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Removes a single link embed from a message, persists the change,
|
||||
* and updates the store.
|
||||
*/
|
||||
removeLinkEmbed$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.removeLinkEmbed),
|
||||
withLatestFrom(this.store.select(selectMessagesEntities)),
|
||||
mergeMap(([{ messageId, url }, entities]) => {
|
||||
const message = entities[messageId];
|
||||
|
||||
if (!message?.linkMetadata)
|
||||
return EMPTY;
|
||||
|
||||
const remaining = message.linkMetadata.filter((meta) => meta.url !== url);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.updateMessage(messageId, { linkMetadata: remaining.length ? remaining : undefined }),
|
||||
'Failed to persist link embed removal',
|
||||
{ messageId }
|
||||
);
|
||||
|
||||
return of(MessagesActions.updateLinkMetadata({
|
||||
messageId,
|
||||
linkMetadata: remaining
|
||||
}));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Central dispatcher for all incoming P2P messages.
|
||||
* Delegates to handler functions in `messages-incoming.handlers.ts`.
|
||||
|
||||
@@ -206,6 +206,17 @@ export const messagesReducer = createReducer(
|
||||
});
|
||||
}),
|
||||
|
||||
// Update link metadata on a message
|
||||
on(MessagesActions.updateLinkMetadata, (state, { messageId, linkMetadata }) =>
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { linkMetadata }
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Clear messages
|
||||
on(MessagesActions.clearMessages, (state) =>
|
||||
messagesAdapter.removeAll({
|
||||
|
||||
Reference in New Issue
Block a user