feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -8,6 +8,7 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
||||
|
||||
## Feature list (alphabetical)
|
||||
|
||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
||||
|
||||
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
||||
|
||||
@@ -25,6 +25,34 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## Lessons
|
||||
|
||||
### Use dense arrays for chunked transfer buffers [custom-emoji] [webrtc]
|
||||
|
||||
- **Trigger:** chunked P2P asset assembly marks a transfer complete after the first chunk because `array.some()` skips sparse holes created by `new Array(total)`.
|
||||
- **Rule:** initialize chunk buffers with `Array.from({ length: total }, () => undefined)` (or another dense initializer) before using `some`/`every`/`filter` to detect completion.
|
||||
- **Why:** a single assigned slot in a sparse array makes `.some((chunk) => !chunk)` return false, so multi-chunk custom emoji transfers are dropped and peers never receive uploaded images larger than one chunk.
|
||||
- **Example:** `CustomEmojiService.receiveTransferStart` stores `chunks: Array.from({ length: total }, () => undefined)` instead of `new Array(total)`.
|
||||
|
||||
### Route custom emoji right-click through the native context menu [custom-emoji] [ux]
|
||||
|
||||
- **Trigger:** adding a second emoji-specific context menu beside `NativeContextMenuComponent`, or attaching handlers only to `<img>` nodes.
|
||||
- **Rule:** mark emoji hosts with `data-custom-emoji` / `data-custom-emoji-library` plus `data-custom-emoji-id`, let `NativeContextMenuComponent` own add/remove actions, and use a capture-phase `preventDefault` so Electron/browser image menus do not override them.
|
||||
- **Why:** the shell context menu already intercepts every image right-click; duplicate menus fight each other and button/div wrappers miss img-only handlers.
|
||||
- **Example:** reaction pills and picker buttons carry the data attributes; `resolveCustomEmojiContextMenuTarget()` opens **Add to emoji library** / **Remove from emoji library** from the global menu.
|
||||
|
||||
### Separate known emoji assets from saved library [custom-emoji] [ux]
|
||||
|
||||
- **Trigger:** syncing remote custom emoji directly into the picker/library when it is first seen in chat.
|
||||
- **Rule:** store remote emoji as known renderable assets, but only show them in the user's picker after an explicit save action such as right-clicking the rendered emoji.
|
||||
- **Why:** users need messages to render, but they should control which seen emoji become part of their local emoji library.
|
||||
- **Example:** `CustomEmojiService.emojis` filters to saved emoji, while `findEmoji(id)` can still resolve unsaved known assets for message rendering.
|
||||
|
||||
### Chunk custom emoji assets over data channels [custom-emoji] [webrtc]
|
||||
|
||||
- **Trigger:** sending uploaded custom emoji image data through a single `custom-emoji-full` peer event.
|
||||
- **Rule:** stream custom emoji assets as a metadata envelope plus bounded `custom-emoji-chunk` events; use buffered sends for back-pressure, but never rely on buffering to make oversized messages safe.
|
||||
- **Why:** a single base64 data URL can exceed browser SCTP message limits and fire `RTCDataChannel.onerror`, breaking the app-wide chat channel.
|
||||
- **Example:** send `{ type: 'custom-emoji-full', customEmojiTransfer, total }`, then `custom-emoji-chunk` events with small `data` slices.
|
||||
|
||||
### Re-clear visible notification channels after recompute [notifications] [startup]
|
||||
|
||||
- **Trigger:** fixing startup unread badges by only changing read-marker writes or initial hydration.
|
||||
@@ -60,6 +88,20 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
|
||||
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
|
||||
|
||||
### Resolve Electron drag-and-drop file paths with webUtils [attachments] [electron]
|
||||
|
||||
- **Trigger:** large videos play after drag-and-drop upload, but after restart the uploader sees a peer-download error even though they sent the file from disk.
|
||||
- **Rule:** when accepting dropped or pasted files in Electron, call `webUtils.getPathForFile(file)` from preload (`getPathForFile` on `electronAPI`) and annotate the `File` before `publishAttachments`; never rely on `File.path` in the renderer.
|
||||
- **Why:** Chromium removed direct `File.path` access in modern Electron; without `getPathForFile`, large uploads only exist as in-memory blobs and cannot be copied into app data for reload playback.
|
||||
- **Example:** `annotateLocalFilePath(file, { getPathForFile: electronApi.getPathForFile })` in `ChatMessageComposerComponent.addPendingFiles`.
|
||||
|
||||
### Preserve uploader local attachment paths across sync [attachments] [persistence]
|
||||
|
||||
- **Trigger:** large Electron uploads play from `filePath` after send, but after reload the uploader sees "The connected peers do not have this file right now" and must P2P-download their own file.
|
||||
- **Rule:** never persist synced attachment metadata with `filePath`/`savedPath` stripped — merge with stored local paths, finish attachment DB init before applying sync batches, and try local disk restore before sending `file-request` to peers.
|
||||
- **Why:** P2P sync intentionally omits local-only paths; a startup race can overwrite the uploader's saved `filePath` with `null`, and large videos (>10 MB) are not auto-copied to app data so only the original path can restore playback.
|
||||
- **Example:** copy large Electron uploads into app-data on `publishAttachments`, `mergeAttachmentLocalPaths(incomingMeta, storedRecord)` in `persistAttachmentMeta`, `await persistence.whenReady()` in `registerSyncedAttachments`, and `tryRestoreAttachmentFromLocal()` before any `file-request`.
|
||||
|
||||
<!--
|
||||
Add new lessons above this comment, newest at the top.
|
||||
Delete this example once the project has accumulated 2-3 real lessons.
|
||||
|
||||
64
agents-docs/features/custom-emoji.md
Normal file
64
agents-docs/features/custom-emoji.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Custom Emoji
|
||||
|
||||
> **Area:** custom-emoji
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-06-05
|
||||
|
||||
## Overview
|
||||
|
||||
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync emoji assets needed for rendering to connected peers over the existing data-channel mesh.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Own custom emoji asset validation, local persistence, user-saved library membership, shortcut ranking, and peer-to-peer asset sync.
|
||||
- Expose a shared picker consumed by chat message reactions and the chat composer.
|
||||
- Keep usage ranking local to the current user; usage counts are not synced.
|
||||
- Does not store custom emoji on the signaling server.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Custom emoji asset**: A user-created image stored as a data URL with id, name, mime, size, hash, creator, timestamps, and optional saved-library membership.
|
||||
- **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved.
|
||||
- **Saved custom emoji**: A known asset with `savedByUser` enabled; saved emoji appear in the picker and shortcut ranking.
|
||||
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector.
|
||||
- **Custom emoji token**: The stable message/reaction representation `:emoji[id](name)`, resolved locally to the synced image asset when rendering.
|
||||
- **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
|
||||
|
||||
## Peer Envelope Contract
|
||||
|
||||
Custom emoji uses `ChatEvent` data-channel envelopes:
|
||||
|
||||
- `custom-emoji-summary`: `{ customEmojiSummaries: [{ id, hash, updatedAt }] }`
|
||||
- `custom-emoji-request`: `{ ids: string[] }`
|
||||
- `custom-emoji-full`: `{ customEmojiTransfer: Omit<CustomEmoji, 'dataUrl'>, total: number }`
|
||||
- `custom-emoji-chunk`: `{ customEmojiId, index, total, data }`
|
||||
|
||||
When a peer connects, each side sends a summary of known assets. The receiver requests missing or stale emoji by id, and the owner replies with a small manifest followed by bounded base64 chunks using buffered peer sends. Creating a new emoji also streams that manifest and chunk sequence to every currently connected peer. Outgoing room chat messages, edits, reactions, and direct messages proactively push every referenced custom emoji asset to connected peers in parallel with the message event, so receivers do not wait for a request round-trip. Small assets that fit under `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` travel inline in one `custom-emoji-full` event; larger assets use manifest plus chunks. Incoming chat messages and chat-sync batches still scan for `:emoji[id](name)` tokens and request any missing assets from the sender as a repair path. Full inline `customEmoji` payloads remain accepted for backward compatibility.
|
||||
|
||||
## Business Rules
|
||||
|
||||
- Uploads are capped at 1 MB.
|
||||
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG.
|
||||
- Local shortcut ranking is keyed by the active user and includes Unicode emoji plus saved custom emoji only.
|
||||
- Message rendering reserves inline emoji space with a transparent placeholder image while a referenced custom emoji asset is not yet available; deferred markdown placeholders rewrite tokens to readable `:name:` aliases so raw `:emoji[id](name)` text never flashes in chat.
|
||||
- Seen custom emoji are not added to the picker automatically; right-click a rendered custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the app context menu (`NativeContextMenuComponent`).
|
||||
- Saved custom emoji can be removed from the picker library by right-clicking them inside the emoji picker and choosing **Remove from emoji library**; the asset stays available for rendering messages that already reference it.
|
||||
- Emoji hosts are marked with `data-custom-emoji` / `data-custom-emoji-library` plus `data-custom-emoji-id` so the global context menu can distinguish them from regular images and suppress the default **Copy Image** action.
|
||||
- The full emoji picker includes a search field that filters built-in Unicode emoji by common terms and saved custom emoji by name.
|
||||
- Custom emoji data-channel chunks are capped below typical SCTP message limits; back-pressure alone is not enough because a single oversized send can fire `RTCDataChannel.onerror`.
|
||||
- Completed transfers are persisted only when the reconstructed data URL matches the manifest size and hash; corrupt local rows are dropped before summaries are advertised.
|
||||
|
||||
## Data Access
|
||||
|
||||
- Browser runtime stores custom emoji in IndexedDB store `customEmojis`.
|
||||
- Electron runtime stores custom emoji in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis`.
|
||||
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests cover upload size validation, shortcut selection, picker search filtering, custom emoji token generation, data-channel chunk splitting, readable composer alias rewriting, transfer integrity, saved-library membership, and add/remove library context-menu actions.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Emoji payloads are image-only and size-limited before persistence or broadcast.
|
||||
- Assets sync only to already connected peers; the signaling server does not persist or proxy emoji images.
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
CustomEmojiEntity,
|
||||
MetaEntity,
|
||||
PluginDataEntity
|
||||
} from '../../../entities';
|
||||
@@ -27,6 +28,7 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
||||
await dataSource.getRepository(ReactionEntity).clear();
|
||||
await dataSource.getRepository(BanEntity).clear();
|
||||
await dataSource.getRepository(AttachmentEntity).clear();
|
||||
await dataSource.getRepository(CustomEmojiEntity).clear();
|
||||
await dataSource.getRepository(MetaEntity).clear();
|
||||
await dataSource.getRepository(PluginDataEntity).clear();
|
||||
}
|
||||
|
||||
7
electron/cqrs/commands/handlers/deleteCustomEmoji.ts
Normal file
7
electron/cqrs/commands/handlers/deleteCustomEmoji.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CustomEmojiEntity } from '../../../entities';
|
||||
import { DeleteCustomEmojiCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteCustomEmoji(command: DeleteCustomEmojiCommand, dataSource: DataSource): Promise<void> {
|
||||
await dataSource.getRepository(CustomEmojiEntity).delete({ id: command.payload.emojiId });
|
||||
}
|
||||
19
electron/cqrs/commands/handlers/saveCustomEmoji.ts
Normal file
19
electron/cqrs/commands/handlers/saveCustomEmoji.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CustomEmojiEntity } from '../../../entities';
|
||||
import { SaveCustomEmojiCommand } from '../../types';
|
||||
|
||||
export async function handleSaveCustomEmoji(command: SaveCustomEmojiCommand, dataSource: DataSource): Promise<void> {
|
||||
const { emoji } = command.payload;
|
||||
|
||||
await dataSource.getRepository(CustomEmojiEntity).save({
|
||||
id: emoji.id,
|
||||
name: emoji.name,
|
||||
creatorUserId: emoji.creatorUserId,
|
||||
dataUrl: emoji.dataUrl,
|
||||
hash: emoji.hash,
|
||||
mime: emoji.mime,
|
||||
size: emoji.size,
|
||||
createdAt: emoji.createdAt,
|
||||
updatedAt: emoji.updatedAt
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
RemoveBanCommand,
|
||||
SaveAttachmentCommand,
|
||||
DeleteAttachmentsForMessageCommand,
|
||||
SaveCustomEmojiCommand,
|
||||
DeleteCustomEmojiCommand,
|
||||
SavePluginDataCommand,
|
||||
DeletePluginDataCommand,
|
||||
SaveMetaCommand
|
||||
@@ -39,6 +41,8 @@ import { handleSaveBan } from './handlers/saveBan';
|
||||
import { handleRemoveBan } from './handlers/removeBan';
|
||||
import { handleSaveAttachment } from './handlers/saveAttachment';
|
||||
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
|
||||
import { handleSaveCustomEmoji } from './handlers/saveCustomEmoji';
|
||||
import { handleDeleteCustomEmoji } from './handlers/deleteCustomEmoji';
|
||||
import { handleSavePluginData } from './handlers/savePluginData';
|
||||
import { handleDeletePluginData } from './handlers/deletePluginData';
|
||||
import { handleSaveMeta } from './handlers/saveMeta';
|
||||
@@ -61,6 +65,8 @@ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandType
|
||||
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
|
||||
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
|
||||
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
|
||||
[CommandType.SaveCustomEmoji]: (cmd) => handleSaveCustomEmoji(cmd as SaveCustomEmojiCommand, dataSource),
|
||||
[CommandType.DeleteCustomEmoji]: (cmd) => handleDeleteCustomEmoji(cmd as DeleteCustomEmojiCommand, dataSource),
|
||||
[CommandType.SavePluginData]: (cmd) => handleSavePluginData(cmd as SavePluginDataCommand, dataSource),
|
||||
[CommandType.DeletePluginData]: (cmd) => handleDeletePluginData(cmd as DeletePluginDataCommand, dataSource),
|
||||
[CommandType.SaveMeta]: (cmd) => handleSaveMeta(cmd as SaveMetaCommand, dataSource),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RoomEntity } from '../entities/RoomEntity';
|
||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||
import { BanEntity } from '../entities/BanEntity';
|
||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||
import { CustomEmojiEntity } from '../entities/CustomEmojiEntity';
|
||||
import { ReactionPayload } from './types';
|
||||
import {
|
||||
relationRecordToRoomPayload,
|
||||
@@ -140,6 +141,20 @@ export function rowToAttachment(row: AttachmentEntity) {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToCustomEmoji(row: CustomEmojiEntity) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
creatorUserId: row.creatorUserId,
|
||||
dataUrl: row.dataUrl,
|
||||
hash: row.hash,
|
||||
mime: row.mime,
|
||||
size: row.size,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToBan(row: BanEntity) {
|
||||
return {
|
||||
oderId: row.oderId,
|
||||
|
||||
9
electron/cqrs/queries/handlers/getCustomEmojis.ts
Normal file
9
electron/cqrs/queries/handlers/getCustomEmojis.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CustomEmojiEntity } from '../../../entities';
|
||||
import { rowToCustomEmoji } from '../../mappers';
|
||||
|
||||
export async function handleGetCustomEmojis(dataSource: DataSource) {
|
||||
const rows = await dataSource.getRepository(CustomEmojiEntity).find({ order: { updatedAt: 'DESC' } });
|
||||
|
||||
return rows.map(rowToCustomEmoji);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
|
||||
import { handleIsUserBanned } from './handlers/isUserBanned';
|
||||
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
|
||||
import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
||||
import { handleGetCustomEmojis } from './handlers/getCustomEmojis';
|
||||
import { handleGetPluginData } from './handlers/getPluginData';
|
||||
import { handleGetMeta } from './handlers/getMeta';
|
||||
|
||||
@@ -50,6 +51,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
|
||||
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
|
||||
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
|
||||
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource),
|
||||
[QueryType.GetCustomEmojis]: () => handleGetCustomEmojis(dataSource),
|
||||
[QueryType.GetPluginData]: (query) => handleGetPluginData(query as GetPluginDataQuery, dataSource),
|
||||
[QueryType.GetMeta]: (query) => handleGetMeta(query as GetMetaQuery, dataSource)
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ export const CommandType = {
|
||||
RemoveBan: 'remove-ban',
|
||||
SaveAttachment: 'save-attachment',
|
||||
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
|
||||
SaveCustomEmoji: 'save-custom-emoji',
|
||||
DeleteCustomEmoji: 'delete-custom-emoji',
|
||||
SavePluginData: 'save-plugin-data',
|
||||
DeletePluginData: 'delete-plugin-data',
|
||||
SaveMeta: 'save-meta',
|
||||
@@ -39,6 +41,7 @@ export const QueryType = {
|
||||
IsUserBanned: 'is-user-banned',
|
||||
GetAttachmentsForMessage: 'get-attachments-for-message',
|
||||
GetAllAttachments: 'get-all-attachments',
|
||||
GetCustomEmojis: 'get-custom-emojis',
|
||||
GetPluginData: 'get-plugin-data',
|
||||
GetMeta: 'get-meta'
|
||||
} as const;
|
||||
@@ -178,6 +181,18 @@ export interface AttachmentPayload {
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
export interface CustomEmojiPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
creatorUserId: string;
|
||||
dataUrl: string;
|
||||
hash: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type PluginDataScopePayload = 'local' | 'server';
|
||||
|
||||
export interface PluginDataPayload {
|
||||
@@ -204,6 +219,8 @@ export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { b
|
||||
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
|
||||
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
|
||||
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
|
||||
export interface SaveCustomEmojiCommand { type: typeof CommandType.SaveCustomEmoji; payload: { emoji: CustomEmojiPayload } }
|
||||
export interface DeleteCustomEmojiCommand { type: typeof CommandType.DeleteCustomEmoji; payload: { emojiId: string } }
|
||||
export interface SavePluginDataCommand { type: typeof CommandType.SavePluginData; payload: PluginDataPayload }
|
||||
export interface DeletePluginDataCommand { type: typeof CommandType.DeletePluginData; payload: Omit<PluginDataPayload, 'value'> }
|
||||
export interface SaveMetaCommand { type: typeof CommandType.SaveMeta; payload: { key: string; value: string | null } }
|
||||
@@ -226,6 +243,8 @@ export type Command =
|
||||
| RemoveBanCommand
|
||||
| SaveAttachmentCommand
|
||||
| DeleteAttachmentsForMessageCommand
|
||||
| SaveCustomEmojiCommand
|
||||
| DeleteCustomEmojiCommand
|
||||
| SavePluginDataCommand
|
||||
| DeletePluginDataCommand
|
||||
| SaveMetaCommand
|
||||
@@ -255,6 +274,7 @@ export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; pa
|
||||
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
|
||||
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
|
||||
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
|
||||
export interface GetCustomEmojisQuery { type: typeof QueryType.GetCustomEmojis; payload: Record<string, never> }
|
||||
export interface GetPluginDataQuery { type: typeof QueryType.GetPluginData; payload: Omit<PluginDataPayload, 'value'> }
|
||||
export interface GetMetaQuery { type: typeof QueryType.GetMeta; payload: { key: string } }
|
||||
|
||||
@@ -274,5 +294,6 @@ export type Query =
|
||||
| IsUserBannedQuery
|
||||
| GetAttachmentsForMessageQuery
|
||||
| GetAllAttachmentsQuery
|
||||
| GetCustomEmojisQuery
|
||||
| GetPluginDataQuery
|
||||
| GetMetaQuery;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
CustomEmojiEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
MetaEntity,
|
||||
@@ -50,6 +51,7 @@ export const AppDataSource = new DataSource({
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
CustomEmojiEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
MetaEntity,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
CustomEmojiEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
MetaEntity,
|
||||
@@ -224,6 +225,7 @@ export async function initializeDatabase(): Promise<void> {
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
CustomEmojiEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
MetaEntity,
|
||||
|
||||
35
electron/entities/CustomEmojiEntity.ts
Normal file
35
electron/entities/CustomEmojiEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('custom_emojis')
|
||||
export class CustomEmojiEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
creatorUserId!: string;
|
||||
|
||||
@Column('text')
|
||||
dataUrl!: string;
|
||||
|
||||
@Column('text')
|
||||
hash!: string;
|
||||
|
||||
@Column('text')
|
||||
mime!: string;
|
||||
|
||||
@Column('integer')
|
||||
size!: number;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { RoomRoleEntity } from './RoomRoleEntity';
|
||||
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
|
||||
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
|
||||
export { ReactionEntity } from './ReactionEntity';
|
||||
export { CustomEmojiEntity } from './CustomEmojiEntity';
|
||||
export { BanEntity } from './BanEntity';
|
||||
export { AttachmentEntity } from './AttachmentEntity';
|
||||
export { MetaEntity } from './MetaEntity';
|
||||
|
||||
@@ -553,6 +553,29 @@ export function setupSystemHandlers(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('copy-file', async (_event, sourceFilePath: string, destinationFilePath: string) => {
|
||||
if (typeof sourceFilePath !== 'string' || !sourceFilePath.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof destinationFilePath !== 'string' || !destinationFilePath.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(sourceFilePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.copyFile(sourceFilePath, destinationFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('file-exists', async (_event, filePath: string) => {
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
|
||||
27
electron/migrations/1000000000011-AddCustomEmojis.ts
Normal file
27
electron/migrations/1000000000011-AddCustomEmojis.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCustomEmojis1000000000011 implements MigrationInterface {
|
||||
name = 'AddCustomEmojis1000000000011';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "custom_emojis" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"creatorUserId" TEXT NOT NULL,
|
||||
"dataUrl" TEXT NOT NULL,
|
||||
"hash" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_custom_emojis_updated_at" ON "custom_emojis" ("updatedAt")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_custom_emojis_creator" ON "custom_emojis" ("creatorUserId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_custom_emojis_creator"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_custom_emojis_updated_at"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "custom_emojis"`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import { Command, Query } from './cqrs/types';
|
||||
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||
@@ -310,6 +310,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>;
|
||||
@@ -441,6 +443,8 @@ const electronAPI: ElectronAPI = {
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
||||
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
||||
copyFile: (sourceFilePath, destinationFilePath) => ipcRenderer.invoke('copy-file', sourceFilePath, destinationFilePath),
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||
getFileUrl: (filePath) => ipcRenderer.invoke('get-file-url', filePath),
|
||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
|
||||
@@ -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)) {
|
||||
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
return annotateLocalFilePath(file, {
|
||||
getPathForFile: payload.path
|
||||
? () => payload.path!
|
||||
: this.electronBridge.getApi()?.getPathForFile
|
||||
});
|
||||
} catch {
|
||||
(file as LocalFileWithPath).path = payload.path;
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
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();
|
||||
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()"
|
||||
class="video-bottom-overlay"
|
||||
[class.hidden-overlay]="!controlsVisible()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
@@ -100,7 +90,18 @@
|
||||
</button>
|
||||
|
||||
<span class="video-time-label">{{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="seekSliderSteps"
|
||||
step="any"
|
||||
[value]="seekSliderValue()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.--progress.%]="progressPercent()"
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
|
||||
<div class="video-volume-group">
|
||||
<button
|
||||
@@ -123,26 +124,11 @@
|
||||
[value]="isMuted() ? 0 : volumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
[style.--progress.%]="volumeProgressPercent()"
|
||||
aria-label="Video volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (isFullscreen()) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleFullscreen()"
|
||||
@@ -158,3 +144,4 @@
|
||||
</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.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;
|
||||
|
||||
return from((async () => {
|
||||
if (hasAttachmentMetaMap(scopedEvent.attachments)) {
|
||||
attachments.registerSyncedAttachments(
|
||||
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