feat: Add emoji and alot of other fixes

This commit is contained in:
2026-06-05 05:40:18 +02:00
parent ca069e2f61
commit 6865147e8f
72 changed files with 3885 additions and 413 deletions

View File

@@ -44,7 +44,11 @@ export class AttachmentManagerService {
effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase();
void this.persistence.initFromDatabase().then(() => {
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
}
});
}
});
@@ -56,12 +60,14 @@ export class AttachmentManagerService {
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
@@ -110,11 +116,11 @@ export class AttachmentManagerService {
return this.transfer.getAttachmentMetasForMessages(messageIds);
}
registerSyncedAttachments(
async registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
): Promise<void> {
await this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
for (const attachment of attachments) {
@@ -123,20 +129,20 @@ export class AttachmentManagerService {
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestFromAnyPeer(messageId, attachment);
requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFromAnyPeer(messageId, attachment);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
this.transfer.handleFileNotFound(payload);
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestImageFromAnyPeer(messageId, attachment);
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.transfer.requestFile(messageId, attachment);
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
async publishAttachments(
@@ -180,11 +186,66 @@ export class AttachmentManagerService {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
}
private async restoreLocalAttachmentsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
await this.persistence.whenReady();
const messageIds = await this.collectMessageIdsForRoom(roomId);
let hasChanges = false;
for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
}
}
if (hasChanges) {
this.runtimeStore.touch();
}
}
private async collectMessageIdsForRoom(roomId: string): Promise<string[]> {
if (isDirectMessageAttachmentRoomId(roomId)) {
const messageIds: string[] = [];
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
messageIds.push(messageId);
}
}
return messageIds;
}
if (!this.database.isReady()) {
return Array.from(this.runtimeStore.getAttachmentEntries())
.filter(([messageId]) => this.runtimeStore.getMessageRoomId(messageId) === roomId)
.map(([messageId]) => messageId);
}
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
}
return messages.map((message) => message.id);
}
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
await this.restoreLocalAttachmentsForRoom(roomId);
if (isDirectMessageAttachmentRoomId(roomId)) {
await this.requestAutoDownloadsForRuntimeRoom(roomId);
return;
@@ -242,7 +303,7 @@ export class AttachmentManagerService {
if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue;
this.transfer.requestFromAnyPeer(messageId, attachment);
void this.transfer.requestFromAnyPeer(messageId, attachment);
}
}

View File

@@ -7,10 +7,13 @@ import { AttachmentStorageService } from '../../infrastructure/services/attachme
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
private initPromise: Promise<void> | null = null;
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly ngrxStore = inject(Store);
private readonly attachmentStorage = inject(AttachmentStorageService);
@@ -51,11 +54,26 @@ export class AttachmentPersistenceService {
}
}
whenReady(): Promise<void> {
if (this.database.isReady()) {
return this.initFromDatabase();
}
return this.initPromise ?? Promise.resolve();
}
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady())
return;
try {
const storedRecords = await this.database.getAttachmentsForMessage(attachment.messageId);
const storedRecord = storedRecords.find((record) => record.id === attachment.id);
const localPaths = mergeAttachmentLocalPaths(attachment, storedRecord);
attachment.filePath = localPaths.filePath ?? undefined;
attachment.savedPath = localPaths.savedPath ?? undefined;
await this.database.saveAttachment({
id: attachment.id,
messageId: attachment.messageId,
@@ -64,8 +82,8 @@ export class AttachmentPersistenceService {
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: attachment.filePath,
savedPath: attachment.savedPath
filePath: localPaths.filePath ?? undefined,
savedPath: localPaths.savedPath ?? undefined
});
} catch { /* persistence is best-effort */ }
}
@@ -87,9 +105,73 @@ export class AttachmentPersistenceService {
}
async initFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
if (!this.initPromise) {
this.initPromise = this.runInitFromDatabase();
}
return this.initPromise;
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
if (attachment.available) {
return true;
}
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (!diskPath) {
const roomName = await this.resolveStorageContainerName(attachment);
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
if (diskPath) {
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
}
}
if (!diskPath) {
return false;
}
if (await this.restoreMediaAttachmentFromFileUrl(attachment, diskPath)) {
attachment.requestError = undefined;
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
this.restoreAttachmentFromDisk(attachment, base64);
attachment.requestError = undefined;
return true;
}
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
try {
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!diskPath) {
return null;
}
const copied = await this.attachmentStorage.copyFile(sourcePath, diskPath);
if (!copied) {
await this.attachmentStorage.deleteFile(diskPath);
return null;
}
attachment.savedPath = diskPath;
await this.persistAttachmentMeta(attachment);
return diskPath;
} catch {
return null;
}
}
async resolveMessageRoomId(messageId: string): Promise<string | null> {
@@ -173,50 +255,20 @@ export class AttachmentPersistenceService {
} catch { /* migration is best-effort */ }
}
private async runInitFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
private async tryLoadSavedFiles(): Promise<void> {
try {
let hasChanges = false;
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (attachment.available)
continue;
if (attachment.savedPath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
hasChanges = true;
continue;
}
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
this.restoreAttachmentFromDisk(attachment, savedBase64);
hasChanges = true;
continue;
}
}
if (attachment.filePath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
hasChanges = true;
continue;
}
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) {
this.restoreAttachmentFromDisk(attachment, originalBase64);
hasChanges = true;
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
const response = await fetch(attachment.objectUrl);
void this.saveFileToDisk(attachment, await response.blob());
}
continue;
}
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
}
}

View File

@@ -1,16 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR
NO_CONNECTED_PEERS_REQUEST_ERROR,
UPLOADER_LOCAL_FILE_MISSING_ERROR
} from '../../domain/constants/attachment-transfer.constants';
import {
type FileAnnounceEvent,
@@ -46,6 +50,7 @@ interface ValidFileChunkPayload {
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly ngrxStore = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly attachmentStorage = inject(AttachmentStorageService);
@@ -79,10 +84,12 @@ export class AttachmentTransferService {
return result;
}
registerSyncedAttachments(
async registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
): Promise<void> {
await this.persistence.whenReady();
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.runtimeStore.rememberMessageRoom(messageId, roomId);
@@ -119,12 +126,28 @@ export class AttachmentTransferService {
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
const clearedRequestError = this.clearAttachmentRequestError(attachment);
if (!attachment.available) {
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (restoredLocally) {
this.runtimeStore.touch();
return;
}
}
const connectedPeers = this.webrtc.getConnectedPeers();
const currentUserId = await this.resolveCurrentUserId();
const isUploader = !!attachment.uploaderPeerId &&
!!currentUserId &&
attachment.uploaderPeerId === currentUserId;
if (connectedPeers.length === 0) {
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
attachment.requestError = isUploader
? UPLOADER_LOCAL_FILE_MISSING_ERROR
: NO_CONNECTED_PEERS_REQUEST_ERROR;
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
@@ -157,12 +180,12 @@ export class AttachmentTransferService {
}
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.requestFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.requestFromAnyPeer(messageId, attachment);
}
hasPendingRequest(messageId: string, fileId: string): boolean {
@@ -209,6 +232,36 @@ export class AttachmentTransferService {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
} else if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
)) {
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath!);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
} else if (
this.isPlayableMedia(attachment) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES &&
this.attachmentStorage.canWriteFiles()
) {
const savedPath = await this.persistence.saveFileToDisk(attachment, file);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
}
const fileAnnounceEvent: FileAnnounceEvent = {
@@ -471,6 +524,15 @@ export class AttachmentTransferService {
);
}
private resolveCurrentUserId(): Promise<string | null> {
return new Promise<string | null>((resolve) => {
this.ngrxStore
.select(selectCurrentUserId)
.pipe(take(1))
.subscribe((userId) => resolve(userId));
});
}
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
return `${messageId}:${fileId}:${peerId}`;
}

View File

@@ -18,3 +18,7 @@ export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are availabl
/** User-facing error when connected peers cannot provide a requested file. */
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
/** User-facing error when the uploader's local copy cannot be restored. */
export const UPLOADER_LOCAL_FILE_MISSING_ERROR =
'Your original upload could not be found on this device. Re-upload the file to restore playback.';

View File

@@ -0,0 +1,33 @@
import { mergeAttachmentLocalPaths } from './attachment-persistence.rules';
describe('attachment persistence rules', () => {
it('keeps incoming local paths when they are set', () => {
expect(mergeAttachmentLocalPaths(
{ filePath: '/tmp/new.mp4', savedPath: '/data/saved.mp4' },
{ filePath: '/tmp/old.mp4', savedPath: '/data/old.mp4' }
)).toEqual({
filePath: '/tmp/new.mp4',
savedPath: '/data/saved.mp4'
});
});
it('preserves stored local paths when incoming sync metadata omits them', () => {
expect(mergeAttachmentLocalPaths(
{},
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
)).toEqual({
filePath: '/home/ludde/video.mp4',
savedPath: '/appdata/video.mp4'
});
});
it('does not overwrite a stored path with an explicit null', () => {
expect(mergeAttachmentLocalPaths(
{ filePath: null, savedPath: null },
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
)).toEqual({
filePath: '/home/ludde/video.mp4',
savedPath: '/appdata/video.mp4'
});
});
});

View File

@@ -0,0 +1,26 @@
export interface AttachmentLocalPaths {
filePath?: string | null;
savedPath?: string | null;
}
export function mergeAttachmentLocalPaths(
incoming: AttachmentLocalPaths,
stored?: AttachmentLocalPaths | null
): Required<AttachmentLocalPaths> {
const resolvePath = (incomingPath: string | null | undefined, storedPath: string | null | undefined): string | null => {
if (typeof incomingPath === 'string' && incomingPath.trim()) {
return incomingPath;
}
if (typeof storedPath === 'string' && storedPath.trim()) {
return storedPath;
}
return null;
};
return {
filePath: resolvePath(incoming.filePath, stored?.filePath),
savedPath: resolvePath(incoming.savedPath, stored?.savedPath)
};
}

View File

@@ -1,4 +1,8 @@
import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId } from './attachment.logic';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData
} from './attachment.logic';
describe('attachment logic', () => {
it('extracts watched server room ids from room URLs', () => {
@@ -21,4 +25,23 @@ describe('attachment logic', () => {
expect(isDirectMessageAttachmentRoomId('room-1')).toBe(false);
expect(isDirectMessageAttachmentRoomId(null)).toBe(false);
});
it('copies large uploader media into app data when a source path exists', () => {
expect(shouldCopyUploaderMediaToAppData({
size: 64 * 1024 * 1024,
mime: 'video/mp4'
}, '/home/ludde/video.mp4', true)).toBe(true);
});
it('skips app-data copy for small uploads and missing source paths', () => {
expect(shouldCopyUploaderMediaToAppData({
size: 1024,
mime: 'video/mp4'
}, '/home/ludde/video.mp4', true)).toBe(false);
expect(shouldCopyUploaderMediaToAppData({
size: 64 * 1024 * 1024,
mime: 'video/mp4'
}, undefined, true)).toBe(false);
});
});

View File

@@ -22,6 +22,17 @@ export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, '
attachment.mime.startsWith('audio/');
}
export function shouldCopyUploaderMediaToAppData(
attachment: Pick<Attachment, 'size' | 'mime'>,
sourcePath?: string | null,
canCopyFiles = false
): boolean {
return canCopyFiles &&
!!sourcePath &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
export function getWatchedAttachmentRoomIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const directMessageMatch = path.match(DIRECT_MESSAGE_URL_PATTERN);

View File

@@ -0,0 +1,27 @@
import { annotateLocalFilePath, resolveLocalFilePath } from './local-file-path.rules';
describe('local file path rules', () => {
it('prefers an existing path property on the file', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
Object.defineProperty(file, 'path', { value: '/tmp/clip.mp4' });
expect(resolveLocalFilePath(file)).toBe('/tmp/clip.mp4');
});
it('resolves drag-and-drop files through Electron getPathForFile', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
expect(resolveLocalFilePath(file, {
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
})).toBe('/home/ludde/Videos/clip.mp4');
});
it('annotates a file object with the resolved path', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
const annotated = annotateLocalFilePath(file, {
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
});
expect(resolveLocalFilePath(annotated)).toBe('/home/ludde/Videos/clip.mp4');
});
});

View File

@@ -0,0 +1,54 @@
export type LocalFilePathResolver = (file: File) => string;
type LocalFileWithPath = File & {
path?: string;
};
export function resolveLocalFilePath(
file: File,
options?: {
getPathForFile?: LocalFilePathResolver;
}
): string | undefined {
const existingPath = (file as LocalFileWithPath).path?.trim();
if (existingPath) {
return existingPath;
}
if (!options?.getPathForFile) {
return undefined;
}
try {
const resolvedPath = options.getPathForFile(file).trim();
return resolvedPath || undefined;
} catch {
return undefined;
}
}
export function annotateLocalFilePath(
file: File,
options?: {
getPathForFile?: LocalFilePathResolver;
}
): File {
const resolvedPath = resolveLocalFilePath(file, options);
if (!resolvedPath) {
return file;
}
try {
Object.defineProperty(file, 'path', {
configurable: true,
value: resolvedPath
});
} catch {
(file as LocalFileWithPath).path = resolvedPath;
}
return file;
}

View File

@@ -1,3 +1,4 @@
export * from './application/facades/attachment.facade';
export * from './domain/constants/attachment.constants';
export * from './domain/logic/local-file-path.rules';
export * from './domain/models/attachment.model';

View File

@@ -25,10 +25,46 @@ export class AttachmentStorageService {
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
}
canCopyFiles(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.copyFile && !!electronApi.ensureDir && !!electronApi.getAppDataPath;
}
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
return this.findExistingPath([attachment.savedPath, attachment.filePath]);
}
async resolveCanonicalStoredPath(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> {
const appDataPath = await this.resolveAppDataPath();
if (!appDataPath) {
return null;
}
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
return this.findExistingPath([diskPath]);
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
return false;
}
try {
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
} catch {
return false;
}
}
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {

View File

@@ -1,6 +1,6 @@
# Chat Domain
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
Text messaging, reactions, custom emoji, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
## Module map
@@ -19,7 +19,7 @@ chat/
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
│ │ ├── chat-messages.component.ts Root component: replies, emoji/GIF picker, reactions, drag-drop
│ │ ├── components/
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
@@ -39,7 +39,7 @@ chat/
## Component composition
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting emoji/GIF content.
```mermaid
graph TD
@@ -138,6 +138,12 @@ graph LR
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
```
## Custom emoji
The chat domain consumes the custom emoji picker from `domains/custom-emoji`. Message action rows show seven shortcuts ranked by the current user's local usage, followed by an eighth button that opens the full selector for reactions. The composer has a muted emoji-only button beside GIF; hovering it randomizes the displayed Unicode face and selecting an entry inserts that emoji into the draft.
Custom image emoji are stored locally through `DatabaseService` and sync peer-to-peer with `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, and `custom-emoji-chunk` data-channel events. Uploads use the same image types as profile avatars (`.webp`, `.gif`, `.jpg`, `.jpeg`) and are capped at 1 MB. The composer inserts saved custom emoji as readable inline aliases such as `:party:`, so they can sit in the middle of text like `This is :party: cool`; sending rewrites known aliases to stable `:emoji[id](name)` tokens and proactively pushes the referenced assets to connected peers alongside the outgoing message, edit, or reaction. Rendering resolves stable tokens against synced known assets and shows a sized placeholder image until the asset arrives; deferred markdown placeholders use readable `:name:` aliases instead of raw tokens. A repair request is still sent if a token is seen without a local asset. Seen remote emoji do not enter the picker automatically; right-click a custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the context menu. Right-click a saved custom emoji inside the picker to remove it from the local library. The full picker includes search that filters Unicode emoji by common terms and saved custom emoji by name.
## Domain rules
| Function | Purpose |

View File

@@ -32,6 +32,7 @@
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
[currentUserId]="currentUser()?.id ?? null"
[klipyEnabled]="klipyEnabled()"
[klipySignalSource]="currentRoom()"
(messageSubmitted)="handleMessageSubmitted($event)"

View File

@@ -177,6 +177,32 @@
</button>
}
<div class="relative">
<button
type="button"
(click)="$event.stopPropagation(); toggleEmojiPicker()"
(mouseenter)="randomizeEmojiButton()"
class="inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border border-border/70 bg-secondary/35 px-3 text-lg grayscale transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:grayscale-0"
[class.opacity-100]="inputHovered() || showEmojiPicker()"
[class.opacity-60]="!inputHovered() && !showEmojiPicker()"
[class.grayscale-0]="showEmojiPicker()"
aria-label="Open emoji selector"
title="Open emoji selector"
>
{{ emojiButton() }}
</button>
@if (showEmojiPicker()) {
<div class="absolute bottom-full right-0 z-20 mb-2">
<app-custom-emoji-picker
[currentUserId]="currentUserId()"
(emojiSelected)="insertEmoji($event)"
(dismissed)="closeEmojiPicker()"
/>
</div>
}
</div>
<button
appThemeNode="chatComposerSendButton"
type="button"
@@ -213,8 +239,8 @@
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipyEnabled()"
[class.pr-40]="klipyEnabled()"
[class.pr-28]="!klipyEnabled()"
[class.pr-52]="klipyEnabled()"
></textarea>
@if (dragActive()) {

View File

@@ -34,6 +34,13 @@ import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallb
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model';
import {
CustomEmojiPickerComponent,
CustomEmojiService,
buildCustomEmojiTextAlias,
replaceCustomEmojiTextAliases
} from '../../../../../custom-emoji';
import { annotateLocalFilePath } from '../../../../../attachment';
type LocalFileWithPath = File & {
path?: string;
@@ -49,6 +56,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective,
CustomEmojiPickerComponent,
TypingIndicatorComponent,
ThemeNodeDirective
],
@@ -74,6 +82,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly currentUserId = input<string | null>(null);
readonly klipyEnabled = input(false);
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
readonly textareaTestId = input<string | null>(null);
@@ -89,8 +98,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly pluginApi = inject(PluginClientApiService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly customEmoji = inject(CustomEmojiService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly showEmojiPicker = signal(false);
readonly emojiButton = signal('🙂');
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
@@ -227,6 +239,46 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.klipyGifPickerToggleRequested.emit();
}
toggleEmojiPicker(): void {
this.showEmojiPicker.update((open) => !open);
}
closeEmojiPicker(): void {
this.showEmojiPicker.set(false);
}
randomizeEmojiButton(): void {
const emojis = [
'🙂',
'😄',
'🔥',
'🎉',
'✨',
'😎',
'😂',
'👀'
];
const next = emojis[Math.floor(Math.random() * emojis.length)] ?? '🙂';
this.emojiButton.set(next);
}
insertEmoji(emoji: string): void {
const customEmojiId = emoji.match(/^:emoji\[([^\]]+)]\(/)?.[1];
if (customEmojiId) {
const selectedEmoji = this.customEmoji.findEmoji(customEmojiId);
if (selectedEmoji) {
this.insertTextAtSelection(buildCustomEmojiTextAlias(selectedEmoji));
}
return;
}
this.insertTextAtSelection(emoji);
}
runPluginComposerAction(action: PluginApiActionContribution): void {
void Promise.resolve()
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
@@ -418,7 +470,10 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
if (files.length === 0)
return;
const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files);
const mergedFiles = this.mergeUniqueFiles(
this.pendingFiles,
files.map((file) => this.annotateDroppedFilePath(file))
);
if (mergedFiles.length === this.pendingFiles.length)
return;
@@ -546,6 +601,14 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
return ((file as LocalFileWithPath).path || '').trim();
}
private annotateDroppedFilePath(file: File): File {
const electronApi = this.electronBridge.getApi();
return annotateLocalFilePath(file, {
getPathForFile: electronApi?.getPathForFile
});
}
private async extractPastedFiles(event: ClipboardEvent): Promise<File[]> {
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
@@ -576,18 +639,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
type: payload.mime
});
if (payload.path) {
try {
Object.defineProperty(file, 'path', {
configurable: true,
value: payload.path
});
} catch {
(file as LocalFileWithPath).path = payload.path;
}
}
return file;
return annotateLocalFilePath(file, {
getPathForFile: payload.path
? () => payload.path!
: this.electronBridge.getApi()?.getPathForFile
});
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
@@ -602,7 +658,8 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
private buildOutgoingMessageContent(raw: string): string {
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
const rawWithCustomEmoji = replaceCustomEmojiTextAliases(raw, (name) => this.customEmoji.findEmojiByName(name));
const withEmbeddedImages = this.markdown.appendImageMarkdown(rawWithCustomEmoji);
const gif = this.pendingKlipyGif();
if (!gif)
@@ -613,6 +670,21 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
}
private insertTextAtSelection(value: string): void {
const selection = this.getSelection();
const prefix = this.messageContent.slice(0, selection.start);
const suffix = this.messageContent.slice(selection.end);
const needsLeadingSpace = prefix.length > 0 && !/\s$/.test(prefix);
const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix);
const insertText = `${needsLeadingSpace ? ' ' : ''}${value}${needsTrailingSpace ? ' ' : ''}`;
const nextCaret = prefix.length + insertText.length;
this.messageContent = `${prefix}${insertText}${suffix}`;
this.showEmojiPicker.set(false);
this.setSelection(nextCaret, nextCaret);
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
private buildKlipyGifMarkdown(gif: KlipyGif): string {
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}

View File

@@ -55,7 +55,7 @@
/>
@if (reply) {
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : reply.content }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : formatMessagePreview(reply.content) }}</span>
} @else {
<span class="italic">Original message not found</span>
}
@@ -117,7 +117,7 @@
<app-chat-message-markdown [content]="msg.content" />
</div>
} @placeholder {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ formatMessagePreview(msg.content) }}</div>
}
} @else {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
@@ -476,11 +476,21 @@
<button
appThemeNode="chatReactionPill"
(click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
class="flex items-center gap-1.5 rounded-md bg-secondary px-2.5 py-1 text-sm transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
[attr.data-custom-emoji]="isCustomEmojiToken(reaction.emoji) ? '' : null"
[attr.data-custom-emoji-id]="reactionCustomEmojiId(reaction.emoji)"
>
<span>{{ reaction.emoji }}</span>
@if (isCustomEmojiToken(reaction.emoji) && customEmojiUrl(reaction.emoji); as reactionEmojiUrl) {
<img
[src]="reactionEmojiUrl"
alt="Custom emoji"
class="h-6 w-6 object-contain"
/>
} @else {
<span class="flex h-6 w-6 items-center justify-center text-[1.5rem] leading-none">{{ reaction.emoji }}</span>
}
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
@@ -495,7 +505,7 @@
>
<div class="relative">
<button
(click)="toggleEmojiPicker()"
(click)="$event.stopPropagation(); toggleEmojiPicker()"
class="grid h-8 w-8 place-items-center rounded-l-lg transition-colors hover:bg-secondary"
>
<ng-icon
@@ -505,15 +515,12 @@
</button>
@if (showEmojiPicker()) {
<div class="absolute bottom-full right-0 z-10 mb-2 flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(emoji)"
class="rounded p-1 text-lg transition-colors hover:bg-secondary"
>
{{ emoji }}
</button>
}
<div class="absolute bottom-full right-0 z-10 mb-2">
<app-custom-emoji-picker
[currentUserId]="currentUserId()"
(emojiSelected)="addReaction($event)"
(dismissed)="closeEmojiPicker()"
/>
</div>
}
</div>
@@ -564,13 +571,21 @@
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (emoji of commonEmojis; track emoji) {
@for (entry of emojiShortcuts(); track entry.key) {
<button
type="button"
class="rounded p-1 text-xl transition-colors hover:bg-secondary"
(click)="onMobileReact(emoji)"
class="grid h-8 w-8 place-items-center rounded p-1 text-xl transition-colors hover:bg-secondary"
(click)="entry.kind === 'custom' ? onMobileReact(':emoji[' + entry.emoji.id + '](' + entry.emoji.name + ')') : onMobileReact(entry.emoji)"
>
{{ emoji }}
@if (entry.kind === 'custom') {
<img
[src]="entry.emoji.dataUrl"
[alt]="entry.emoji.name"
class="h-6 w-6 object-contain"
/>
} @else {
<span>{{ entry.emoji }}</span>
}
</button>
}
</div>
@@ -632,5 +647,6 @@
</div>
</app-bottom-sheet>
</ng-template>
</div>
}

View File

@@ -162,6 +162,17 @@
border-radius: var(--radius);
}
img.chat-custom-emoji-image {
border-radius: 0;
}
.chat-inline-emoji-large {
display: inline-block;
font-size: 46px;
line-height: 1;
vertical-align: text-bottom;
}
p + p {
margin-top: 0.25em;
}

View File

@@ -55,6 +55,13 @@ import {
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import {
CustomEmojiPickerComponent,
CustomEmojiService,
extractCustomEmojiIds,
isSingleUnicodeEmojiOnlyMessage,
replaceCustomEmojiTokensForPreview
} from '../../../../../custom-emoji';
import {
BottomSheetComponent,
@@ -74,16 +81,6 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
const RICH_MARKDOWN_PATTERNS = [
/(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m,
/!\[[^\]]*\]\([^)]+\)/,
@@ -128,6 +125,7 @@ interface MissingPluginEmbedFallback {
ChatVideoPlayerComponent,
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
CustomEmojiPickerComponent,
UserAvatarComponent,
PluginRenderHostComponent,
ExperimentalVlcPlayerComponent,
@@ -165,6 +163,7 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly customEmoji = inject(CustomEmojiService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
@@ -198,7 +197,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly commonEmojis = COMMON_EMOJIS;
readonly emojiShortcuts = this.customEmoji.shortcutEntries;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
@@ -354,6 +353,10 @@ export class ChatMessageItemComponent implements OnDestroy {
this.showEmojiPicker.update((current) => !current);
}
closeEmojiPicker(): void {
this.showEmojiPicker.set(false);
}
addReaction(emoji: string): void {
this.reactionAdded.emit({
messageId: this.message().id,
@@ -461,6 +464,24 @@ export class ChatMessageItemComponent implements OnDestroy {
this.closeMobileActions();
}
isCustomEmojiToken(emoji: string): boolean {
return /^:emoji\[[^\]]+]\([^)]+\)$/.test(emoji);
}
customEmojiUrl(emoji: string): string | null {
const id = emoji.match(/^:emoji\[([^\]]+)]\(/)?.[1];
if (!id) {
return null;
}
return this.customEmoji.findEmoji(id)?.dataUrl ?? null;
}
reactionCustomEmojiId(emojiToken: string): string | null {
return extractCustomEmojiIds(emojiToken)[0] ?? null;
}
onMobileReply(): void {
this.requestReply();
this.closeMobileActions();
@@ -560,7 +581,12 @@ export class ChatMessageItemComponent implements OnDestroy {
}
requiresRichMarkdown(content: string): boolean {
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
return isSingleUnicodeEmojiOnlyMessage(content)
|| RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
}
formatMessagePreview(content: string): string {
return replaceCustomEmojiTokensForPreview(content);
}
formatBytes(bytes: number): string {

View File

@@ -1,5 +1,5 @@
<remark
[markdown]="content()"
[markdown]="displayContent()"
[processor]="$any(remarkProcessor)"
>
<ng-template
@@ -16,11 +16,34 @@
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<div
class="relative inline-block overflow-hidden"
[attr.data-custom-emoji]="isCustomEmojiMarkdownImage(node.alt) ? '' : null"
[attr.data-custom-emoji-id]="getCustomEmojiId(node.alt)"
[class.rounded-md]="!isCustomEmojiDataUrl(node.url)"
[class.align-text-bottom]="isCustomEmojiDataUrl(node.url)"
[class.mt-2]="!isCustomEmojiDataUrl(node.url)"
[class.border]="!isCustomEmojiDataUrl(node.url)"
[class.border-border\/60]="!isCustomEmojiDataUrl(node.url)"
[class.bg-secondary\/20]="!isCustomEmojiDataUrl(node.url)"
>
<img
[appChatImageProxyFallback]="node.url"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
[alt]="getCustomEmojiAlt(node.alt) || 'Shared image'"
class="block object-contain"
[class.chat-custom-emoji-image]="isCustomEmojiDataUrl(node.url)"
[style.height]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
[style.width]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
[style.max-height]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
[style.max-width]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
[class.inline]="isCustomEmojiDataUrl(node.url)"
[class.h-[1.2em]]="isCustomEmojiDataUrl(node.url) && !largeCustomEmoji()"
[class.w-[1.2em]]="isCustomEmojiDataUrl(node.url) && !largeCustomEmoji()"
[class.h-[46px]]="shouldRenderLargeCustomEmoji(node.url)"
[class.w-[46px]]="shouldRenderLargeCustomEmoji(node.url)"
[class.max-h-80]="!isCustomEmojiDataUrl(node.url)"
[class.max-w-full]="!isCustomEmojiDataUrl(node.url)"
[class.w-auto]="!isCustomEmojiDataUrl(node.url)"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
@@ -32,6 +55,18 @@
}
</div>
</ng-template>
<ng-template
[remarkTemplate]="'text'"
let-node
>
@for (segment of splitTextIntoEmojiSegments(node.value); track $index) {
@if (segment.kind === 'emoji') {
<span [class.chat-inline-emoji-large]="largeUnicodeEmoji()">{{ segment.value }}</span>
} @else {
<span>{{ segment.value }}</span>
}
}
</ng-template>
<ng-template
[remarkTemplate]="'link'"
let-node

View File

@@ -0,0 +1,31 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { CustomEmojiService } from '../../../../../../custom-emoji';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
describe('ChatMessageMarkdownComponent', () => {
function createComponent(): ChatMessageMarkdownComponent {
const injector = Injector.create({
providers: [
ChatMessageMarkdownComponent,
{
provide: CustomEmojiService,
useValue: {
findEmoji: vi.fn(),
emojis: vi.fn(() => [])
}
}
]
});
return runInInjectionContext(injector, () => injector.get(ChatMessageMarkdownComponent));
}
it('marks custom emoji hosts with ids for the global context menu', () => {
const component = createComponent();
expect(component.isCustomEmojiMarkdownImage('custom-emoji:party-id:party')).toBe(true);
expect(component.getCustomEmojiId('custom-emoji:party-id:party')).toBe('party-id');
expect(component.isCustomEmojiMarkdownImage('Shared image')).toBe(false);
expect(component.getCustomEmojiId('Shared image')).toBeNull();
});
});

View File

@@ -1,5 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
@@ -14,6 +20,16 @@ import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fa
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
import { ChatYoutubeEmbedComponent } from '../chat-youtube-embed/chat-youtube-embed.component';
import {
CustomEmojiService,
getCustomEmojiIdFromMarkdownAlt,
getCustomEmojiLabelFromMarkdownAlt,
isCustomEmojiMarkdownAlt,
isCustomEmojiOnlyMessage,
isSingleUnicodeEmojiOnlyMessage,
replaceCustomEmojiMessageTokens,
splitTextIntoEmojiSegments
} from '../../../../../../custom-emoji';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
@@ -54,8 +70,30 @@ const REMARK_PROCESSOR = unified()
templateUrl: './chat-message-markdown.component.html'
})
export class ChatMessageMarkdownComponent {
private readonly customEmoji = inject(CustomEmojiService);
readonly content = input.required<string>();
readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id)));
readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content()));
readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content()));
readonly remarkProcessor = REMARK_PROCESSOR;
readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments;
shouldRenderLargeCustomEmoji(url?: string): boolean {
return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji();
}
getCustomEmojiAlt(alt?: string): string {
return getCustomEmojiLabelFromMarkdownAlt(alt);
}
isCustomEmojiMarkdownImage(alt?: string): boolean {
return isCustomEmojiMarkdownAlt(alt);
}
getCustomEmojiId(alt?: string): string | null {
return getCustomEmojiIdFromMarkdownAlt(alt);
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
@@ -68,6 +106,10 @@ export class ChatMessageMarkdownComponent {
return KLIPY_MEDIA_URL_PATTERN.test(url);
}
isCustomEmojiDataUrl(url?: string): boolean {
return !!url && /^data:image\//i.test(url);
}
isYoutubeUrl(url?: string): boolean {
return isYoutubeUrl(url);
}

View File

@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
EMPTY,
from,
merge,
Observable
} from 'rxjs';
import {
distinctUntilChanged,
map,
mergeMap
} from 'rxjs/operators';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import {
ChatEvent,
CustomEmoji,
CustomEmojiSummaryItem,
CustomEmojiTransferManifest
} from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { CustomEmojiService } from './custom-emoji.service';
@Injectable()
export class CustomEmojiSyncEffects {
private readonly customEmoji = inject(CustomEmojiService);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly incomingEvents$ = merge(
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
);
currentUserLoad$ = createEffect(
() => this.store.select(selectCurrentUser).pipe(
map((user) => user?.id ?? null),
distinctUntilChanged(),
mergeMap((userId) => from(this.customEmoji.loadForUser(userId)).pipe(mergeMap(() => EMPTY)))
),
{ dispatch: false }
);
peerConnectedSummary$ = createEffect(
() => this.webrtc.onPeerConnected.pipe(
mergeMap((peerId) => from(this.customEmoji.sendSummaryToPeer(peerId)).pipe(mergeMap(() => EMPTY)))
),
{ dispatch: false }
);
incomingCustomEmojiEvents$ = createEffect(
() => this.incomingEvents$.pipe(
mergeMap((event) => {
if (event.type === 'message' || event.type === 'chat-message') {
if (!event.fromPeerId || typeof event.message?.content !== 'string') {
return EMPTY;
}
return from(this.customEmoji.requestMissingFromMessageContent(event.fromPeerId, event.message.content)).pipe(mergeMap(() => EMPTY));
}
if (event.type === 'chat-sync-batch' || event.type === 'chat-sync-full') {
if (!event.fromPeerId || !Array.isArray(event.messages)) {
return EMPTY;
}
return from(Promise.all(event.messages.map((message) => this.customEmoji.requestMissingFromMessageContent(
event.fromPeerId as string,
message.content
)))).pipe(mergeMap(() => EMPTY));
}
switch (event.type) {
case 'custom-emoji-summary': {
if (!event.fromPeerId || !Array.isArray(event.customEmojiSummaries)) {
return EMPTY;
}
const ids = this.customEmoji.missingFromSummary(event.customEmojiSummaries as CustomEmojiSummaryItem[]);
if (ids.length === 0) {
return EMPTY;
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'custom-emoji-request',
ids
});
return EMPTY;
}
case 'custom-emoji-request':
if (!event.fromPeerId || !Array.isArray(event.ids)) {
return EMPTY;
}
return from(this.customEmoji.sendRequestedToPeer(event.fromPeerId, event.ids)).pipe(mergeMap(() => EMPTY));
case 'custom-emoji-full':
if (event.customEmoji) {
return from(this.customEmoji.saveRemoteEmoji(event.customEmoji as CustomEmoji)).pipe(mergeMap(() => EMPTY));
}
if (!event.customEmojiTransfer || typeof event.total !== 'number') {
return EMPTY;
}
this.customEmoji.receiveTransferStart(event.customEmojiTransfer as CustomEmojiTransferManifest, event.total);
return EMPTY;
case 'custom-emoji-chunk':
if (!event.customEmojiId || typeof event.index !== 'number' || typeof event.total !== 'number' || typeof event.data !== 'string') {
return EMPTY;
}
return from(this.customEmoji.receiveTransferChunk(event.customEmojiId, event.index, event.total, event.data)).pipe(mergeMap(() => EMPTY));
default:
return EMPTY;
}
})
),
{ dispatch: false }
);
}

View File

@@ -0,0 +1,271 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { Subject } from 'rxjs';
import { DatabaseService } from '../../../infrastructure/persistence';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { CustomEmoji } from '../../../shared-kernel';
import { CustomEmojiService } from './custom-emoji.service';
function customEmoji(overrides: Partial<CustomEmoji> = {}): CustomEmoji {
const dataUrl = overrides.dataUrl ?? 'data:image/webp;base64,YWJjZA==';
return {
id: 'emoji-1',
name: 'party',
creatorUserId: 'user-1',
dataUrl,
hash: overrides.hash ?? '',
mime: 'image/webp',
size: 4,
createdAt: 100,
updatedAt: 100,
...overrides
};
}
async function hashText(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
describe('CustomEmojiService', () => {
let db: Pick<DatabaseService, 'getCustomEmojis' | 'saveCustomEmoji' | 'deleteCustomEmoji'>;
let webrtc: Pick<RealtimeSessionFacade, 'getConnectedPeers' | 'sendToPeer' | 'sendToPeerBuffered' | 'onPeerConnected' | 'onMessageReceived'>;
beforeEach(() => {
db = {
getCustomEmojis: vi.fn(async () => []),
saveCustomEmoji: vi.fn(async () => undefined),
deleteCustomEmoji: vi.fn(async () => undefined)
};
webrtc = {
getConnectedPeers: vi.fn(() => []),
sendToPeer: vi.fn(),
sendToPeerBuffered: vi.fn(async () => undefined),
onPeerConnected: new Subject<string>(),
onMessageReceived: new Subject()
};
});
function createService(): CustomEmojiService {
const injector = Injector.create({
providers: [
CustomEmojiService,
{ provide: DatabaseService, useValue: db },
{ provide: RealtimeSessionFacade, useValue: webrtc }
]
});
return runInInjectionContext(injector, () => injector.get(CustomEmojiService));
}
it('does not persist a stale remote emoji over a newer local emoji', async () => {
const dataUrl = 'data:image/webp;base64,YWJjZA==';
const hash = await hashText(dataUrl);
const local = customEmoji({ dataUrl,
hash,
updatedAt: 200 });
const staleRemote = customEmoji({ dataUrl,
hash,
updatedAt: 100 });
vi.mocked(db.getCustomEmojis).mockResolvedValueOnce([local]);
const service = createService();
await service.loadForUser('user-1');
await service.saveRemoteEmoji(staleRemote);
expect(db.saveCustomEmoji).not.toHaveBeenCalled();
expect(service.findEmoji('emoji-1')).toBe(local);
});
it('does not persist a completed transfer when its data does not match the manifest', async () => {
const service = createService();
const goodDataUrl = 'data:image/webp;base64,QUJDRA==';
const manifest = customEmoji({
dataUrl: goodDataUrl,
hash: await hashText(goodDataUrl),
size: 4
});
const { dataUrl, ...transferManifest } = manifest;
service.receiveTransferStart(transferManifest, 1);
await service.receiveTransferChunk(manifest.id, 0, 1, 'QUJD');
expect(db.saveCustomEmoji).not.toHaveBeenCalled();
expect(service.findEmoji(manifest.id)).toBeUndefined();
});
it('drops invalid locally stored emoji before advertising them to peers', async () => {
const service = createService();
const corruptEmoji = customEmoji({
dataUrl: 'data:image/webp;base64,QUJD',
hash: await hashText('data:image/webp;base64,QUJDRA=='),
size: 4
});
vi.mocked(db.getCustomEmojis).mockResolvedValueOnce([corruptEmoji]);
await service.loadForUser('user-1');
expect(service.findEmoji(corruptEmoji.id)).toBeUndefined();
expect(db.deleteCustomEmoji).toHaveBeenCalledWith(corruptEmoji.id);
expect(service.buildSummary()).toEqual([]);
});
it('removes a saved emoji from the library while keeping it available for rendering', async () => {
const service = createService();
const dataUrl = 'data:image/webp;base64,QUJDRA==';
const savedEmoji = customEmoji({
dataUrl,
hash: await hashText(dataUrl),
size: 4,
savedByUser: true
});
await service.saveRemoteEmoji(savedEmoji);
expect(service.isEmojiInLibrary(savedEmoji.id)).toBe(true);
await service.removeEmojiFromLibrary(savedEmoji.id);
expect(service.isEmojiInLibrary(savedEmoji.id)).toBe(false);
expect(service.findEmoji(savedEmoji.id)).toEqual(expect.objectContaining({ id: savedEmoji.id }));
expect(service.emojis()).toEqual([]);
expect(db.saveCustomEmoji).toHaveBeenLastCalledWith(expect.objectContaining({
id: savedEmoji.id,
savedByUser: false
}));
});
it('keeps synced remote emoji out of the library until the user saves them', async () => {
const service = createService();
const dataUrl = 'data:image/webp;base64,QUJDRA==';
const remoteEmoji = customEmoji({
dataUrl,
hash: await hashText(dataUrl),
size: 4,
updatedAt: 200
});
await service.saveRemoteEmoji(remoteEmoji);
expect(service.findEmoji(remoteEmoji.id)).toEqual(expect.objectContaining({ id: remoteEmoji.id }));
expect(service.emojis()).toEqual([]);
await service.saveEmojiToLibrary(remoteEmoji.id);
expect(service.emojis()).toEqual([
expect.objectContaining({ id: remoteEmoji.id,
savedByUser: true })
]);
});
it('pushes referenced custom emoji assets to every connected peer without waiting for a request', async () => {
const service = createService();
const dataUrl = 'data:image/webp;base64,QUJDRA==';
const emoji = customEmoji({
id: 'party-id',
dataUrl,
hash: await hashText(dataUrl),
size: 4,
savedByUser: true
});
await service.saveRemoteEmoji(emoji);
vi.mocked(webrtc.getConnectedPeers).mockReturnValue(['peer-a', 'peer-b']);
const sent: { peerId: string; event: { type: string } }[] = [];
vi.mocked(webrtc.sendToPeerBuffered).mockImplementation(async (peerId, event) => {
sent.push({ peerId,
event: event as { type: string } });
});
await service.pushEmojisForContent('Hello :emoji[party-id](party)');
expect(sent.map((entry) => entry.peerId).sort()).toEqual(['peer-a', 'peer-b']);
expect(sent.every((entry) => entry.event.type === 'custom-emoji-full')).toBe(true);
expect(sent.every((entry) => 'customEmoji' in entry.event)).toBe(true);
expect(sent.some((entry) => entry.event.type === 'custom-emoji-chunk')).toBe(false);
});
it('requests missing custom emoji assets referenced by incoming messages', async () => {
const service = createService();
await service.requestMissingFromMessageContent('peer-1', 'Take this :emoji[download](download)');
expect(webrtc.sendToPeer).toHaveBeenCalledWith('peer-1', {
type: 'custom-emoji-request',
ids: ['download']
});
});
it('round-trips a multi-chunk emoji from sender to receiver so it renders', async () => {
const sender = createService();
const bytes = new Uint8Array(40 * 1024);
for (let index = 0; index < bytes.length; index++) {
bytes[index] = index % 251;
}
const base64 = Buffer.from(bytes).toString('base64');
const dataUrl = `data:image/webp;base64,${base64}`;
const created = customEmoji({
id: 'download',
name: 'download',
dataUrl,
hash: await hashText(dataUrl),
mime: 'image/webp',
size: bytes.length,
savedByUser: true
});
await sender.loadForUser('creator-1');
await sender.saveRemoteEmoji(created);
expect(sender.findEmoji(created.id)).toBeDefined();
const sent: ({ type: string } & Record<string, unknown>)[] = [];
vi.mocked(webrtc.sendToPeerBuffered).mockImplementation(async (_peerId, event) => {
sent.push(event as { type: string } & Record<string, unknown>);
});
await sender.sendRequestedToPeer('receiver-peer', [created.id]);
const start = sent.find((event) => event.type === 'custom-emoji-full');
const chunks = sent.filter((event) => event.type === 'custom-emoji-chunk');
expect(start).toBeDefined();
expect(chunks.length).toBeGreaterThan(1);
if (!start || typeof start.total !== 'number') {
throw new Error('Expected custom emoji transfer start event.');
}
const receiver = createService();
receiver.receiveTransferStart(
start.customEmojiTransfer as never,
start.total
);
vi.mocked(db.saveCustomEmoji).mockClear();
for (const chunk of chunks) {
await receiver.receiveTransferChunk(
chunk['customEmojiId'] as string,
chunk['index'] as number,
chunk['total'] as number,
chunk['data'] as string
);
}
const received = receiver.findEmoji(created.id);
expect(received).toBeDefined();
expect(received?.dataUrl).toBe(created.dataUrl);
});
});

View File

@@ -0,0 +1,427 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
import { DatabaseService } from '../../../infrastructure/persistence';
import { RealtimeSessionFacade } from '../../../core/realtime';
import {
CustomEmoji,
CustomEmojiTransferManifest,
EmojiShortcutEntry
} from '../../../shared-kernel';
import {
assembleCustomEmojiDataUrl,
buildCustomEmojiMessageToken,
buildCustomEmojiSummary,
canInlineCustomEmojiTransfer,
CUSTOM_EMOJI_ALLOWED_MIME_TYPES,
CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES,
CUSTOM_EMOJI_MAX_SIZE_BYTES,
CUSTOM_EMOJI_MAX_TRANSFER_CHUNKS,
customEmojiKey,
extractCustomEmojiIds,
getCustomEmojiDataUrlByteSize,
selectEmojiShortcutEntries,
shouldRequestCustomEmoji,
splitCustomEmojiDataUrl,
unicodeEmojiKey,
validateCustomEmojiFile
} from '../domain/custom-emoji.rules';
const USAGE_STORAGE_PREFIX = 'metoyou_custom_emoji_usage:';
interface PendingCustomEmojiTransfer {
chunks: (string | undefined)[];
manifest: CustomEmojiTransferManifest;
total: number;
}
@Injectable({ providedIn: 'root' })
export class CustomEmojiService {
private readonly db = inject(DatabaseService);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly emojisState = signal<CustomEmoji[]>([]);
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
private loaded = false;
readonly emojis = computed(() => this.emojisState().filter((emoji) => this.isSavedEmoji(emoji)));
readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({
customEmojis: this.emojis(),
usage: this.usageState()
}));
async loadForUser(userId: string | null | undefined): Promise<void> {
const emojis = await this.db.getCustomEmojis();
const merged = new Map(this.emojisState().map((emoji) => [emoji.id, emoji]));
for (const emoji of emojis) {
if (await this.isValidRemoteEmoji(emoji)) {
const existing = merged.get(emoji.id);
if (!existing || existing.updatedAt < emoji.updatedAt) {
merged.set(emoji.id, emoji);
}
} else {
await this.db.deleteCustomEmoji(emoji.id);
merged.delete(emoji.id);
}
}
this.emojisState.set([...merged.values()].sort((first, second) => second.updatedAt - first.updatedAt));
this.usageState.set(this.readUsage(userId));
this.loaded = true;
}
async ensureLoaded(userId?: string | null): Promise<void> {
if (this.loaded) {
return;
}
await this.loadForUser(userId);
}
async createFromFile(file: File, userId: string): Promise<CustomEmoji> {
const validation = validateCustomEmojiFile(file);
if (!validation.valid) {
throw new Error(validation.reason ?? 'Invalid emoji image.');
}
const dataUrl = await this.readFileAsDataUrl(file);
const now = Date.now();
const emoji: CustomEmoji = {
id: uuidv4(),
name: this.normaliseEmojiName(file.name),
creatorUserId: userId,
dataUrl,
hash: await this.hashText(dataUrl),
mime: file.type.toLowerCase(),
size: file.size,
createdAt: now,
updatedAt: now,
savedByUser: true
};
await this.saveRemoteEmoji(emoji);
await this.broadcastEmoji(emoji);
return emoji;
}
async saveRemoteEmoji(emoji: CustomEmoji): Promise<void> {
const existing = this.emojisState().find((entry) => entry.id === emoji.id);
if (existing && existing.updatedAt >= emoji.updatedAt) {
return;
}
if (!(await this.isValidRemoteEmoji(emoji))) {
return;
}
const nextEmoji: CustomEmoji = {
...emoji,
savedByUser: existing?.savedByUser || emoji.savedByUser === true
};
await this.db.saveCustomEmoji(nextEmoji);
const nextEmojis = [nextEmoji, ...this.emojisState().filter((entry) => entry.id !== nextEmoji.id)];
this.emojisState.set(nextEmojis.sort((first, second) => second.updatedAt - first.updatedAt));
}
findEmoji(id: string): CustomEmoji | undefined {
return this.emojisState().find((emoji) => emoji.id === id);
}
findEmojiByName(name: string): CustomEmoji | undefined {
const normalizedName = name.trim().toLowerCase();
return this.emojis().find((emoji) => emoji.name.toLowerCase() === normalizedName);
}
isEmojiInLibrary(id: string): boolean {
const emoji = this.findEmoji(id);
return !!emoji && this.isSavedEmoji(emoji);
}
async saveEmojiToLibrary(id: string): Promise<void> {
const emoji = this.findEmoji(id);
if (!emoji || this.isSavedEmoji(emoji)) {
return;
}
const savedEmoji = {
...emoji,
savedByUser: true
};
await this.db.saveCustomEmoji(savedEmoji);
this.emojisState.set(this.emojisState().map((entry) => entry.id === id ? savedEmoji : entry));
}
async removeEmojiFromLibrary(id: string): Promise<void> {
const emoji = this.findEmoji(id);
if (!emoji || !this.isSavedEmoji(emoji)) {
return;
}
const unsavedEmoji = {
...emoji,
savedByUser: false
};
await this.db.saveCustomEmoji(unsavedEmoji);
this.emojisState.set(this.emojisState().map((entry) => entry.id === id ? unsavedEmoji : entry));
}
recordUsage(entry: EmojiShortcutEntry, userId: string | null | undefined): void {
const key = entry.kind === 'custom' ? customEmojiKey(entry.emoji.id) : unicodeEmojiKey(entry.emoji);
const usage = new Map(this.usageState());
usage.set(key, (usage.get(key) ?? 0) + 1);
this.usageState.set(usage);
this.writeUsage(userId, usage);
}
buildMessageToken(emoji: CustomEmoji): string {
return buildCustomEmojiMessageToken(emoji);
}
buildSummary() {
return this.emojisState().map((emoji) => buildCustomEmojiSummary(emoji));
}
missingFromSummary(summary: ReturnType<CustomEmojiService['buildSummary']>): string[] {
const local = new Map(this.emojisState().map((emoji) => [emoji.id, emoji]));
return summary.filter((item) => shouldRequestCustomEmoji(local.get(item.id), item)).map((item) => item.id);
}
async sendSummaryToPeer(peerId: string): Promise<void> {
await this.ensureLoaded();
this.webrtc.sendToPeer(peerId, {
type: 'custom-emoji-summary',
customEmojiSummaries: this.buildSummary()
});
}
async sendRequestedToPeer(peerId: string, ids: string[]): Promise<void> {
await this.ensureLoaded();
const emojis = ids
.map((id) => this.findEmoji(id))
.filter((emoji): emoji is CustomEmoji => !!emoji);
await Promise.all(emojis.map((emoji) => this.sendEmojiToPeer(peerId, emoji)));
}
pushEmojisInContent(content: string): void {
void this.pushEmojisForContent(content);
}
async pushEmojisForContent(content: string): Promise<void> {
await this.ensureLoaded();
const peers = this.webrtc.getConnectedPeers();
if (peers.length === 0) {
return;
}
const emojis = extractCustomEmojiIds(content)
.map((id) => this.findEmoji(id))
.filter((emoji): emoji is CustomEmoji => !!emoji);
if (emojis.length === 0) {
return;
}
await Promise.all(
peers.flatMap((peerId) => emojis.map((emoji) => this.sendEmojiToPeer(peerId, emoji)))
);
}
async requestMissingFromMessageContent(peerId: string, content: string): Promise<void> {
await this.ensureLoaded();
const ids = extractCustomEmojiIds(content).filter((id) => !this.findEmoji(id));
if (ids.length === 0) {
return;
}
this.webrtc.sendToPeer(peerId, {
type: 'custom-emoji-request',
ids
});
}
receiveTransferStart(manifest: CustomEmojiTransferManifest, total: number): void {
const existing = this.emojisState().find((entry) => entry.id === manifest.id);
if (existing && existing.updatedAt >= manifest.updatedAt) {
return;
}
if (total < 1 || total > CUSTOM_EMOJI_MAX_TRANSFER_CHUNKS || manifest.size > CUSTOM_EMOJI_MAX_SIZE_BYTES || !this.isAllowedMime(manifest.mime)) {
return;
}
this.pendingTransfers.set(manifest.id, {
chunks: Array.from({ length: total }, () => undefined),
manifest,
total
});
}
async receiveTransferChunk(emojiId: string, index: number, total: number, data: string): Promise<void> {
const transfer = this.pendingTransfers.get(emojiId);
const invalidChunkIndex = !transfer || transfer.total !== total || index < 0 || index >= transfer.total;
const invalidChunkData = !data || data.length > CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES;
if (invalidChunkIndex || invalidChunkData) {
return;
}
transfer.chunks[index] = data;
if (transfer.chunks.some((chunk) => !chunk)) {
return;
}
this.pendingTransfers.delete(emojiId);
const chunks = transfer.chunks.filter((chunk): chunk is string => typeof chunk === 'string');
if (chunks.length !== transfer.total) {
return;
}
await this.saveRemoteEmoji({
...transfer.manifest,
dataUrl: assembleCustomEmojiDataUrl(transfer.manifest.mime, chunks)
});
}
private async broadcastEmoji(emoji: CustomEmoji): Promise<void> {
const peers = this.webrtc.getConnectedPeers();
await Promise.all(peers.map((peerId) => this.sendEmojiToPeer(peerId, emoji)));
}
private async sendEmojiToPeer(peerId: string, emoji: CustomEmoji): Promise<void> {
if (canInlineCustomEmojiTransfer(emoji)) {
await this.webrtc.sendToPeerBuffered(peerId, {
type: 'custom-emoji-full',
customEmoji: emoji
});
return;
}
const transfer = splitCustomEmojiDataUrl(emoji.dataUrl);
const manifest: CustomEmojiTransferManifest = {
id: emoji.id,
name: emoji.name,
creatorUserId: emoji.creatorUserId,
hash: emoji.hash,
mime: emoji.mime,
size: emoji.size,
createdAt: emoji.createdAt,
updatedAt: emoji.updatedAt
};
await this.webrtc.sendToPeerBuffered(peerId, {
type: 'custom-emoji-full',
customEmojiTransfer: manifest,
total: transfer.total
});
for (let chunkIndex = 0; chunkIndex < transfer.chunks.length; chunkIndex++) {
await this.webrtc.sendToPeerBuffered(peerId, {
type: 'custom-emoji-chunk',
customEmojiId: emoji.id,
index: chunkIndex,
total: transfer.total,
data: transfer.chunks[chunkIndex]
});
}
}
private async isValidRemoteEmoji(emoji: CustomEmoji): Promise<boolean> {
if (emoji.size < 1 || emoji.size > CUSTOM_EMOJI_MAX_SIZE_BYTES || !this.isAllowedMime(emoji.mime)) {
return false;
}
if (getCustomEmojiDataUrlByteSize(emoji.dataUrl) !== emoji.size) {
return false;
}
return await this.hashText(emoji.dataUrl) === emoji.hash;
}
private isAllowedMime(mime: string): boolean {
return CUSTOM_EMOJI_ALLOWED_MIME_TYPES.includes(mime.toLowerCase() as typeof CUSTOM_EMOJI_ALLOWED_MIME_TYPES[number]);
}
private isSavedEmoji(emoji: CustomEmoji): boolean {
return emoji.savedByUser !== false;
}
private readUsage(userId: string | null | undefined): ReadonlyMap<string, number> {
if (!userId) {
return new Map();
}
try {
const parsed = JSON.parse(localStorage.getItem(`${USAGE_STORAGE_PREFIX}${userId}`) ?? '{}') as Record<string, number>;
return new Map(Object.entries(parsed).filter(([, value]) => Number.isFinite(value) && value > 0));
} catch {
return new Map();
}
}
private writeUsage(userId: string | null | undefined, usage: ReadonlyMap<string, number>): void {
if (!userId) {
return;
}
localStorage.setItem(`${USAGE_STORAGE_PREFIX}${userId}`, JSON.stringify(Object.fromEntries(usage)));
}
private readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('Unable to read emoji image.'));
reader.onerror = () => reject(reader.error ?? new Error('Unable to read emoji image.'));
reader.readAsDataURL(file);
});
}
private async hashText(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
private normaliseEmojiName(filename: string): string {
const baseName = filename.replace(/\.[^.]+$/, '').toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
return baseName || 'emoji';
}
}

View File

@@ -0,0 +1,248 @@
import {
describe,
expect,
it
} from 'vitest';
import {
CUSTOM_EMOJI_MAX_SIZE_BYTES,
CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES,
canInlineCustomEmojiTransfer,
assembleCustomEmojiDataUrl,
buildCustomEmojiMessageToken,
buildCustomEmojiTextAlias,
extractCustomEmojiIds,
getCustomEmojiIdFromMarkdownAlt,
getCustomEmojiLabelFromMarkdownAlt,
isCustomEmojiOnlyMessage,
isSingleUnicodeEmojiOnlyMessage,
replaceCustomEmojiMessageTokens,
replaceCustomEmojiTokensForPreview,
splitCustomEmojiDataUrl,
replaceCustomEmojiTextAliases,
CUSTOM_EMOJI_ID_ATTRIBUTE,
CUSTOM_EMOJI_CONTEXT_MENU_TARGET,
CUSTOM_EMOJI_LIBRARY_CONTEXT_MENU_TARGET,
filterCustomEmojisForPicker,
filterUnicodeEmojiPickerEntries,
resolveCustomEmojiContextMenuTarget,
selectEmojiShortcutEntries,
splitTextIntoEmojiSegments,
UNICODE_EMOJI_PICKER_ENTRIES,
validateCustomEmojiFile
} from './custom-emoji.rules';
import { CustomEmoji } from '../../../shared-kernel';
function customEmoji(id: string, name: string): CustomEmoji {
return {
id,
name,
creatorUserId: 'user-1',
dataUrl: `data:image/webp;base64,${id}`,
hash: `${id}-hash`,
mime: 'image/webp',
size: 128,
createdAt: 100,
updatedAt: 100
};
}
describe('custom emoji rules', () => {
it('rejects image uploads larger than 1 MB', () => {
const result = validateCustomEmojiFile({
name: 'large.webp',
size: CUSTOM_EMOJI_MAX_SIZE_BYTES + 1,
type: 'image/webp'
});
expect(result.valid).toBe(false);
expect(result.reason).toContain('1 MB');
});
it('selects seven most-used shortcuts and keeps the eighth slot for the modal', () => {
const usage = new Map<string, number>([
['unicode:🔥', 10],
['custom:party', 8],
['unicode:😂', 7],
['unicode:👍', 6],
['unicode:❤️', 5],
['custom:wave', 4],
['unicode:🎉', 3],
['unicode:👀', 2]
]);
const entries = selectEmojiShortcutEntries({
customEmojis: [customEmoji('party', 'party'), customEmoji('wave', 'wave')],
usage,
limit: 7
});
expect(entries).toHaveLength(7);
expect(entries.map((entry) => entry.key)).toEqual([
'unicode:🔥',
'custom:party',
'unicode:😂',
'unicode:👍',
'unicode:❤️',
'custom:wave',
'unicode:🎉'
]);
});
it('builds stable inline message markup for custom emoji sends', () => {
expect(buildCustomEmojiMessageToken(customEmoji('party', 'party'))).toBe(':emoji[party](party)');
});
it('splits custom emoji data into bounded data-channel chunks', () => {
const transfer = splitCustomEmojiDataUrl('data:image/webp;base64,abcdefghijkl', 5);
expect(transfer).toEqual({
chunks: [
'abcde',
'fghij',
'kl'
],
mime: 'image/webp',
total: 3
});
expect(assembleCustomEmojiDataUrl(transfer.mime, transfer.chunks)).toBe('data:image/webp;base64,abcdefghijkl');
});
it('keeps default custom emoji chunks well below risky data-channel message sizes', () => {
expect(CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES).toBeLessThanOrEqual(4 * 1024);
});
it('rewrites readable composer emoji aliases to stable custom emoji tokens', () => {
const party = customEmoji('party-id', 'party');
const wave = customEmoji('wave-id', 'wave');
expect(buildCustomEmojiTextAlias(party)).toBe(':party:');
expect(replaceCustomEmojiTextAliases('This is :party: cool and :wave:', (name) => ({ party, wave })[name] ?? null))
.toBe('This is :emoji[party-id](party) cool and :emoji[wave-id](wave)');
});
it('detects when custom emoji are mixed with text', () => {
expect(isCustomEmojiOnlyMessage(':emoji[party-id](party)')).toBe(true);
expect(isCustomEmojiOnlyMessage(':emoji[party-id](party) :emoji[wave-id](wave)')).toBe(true);
expect(isCustomEmojiOnlyMessage('This is :emoji[party-id](party) cool')).toBe(false);
});
it('detects a lone unicode emoji message for large chat rendering', () => {
expect(isSingleUnicodeEmojiOnlyMessage('😢')).toBe(true);
expect(isSingleUnicodeEmojiOnlyMessage(' 😢 ')).toBe(true);
expect(isSingleUnicodeEmojiOnlyMessage('😮')).toBe(true);
expect(isSingleUnicodeEmojiOnlyMessage('❤️')).toBe(true);
expect(isSingleUnicodeEmojiOnlyMessage('😢 😂')).toBe(false);
expect(isSingleUnicodeEmojiOnlyMessage('Hello 😢')).toBe(false);
expect(isSingleUnicodeEmojiOnlyMessage('😢!')).toBe(false);
expect(isSingleUnicodeEmojiOnlyMessage(':emoji[party-id](party)')).toBe(false);
expect(isSingleUnicodeEmojiOnlyMessage('')).toBe(false);
});
it('rewrites custom emoji tokens for deferred message placeholders', () => {
expect(replaceCustomEmojiTokensForPreview('Hi :emoji[party-id](party) there'))
.toBe('Hi :party: there');
});
it('renders a sized placeholder image while a custom emoji asset is still loading', () => {
const markdown = replaceCustomEmojiMessageTokens(':emoji[party-id](party)', () => null);
expect(markdown).toContain('![custom-emoji:party-id:party]');
expect(markdown).toContain('data:image/gif;base64,');
expect(markdown).not.toContain(':emoji[party-id](party)');
expect(markdown).not.toContain(':party:');
});
it('keeps custom emoji ids available for right-click save actions', () => {
const party = customEmoji('party-id', 'party');
const markdown = replaceCustomEmojiMessageTokens(':emoji[party-id](party)', () => party);
expect(markdown).toBe(`![custom-emoji:party-id:party](${party.dataUrl})`);
expect(getCustomEmojiIdFromMarkdownAlt('custom-emoji:party-id:party')).toBe('party-id');
expect(getCustomEmojiLabelFromMarkdownAlt('custom-emoji:party-id:party')).toBe('party');
});
it('allows small custom emoji payloads to travel inline in one data-channel event', () => {
const party = customEmoji('party-id', 'party');
expect(canInlineCustomEmojiTransfer(party)).toBe(true);
});
it('extracts custom emoji ids from messages for repair sync requests', () => {
expect(extractCustomEmojiIds('Look :emoji[download](download) :emoji[party](party) :emoji[download](download)'))
.toEqual(['download', 'party']);
});
it('returns all picker entries when the search query is blank', () => {
expect(filterUnicodeEmojiPickerEntries(UNICODE_EMOJI_PICKER_ENTRIES, ''))
.toEqual([...UNICODE_EMOJI_PICKER_ENTRIES]);
expect(filterCustomEmojisForPicker([customEmoji('party-id', 'party'), customEmoji('wave-id', 'wave')], ' ')).toHaveLength(2);
});
it('filters unicode picker entries by emoji and search terms', () => {
expect(filterUnicodeEmojiPickerEntries(UNICODE_EMOJI_PICKER_ENTRIES, 'heart').map((entry) => entry.emoji))
.toEqual(['❤️']);
expect(filterUnicodeEmojiPickerEntries(UNICODE_EMOJI_PICKER_ENTRIES, 'fire').map((entry) => entry.emoji))
.toEqual(['🔥']);
expect(filterUnicodeEmojiPickerEntries(UNICODE_EMOJI_PICKER_ENTRIES, '😂').map((entry) => entry.emoji))
.toEqual(['😂']);
});
it('resolves add and remove custom emoji context menu targets from marked hosts', () => {
const addHost = {
closest: vi.fn((selector: string) => selector === `[${CUSTOM_EMOJI_CONTEXT_MENU_TARGET}]` ? addHost : null),
getAttribute: vi.fn((name: string) => name === CUSTOM_EMOJI_ID_ATTRIBUTE ? 'party-id' : null)
};
const libraryHost = {
closest: vi.fn((selector: string) => selector === `[${CUSTOM_EMOJI_LIBRARY_CONTEXT_MENU_TARGET}]` ? libraryHost : null),
getAttribute: vi.fn((name: string) => name === CUSTOM_EMOJI_ID_ATTRIBUTE ? 'wave-id' : null)
};
expect(resolveCustomEmojiContextMenuTarget(addHost as unknown as EventTarget))
.toEqual({ action: 'add',
emojiId: 'party-id' });
expect(resolveCustomEmojiContextMenuTarget(libraryHost as unknown as EventTarget))
.toEqual({ action: 'remove',
emojiId: 'wave-id' });
});
it('filters saved custom emoji by name in the picker search', () => {
const emojis = [
customEmoji('party-id', 'party-parrot'),
customEmoji('wave-id', 'wave'),
customEmoji('cat-id', 'cat-jam')
];
expect(filterCustomEmojisForPicker(emojis, 'par').map((emoji) => emoji.id))
.toEqual(['party-id']);
expect(filterCustomEmojisForPicker(emojis, 'CAT').map((emoji) => emoji.id))
.toEqual(['cat-id']);
});
it('splits chat text into plain and emoji segments for inline rendering', () => {
expect(splitTextIntoEmojiSegments('Hello 🔥 world')).toEqual([
{ kind: 'text',
value: 'Hello ' },
{ kind: 'emoji',
value: '🔥' },
{ kind: 'text',
value: ' world' }
]);
expect(splitTextIntoEmojiSegments('❤️😂')).toEqual([
{ kind: 'emoji',
value: '❤️' },
{ kind: 'emoji',
value: '😂' }
]);
expect(splitTextIntoEmojiSegments('plain text')).toEqual([
{ kind: 'text',
value: 'plain text' }
]);
});
});

View File

@@ -0,0 +1,499 @@
import {
CustomEmoji,
CustomEmojiSummaryItem,
EmojiShortcutEntry
} from '../../../shared-kernel';
export const CUSTOM_EMOJI_MAX_SIZE_BYTES = 1024 * 1024;
export const CUSTOM_EMOJI_ALLOWED_MIME_TYPES = [
'image/webp',
'image/gif',
'image/jpeg'
] as const;
export const CUSTOM_EMOJI_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
export const CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES = 4 * 1024;
export const CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES = 48 * 1024;
export const CUSTOM_EMOJI_MAX_TRANSFER_CHUNKS = Math.ceil((CUSTOM_EMOJI_MAX_SIZE_BYTES * 4 / 3) / CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES) + 2;
export interface UnicodeEmojiPickerEntry {
emoji: string;
searchTerms: readonly string[];
}
export const UNICODE_EMOJI_PICKER_ENTRIES: readonly UnicodeEmojiPickerEntry[] = [
{ emoji: '👍',
searchTerms: [
'thumbs',
'up',
'like',
'thumb',
'+1'
] },
{ emoji: '❤️',
searchTerms: [
'heart',
'love',
'red'
] },
{ emoji: '😂',
searchTerms: [
'joy',
'laugh',
'lol',
'tears',
'funny'
] },
{ emoji: '😮',
searchTerms: [
'wow',
'surprised',
'shock',
'open',
'mouth'
] },
{ emoji: '😢',
searchTerms: [
'cry',
'sad',
'tear'
] },
{ emoji: '🎉',
searchTerms: [
'party',
'celebrate',
'tada',
'confetti'
] },
{ emoji: '🔥',
searchTerms: [
'fire',
'lit',
'hot',
'flame'
] },
{ emoji: '👀',
searchTerms: [
'eyes',
'look',
'watch',
'see'
] }
];
export const DEFAULT_UNICODE_EMOJIS = UNICODE_EMOJI_PICKER_ENTRIES.map((entry) => entry.emoji);
export type ChatTextSegment = {
kind: 'text' | 'emoji';
value: string;
};
const UNICODE_EMOJI_IN_TEXT_PATTERN = /\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*|[#*0-9]\uFE0F?\u20E3/gu;
export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
if (!text) {
return [];
}
const segments: ChatTextSegment[] = [];
let lastIndex = 0;
for (const match of text.matchAll(UNICODE_EMOJI_IN_TEXT_PATTERN)) {
const index = match.index ?? 0;
if (index > lastIndex) {
segments.push({ kind: 'text',
value: text.slice(lastIndex, index) });
}
segments.push({ kind: 'emoji',
value: match[0] });
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ kind: 'text',
value: text.slice(lastIndex) });
}
return segments.length > 0 ? segments : [{ kind: 'text',
value: text }];
}
export interface CustomEmojiFileLike {
name: string;
size: number;
type: string;
}
export interface CustomEmojiValidationResult {
valid: boolean;
reason?: string;
}
export interface SelectEmojiShortcutEntriesInput {
customEmojis: readonly CustomEmoji[];
usage: ReadonlyMap<string, number>;
limit?: number;
}
const CUSTOM_EMOJI_TOKEN_PATTERN = /:emoji\[([^\]\s]+)]\(([^)]+)\)/g;
const CUSTOM_EMOJI_TOKEN_PRESENT_PATTERN = /:emoji\[[^\]\s]+]\([^)]+\)/;
const CUSTOM_EMOJI_TEXT_ALIAS_PATTERN = /:([a-z0-9_-]+):/g;
const CUSTOM_EMOJI_DATA_URL_PATTERN = /^data:([^;,]+);base64,(.*)$/;
const CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX = 'custom-emoji:';
const CUSTOM_EMOJI_PENDING_DATA_URL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
export interface CustomEmojiDataUrlTransfer {
mime: string;
chunks: string[];
total: number;
}
export function validateCustomEmojiFile(file: CustomEmojiFileLike): CustomEmojiValidationResult {
if (file.size > CUSTOM_EMOJI_MAX_SIZE_BYTES) {
return { valid: false,
reason: 'Emoji images can be max 1 MB.' };
}
if (!CUSTOM_EMOJI_ALLOWED_MIME_TYPES.includes(file.type.toLowerCase() as typeof CUSTOM_EMOJI_ALLOWED_MIME_TYPES[number])) {
return { valid: false,
reason: 'Emoji images must be WebP, GIF, JPG, or JPEG.' };
}
return { valid: true };
}
export function selectEmojiShortcutEntries(input: SelectEmojiShortcutEntriesInput): EmojiShortcutEntry[] {
const limit = input.limit ?? 7;
const customByKey = new Map(input.customEmojis.map((emoji) => [customEmojiKey(emoji.id), emoji]));
const entries: EmojiShortcutEntry[] = [];
const usedKeys = new Set<string>();
const rankedKeys = [...input.usage.entries()]
.filter(([, count]) => count > 0)
.sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0]));
for (const [key] of rankedKeys) {
const entry = entryFromKey(key, customByKey);
if (!entry || usedKeys.has(entry.key)) {
continue;
}
entries.push(entry);
usedKeys.add(entry.key);
if (entries.length >= limit) {
return entries;
}
}
for (const emoji of DEFAULT_UNICODE_EMOJIS) {
const key = unicodeEmojiKey(emoji);
if (usedKeys.has(key)) {
continue;
}
entries.push({ kind: 'unicode',
key,
emoji,
label: emoji });
usedKeys.add(key);
if (entries.length >= limit) {
return entries;
}
}
for (const emoji of input.customEmojis) {
const key = customEmojiKey(emoji.id);
if (usedKeys.has(key)) {
continue;
}
entries.push({ kind: 'custom',
key,
emoji,
label: emoji.name });
usedKeys.add(key);
if (entries.length >= limit) {
return entries;
}
}
return entries;
}
export function buildCustomEmojiMessageToken(emoji: CustomEmoji): string {
return `:emoji[${emoji.id}](${emoji.name})`;
}
export function buildCustomEmojiTextAlias(emoji: CustomEmoji): string {
return `:${emoji.name}:`;
}
export function replaceCustomEmojiTextAliases(
content: string,
resolveEmoji: (name: string) => CustomEmoji | null | undefined
): string {
return content.replace(CUSTOM_EMOJI_TEXT_ALIAS_PATTERN, (token, name: string) => {
const emoji = resolveEmoji(name);
return emoji ? buildCustomEmojiMessageToken(emoji) : token;
});
}
export function replaceCustomEmojiTokensForPreview(content: string): string {
return content.replace(CUSTOM_EMOJI_TOKEN_PATTERN, (_token, _id: string, name: string) => `:${name}:`);
}
export function replaceCustomEmojiMessageTokens(
content: string,
resolveEmoji: (id: string) => CustomEmoji | null | undefined
): string {
return content.replace(CUSTOM_EMOJI_TOKEN_PATTERN, (token, id: string, name: string) => {
const emoji = resolveEmoji(id);
if (!emoji) {
return `![${CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX}${id}:${name}](${CUSTOM_EMOJI_PENDING_DATA_URL})`;
}
return `![${CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX}${id}:${name}](${emoji.dataUrl})`;
});
}
export function extractCustomEmojiIds(content: string): string[] {
return [...content.matchAll(CUSTOM_EMOJI_TOKEN_PATTERN)].reduce<string[]>((ids, match) => {
const id = match[1];
if (id && !ids.includes(id)) {
ids.push(id);
}
return ids;
}, []);
}
export function getCustomEmojiIdFromMarkdownAlt(alt?: string): string | null {
if (!alt?.startsWith(CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX)) {
return null;
}
const id = alt.slice(CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX.length).split(':', 1)[0]?.trim();
return id || null;
}
export function isCustomEmojiMarkdownAlt(alt?: string): boolean {
return !!getCustomEmojiIdFromMarkdownAlt(alt);
}
export const CUSTOM_EMOJI_CONTEXT_MENU_TARGET = 'data-custom-emoji';
export const CUSTOM_EMOJI_LIBRARY_CONTEXT_MENU_TARGET = 'data-custom-emoji-library';
export const CUSTOM_EMOJI_ID_ATTRIBUTE = 'data-custom-emoji-id';
export type CustomEmojiContextMenuAction = 'add' | 'remove';
export interface CustomEmojiContextMenuTarget {
action: CustomEmojiContextMenuAction;
emojiId: string;
}
export function resolveCustomEmojiContextMenuTarget(target: EventTarget | null): CustomEmojiContextMenuTarget | null {
const element = getElementFromEventTarget(target);
if (!element) {
return null;
}
const libraryHost = element.closest(`[${CUSTOM_EMOJI_LIBRARY_CONTEXT_MENU_TARGET}]`);
if (libraryHost) {
const emojiId = libraryHost.getAttribute(CUSTOM_EMOJI_ID_ATTRIBUTE)?.trim();
return emojiId ? { action: 'remove',
emojiId } : null;
}
const addHost = element.closest(`[${CUSTOM_EMOJI_CONTEXT_MENU_TARGET}]`);
if (addHost) {
const emojiId = addHost.getAttribute(CUSTOM_EMOJI_ID_ATTRIBUTE)?.trim();
return emojiId ? { action: 'add',
emojiId } : null;
}
return null;
}
function getElementFromEventTarget(target: EventTarget | null): Element | null {
if (target && typeof (target as Element).closest === 'function') {
return target as Element;
}
const parentElement = (target as Node | null)?.parentElement;
return parentElement && typeof parentElement.closest === 'function'
? parentElement
: null;
}
export function getCustomEmojiLabelFromMarkdownAlt(alt?: string): string {
if (!alt?.startsWith(CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX)) {
return alt || 'Custom emoji';
}
return alt.slice(CUSTOM_EMOJI_MARKDOWN_ALT_PREFIX.length).split(':')
.slice(1)
.join(':') || 'Custom emoji';
}
export function isCustomEmojiOnlyMessage(content: string): boolean {
const withoutTokens = content.replace(CUSTOM_EMOJI_TOKEN_PATTERN, '').trim();
return withoutTokens.length === 0 && CUSTOM_EMOJI_TOKEN_PRESENT_PATTERN.test(content);
}
export function isSingleUnicodeEmojiOnlyMessage(content: string): boolean {
if (CUSTOM_EMOJI_TOKEN_PRESENT_PATTERN.test(content)) {
return false;
}
const segments = splitTextIntoEmojiSegments(content.trim());
return segments.length === 1 && segments[0]?.kind === 'emoji';
}
export function buildCustomEmojiSummary(emoji: CustomEmoji): CustomEmojiSummaryItem {
return {
id: emoji.id,
hash: emoji.hash,
updatedAt: emoji.updatedAt
};
}
export function splitCustomEmojiDataUrl(dataUrl: string, chunkSize = CUSTOM_EMOJI_DATA_CHANNEL_CHUNK_SIZE_BYTES): CustomEmojiDataUrlTransfer {
const match = dataUrl.match(CUSTOM_EMOJI_DATA_URL_PATTERN);
if (!match || chunkSize < 1) {
return {
chunks: [],
mime: 'image/webp',
total: 0
};
}
const mime = match[1] || 'image/webp';
const base64 = match[2] || '';
const chunks: string[] = [];
for (let offset = 0; offset < base64.length; offset += chunkSize) {
chunks.push(base64.slice(offset, offset + chunkSize));
}
return {
chunks,
mime,
total: chunks.length
};
}
export function assembleCustomEmojiDataUrl(mime: string, chunks: readonly string[]): string {
return `data:${mime};base64,${chunks.join('')}`;
}
export function getCustomEmojiDataUrlByteSize(dataUrl: string): number | null {
const match = dataUrl.match(CUSTOM_EMOJI_DATA_URL_PATTERN);
if (!match) {
return null;
}
const base64 = match[2] || '';
const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0);
return Math.floor(base64.length * 3 / 4) - padding;
}
export function canInlineCustomEmojiTransfer(emoji: CustomEmoji): boolean {
const envelope = JSON.stringify({
type: 'custom-emoji-full',
customEmoji: emoji
});
return new TextEncoder().encode(envelope).byteLength <= CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES;
}
export function shouldRequestCustomEmoji(local: CustomEmoji | undefined, remote: CustomEmojiSummaryItem): boolean {
if (!local) {
return true;
}
return local.hash !== remote.hash || local.updatedAt < remote.updatedAt;
}
export function unicodeEmojiKey(emoji: string): string {
return `unicode:${emoji}`;
}
export function customEmojiKey(id: string): string {
return `custom:${id}`;
}
export function normalizeEmojiPickerSearchQuery(query: string): string {
return query.trim().toLowerCase();
}
export function matchesEmojiPickerSearch(
searchTerms: readonly string[],
emoji: string,
query: string
): boolean {
if (emoji.includes(query)) {
return true;
}
return searchTerms.some((term) => term.includes(query));
}
export function filterUnicodeEmojiPickerEntries(
entries: readonly UnicodeEmojiPickerEntry[],
query: string
): UnicodeEmojiPickerEntry[] {
const normalizedQuery = normalizeEmojiPickerSearchQuery(query);
if (!normalizedQuery) {
return [...entries];
}
return entries.filter((entry) => matchesEmojiPickerSearch(entry.searchTerms, entry.emoji, normalizedQuery));
}
export function filterCustomEmojisForPicker(
emojis: readonly CustomEmoji[],
query: string
): CustomEmoji[] {
const normalizedQuery = normalizeEmojiPickerSearchQuery(query);
if (!normalizedQuery) {
return [...emojis];
}
return emojis.filter((emoji) => emoji.name.toLowerCase().includes(normalizedQuery));
}
function entryFromKey(key: string, customByKey: ReadonlyMap<string, CustomEmoji>): EmojiShortcutEntry | null {
if (key.startsWith('unicode:')) {
const emoji = key.slice('unicode:'.length);
return emoji ? { kind: 'unicode', key, emoji, label: emoji } : null;
}
const custom = customByKey.get(key);
return custom ? { kind: 'custom', key, emoji: custom, label: custom.name } : null;
}

View File

@@ -0,0 +1,137 @@
<div class="relative">
@if (compact()) {
<div class="flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@for (entry of shortcuts(); track entry.key) {
<button
type="button"
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
[attr.aria-label]="entry.label"
[title]="entry.label"
(click)="selectShortcut(entry)"
>
@if (entry.kind === 'custom') {
<img
[src]="entry.emoji.dataUrl"
[alt]="entry.emoji.name"
class="h-10 w-10 object-contain"
/>
} @else {
<span class="text-[2rem] leading-none">{{ entry.emoji }}</span>
}
</button>
}
<button
type="button"
class="grid h-10 w-10 place-items-center rounded text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Open emoji selector"
title="Open emoji selector"
(click)="openModal()"
>
<ng-icon
name="lucideSmile"
class="h-5 w-5"
/>
</button>
</div>
}
@if (!compact() || modalOpen()) {
<div class="absolute bottom-full right-0 z-20 mb-2 w-72 rounded-lg border border-border bg-card p-3 shadow-xl">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-foreground">Emoji</p>
@if (compact()) {
<button
type="button"
class="grid h-7 w-7 place-items-center rounded hover:bg-secondary"
aria-label="Close emoji selector"
(click)="closeModal()"
>
<ng-icon
name="lucideX"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
</div>
<label class="relative mb-3 block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search emoji"
aria-label="Search emoji"
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
</label>
<label class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground">
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
<span>{{ uploading() ? 'Uploading...' : 'Upload emoji' }}</span>
<input
type="file"
class="hidden"
[accept]="acceptAttribute"
[disabled]="uploading()"
(change)="uploadEmoji($event)"
/>
</label>
@if (uploadError()) {
<div class="mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{{ uploadError() }}
</div>
}
@if (showEmptySearchState()) {
<div class="mb-3 rounded-md border border-border/70 bg-secondary/20 px-3 py-4 text-center text-xs text-muted-foreground">
No emoji match your search.
</div>
}
@if (filteredUnicodeEntries().length > 0) {
<div class="mb-3 grid grid-cols-6 gap-1">
@for (entry of filteredUnicodeEntries(); track entry.emoji) {
<button
type="button"
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
(click)="selectUnicode(entry.emoji)"
>
<span class="text-[2rem] leading-none">{{ entry.emoji }}</span>
</button>
}
</div>
}
@if (filteredCustomEmojis().length > 0) {
<div class="grid max-h-44 grid-cols-6 gap-1 overflow-y-auto pr-1">
@for (emoji of filteredCustomEmojis(); track emoji.id) {
<button
type="button"
class="grid h-10 w-10 place-items-center rounded transition-colors hover:bg-secondary"
[title]="emoji.name"
data-custom-emoji-library
[attr.data-custom-emoji-id]="emoji.id"
(click)="selectCustom(emoji)"
>
<img
[src]="emoji.dataUrl"
[alt]="emoji.name"
class="h-10 w-10 object-contain"
/>
</button>
}
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,151 @@
import {
ElementRef,
Injector,
computed,
runInInjectionContext,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { CustomEmoji } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import { CustomEmojiPickerComponent } from './custom-emoji-picker.component';
const savedEmojis: CustomEmoji[] = [
{
id: 'party-id',
name: 'party-parrot',
creatorUserId: 'user-1',
dataUrl: 'data:image/webp;base64,party',
hash: 'party-hash',
mime: 'image/webp',
size: 128,
createdAt: 100,
updatedAt: 100,
savedByUser: true
},
{
id: 'wave-id',
name: 'wave',
creatorUserId: 'user-1',
dataUrl: 'data:image/webp;base64,wave',
hash: 'wave-hash',
mime: 'image/webp',
size: 128,
createdAt: 100,
updatedAt: 100,
savedByUser: true
}
];
function createEffectSchedulerMock() {
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
return {
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
}),
flush: vi.fn(() => {
for (const scheduledEffect of scheduledEffects) {
if (scheduledEffect.dirty) {
scheduledEffect.run();
}
}
}),
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.delete(scheduledEffect);
}),
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
})
};
}
describe('CustomEmojiPickerComponent', () => {
function createComponent(
hostContainsTarget: boolean,
emojis: CustomEmoji[] = []
): CustomEmojiPickerComponent {
const injector = Injector.create({
providers: [
CustomEmojiPickerComponent,
{
provide: ChangeDetectionScheduler,
useValue: { notify: vi.fn() }
},
{
provide: EffectScheduler,
useValue: createEffectSchedulerMock()
},
{
provide: ElementRef,
useValue: new ElementRef({
contains: () => hostContainsTarget
})
},
{
provide: CustomEmojiService,
useValue: {
loadForUser: vi.fn(async () => undefined),
shortcutEntries: computed(() => []),
emojis: computed(() => emojis)
}
}
]
});
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));
}
it('emits dismissed when clicking outside the picker', () => {
const component = createComponent(false);
const dismissed = vi.fn();
component.dismissed.subscribe(() => dismissed());
component.onDocumentClick({ target: {} } as MouseEvent);
expect(dismissed).toHaveBeenCalledTimes(1);
});
it('does not emit dismissed when clicking inside the picker', () => {
const component = createComponent(true);
const dismissed = vi.fn();
component.dismissed.subscribe(() => dismissed());
component.onDocumentClick({ target: {} } as MouseEvent);
expect(dismissed).not.toHaveBeenCalled();
});
it('filters unicode and custom emoji when the picker search query changes', () => {
const component = createComponent(true, savedEmojis);
component.setSearchQuery('heart');
expect(component.filteredUnicodeEntries().map((entry) => entry.emoji)).toEqual(['❤️']);
expect(component.filteredCustomEmojis()).toEqual([]);
expect(component.showEmptySearchState()).toBe(false);
component.setSearchQuery('par');
expect(component.filteredUnicodeEntries().map((entry) => entry.emoji)).toEqual(['🎉']);
expect(component.filteredCustomEmojis().map((emoji) => emoji.id)).toEqual(['party-id']);
});
it('shows an empty search state when no emoji matches the query', () => {
const component = createComponent(true, savedEmojis);
component.setSearchQuery('missing-emoji');
expect(component.showEmptySearchState()).toBe(true);
});
it('clears the search query when the picker is dismissed', () => {
const component = createComponent(true, savedEmojis);
component.setSearchQuery('party');
component.dismiss();
expect(component.searchQuery()).toBe('');
});
});

View File

@@ -0,0 +1,160 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
HostListener,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePlus,
lucideSearch,
lucideSmile,
lucideUpload,
lucideX
} from '@ng-icons/lucide';
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import {
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
UNICODE_EMOJI_PICKER_ENTRIES,
buildCustomEmojiMessageToken,
filterCustomEmojisForPicker,
filterUnicodeEmojiPickerEntries,
normalizeEmojiPickerSearchQuery
} from '../../domain/custom-emoji.rules';
@Component({
selector: 'app-custom-emoji-picker',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
templateUrl: './custom-emoji-picker.component.html'
})
export class CustomEmojiPickerComponent {
private readonly customEmoji = inject(CustomEmojiService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly currentUserId = input<string | null>(null);
readonly compact = input(true);
readonly emojiSelected = output<string>();
readonly dismissed = output();
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
readonly modalOpen = signal(false);
readonly uploadError = signal<string | null>(null);
readonly uploading = signal(false);
readonly shortcuts = this.customEmoji.shortcutEntries;
readonly customEmojis = this.customEmoji.emojis;
readonly searchQuery = signal('');
readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries(
UNICODE_EMOJI_PICKER_ENTRIES,
this.searchQuery()
));
readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker(
this.customEmojis(),
this.searchQuery()
));
readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0);
readonly showEmptySearchState = computed(() => this.hasActiveSearch()
&& this.filteredUnicodeEntries().length === 0
&& this.filteredCustomEmojis().length === 0);
private readonly loadForUser = effect(() => {
void this.customEmoji.loadForUser(this.currentUserId());
});
setSearchQuery(query: string): void {
this.searchQuery.set(query);
}
onSearchInput(event: Event): void {
this.setSearchQuery((event.target as HTMLInputElement).value);
}
selectShortcut(entry: EmojiShortcutEntry): void {
this.customEmoji.recordUsage(entry, this.currentUserId());
this.emojiSelected.emit(entry.kind === 'custom' ? buildCustomEmojiMessageToken(entry.emoji) : entry.emoji);
}
selectUnicode(emoji: string): void {
this.customEmoji.recordUsage({ kind: 'unicode',
key: `unicode:${emoji}`,
emoji,
label: emoji }, this.currentUserId());
this.emojiSelected.emit(emoji);
this.modalOpen.set(false);
}
selectCustom(emoji: CustomEmoji): void {
this.customEmoji.recordUsage({ kind: 'custom',
key: `custom:${emoji.id}`,
emoji,
label: emoji.name }, this.currentUserId());
this.emojiSelected.emit(buildCustomEmojiMessageToken(emoji));
this.modalOpen.set(false);
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target;
if (target == null || this.host.nativeElement.contains(target as Node)) {
return;
}
this.dismiss();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.dismiss();
}
openModal(): void {
this.modalOpen.set(true);
}
closeModal(): void {
this.dismiss();
}
dismiss(): void {
this.modalOpen.set(false);
this.searchQuery.set('');
this.dismissed.emit();
}
async uploadEmoji(event: Event): Promise<void> {
const inputElement = event.target as HTMLInputElement;
const file = inputElement.files?.[0];
const userId = this.currentUserId();
inputElement.value = '';
if (!file || !userId) {
return;
}
this.uploadError.set(null);
this.uploading.set(true);
try {
const emoji = await this.customEmoji.createFromFile(file, userId);
this.selectCustom(emoji);
} catch (error) {
this.uploadError.set(error instanceof Error ? error.message : 'Unable to upload emoji.');
} finally {
this.uploading.set(false);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from './application/custom-emoji.service';
export * from './application/custom-emoji-sync.effects';
export * from './domain/custom-emoji.rules';
export * from './feature/custom-emoji-picker/custom-emoji-picker.component';

View File

@@ -13,6 +13,7 @@ import { DirectMessageRepository } from '../../infrastructure/direct-message.rep
import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji';
import {
advanceDirectMessageStatus,
createDirectConversation,
@@ -65,6 +66,7 @@ export class DirectMessageService {
private readonly offlineQueue = inject(OfflineMessageQueueService);
private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade);
private readonly customEmoji = inject(CustomEmojiService);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -694,6 +696,12 @@ export class DirectMessageService {
await this.persistConversation(ownerId, updatedConversation);
if (payload.type === 'edit' && payload.content) {
this.customEmoji.pushEmojisInContent(payload.content);
} else if (payload.type === 'reaction-add' && payload.reaction?.emoji) {
this.customEmoji.pushEmojisInContent(payload.reaction.emoji);
}
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-mutation',
@@ -761,6 +769,8 @@ export class DirectMessageService {
let sentCount = 0;
this.customEmoji.pushEmojisInContent(message.content);
for (const recipientId of recipientIds) {
if (this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message',

View File

@@ -11,6 +11,7 @@ import {
lucideClock3
} from '@ng-icons/lucide';
import { ChatMessageMarkdownComponent } from '../../../chat';
import { isSingleUnicodeEmojiOnlyMessage } from '../../../custom-emoji';
import type { DirectMessage } from '../../domain/models/direct-message.model';
const RICH_MARKDOWN_PATTERNS = [
@@ -40,7 +41,8 @@ export class DmMessageComponent {
readonly isOutgoing = computed(() => this.message().senderId === this.currentUserId());
requiresRichMarkdown(content: string): boolean {
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
return isSingleUnicodeEmojiOnlyMessage(content)
|| RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
}
statusIcon(status: DirectMessage['status']): string {