feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -19,6 +19,7 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
||||
| **Shared kernel** | Cross-domain contracts in `src/app/shared-kernel/` — wire-format models, P2P transfer utilities, plugin contracts, signaling contracts — imported by multiple domains. | "common", "core" |
|
||||
| **Infrastructure** | Technical-runtime concerns shared by domains: `persistence/` (client-side store wiring) and `realtime/` (WebSocket adapter). Not a domain. | "shared", "lib" |
|
||||
| **Rules file** | A pure-function module suffixed `*.rules.ts` that encodes domain logic without Angular or NgRx dependencies — easy to unit-test. | "helpers", "utils" |
|
||||
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
||||
|
||||
## Relationships
|
||||
|
||||
@@ -26,6 +27,7 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
||||
- A **Domain** may consume **Shared kernel** contracts but must never import from another **Domain** directly — cross-domain coupling goes through the shared kernel or NgRx events.
|
||||
- The **Realtime** infrastructure adapts server WebSocket envelopes (defined in `src/app/shared-kernel/signaling-contracts.ts` and mirrored in `server/src/websocket/types.ts`) into NgRx actions consumed by domains.
|
||||
- The **Plugins** domain consumes plugin manifests loaded by Electron's `plugin-library.ts` and exposes a sandboxed runtime that other domains may hook into.
|
||||
- The **Custom emoji** domain owns custom emoji assets and usage ranking; the **Chat** domain consumes it for reactions and composer insertion.
|
||||
|
||||
## Boundaries / IO
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { messagesReducer } from './store/messages/messages.reducer';
|
||||
import { usersReducer } from './store/users/users.reducer';
|
||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { NotificationsEffects } from './domains/notifications';
|
||||
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||
@@ -37,6 +38,7 @@ export const appConfig: ApplicationConfig = {
|
||||
}),
|
||||
provideEffects([
|
||||
NotificationsEffects,
|
||||
CustomEmojiSyncEffects,
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
UserAvatarEffects,
|
||||
|
||||
@@ -290,6 +290,8 @@ export interface ElectronApi {
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
copyFile: (sourceFilePath: string, destinationFilePath: string) => Promise<boolean>;
|
||||
getPathForFile: (file: File) => string;
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
getFileUrl: (filePath: string) => Promise<string | null>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||
[currentUserId]="currentUser()?.id ?? null"
|
||||
[klipyEnabled]="klipyEnabled()"
|
||||
[klipySignalSource]="currentRoom()"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 `})`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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(``);
|
||||
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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ``;
|
||||
}
|
||||
|
||||
return ``;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
toju-app/src/app/domains/custom-emoji/index.ts
Normal file
4
toju-app/src/app/domains/custom-emoji/index.ts
Normal 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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
@if (customEmojiMenu(); as emojiMenu) {
|
||||
<app-context-menu
|
||||
[x]="emojiMenu.posX"
|
||||
[y]="emojiMenu.posY"
|
||||
[width]="'w-56'"
|
||||
sheetTitle="Emoji"
|
||||
(closed)="close()"
|
||||
>
|
||||
@if (emojiMenu.action === 'add') {
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
(click)="addCustomEmojiToLibrary()"
|
||||
>
|
||||
Add to emoji library
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item-danger"
|
||||
(click)="removeCustomEmojiFromLibrary()"
|
||||
>
|
||||
Remove from emoji library
|
||||
</button>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@if (params()) {
|
||||
<app-context-menu
|
||||
[x]="params()!.posX"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { CustomEmojiService } from '../../../domains/custom-emoji';
|
||||
import { NativeContextMenuComponent } from './native-context-menu.component';
|
||||
|
||||
function createDomTarget(action: 'add' | 'remove', emojiId: string): Element {
|
||||
const host = {
|
||||
getAttribute: vi.fn((name: string) => name === 'data-custom-emoji-id' ? emojiId : null)
|
||||
} as unknown as Element;
|
||||
|
||||
return {
|
||||
closest: vi.fn((selector: string) => {
|
||||
if (action === 'remove' && selector.includes('data-custom-emoji-library')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
if (action === 'add' && selector.includes('data-custom-emoji') && !selector.includes('library')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
} as unknown as Element;
|
||||
}
|
||||
|
||||
describe('NativeContextMenuComponent', () => {
|
||||
function createComponent(options?: {
|
||||
findEmoji?: unknown;
|
||||
isEmojiInLibrary?: boolean;
|
||||
}): NativeContextMenuComponent {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
NativeContextMenuComponent,
|
||||
{
|
||||
provide: DOCUMENT,
|
||||
useValue: {
|
||||
getSelection: () => null,
|
||||
createRange: () => ({
|
||||
selectNodeContents: vi.fn(),
|
||||
collapse: vi.fn(),
|
||||
cloneRange: () => ({})
|
||||
}),
|
||||
body: null
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ViewportService,
|
||||
useValue: {
|
||||
isMobile: () => false
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ElectronBridgeService,
|
||||
useValue: {
|
||||
isAvailable: false,
|
||||
getApi: () => null
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: CustomEmojiService,
|
||||
useValue: {
|
||||
findEmoji: vi.fn(() => options?.findEmoji ?? { id: 'party-id' }),
|
||||
isEmojiInLibrary: vi.fn(() => options?.isEmojiInLibrary ?? false),
|
||||
saveEmojiToLibrary: vi.fn(async () => undefined),
|
||||
removeEmojiFromLibrary: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(NativeContextMenuComponent));
|
||||
}
|
||||
|
||||
it('opens the add-to-library menu for marked chat emoji targets', () => {
|
||||
const component = createComponent();
|
||||
const target = createDomTarget('add', 'party-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 12,
|
||||
clientY: 34
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toEqual({
|
||||
action: 'add',
|
||||
emojiId: 'party-id',
|
||||
posX: 12,
|
||||
posY: 34
|
||||
});
|
||||
|
||||
expect(component.params()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the remove-from-library menu for marked picker emoji targets', () => {
|
||||
const component = createComponent({ isEmojiInLibrary: true });
|
||||
const target = createDomTarget('remove', 'wave-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 8,
|
||||
clientY: 16
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toEqual({
|
||||
action: 'remove',
|
||||
emojiId: 'wave-id',
|
||||
posX: 8,
|
||||
posY: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open a custom emoji menu when the emoji is already in the library', () => {
|
||||
const component = createComponent({ isEmojiInLibrary: true });
|
||||
const target = createDomTarget('add', 'party-id');
|
||||
|
||||
component.onDocumentContextMenu({
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 8,
|
||||
clientY: 16
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
expect(component.customEmojiMenu()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import { ElectronBridgeService } from '../../../core/platform/electron/electron-
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||
import {
|
||||
CustomEmojiContextMenuTarget,
|
||||
CustomEmojiService,
|
||||
resolveCustomEmojiContextMenuTarget
|
||||
} from '../../../domains/custom-emoji';
|
||||
|
||||
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
|
||||
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
|
||||
@@ -53,8 +58,10 @@ const NON_TEXT_INPUT_TYPES = new Set([
|
||||
})
|
||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
params = signal<ContextMenuParams | null>(null);
|
||||
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private cleanup: (() => void) | null = null;
|
||||
@@ -62,6 +69,17 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
onDocumentContextMenu(event: MouseEvent): void {
|
||||
const customEmojiTarget = resolveCustomEmojiContextMenuTarget(event.target);
|
||||
|
||||
if (customEmojiTarget) {
|
||||
this.openCustomEmojiMenu(event, customEmojiTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// On mobile (non-Electron), let the OS-native context menu handle text inputs,
|
||||
// selection, links, and images. Intercepting here suppresses the OS menu and
|
||||
// leaves the user without copy/paste/select-all affordances.
|
||||
@@ -87,6 +105,8 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.document.addEventListener('contextmenu', this.onDocumentContextMenuCapture, true);
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.onContextMenu) {
|
||||
@@ -109,15 +129,45 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.document.removeEventListener('contextmenu', this.onDocumentContextMenuCapture, true);
|
||||
this.cleanup?.();
|
||||
this.cleanup = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentContextMenuCapture = (event: MouseEvent): void => {
|
||||
if (resolveCustomEmojiContextMenuTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
close(): void {
|
||||
this.params.set(null);
|
||||
this.customEmojiMenu.set(null);
|
||||
this.selectionSnapshot = null;
|
||||
}
|
||||
|
||||
async addCustomEmojiToLibrary(): Promise<void> {
|
||||
const menu = this.customEmojiMenu();
|
||||
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.customEmoji.saveEmojiToLibrary(menu.emojiId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async removeCustomEmojiFromLibrary(): Promise<void> {
|
||||
const menu = this.customEmojiMenu();
|
||||
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.customEmoji.removeEmojiFromLibrary(menu.emojiId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
@@ -136,6 +186,31 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
void this.runAction(action);
|
||||
}
|
||||
|
||||
private openCustomEmojiMenu(event: MouseEvent, target: CustomEmojiContextMenuTarget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.params.set(null);
|
||||
this.selectionSnapshot = null;
|
||||
|
||||
if (target.action === 'add') {
|
||||
if (!this.customEmoji.findEmoji(target.emojiId) || this.customEmoji.isEmojiInLibrary(target.emojiId)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.action === 'remove' && !this.customEmoji.isEmojiInLibrary(target.emojiId)) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.customEmojiMenu.set({
|
||||
...target,
|
||||
posX: event.clientX,
|
||||
posY: event.clientY
|
||||
});
|
||||
}
|
||||
|
||||
private async runAction(action: ContextMenuAction): Promise<void> {
|
||||
try {
|
||||
switch (action) {
|
||||
@@ -554,11 +629,15 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private getTargetElement(target: EventTarget | null): Element | null {
|
||||
if (target instanceof Element) {
|
||||
return target;
|
||||
if (target && typeof (target as Element).closest === 'function') {
|
||||
return target as Element;
|
||||
}
|
||||
|
||||
return target instanceof Node ? target.parentElement : null;
|
||||
const parentElement = (target as Node | null)?.parentElement;
|
||||
|
||||
return parentElement && typeof parentElement.closest === 'function'
|
||||
? parentElement
|
||||
: null;
|
||||
}
|
||||
|
||||
private resolveEditableTarget(target: Element | null): ContextMenuTarget | null {
|
||||
@@ -592,13 +671,17 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private resolveImageUrl(target: Element | null): string {
|
||||
const imageTarget = target instanceof HTMLImageElement
|
||||
? target
|
||||
: target?.closest('img');
|
||||
const imageTarget = this.asImageLikeElement(target) ?? this.asImageLikeElement(target?.closest('img') ?? null);
|
||||
|
||||
return imageTarget instanceof HTMLImageElement
|
||||
? imageTarget.currentSrc || imageTarget.src
|
||||
: '';
|
||||
return imageTarget?.currentSrc || imageTarget?.src || '';
|
||||
}
|
||||
|
||||
private asImageLikeElement(target: Element | null): { currentSrc?: string; src?: string } | null {
|
||||
if (!target || !('src' in target) || typeof target.src !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return target as { currentSrc?: string; src: string };
|
||||
}
|
||||
|
||||
private isTextControl(target: ContextMenuTarget | null): target is TextControlElement {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Persistence Infrastructure
|
||||
|
||||
Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime.
|
||||
Offline-first storage layer that keeps messages, users, rooms, reactions, custom emoji, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime.
|
||||
|
||||
Persisted data is treated as belonging to the authenticated user that created it. In the browser runtime, IndexedDB is user-scoped: the renderer opens a per-user database for the active account and switches scopes during authentication so one account never boots into another account's stored rooms, messages, or settings.
|
||||
|
||||
@@ -45,11 +45,12 @@ Both backends store the same entity types:
|
||||
| `users` | `oderId` | | User profiles |
|
||||
| `rooms` | `id` | | Server/room metadata |
|
||||
| `reactions` | `oderId-emoji-messageId` | | Emoji reactions, deduplicated per user |
|
||||
| `customEmojis` / `custom_emojis` | `id` | `updatedAt`, `creatorUserId` | Known custom emoji image assets synced over peer data channels; `savedByUser` controls picker/library membership |
|
||||
| `bans` | `oderId` | | Active bans per room |
|
||||
| `attachments` | `id` | | File/image metadata tied to messages |
|
||||
| `meta` | `key` | | Key-value pairs (e.g. `currentUserId`) |
|
||||
|
||||
The IndexedDB schema is at version 2.
|
||||
The IndexedDB schema is at version 3.
|
||||
|
||||
The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative mixed text-and-voice channel list from server-directory responses so every member converges on the same room structure.
|
||||
|
||||
@@ -119,6 +120,8 @@ Every method on `DatabaseService` maps 1:1 to both backends:
|
||||
|
||||
**Attachments**: `saveAttachment`, `getAttachmentsForMessage`, `getAllAttachments`, `deleteAttachmentsForMessage`
|
||||
|
||||
**Custom emoji**: `saveCustomEmoji`, `getCustomEmojis`, `deleteCustomEmoji`
|
||||
|
||||
**Lifecycle**: `initialize`, `clearAllData`
|
||||
|
||||
The facade also exposes an `isReady` signal that flips to `true` after `initialize()` completes, so components can gate rendering until the DB is available.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta } from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import type { RoomMessageStats } from './database.service';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { RoomMessageStats } from './database.service';
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 2;
|
||||
const DATABASE_VERSION = 3;
|
||||
/** Names of every object store used by the application. */
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
@@ -25,6 +25,7 @@ const STORE_REACTIONS = 'reactions';
|
||||
const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
const STORE_CUSTOM_EMOJIS = 'customEmojis';
|
||||
/** All object store names, used when clearing the entire database. */
|
||||
const ALL_STORE_NAMES: string[] = [
|
||||
STORE_MESSAGES,
|
||||
@@ -33,6 +34,7 @@ const ALL_STORE_NAMES: string[] = [
|
||||
STORE_REACTIONS,
|
||||
STORE_BANS,
|
||||
STORE_ATTACHMENTS,
|
||||
STORE_CUSTOM_EMOJIS,
|
||||
STORE_META
|
||||
];
|
||||
|
||||
@@ -334,6 +336,18 @@ export class BrowserDatabaseService {
|
||||
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
|
||||
}
|
||||
|
||||
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
|
||||
await this.put(STORE_CUSTOM_EMOJIS, emoji);
|
||||
}
|
||||
|
||||
async getCustomEmojis(): Promise<CustomEmoji[]> {
|
||||
return this.getAll<CustomEmoji>(STORE_CUSTOM_EMOJIS);
|
||||
}
|
||||
|
||||
async deleteCustomEmoji(emojiId: string): Promise<void> {
|
||||
await this.deleteRecord(STORE_CUSTOM_EMOJIS, emojiId);
|
||||
}
|
||||
|
||||
/** Delete every attachment record for a specific message. */
|
||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
|
||||
@@ -459,6 +473,11 @@ export class BrowserDatabaseService {
|
||||
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
|
||||
|
||||
const customEmojisStore = this.ensureStore(database, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(customEmojisStore, 'updatedAt', 'updatedAt');
|
||||
this.ensureIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
|
||||
}
|
||||
|
||||
private ensureStore(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let browserDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let electronDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
browserDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
electronDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): DatabaseService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
DatabaseService,
|
||||
{ provide: PlatformService, useValue: { isBrowser: true, isElectron: false } },
|
||||
{ provide: BrowserDatabaseService, useValue: browserDatabase },
|
||||
{ provide: ElectronDatabaseService, useValue: electronDatabase }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(DatabaseService));
|
||||
}
|
||||
|
||||
it('initializes the selected backend before the first delegated read', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta } from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
@@ -36,6 +36,7 @@ export class DatabaseService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
@@ -47,12 +48,39 @@ export class DatabaseService {
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
async initialize(): Promise<void> {
|
||||
await this.backend.initialize();
|
||||
this.isReady.set(true);
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = this.backend;
|
||||
|
||||
this.initializationPromise = backend.initialize()
|
||||
.then(() => {
|
||||
this.isReady.set(true);
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializationPromise = null;
|
||||
});
|
||||
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
if (this.isReady())
|
||||
return;
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private async withReady<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await this.ensureReady();
|
||||
|
||||
return operation();
|
||||
}
|
||||
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||
saveMessage(message: Message) { return this.withReady(() => this.backend.saveMessage(message)); }
|
||||
|
||||
/** Retrieve the latest messages for a room or channel with optional pagination.
|
||||
*
|
||||
@@ -66,95 +94,104 @@ export class DatabaseService {
|
||||
offset = 0,
|
||||
channelId?: string,
|
||||
beforeTimestamp?: number
|
||||
) { return this.backend.getMessages(roomId, limit, offset, channelId, beforeTimestamp); }
|
||||
) { return this.withReady(() => this.backend.getMessages(roomId, limit, offset, channelId, beforeTimestamp)); }
|
||||
|
||||
/** Retrieve messages newer than a given timestamp for a room. */
|
||||
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }
|
||||
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.withReady(() => this.backend.getMessagesSince(roomId, sinceTimestamp)); }
|
||||
|
||||
/** Retrieve aggregate message stats for sync handshakes without loading history. */
|
||||
getRoomMessageStats(roomId: string) { return this.backend.getRoomMessageStats(roomId); }
|
||||
getRoomMessageStats(roomId: string) { return this.withReady(() => this.backend.getRoomMessageStats(roomId)); }
|
||||
|
||||
/** Permanently delete a message by ID. */
|
||||
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
|
||||
deleteMessage(messageId: string) { return this.withReady(() => this.backend.deleteMessage(messageId)); }
|
||||
|
||||
/** Apply partial updates to an existing message. */
|
||||
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
|
||||
updateMessage(messageId: string, updates: Partial<Message>) { return this.withReady(() => this.backend.updateMessage(messageId, updates)); }
|
||||
|
||||
/** Retrieve a single message by ID. */
|
||||
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
|
||||
getMessageById(messageId: string) { return this.withReady(() => this.backend.getMessageById(messageId)); }
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
|
||||
clearRoomMessages(roomId: string) { return this.withReady(() => this.backend.clearRoomMessages(roomId)); }
|
||||
|
||||
/** Persist a reaction. */
|
||||
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
|
||||
saveReaction(reaction: Reaction) { return this.withReady(() => this.backend.saveReaction(reaction)); }
|
||||
|
||||
/** Remove a specific reaction (user + emoji + message). */
|
||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
|
||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.withReady(() => this.backend.removeReaction(messageId, userId, emoji)); }
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
|
||||
getReactionsForMessage(messageId: string) { return this.withReady(() => this.backend.getReactionsForMessage(messageId)); }
|
||||
|
||||
/** Persist a user record. */
|
||||
saveUser(user: User) { return this.backend.saveUser(user); }
|
||||
saveUser(user: User) { return this.withReady(() => this.backend.saveUser(user)); }
|
||||
|
||||
/** Retrieve a user by ID. */
|
||||
getUser(userId: string) { return this.backend.getUser(userId); }
|
||||
getUser(userId: string) { return this.withReady(() => this.backend.getUser(userId)); }
|
||||
|
||||
/** Retrieve the current (logged-in) user. */
|
||||
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||
getCurrentUser() { return this.withReady(() => this.backend.getCurrentUser()); }
|
||||
|
||||
/** Retrieve the persisted current user ID without loading the full user. */
|
||||
getCurrentUserId() { return this.backend.getCurrentUserId(); }
|
||||
getCurrentUserId() { return this.withReady(() => this.backend.getCurrentUserId()); }
|
||||
|
||||
/** Store the current user ID. */
|
||||
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||
setCurrentUserId(userId: string) { return this.withReady(() => this.backend.setCurrentUserId(userId)); }
|
||||
|
||||
/** Retrieve users in a room. */
|
||||
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
|
||||
getUsersByRoom(roomId: string) { return this.withReady(() => this.backend.getUsersByRoom(roomId)); }
|
||||
|
||||
/** Apply partial updates to an existing user. */
|
||||
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
|
||||
updateUser(userId: string, updates: Partial<User>) { return this.withReady(() => this.backend.updateUser(userId, updates)); }
|
||||
|
||||
/** Persist a room record. */
|
||||
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
||||
saveRoom(room: Room) { return this.withReady(() => this.backend.saveRoom(room)); }
|
||||
|
||||
/** Retrieve a room by ID. */
|
||||
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
||||
getRoom(roomId: string) { return this.withReady(() => this.backend.getRoom(roomId)); }
|
||||
|
||||
/** Return every persisted room. */
|
||||
getAllRooms() { return this.backend.getAllRooms(); }
|
||||
getAllRooms() { return this.withReady(() => this.backend.getAllRooms()); }
|
||||
|
||||
/** Delete a room and its associated messages. */
|
||||
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
|
||||
deleteRoom(roomId: string) { return this.withReady(() => this.backend.deleteRoom(roomId)); }
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
|
||||
updateRoom(roomId: string, updates: Partial<Room>) { return this.withReady(() => this.backend.updateRoom(roomId, updates)); }
|
||||
|
||||
/** Persist a ban entry. */
|
||||
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
||||
saveBan(ban: BanEntry) { return this.withReady(() => this.backend.saveBan(ban)); }
|
||||
|
||||
/** Remove a ban by oderId. */
|
||||
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
||||
removeBan(oderId: string) { return this.withReady(() => this.backend.removeBan(oderId)); }
|
||||
|
||||
/** Return active bans for a room. */
|
||||
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
|
||||
getBansForRoom(roomId: string) { return this.withReady(() => this.backend.getBansForRoom(roomId)); }
|
||||
|
||||
/** Check whether a user is currently banned from a room. */
|
||||
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
||||
isUserBanned(userId: string, roomId: string) { return this.withReady(() => this.backend.isUserBanned(userId, roomId)); }
|
||||
|
||||
/** Persist attachment metadata. */
|
||||
saveAttachment(attachment: ChatAttachmentMeta) { return this.backend.saveAttachment(attachment); }
|
||||
saveAttachment(attachment: ChatAttachmentMeta) { return this.withReady(() => this.backend.saveAttachment(attachment)); }
|
||||
|
||||
/** Return all attachment records for a message. */
|
||||
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||
getAttachmentsForMessage(messageId: string) { return this.withReady(() => this.backend.getAttachmentsForMessage(messageId)); }
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
getAllAttachments() { return this.backend.getAllAttachments(); }
|
||||
getAllAttachments() { return this.withReady(() => this.backend.getAllAttachments()); }
|
||||
|
||||
/** Persist a custom emoji asset. */
|
||||
saveCustomEmoji(emoji: CustomEmoji) { return this.withReady(() => this.backend.saveCustomEmoji(emoji)); }
|
||||
|
||||
/** Return every known custom emoji asset. */
|
||||
getCustomEmojis() { return this.withReady(() => this.backend.getCustomEmojis()); }
|
||||
|
||||
/** Delete a custom emoji asset. */
|
||||
deleteCustomEmoji(emojiId: string) { return this.withReady(() => this.backend.deleteCustomEmoji(emojiId)); }
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
||||
deleteAttachmentsForMessage(messageId: string) { return this.withReady(() => this.backend.deleteAttachmentsForMessage(messageId)); }
|
||||
|
||||
/** Wipe all persisted data. */
|
||||
clearAllData() { return this.backend.clearAllData(); }
|
||||
clearAllData() { return this.withReady(() => this.backend.clearAllData()); }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { CustomEmoji } from '../../shared-kernel';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import type { RoomMessageStats } from './database.service';
|
||||
@@ -203,6 +204,18 @@ export class ElectronDatabaseService {
|
||||
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
|
||||
}
|
||||
|
||||
saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
|
||||
return this.api.command({ type: 'save-custom-emoji', payload: { emoji } });
|
||||
}
|
||||
|
||||
getCustomEmojis(): Promise<CustomEmoji[]> {
|
||||
return this.api.query<CustomEmoji[]>({ type: 'get-custom-emojis', payload: {} });
|
||||
}
|
||||
|
||||
deleteCustomEmoji(emojiId: string): Promise<void> {
|
||||
return this.api.command({ type: 'delete-custom-emoji', payload: { emojiId } });
|
||||
}
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
return this.api.command({ type: 'delete-attachments-for-message', payload: { messageId } });
|
||||
|
||||
@@ -10,6 +10,11 @@ import type { VoiceState } from './voice-state.models';
|
||||
import type { GameActivity } from './game-activity.models';
|
||||
import type { BanEntry } from './moderation.models';
|
||||
import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts';
|
||||
import type {
|
||||
CustomEmoji,
|
||||
CustomEmojiSummaryItem,
|
||||
CustomEmojiTransferManifest
|
||||
} from './custom-emoji.models';
|
||||
import type {
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
@@ -96,6 +101,10 @@ export interface ChatEventBase {
|
||||
directMessageSync?: DirectMessageSyncEventPayload;
|
||||
directCall?: DirectCallEventPayload;
|
||||
pluginMessage?: unknown;
|
||||
customEmoji?: CustomEmoji;
|
||||
customEmojiTransfer?: CustomEmojiTransferManifest;
|
||||
customEmojiSummaries?: CustomEmojiSummaryItem[];
|
||||
customEmojiId?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageEvent extends ChatEventBase {
|
||||
@@ -424,6 +433,31 @@ export interface PluginMessageBusPeerEvent extends ChatEventBase {
|
||||
pluginMessage: unknown;
|
||||
}
|
||||
|
||||
export interface CustomEmojiSummaryEvent extends ChatEventBase {
|
||||
type: 'custom-emoji-summary';
|
||||
customEmojiSummaries: CustomEmojiSummaryItem[];
|
||||
}
|
||||
|
||||
export interface CustomEmojiRequestEvent extends ChatEventBase {
|
||||
type: 'custom-emoji-request';
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface CustomEmojiFullEvent extends ChatEventBase {
|
||||
type: 'custom-emoji-full';
|
||||
customEmoji?: CustomEmoji;
|
||||
customEmojiTransfer?: CustomEmojiTransferManifest;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface CustomEmojiChunkEvent extends ChatEventBase {
|
||||
type: 'custom-emoji-chunk';
|
||||
customEmojiId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** Discriminated union of all P2P chat events. Narrow via `event.type`. */
|
||||
export type ChatEvent =
|
||||
| ChatMessageEvent
|
||||
@@ -481,7 +515,11 @@ export type ChatEvent =
|
||||
| DirectMessageSyncRequestPeerEvent
|
||||
| DirectMessageSyncPeerEvent
|
||||
| DirectCallPeerEvent
|
||||
| PluginMessageBusPeerEvent;
|
||||
| PluginMessageBusPeerEvent
|
||||
| CustomEmojiSummaryEvent
|
||||
| CustomEmojiRequestEvent
|
||||
| CustomEmojiFullEvent
|
||||
| CustomEmojiChunkEvent;
|
||||
|
||||
/** All possible `type` values, derived from the union. */
|
||||
export type ChatEventType = ChatEvent['type'];
|
||||
|
||||
36
toju-app/src/app/shared-kernel/custom-emoji.models.ts
Normal file
36
toju-app/src/app/shared-kernel/custom-emoji.models.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CustomEmoji {
|
||||
id: string;
|
||||
name: string;
|
||||
creatorUserId: string;
|
||||
dataUrl: string;
|
||||
hash: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
savedByUser?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEmojiSummaryItem {
|
||||
id: string;
|
||||
hash: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type CustomEmojiTransferManifest = Omit<CustomEmoji, 'dataUrl'>;
|
||||
|
||||
export type EmojiShortcutEntry = UnicodeEmojiShortcutEntry | CustomEmojiShortcutEntry;
|
||||
|
||||
export interface UnicodeEmojiShortcutEntry {
|
||||
kind: 'unicode';
|
||||
key: string;
|
||||
emoji: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CustomEmojiShortcutEntry {
|
||||
kind: 'custom';
|
||||
key: string;
|
||||
emoji: CustomEmoji;
|
||||
label: string;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export * from './chat-events';
|
||||
export * from './media-preferences';
|
||||
export * from './signaling-contracts';
|
||||
export * from './attachment-contracts';
|
||||
export * from './custom-emoji.models';
|
||||
export * from './plugin-system.contracts';
|
||||
export * from './p2p-transfer.constants';
|
||||
export * from './p2p-transfer.utils';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
MEDIA_SEEK_SLIDER_STEPS,
|
||||
mediaSeekSliderValue,
|
||||
mediaSeekTimeFromSliderValue
|
||||
} from './chat-video-player-seek.rules';
|
||||
|
||||
describe('chat-video-player seek slider rules', () => {
|
||||
it('maps playback time to a fixed high-resolution slider range', () => {
|
||||
expect(mediaSeekSliderValue(30, 120)).toBe(2500);
|
||||
expect(mediaSeekSliderValue(60, 120)).toBe(5000);
|
||||
expect(mediaSeekSliderValue(120, 120)).toBe(MEDIA_SEEK_SLIDER_STEPS);
|
||||
});
|
||||
|
||||
it('returns zero when duration is unknown', () => {
|
||||
expect(mediaSeekSliderValue(12, 0)).toBe(0);
|
||||
expect(mediaSeekSliderValue(12, Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('converts slider values back to playback time', () => {
|
||||
expect(mediaSeekTimeFromSliderValue(2500, 120)).toBe(30);
|
||||
expect(mediaSeekTimeFromSliderValue(MEDIA_SEEK_SLIDER_STEPS, 120)).toBe(120);
|
||||
});
|
||||
|
||||
it('round-trips sub-second positions without snapping to whole seconds', () => {
|
||||
const duration = 367.891;
|
||||
const currentTime = 123.456;
|
||||
const sliderValue = mediaSeekSliderValue(currentTime, duration);
|
||||
const restoredTime = mediaSeekTimeFromSliderValue(sliderValue, duration);
|
||||
|
||||
expect(Math.abs(restoredTime - currentTime)).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Fixed resolution for HTML range seek inputs - avoids 1-second steps when max = duration. */
|
||||
export const MEDIA_SEEK_SLIDER_STEPS = 10_000;
|
||||
|
||||
export function mediaSeekSliderValue(currentTimeSeconds: number, durationSeconds: number): number {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
|
||||
return 0;
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(durationSeconds, currentTimeSeconds));
|
||||
|
||||
return (clampedTime / durationSeconds) * MEDIA_SEEK_SLIDER_STEPS;
|
||||
}
|
||||
|
||||
export function mediaSeekTimeFromSliderValue(sliderValue: number, durationSeconds: number): number {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
|
||||
return 0;
|
||||
|
||||
const clampedValue = Math.max(0, Math.min(MEDIA_SEEK_SLIDER_STEPS, sliderValue));
|
||||
|
||||
return (clampedValue / MEDIA_SEEK_SLIDER_STEPS) * durationSeconds;
|
||||
}
|
||||
@@ -2,32 +2,11 @@
|
||||
#playerRoot
|
||||
class="video-player-shell"
|
||||
[class.fullscreen]="isFullscreen()"
|
||||
[class.controls-hidden]="isFullscreen() && !controlsVisible()"
|
||||
[class.paused]="!isPlaying()"
|
||||
[class.controls-hidden]="!controlsVisible()"
|
||||
(mouseenter)="onPlayerMouseMove()"
|
||||
(mousemove)="onPlayerMouseMove()"
|
||||
>
|
||||
@if (!isFullscreen()) {
|
||||
<div class="video-top-bar">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="video-stage"
|
||||
(click)="onVideoClick()"
|
||||
@@ -52,6 +31,30 @@
|
||||
(volumechange)="onVolumeChange()"
|
||||
></video>
|
||||
|
||||
<div
|
||||
class="video-top-overlay"
|
||||
[class.hidden-overlay]="!controlsVisible()"
|
||||
>
|
||||
<div class="video-meta min-w-0 flex-1">
|
||||
<div class="video-filename">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="video-size">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload(); $event.stopPropagation()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!isPlaying()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -66,26 +69,13 @@
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-bottom-bar"
|
||||
[class.fullscreen-overlay]="isFullscreen()"
|
||||
[class.hidden-overlay]="isFullscreen() && !controlsVisible()"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="durationSeconds() || 0"
|
||||
[value]="currentTimeSeconds()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.background]="seekTrackBackground()"
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
|
||||
<div class="video-controls-row">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
class="video-bottom-overlay"
|
||||
[class.hidden-overlay]="!controlsVisible()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="video-controls-row">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
@@ -99,62 +89,59 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="video-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
|
||||
</div>
|
||||
|
||||
<div class="video-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="video-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<span class="video-time-label">{{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }}</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="isMuted() ? 0 : volumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
aria-label="Video volume"
|
||||
[max]="seekSliderSteps"
|
||||
step="any"
|
||||
[value]="seekSliderValue()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.--progress.%]="progressPercent()"
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (isFullscreen()) {
|
||||
<div class="video-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="video-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="isMuted() ? 0 : volumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.--progress.%]="volumeProgressPercent()"
|
||||
aria-label="Video volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
(click)="toggleFullscreen()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleFullscreen()"
|
||||
class="video-control-btn"
|
||||
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,52 +6,28 @@
|
||||
.video-player-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.16), transparent 38%),
|
||||
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(222deg 47% 8%) 100%);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-top-bar,
|
||||
.video-bottom-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.video-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 82%) 0%, rgb(6 10 18 / 30%) 100%);
|
||||
}
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .video-stage {
|
||||
height: 100vh;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen.controls-hidden .video-stage,
|
||||
.video-player-shell.fullscreen.controls-hidden .chat-video-element {
|
||||
.video-player-shell.controls-hidden:not(.paused) .video-stage,
|
||||
.video-player-shell.controls-hidden:not(.paused) .chat-video-element {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
@@ -59,9 +35,10 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: min(28rem, 70vh);
|
||||
background: rgb(0 0 0 / 85%);
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .chat-video-element {
|
||||
@@ -70,94 +47,120 @@
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.video-top-overlay,
|
||||
.video-bottom-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
color: #fff;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.video-top-overlay {
|
||||
top: 0;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(180deg, rgb(0 0 0 / 72%) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.video-bottom-overlay {
|
||||
bottom: 0;
|
||||
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
|
||||
background: linear-gradient(0deg, rgb(0 0 0 / 78%) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.video-top-overlay.hidden-overlay,
|
||||
.video-bottom-overlay.hidden-overlay {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-filename {
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-size {
|
||||
margin-top: 0.125rem;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.video-play-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: rgb(8 14 24 / 78%);
|
||||
box-shadow: 0 12px 24px rgb(0 0 0 / 35%);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background-color 0.16s ease;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.video-play-overlay:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
background: rgb(12 18 30 / 88%);
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding: 0.75rem 0.875rem 0.875rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 38%) 0%, rgb(6 10 18 / 86%) 100%);
|
||||
}
|
||||
|
||||
.video-bottom-bar.fullscreen-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
padding-bottom: max(0.875rem, env(safe-area-inset-bottom));
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.video-bottom-bar.hidden-overlay {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
pointer-events: none;
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-control-btn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card) / 0.72);
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.video-control-btn:hover {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
transform: translateY(-1px);
|
||||
background: rgb(255 255 255 / 14%);
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-size: 0.6875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.seek-slider,
|
||||
@@ -166,25 +169,31 @@
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fff 0%,
|
||||
#fff var(--progress, 0%),
|
||||
rgb(255 255 255 / 28%) var(--progress, 0%),
|
||||
rgb(255 255 255 / 28%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 3rem;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--primary)) var(--value, 0%), hsl(var(--secondary)) var(--value, 0%), hsl(var(--secondary)) 100%);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 5.5rem;
|
||||
height: 6px;
|
||||
width: 4.5rem;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track,
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -193,51 +202,50 @@
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track,
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
height: 4px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
background: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-progress {
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb,
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.video-top-bar {
|
||||
padding-inline: 0.75rem;
|
||||
.video-top-overlay,
|
||||
.video-bottom-overlay {
|
||||
padding-inline: 0.625rem;
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
.video-volume-group .volume-slider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
computed,
|
||||
input,
|
||||
@@ -20,6 +21,11 @@ import {
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
import {
|
||||
MEDIA_SEEK_SLIDER_STEPS,
|
||||
mediaSeekSliderValue,
|
||||
mediaSeekTimeFromSliderValue
|
||||
} from './chat-video-player-seek.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-video-player',
|
||||
@@ -40,14 +46,14 @@ import {
|
||||
styleUrl: './chat-video-player.component.scss'
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
export class ChatVideoPlayerComponent implements OnInit, OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
private readonly SINGLE_CLICK_DELAY_MS = 300;
|
||||
private readonly FULLSCREEN_IDLE_MS = 2200;
|
||||
private readonly CONTROLS_IDLE_MS = 2200;
|
||||
|
||||
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
@@ -63,6 +69,8 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
private singleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly seekSliderSteps = MEDIA_SEEK_SLIDER_STEPS;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
@@ -71,16 +79,8 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => {
|
||||
const progress = Math.max(0, Math.min(100, this.progressPercent()));
|
||||
|
||||
return this.buildSliderBackground(progress);
|
||||
});
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
seekSliderValue = computed(() => mediaSeekSliderValue(this.currentTimeSeconds(), this.durationSeconds()));
|
||||
volumeProgressPercent = computed(() => Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent())));
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
@@ -89,13 +89,11 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
this.isFullscreen.set(isFullscreen);
|
||||
|
||||
if (isFullscreen) {
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
ngOnInit(): void {
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -104,9 +102,6 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
onPlayerMouseMove(): void {
|
||||
if (!this.isFullscreen())
|
||||
return;
|
||||
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
@@ -184,11 +179,13 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
const nextTime = Number((event.target as HTMLInputElement).value);
|
||||
const sliderValue = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
if (!Number.isFinite(sliderValue))
|
||||
return;
|
||||
|
||||
const nextTime = mediaSeekTimeFromSliderValue(sliderValue, this.durationSeconds());
|
||||
|
||||
video.currentTime = nextTime;
|
||||
this.currentTimeSeconds.set(nextTime);
|
||||
this.revealControlsTemporarily();
|
||||
@@ -272,27 +269,12 @@ export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
private buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
private revealControlsTemporarily(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
this.controlsVisible.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
this.controlsHideTimer = setTimeout(() => {
|
||||
this.controlsVisible.set(false);
|
||||
}, this.FULLSCREEN_IDLE_MS);
|
||||
}, this.CONTROLS_IDLE_MS);
|
||||
}
|
||||
|
||||
private clearControlsHideTimer(): void {
|
||||
|
||||
@@ -266,14 +266,16 @@ function handleSyncBatch(
|
||||
|
||||
const { db, attachments } = ctx;
|
||||
|
||||
if (hasAttachmentMetaMap(scopedEvent.attachments)) {
|
||||
attachments.registerSyncedAttachments(
|
||||
scopedEvent.attachments,
|
||||
Object.fromEntries(scopedEvent.messages.map((message) => [message.id, message.roomId]))
|
||||
);
|
||||
}
|
||||
return from((async () => {
|
||||
if (hasAttachmentMetaMap(scopedEvent.attachments)) {
|
||||
await attachments.registerSyncedAttachments(
|
||||
scopedEvent.attachments,
|
||||
Object.fromEntries(scopedEvent.messages.map((message) => [message.id, message.roomId]))
|
||||
);
|
||||
}
|
||||
|
||||
return from(processSyncBatch(scopedEvent, db, attachments)).pipe(
|
||||
return processSyncBatch(scopedEvent, db, attachments);
|
||||
})()).pipe(
|
||||
mergeMap((toUpsert) =>
|
||||
toUpsert.length > 0
|
||||
? of(MessagesActions.syncMessages({ messages: toUpsert }))
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||
import { DebuggingService } from '../../core/services';
|
||||
import { AttachmentFacade } from '../../domains/attachment';
|
||||
import { CustomEmojiService } from '../../domains/custom-emoji';
|
||||
import { hasDedicatedChatEmbed } from '../../domains/chat/domain/rules/link-embed.rules';
|
||||
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
|
||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||
@@ -65,6 +66,7 @@ export class MessagesEffects {
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
@@ -257,6 +259,7 @@ export class MessagesEffects {
|
||||
}
|
||||
);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message',
|
||||
message });
|
||||
|
||||
@@ -301,6 +304,7 @@ export class MessagesEffects {
|
||||
}
|
||||
);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited',
|
||||
messageId,
|
||||
content,
|
||||
@@ -460,6 +464,7 @@ export class MessagesEffects {
|
||||
}
|
||||
);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(emoji);
|
||||
this.webrtc.broadcastMessage({ type: 'reaction-added',
|
||||
messageId,
|
||||
reaction });
|
||||
|
||||
Reference in New Issue
Block a user